mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
feat(scripts): port create-new-feature, setup-plan and setup-tasks to Python (#3386)
* feat(scripts): port create-new-feature, setup-plan and setup-tasks to Python Ports the three core workflow scripts to Python as part of #3280, following the check-prerequisites PoC pattern from #3302. Adds resolve_template() to the shared common.py module and parity tests that run bash and Python side by side. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): treat only None env as unset in parity run helper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): fall back to directory scan on any registry error, skip hidden preset dirs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(templates): add py: lines for setup_plan and setup_tasks Ships with the scripts they reference; the remaining templates got their py: lines in #3403. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: support py variant in skills placeholder resolver resolve_skill_placeholders only accepted sh/ps, so a py init option fell into the fallback path and {SCRIPT} rendered without an interpreter prefix. Accept py and prefix the resolved interpreter, matching process_template. Also guard ps_cmd against a missing PowerShell with a clear assert. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: pin clean-error behavior for invalid --number Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(scripts): reword unused-arg comment to match implementation The loop accepts and silently ignores extra positional args (it doesn't build a collected list); match the wording to what the code and setup-plan.sh actually do. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: fall back when configured script variant is missing from frontmatter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): reject signed/whitespace --number values to match bash 10# parity The bash twin uses $((10#$BRANCH_NUMBER)), which rejects signed and whitespace-padded values. Python's int() accepted them (e.g. -1), producing a malformed -01-... prefix that sequential scans ignore. Restrict --number to unsigned decimal digits before conversion, and pin the parity with a bash-comparison test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): complete Python port installation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(integrations): fall back for missing script variants Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: make Python script checks platform-aware Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix Windows Python command invocation parity Use PowerShell's call operator for spaced Python interpreter paths and align setup-tasks missing-template errors across script variants. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): preserve cross-platform Python parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: reject signed PowerShell feature numbers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): align feature number range Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): reject exhausted feature numbers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): complete create feature parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): align create feature outputs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): harden cross-platform parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): keep truncation JSON clean Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): align setup failure parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): close parity edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): propagate PowerShell setup errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): harden fallback resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): stabilize PowerShell fallbacks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): complete setup-plan parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(cli): require runnable script fallbacks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(cli): preserve shell fallback without preference Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): restore help and symlink parity - setup-tasks.ps1: check -Help before unknown-argument validation so '-Help --bogus' exits 0 like the Bash/Python variants - common.py: strip the repo root prefix lexically in persist_feature_json instead of resolve(), so a symlinked specs/ still persists the relative 'specs/NNN-name' path the Bash/PowerShell helpers store Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): align persist-hint quoting with shlex.quote - create-new-feature.sh: replace printf %q with a shell_quote helper that emits shlex.quote-identical output, so the persistence hints stay byte-identical between the Bash and Python variants (printf %q output also varies between bash versions) - promote the negative --number test to an all-variants parity test now that Bash and PowerShell reject signed values consistently - add a spaced-repo-path parity test for the persistence hints Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -90,6 +90,19 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MAX_FEATURE_NUMBER=9223372036854775807
|
||||
|
||||
is_feature_number_in_range() {
|
||||
local value="$1"
|
||||
local normalized="${value#"${value%%[!0]*}"}"
|
||||
[ -n "$normalized" ] || normalized=0
|
||||
[ ${#normalized} -lt ${#MAX_FEATURE_NUMBER} ] && return 0
|
||||
[ ${#normalized} -gt ${#MAX_FEATURE_NUMBER} ] && return 1
|
||||
# Equal-length digit strings must be compared without arithmetic overflow.
|
||||
# shellcheck disable=SC2071
|
||||
[[ "$normalized" < "$MAX_FEATURE_NUMBER" || "$normalized" == "$MAX_FEATURE_NUMBER" ]]
|
||||
}
|
||||
|
||||
# Function to get highest number from specs directory
|
||||
get_highest_from_specs() {
|
||||
local specs_dir="$1"
|
||||
@@ -102,9 +115,11 @@ get_highest_from_specs() {
|
||||
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
||||
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
if is_feature_number_in_range "$number"; then
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
@@ -119,6 +134,19 @@ clean_branch_name() {
|
||||
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||
}
|
||||
|
||||
# Quote a value for POSIX shell reuse, byte-identical to Python's shlex.quote
|
||||
# so the persistence hints match the Python variant exactly (printf %q output
|
||||
# differs between bash versions and from shlex.quote for spaces/metachars).
|
||||
shell_quote() {
|
||||
local value="$1" LC_ALL=C
|
||||
if [[ "$value" =~ ^[A-Za-z0-9_@%+=:,./-]+$ ]]; then
|
||||
printf '%s' "$value"
|
||||
else
|
||||
local q="'\"'\"'"
|
||||
printf "'%s'" "${value//\'/$q}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Resolve repository root using common.sh functions which prioritize .specify
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
@@ -202,9 +230,24 @@ if [ "$USE_TIMESTAMP" = true ]; then
|
||||
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
else
|
||||
if [ -n "$BRANCH_NUMBER" ] && [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||
echo "Error: --number must be an unsigned integer, got '$BRANCH_NUMBER'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Bash arithmetic is signed 64-bit; reject digit strings that would wrap.
|
||||
if [ -n "$BRANCH_NUMBER" ] && ! is_feature_number_in_range "$BRANCH_NUMBER"; then
|
||||
echo "Error: --number must be between 0 and $MAX_FEATURE_NUMBER, got '$BRANCH_NUMBER'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine branch number from existing feature directories
|
||||
if [ -z "$BRANCH_NUMBER" ]; then
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
if [ "$HIGHEST" -eq "$MAX_FEATURE_NUMBER" ]; then
|
||||
echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2
|
||||
exit 1
|
||||
fi
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
fi
|
||||
|
||||
@@ -264,8 +307,8 @@ if [ "$DRY_RUN" != true ]; then
|
||||
_persist_feature_json "$REPO_ROOT" "$FEATURE_DIR"
|
||||
|
||||
# Inform the user how to set feature state in their own shell
|
||||
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR" >&2
|
||||
printf '# To persist: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")" >&2
|
||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")" >&2
|
||||
fi
|
||||
|
||||
if $JSON_MODE; then
|
||||
@@ -295,7 +338,7 @@ else
|
||||
echo "SPEC_FILE: $SPEC_FILE"
|
||||
echo "FEATURE_NUM: $FEATURE_NUM"
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
|
||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR"
|
||||
printf '# To persist in your shell: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")"
|
||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -29,13 +29,16 @@ function Find-SpecifyRoot {
|
||||
# command against a member project from a monorepo root without cd.
|
||||
#
|
||||
# Precondition: $env:SPECIFY_INIT_DIR is set. Returns the validated project root,
|
||||
# or writes an error and exits 1. Strict by design: the path must exist and
|
||||
# or writes an error and exits 1 unless -ReturnNullOnError is set. Strict by
|
||||
# design: the path must exist and
|
||||
# contain .specify/, with no silent fallback. (An empty string is falsy, so the
|
||||
# caller's `if ($env:SPECIFY_INIT_DIR)` guard treats empty as unset.)
|
||||
#
|
||||
# This is the single resolver: bundled extensions inherit it by sourcing core
|
||||
# (e.g. the git extension's create-new-feature-branch) rather than duplicating it.
|
||||
function Resolve-SpecifyInitDir {
|
||||
param([switch]$ReturnNullOnError)
|
||||
|
||||
$initDir = $env:SPECIFY_INIT_DIR
|
||||
# Normalize: relative paths resolve against the current directory.
|
||||
if (-not [System.IO.Path]::IsPathRooted($initDir)) {
|
||||
@@ -47,6 +50,7 @@ function Resolve-SpecifyInitDir {
|
||||
# "not a Spec Kit project" error below.
|
||||
if (-not $resolved -or -not (Test-Path -LiteralPath $resolved.Path -PathType Container)) {
|
||||
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
# Resolve-Path echoes back any trailing separator from the input; trim it so
|
||||
@@ -56,6 +60,7 @@ function Resolve-SpecifyInitDir {
|
||||
$initRoot = [System.IO.Path]::TrimEndingDirectorySeparator($resolved.Path)
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
|
||||
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
return $initRoot
|
||||
@@ -64,9 +69,11 @@ function Resolve-SpecifyInitDir {
|
||||
# Get repository root, prioritizing .specify directory
|
||||
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
|
||||
function Get-RepoRoot {
|
||||
param([switch]$ReturnNullOnError)
|
||||
|
||||
# Explicit project override wins (see Resolve-SpecifyInitDir).
|
||||
if ($env:SPECIFY_INIT_DIR) {
|
||||
return (Resolve-SpecifyInitDir)
|
||||
return (Resolve-SpecifyInitDir -ReturnNullOnError:$ReturnNullOnError)
|
||||
}
|
||||
|
||||
# First, look for .specify directory (spec-kit's own marker)
|
||||
@@ -147,10 +154,12 @@ function Get-FeaturePathsEnv {
|
||||
# so pure path resolution never writes .specify/feature.json, which would
|
||||
# dirty the working tree or overwrite a pinned value (issue #3025).
|
||||
param(
|
||||
[switch]$NoPersist
|
||||
[switch]$NoPersist,
|
||||
[switch]$ReturnNullOnError
|
||||
)
|
||||
|
||||
$repoRoot = Get-RepoRoot
|
||||
$repoRoot = Get-RepoRoot -ReturnNullOnError:$ReturnNullOnError
|
||||
if (-not $repoRoot) { return $null }
|
||||
$currentBranch = Get-CurrentBranch
|
||||
|
||||
# Resolve feature directory. Priority:
|
||||
@@ -174,7 +183,8 @@ function Get-FeaturePathsEnv {
|
||||
try {
|
||||
$featureConfig = $featureJsonRaw | ConvertFrom-Json
|
||||
} catch {
|
||||
[Console]::Error.WriteLine("ERROR: Failed to parse .specify/feature.json: $_")
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
if ($featureConfig.feature_directory) {
|
||||
@@ -185,10 +195,12 @@ function Get-FeaturePathsEnv {
|
||||
}
|
||||
} else {
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -334,30 +346,64 @@ function Resolve-Template {
|
||||
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 |
|
||||
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 $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 { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
|
||||
Sort-Object { & $priorityFor $_ } |
|
||||
ForEach-Object { $_.Name }
|
||||
}
|
||||
$registryParsed = $true
|
||||
} catch {
|
||||
# Fallback: alphabetical directory order
|
||||
$sortedPresets = @()
|
||||
$registryParsed = $false
|
||||
}
|
||||
}
|
||||
|
||||
if ($sortedPresets.Count -gt 0) {
|
||||
if ($registryParsed) {
|
||||
foreach ($presetId in $sortedPresets) {
|
||||
$candidate = Join-Path $presetsDir "$presetId/templates/$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 '.*' }) {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ param(
|
||||
[switch]$DryRun,
|
||||
[string]$ShortName,
|
||||
[Parameter()]
|
||||
[long]$Number = 0,
|
||||
[string]$Number = '',
|
||||
[switch]$Timestamp,
|
||||
[switch]$Help,
|
||||
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
||||
@@ -142,12 +142,13 @@ if ($ShortName) {
|
||||
$branchSuffix = Get-BranchName -Description $featureDesc
|
||||
}
|
||||
|
||||
# Warn if -Number and -Timestamp are both specified. Use ContainsKey (not
|
||||
# `-ne 0`) so an explicit `-Number 0` is also detected, matching the bash twin's
|
||||
# `[ -n "$BRANCH_NUMBER" ]` check.
|
||||
if ($Timestamp -and $PSBoundParameters.ContainsKey('Number')) {
|
||||
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
|
||||
$Number = 0
|
||||
# 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
|
||||
@@ -158,11 +159,23 @@ if ($Timestamp) {
|
||||
# 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.
|
||||
if (-not $PSBoundParameters.ContainsKey('Number')) {
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
[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 $Number)
|
||||
$featureNum = ('{0:000}' -f $resolvedNumber)
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
}
|
||||
|
||||
@@ -183,9 +196,9 @@ if ($branchName.Length -gt $maxBranchLength) {
|
||||
$originalBranchName = $branchName
|
||||
$branchName = "$featureNum-$truncatedSuffix"
|
||||
|
||||
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
|
||||
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
|
||||
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
|
||||
[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
|
||||
@@ -225,6 +238,13 @@ if (-not $DryRun) {
|
||||
# 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) {
|
||||
@@ -242,7 +262,7 @@ if ($Json) {
|
||||
Write-Output "SPEC_FILE: $specFile"
|
||||
Write-Output "FEATURE_NUM: $featureNum"
|
||||
if (-not $DryRun) {
|
||||
Write-Output "SPECIFY_FEATURE set to: $branchName"
|
||||
Write-Output "SPECIFY_FEATURE_DIRECTORY set to: $featureDir"
|
||||
Write-Output "# To persist in your shell: $featureAssignment"
|
||||
Write-Output "# $directoryAssignment"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Json,
|
||||
[switch]$Help
|
||||
[switch]$Help,
|
||||
# Capture extra positional arguments to match Bash/Python behavior.
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$RemainingArgs
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -21,7 +24,11 @@ if ($Help) {
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Get all paths and variables from common functions
|
||||
$paths = Get-FeaturePathsEnv
|
||||
$paths = Get-FeaturePathsEnv -ReturnNullOnError
|
||||
if (-not $paths) {
|
||||
[Console]::Error.WriteLine("ERROR: Failed to resolve feature paths")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Ensure the feature directory exists
|
||||
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
||||
|
||||
@@ -3,21 +3,34 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Json,
|
||||
[switch]$Help
|
||||
[switch]$Help,
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$RemainingArgs
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Help wins over unknown-argument validation to match the Bash/Python
|
||||
# variants, which stop at --help and exit 0.
|
||||
if ($Help) {
|
||||
Write-Output "Usage: setup-tasks.ps1 [-Json] [-Help]"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($RemainingArgs.Count -gt 0) {
|
||||
[Console]::Error.WriteLine("ERROR: Unknown option '$($RemainingArgs[0])'")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Source common functions
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Get feature paths
|
||||
$paths = Get-FeaturePathsEnv
|
||||
$paths = Get-FeaturePathsEnv -ReturnNullOnError
|
||||
if (-not $paths) {
|
||||
[Console]::Error.WriteLine("ERROR: Failed to resolve feature paths")
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
|
||||
@@ -45,8 +58,8 @@ 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)) {
|
||||
$expectedCoreTemplate = Join-Path $paths.REPO_ROOT '.specify/templates/tasks-template.md'
|
||||
[Console]::Error.WriteLine("ERROR: Tasks template not found for repository root: $($paths.REPO_ROOT)`nTemplate resolution order: overrides -> presets -> extensions -> core.`nExpected shared/core template location: $expectedCoreTemplate`nTo continue, verify whether 'tasks-template.md' is available in '.specify/templates/overrides/', preset templates, extension templates, or restore the shared/core templates (for example by re-running 'specify init') so that '.specify/templates/tasks-template.md' exists.")
|
||||
[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
|
||||
|
||||
@@ -84,7 +84,7 @@ def read_feature_json_feature_directory(repo_root: Path) -> str:
|
||||
return ""
|
||||
try:
|
||||
data = json.loads(feature_json.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return ""
|
||||
value = data.get("feature_directory") if isinstance(data, dict) else None
|
||||
return value if isinstance(value, str) else ""
|
||||
@@ -95,16 +95,17 @@ def _json_dump(data: dict[str, str]) -> str:
|
||||
|
||||
|
||||
def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
|
||||
# Strip the repo root prefix lexically (no resolve()) to mirror the
|
||||
# Bash/PowerShell helpers: with a symlinked <repo>/specs, resolve() would
|
||||
# escape the repo and persist a machine-specific absolute path instead of
|
||||
# the relative "specs/NNN-name" the other variants store.
|
||||
value = feature_dir_value
|
||||
try:
|
||||
relative = Path(value)
|
||||
if relative.is_absolute():
|
||||
try:
|
||||
value = relative.resolve().relative_to(repo_root.resolve()).as_posix()
|
||||
except ValueError:
|
||||
value = str(relative)
|
||||
except OSError:
|
||||
pass
|
||||
relative = Path(value)
|
||||
if relative.is_absolute():
|
||||
try:
|
||||
value = relative.relative_to(repo_root).as_posix()
|
||||
except ValueError:
|
||||
value = str(relative)
|
||||
|
||||
current = read_feature_json_feature_directory(repo_root)
|
||||
if current == value:
|
||||
@@ -112,9 +113,8 @@ def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
|
||||
|
||||
specify_dir = repo_root / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "feature.json").write_text(
|
||||
_json_dump({"feature_directory": value}),
|
||||
encoding="utf-8",
|
||||
(specify_dir / "feature.json").write_bytes(
|
||||
_json_dump({"feature_directory": value}).encode("utf-8")
|
||||
)
|
||||
|
||||
|
||||
@@ -182,6 +182,78 @@ def get_feature_paths(
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
try:
|
||||
data = json.loads(registry.read_text(encoding="utf-8"))
|
||||
presets = data.get("presets", {})
|
||||
return [
|
||||
pid
|
||||
for pid, meta in sorted(
|
||||
presets.items(),
|
||||
key=lambda kv: kv[1].get("priority", 10)
|
||||
if isinstance(kv[1], dict)
|
||||
else 10,
|
||||
)
|
||||
if isinstance(meta, dict) and meta.get("enabled", True) is not False
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return sorted(
|
||||
p.name
|
||||
for p in presets_dir.iterdir()
|
||||
if p.is_dir() and not p.name.startswith(".")
|
||||
)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def resolve_template(template_name: str, repo_root: Path) -> Path | None:
|
||||
"""Resolve a template name to a file path using the priority stack.
|
||||
|
||||
Order (mirrors resolve_template in scripts/bash/common.sh):
|
||||
1. .specify/templates/overrides/
|
||||
2. .specify/presets/<preset-id>/templates/ (sorted by .registry priority)
|
||||
3. .specify/extensions/<ext-id>/templates/ (hidden directories skipped)
|
||||
4. .specify/templates/ (core)
|
||||
"""
|
||||
base = repo_root / ".specify" / "templates"
|
||||
|
||||
override = base / "overrides" / f"{template_name}.md"
|
||||
if override.is_file():
|
||||
return override
|
||||
|
||||
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():
|
||||
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():
|
||||
return candidate
|
||||
|
||||
core = base / f"{template_name}.md"
|
||||
if core.is_file():
|
||||
return core
|
||||
return None
|
||||
|
||||
|
||||
def get_invoke_separator(repo_root: Path) -> str:
|
||||
integration_json = repo_root / ".specify" / "integration.json"
|
||||
if not integration_json.is_file():
|
||||
|
||||
355
scripts/python/create_new_feature.py
Normal file
355
scripts/python/create_new_feature.py
Normal file
@@ -0,0 +1,355 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a new feature directory and spec file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
def _json_line(payload: object) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
_STOP_WORDS = frozenset(
|
||||
"""
|
||||
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
|
||||
""".split()
|
||||
)
|
||||
|
||||
_MAX_BRANCH_LENGTH = 244
|
||||
_MAX_FEATURE_NUMBER = 2**63 - 1
|
||||
|
||||
|
||||
def _int64_from_digits(value: str) -> int | None:
|
||||
normalized = value.lstrip("0") or "0"
|
||||
maximum = str(_MAX_FEATURE_NUMBER)
|
||||
if len(normalized) > len(maximum) or (
|
||||
len(normalized) == len(maximum) and normalized > maximum
|
||||
):
|
||||
return None
|
||||
return int(normalized, 10)
|
||||
|
||||
|
||||
def _persistence_assignments(
|
||||
branch_name: str, feature_dir: str, *, powershell: bool
|
||||
) -> tuple[str, str]:
|
||||
if powershell:
|
||||
quoted_branch = "'" + branch_name.replace("'", "''") + "'"
|
||||
quoted_dir = "'" + feature_dir.replace("'", "''") + "'"
|
||||
return (
|
||||
f"$env:SPECIFY_FEATURE = {quoted_branch}",
|
||||
f"$env:SPECIFY_FEATURE_DIRECTORY = {quoted_dir}",
|
||||
)
|
||||
return (
|
||||
f"export SPECIFY_FEATURE={shlex.quote(branch_name)}",
|
||||
f"export SPECIFY_FEATURE_DIRECTORY={shlex.quote(feature_dir)}",
|
||||
)
|
||||
|
||||
|
||||
def _usage(argv0: str) -> str:
|
||||
return (
|
||||
f"Usage: {argv0} [--json] [--dry-run] [--allow-existing-branch] "
|
||||
"[--short-name <name>] [--number N] [--timestamp] <feature_description>"
|
||||
)
|
||||
|
||||
|
||||
def _help_text(argv0: str) -> str:
|
||||
return f"""{_usage(argv0)}
|
||||
|
||||
Options:
|
||||
--json Output in JSON format
|
||||
--dry-run Compute feature name and paths without creating directories or files
|
||||
--allow-existing-branch Reuse an existing feature directory if it already exists
|
||||
--short-name <name> Provide a custom short name (2-4 words) for the feature
|
||||
--number N Specify branch number manually (overrides auto-detection)
|
||||
--timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering
|
||||
--help, -h Show this help message
|
||||
|
||||
Examples:
|
||||
{argv0} 'Add user authentication system' --short-name 'user-auth'
|
||||
{argv0} 'Implement OAuth2 integration for API' --number 5
|
||||
{argv0} --timestamp --short-name 'user-auth' 'Add user authentication'
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Args:
|
||||
json_mode: bool = False
|
||||
dry_run: bool = False
|
||||
allow_existing: bool = False
|
||||
short_name: str = ""
|
||||
branch_number: str = ""
|
||||
use_timestamp: bool = False
|
||||
description: str = ""
|
||||
|
||||
|
||||
def _parse_args(argv: list[str], argv0: str) -> Args:
|
||||
json_mode = False
|
||||
dry_run = False
|
||||
allow_existing = False
|
||||
short_name = ""
|
||||
branch_number = ""
|
||||
use_timestamp = False
|
||||
rest: list[str] = []
|
||||
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
arg = argv[i]
|
||||
if arg == "--json":
|
||||
json_mode = True
|
||||
elif arg == "--dry-run":
|
||||
dry_run = True
|
||||
elif arg == "--allow-existing-branch":
|
||||
allow_existing = True
|
||||
elif arg in {"--short-name", "--number"}:
|
||||
if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
|
||||
print(f"Error: {arg} requires a value", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
i += 1
|
||||
if arg == "--short-name":
|
||||
short_name = argv[i]
|
||||
else:
|
||||
branch_number = argv[i]
|
||||
elif arg == "--timestamp":
|
||||
use_timestamp = True
|
||||
elif arg in {"--help", "-h"}:
|
||||
sys.stdout.write(_help_text(argv0))
|
||||
raise SystemExit(0)
|
||||
else:
|
||||
rest.append(arg)
|
||||
i += 1
|
||||
|
||||
description = " ".join(rest).strip()
|
||||
if not description:
|
||||
if rest:
|
||||
print(
|
||||
"Error: Feature description cannot be empty or contain only whitespace",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(_usage(argv0), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
return Args(
|
||||
json_mode=json_mode,
|
||||
dry_run=dry_run,
|
||||
allow_existing=allow_existing,
|
||||
short_name=short_name,
|
||||
branch_number=branch_number,
|
||||
use_timestamp=use_timestamp,
|
||||
description=description,
|
||||
)
|
||||
|
||||
|
||||
def _clean_branch_name(name: str) -> str:
|
||||
cleaned = re.sub(r"[^a-z0-9]", "-", name.lower())
|
||||
cleaned = re.sub(r"-+", "-", cleaned)
|
||||
return cleaned.strip("-")
|
||||
|
||||
|
||||
def _generate_branch_name(description: str) -> str:
|
||||
clean = re.sub(r"[^a-z0-9]", " ", description.lower())
|
||||
meaningful: list[str] = []
|
||||
for word in clean.split():
|
||||
if word in _STOP_WORDS:
|
||||
continue
|
||||
if len(word) >= 3:
|
||||
meaningful.append(word)
|
||||
# Keep short words that appear as an uppercase acronym in the original,
|
||||
# mirroring the bash twin's case-sensitive `grep -qw` check.
|
||||
elif re.search(
|
||||
rf"(?<![0-9A-Za-z_]){re.escape(word.upper())}(?![0-9A-Za-z_])",
|
||||
description,
|
||||
):
|
||||
meaningful.append(word)
|
||||
|
||||
if meaningful:
|
||||
max_words = 4 if len(meaningful) == 4 else 3
|
||||
return "-".join(meaningful[:max_words])
|
||||
|
||||
cleaned = _clean_branch_name(description)
|
||||
return "-".join([part for part in cleaned.split("-") if part][:3])
|
||||
|
||||
|
||||
def _get_highest_from_specs(specs_dir: Path) -> int:
|
||||
highest = 0
|
||||
if not specs_dir.is_dir():
|
||||
return highest
|
||||
for entry in specs_dir.iterdir():
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
name = entry.name
|
||||
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
||||
if re.match(r"^[0-9]{3,}-", name) and not re.match(
|
||||
r"^[0-9]{8}-[0-9]{6}-", name
|
||||
):
|
||||
number = _int64_from_digits(re.match(r"^[0-9]+", name).group())
|
||||
if number is not None:
|
||||
highest = max(highest, number)
|
||||
return highest
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
argv0 = sys.argv[0]
|
||||
args = _parse_args(list(argv if argv is not None else sys.argv[1:]), argv0)
|
||||
|
||||
repo_root = get_repo_root(Path(__file__))
|
||||
specs_dir = repo_root / "specs"
|
||||
if not args.dry_run:
|
||||
specs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.short_name:
|
||||
branch_suffix = _clean_branch_name(args.short_name)
|
||||
else:
|
||||
branch_suffix = _generate_branch_name(args.description)
|
||||
|
||||
branch_number = args.branch_number
|
||||
if args.use_timestamp and branch_number:
|
||||
print(
|
||||
"[specify] Warning: --number is ignored when --timestamp is used",
|
||||
file=sys.stderr,
|
||||
)
|
||||
branch_number = ""
|
||||
|
||||
if args.use_timestamp:
|
||||
feature_num = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
else:
|
||||
if branch_number:
|
||||
# Mirrors bash: $((10#$BRANCH_NUMBER)) only accepts unsigned
|
||||
# decimal digits, rejecting signs, whitespace, and other
|
||||
# characters that int() would otherwise tolerate.
|
||||
if not re.fullmatch(r"[0-9]+", branch_number):
|
||||
print(
|
||||
"Error: --number must be an unsigned integer, "
|
||||
f"got '{branch_number}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
number = _int64_from_digits(branch_number)
|
||||
if number is None:
|
||||
print(
|
||||
"Error: --number must be between 0 and "
|
||||
f"{_MAX_FEATURE_NUMBER}, got '{branch_number}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
else:
|
||||
number = _get_highest_from_specs(specs_dir) + 1
|
||||
if number > _MAX_FEATURE_NUMBER:
|
||||
rejected_number = branch_number or str(number)
|
||||
number_label = "--number" if branch_number else "feature number"
|
||||
print(
|
||||
f"Error: {number_label} must be between 0 and "
|
||||
f"{_MAX_FEATURE_NUMBER}, got '{rejected_number}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
feature_num = f"{number:03d}"
|
||||
|
||||
max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
|
||||
if max_suffix_length <= 0:
|
||||
print("Error: feature number is too long for a branch name", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
branch_name = f"{feature_num}-{branch_suffix}"
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names.
|
||||
if len(branch_name) > _MAX_BRANCH_LENGTH:
|
||||
truncated_suffix = re.sub(r"-$", "", branch_suffix[:max_suffix_length])
|
||||
original_branch_name = branch_name
|
||||
branch_name = f"{feature_num}-{truncated_suffix}"
|
||||
print(
|
||||
"[specify] Warning: Branch name exceeded GitHub's 244-byte limit",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
f"[specify] Original: {original_branch_name} "
|
||||
f"({len(original_branch_name)} bytes)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
f"[specify] Truncated to: {branch_name} ({len(branch_name)} bytes)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
feature_dir = specs_dir / branch_name
|
||||
spec_file = feature_dir / "spec.md"
|
||||
|
||||
if not args.dry_run:
|
||||
if feature_dir.is_dir() and not args.allow_existing:
|
||||
if args.use_timestamp:
|
||||
print(
|
||||
f"Error: Feature directory '{feature_dir}' already exists. "
|
||||
"Rerun to get a new timestamp or use a different --short-name.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"Error: Feature directory '{feature_dir}' already exists. "
|
||||
"Please use a different feature name or specify a different "
|
||||
"number with --number.",
|
||||
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)
|
||||
else:
|
||||
print(
|
||||
"Warning: Spec template not found; created empty spec file",
|
||||
file=sys.stderr,
|
||||
)
|
||||
spec_file.touch()
|
||||
|
||||
# Persist to .specify/feature.json so downstream commands can find the feature.
|
||||
persist_feature_json(repo_root, f"specs/{branch_name}")
|
||||
|
||||
# Inform the user how to set feature state in their own shell.
|
||||
feature_assignment, directory_assignment = _persistence_assignments(
|
||||
branch_name,
|
||||
str(feature_dir),
|
||||
powershell=sys.platform == "win32",
|
||||
)
|
||||
print(f"# To persist: {feature_assignment}", file=sys.stderr)
|
||||
print(f"# {directory_assignment}", file=sys.stderr)
|
||||
|
||||
if args.json_mode:
|
||||
payload: dict[str, object] = {
|
||||
"BRANCH_NAME": branch_name,
|
||||
"SPEC_FILE": str(spec_file),
|
||||
"FEATURE_NUM": feature_num,
|
||||
}
|
||||
if args.dry_run:
|
||||
payload["DRY_RUN"] = True
|
||||
sys.stdout.write(_json_line(payload))
|
||||
else:
|
||||
print(f"BRANCH_NAME: {branch_name}")
|
||||
print(f"SPEC_FILE: {spec_file}")
|
||||
print(f"FEATURE_NUM: {feature_num}")
|
||||
if not args.dry_run:
|
||||
print(f"# To persist in your shell: {feature_assignment}")
|
||||
print(f"# {directory_assignment}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
86
scripts/python/setup_plan.py
Normal file
86
scripts/python/setup_plan.py
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Setup implementation plan for a feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from common import get_feature_paths, resolve_template
|
||||
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
|
||||
|
||||
|
||||
def _json_line(payload: object) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
def _help_text(argv0: str) -> str:
|
||||
return f"""Usage: {argv0} [--json]
|
||||
--json Output results in JSON format
|
||||
--help Show this help message
|
||||
"""
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = list(argv if argv is not None else sys.argv[1:])
|
||||
json_mode = False
|
||||
for arg in args:
|
||||
if arg == "--json":
|
||||
json_mode = True
|
||||
elif arg in {"--help", "-h"}:
|
||||
sys.stdout.write(_help_text(sys.argv[0]))
|
||||
return 0
|
||||
# Other arguments are accepted and silently ignored, matching setup-plan.sh.
|
||||
|
||||
try:
|
||||
paths = get_feature_paths(script_file=Path(__file__))
|
||||
except SystemExit as exc:
|
||||
if exc.code == 0:
|
||||
return 0
|
||||
print("ERROR: Failed to resolve feature paths", file=sys.stderr)
|
||||
return int(exc.code) if isinstance(exc.code, int) else 1
|
||||
|
||||
paths.feature_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Status messages go to stderr in JSON mode so stdout stays pure JSON.
|
||||
status_stream = sys.stderr if json_mode else sys.stdout
|
||||
if paths.impl_plan.is_file():
|
||||
print(
|
||||
f"Plan already exists at {paths.impl_plan}, skipping template copy",
|
||||
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)
|
||||
print(f"Copied plan template to {paths.impl_plan}", file=status_stream)
|
||||
else:
|
||||
print("Warning: Plan template not found", file=status_stream)
|
||||
paths.impl_plan.touch()
|
||||
|
||||
if json_mode:
|
||||
sys.stdout.write(
|
||||
_json_line(
|
||||
{
|
||||
"FEATURE_SPEC": str(paths.feature_spec),
|
||||
"IMPL_PLAN": str(paths.impl_plan),
|
||||
"SPECS_DIR": str(paths.feature_dir),
|
||||
"BRANCH": paths.current_branch,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(f"FEATURE_SPEC: {paths.feature_spec}")
|
||||
print(f"IMPL_PLAN: {paths.impl_plan}")
|
||||
print(f"SPECS_DIR: {paths.feature_dir}")
|
||||
print(f"BRANCH: {paths.current_branch}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
145
scripts/python/setup_tasks.py
Normal file
145
scripts/python/setup_tasks.py
Normal file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check tasks prerequisites and resolve the tasks template."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from common import (
|
||||
FeaturePaths,
|
||||
format_speckit_command,
|
||||
get_feature_paths,
|
||||
resolve_template,
|
||||
)
|
||||
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,
|
||||
resolve_template,
|
||||
)
|
||||
|
||||
|
||||
def _json_line(payload: object) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
def _help_text(argv0: str) -> str:
|
||||
return f"""Usage: {argv0} [--json]
|
||||
--json Output results in JSON format
|
||||
--help Show this help message
|
||||
"""
|
||||
|
||||
|
||||
def _dir_has_entries(path: Path) -> bool:
|
||||
try:
|
||||
return path.is_dir() and any(path.iterdir())
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _available_docs(paths: FeaturePaths) -> list[str]:
|
||||
docs: list[str] = []
|
||||
if paths.research.is_file():
|
||||
docs.append("research.md")
|
||||
if paths.data_model.is_file():
|
||||
docs.append("data-model.md")
|
||||
if _dir_has_entries(paths.contracts_dir):
|
||||
docs.append("contracts/")
|
||||
if paths.quickstart.is_file():
|
||||
docs.append("quickstart.md")
|
||||
return docs
|
||||
|
||||
|
||||
def _check_file(path: Path, description: str) -> None:
|
||||
marker = "✓" if path.is_file() else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def _check_dir(path: Path, description: str) -> None:
|
||||
marker = "✓" if _dir_has_entries(path) else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
json_mode = False
|
||||
for arg in list(argv if argv is not None else sys.argv[1:]):
|
||||
if arg == "--json":
|
||||
json_mode = True
|
||||
elif arg in {"--help", "-h"}:
|
||||
sys.stdout.write(_help_text(sys.argv[0]))
|
||||
return 0
|
||||
else:
|
||||
print(f"ERROR: Unknown option '{arg}'", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
paths = get_feature_paths(script_file=Path(__file__))
|
||||
except SystemExit as exc:
|
||||
if exc.code == 0:
|
||||
return 0
|
||||
print("ERROR: Failed to resolve feature paths", file=sys.stderr)
|
||||
return int(exc.code) if isinstance(exc.code, int) else 1
|
||||
|
||||
if not paths.impl_plan.is_file():
|
||||
print(f"ERROR: plan.md not found in {paths.feature_dir}", file=sys.stderr)
|
||||
print(
|
||||
f"Run {format_speckit_command('plan', paths.repo_root)} first to create the implementation plan.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if not paths.feature_spec.is_file():
|
||||
print(f"ERROR: spec.md not found in {paths.feature_dir}", file=sys.stderr)
|
||||
print(
|
||||
f"Run {format_speckit_command('specify', paths.repo_root)} first to create the feature structure.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
docs = _available_docs(paths)
|
||||
|
||||
tasks_template = resolve_template("tasks-template", paths.repo_root)
|
||||
if tasks_template is None or not tasks_template.is_file():
|
||||
print(
|
||||
"ERROR: Could not resolve required tasks-template from the template "
|
||||
f"override stack for {paths.repo_root}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"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.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if json_mode:
|
||||
sys.stdout.write(
|
||||
_json_line(
|
||||
{
|
||||
"FEATURE_DIR": str(paths.feature_dir),
|
||||
"AVAILABLE_DOCS": docs,
|
||||
"TASKS_TEMPLATE": str(tasks_template),
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(f"FEATURE_DIR: {paths.feature_dir}")
|
||||
print(f"TASKS_TEMPLATE: {tasks_template}")
|
||||
print("AVAILABLE_DOCS:")
|
||||
_check_file(paths.research, "research.md")
|
||||
_check_file(paths.data_model, "data-model.md")
|
||||
_check_dir(paths.contracts_dir, "contracts/")
|
||||
_check_file(paths.quickstart, "quickstart.md")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -140,10 +140,9 @@ def _install_shared_infra(
|
||||
"""Install shared infrastructure files into *project_path*.
|
||||
|
||||
Copies ``.specify/scripts/<variant>/`` and ``.specify/templates/`` from
|
||||
the bundled core_pack or source checkout, where ``<variant>`` is
|
||||
``bash`` when *script_type* is ``"sh"``, ``python`` when it is ``"py"``,
|
||||
and ``powershell`` when it is ``"ps"``. Tracks all installed files in
|
||||
``speckit.manifest.json``.
|
||||
the bundled core_pack or source checkout. ``sh`` installs Bash, ``ps``
|
||||
installs PowerShell, and ``py`` installs Python plus the platform shell
|
||||
fallback. Tracks all installed files in ``speckit.manifest.json``.
|
||||
|
||||
Shared scripts and page templates are processed to resolve
|
||||
``__SPECKIT_COMMAND_<NAME>__`` placeholders using *invoke_separator*
|
||||
|
||||
@@ -7,7 +7,6 @@ command files into agent-specific directories in the correct format.
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
@@ -486,26 +485,19 @@ class CommandRegistrar:
|
||||
init_opts = {}
|
||||
|
||||
script_variant = init_opts.get("script")
|
||||
if script_variant not in {"sh", "ps"}:
|
||||
fallback_order = []
|
||||
default_variant = (
|
||||
"ps" if platform.system().lower().startswith("win") else "sh"
|
||||
if scripts:
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
|
||||
script_variant = IntegrationBase.select_script_variant(
|
||||
script_variant, scripts
|
||||
)
|
||||
secondary_variant = "sh" if default_variant == "ps" else "ps"
|
||||
|
||||
if default_variant in scripts:
|
||||
fallback_order.append(default_variant)
|
||||
if secondary_variant in scripts:
|
||||
fallback_order.append(secondary_variant)
|
||||
|
||||
for key in scripts:
|
||||
if key not in fallback_order:
|
||||
fallback_order.append(key)
|
||||
|
||||
script_variant = fallback_order[0] if fallback_order else None
|
||||
|
||||
script_command = scripts.get(script_variant) if script_variant else None
|
||||
if script_command:
|
||||
if script_variant == "py":
|
||||
script_command = IntegrationBase.build_python_invocation(
|
||||
script_command, project_root
|
||||
)
|
||||
script_command = script_command.replace("{ARGS}", "$ARGUMENTS")
|
||||
body = body.replace("{SCRIPT}", script_command)
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ def register(app: typer.Typer) -> None:
|
||||
help="Name for your new project directory (optional if using --here, or use '.' for current directory)",
|
||||
),
|
||||
script_type: str = typer.Option(
|
||||
None, "--script", help="Script type to use: sh or ps"
|
||||
None, "--script", help="Script type to use: sh, ps, or py"
|
||||
),
|
||||
ignore_agent_tools: bool = typer.Option(
|
||||
False,
|
||||
|
||||
@@ -38,7 +38,7 @@ from ._helpers import (
|
||||
@integration_app.command("install")
|
||||
def integration_install(
|
||||
key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"),
|
||||
force: bool = typer.Option(False, "--force", help="Allow multi-install when integrations are not declared safe"),
|
||||
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
|
||||
):
|
||||
|
||||
@@ -131,7 +131,7 @@ def _installed_presets_affecting_agent(project_root, agent_key: str) -> list[str
|
||||
@integration_app.command("switch")
|
||||
def integration_switch(
|
||||
target: str = typer.Argument(help="Integration key to switch to"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"),
|
||||
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall of the previous integration"),
|
||||
refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"),
|
||||
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'),
|
||||
@@ -425,7 +425,7 @@ def integration_switch(
|
||||
def integration_upgrade(
|
||||
key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"),
|
||||
force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"),
|
||||
integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"),
|
||||
):
|
||||
"""Upgrade an integration by reinstalling with diff-aware file handling.
|
||||
|
||||
@@ -14,6 +14,7 @@ Provides:
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
@@ -668,6 +669,46 @@ class IntegrationBase(ABC):
|
||||
return name
|
||||
return sys.executable or "python3"
|
||||
|
||||
@staticmethod
|
||||
def build_python_invocation(
|
||||
script_command: str, project_root: Path | None = None
|
||||
) -> str:
|
||||
"""Build a Python script command for the current platform shell."""
|
||||
interpreter = IntegrationBase.resolve_python_interpreter(project_root)
|
||||
if os.name == "nt" and not re.fullmatch(r"[A-Za-z0-9_./:\\-]+", interpreter):
|
||||
quoted_interpreter = interpreter.replace("'", "''")
|
||||
interpreter = f"& '{quoted_interpreter}'"
|
||||
elif os.name != "nt":
|
||||
interpreter = shlex.quote(interpreter)
|
||||
return f"{interpreter} {script_command}"
|
||||
|
||||
@staticmethod
|
||||
def select_script_variant(
|
||||
requested: object, script_commands: dict[str, str]
|
||||
) -> str:
|
||||
"""Select the requested variant or a runnable platform fallback."""
|
||||
if isinstance(requested, str) and requested in script_commands:
|
||||
return requested
|
||||
|
||||
platform_variant = (
|
||||
"ps" if platform.system().lower().startswith("win") else "sh"
|
||||
)
|
||||
secondary_variant = "sh" if platform_variant == "ps" else "ps"
|
||||
fallbacks = (
|
||||
(platform_variant, "py")
|
||||
if requested == "py"
|
||||
else (platform_variant, secondary_variant, "py")
|
||||
)
|
||||
for candidate in fallbacks:
|
||||
if candidate in script_commands:
|
||||
return candidate
|
||||
|
||||
available = ", ".join(sorted(script_commands)) or "none"
|
||||
raise ValueError(
|
||||
"No runnable script variant for this platform: "
|
||||
f"requested {requested!r}; available: {available}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _interpreter_runs(path: str) -> bool:
|
||||
"""Return True when *path* executes as a Python interpreter.
|
||||
@@ -702,7 +743,8 @@ class IntegrationBase(ABC):
|
||||
"""Process a raw command template into agent-ready content.
|
||||
|
||||
Performs the same transformations as the release script:
|
||||
1. Extract ``scripts.<script_type>`` value from YAML frontmatter
|
||||
1. Select ``scripts.<script_type>`` from YAML frontmatter, falling
|
||||
back to a runnable platform shell or Python variant when unavailable
|
||||
2. Replace ``{SCRIPT}`` with the extracted script command
|
||||
3. Strip ``scripts:`` section from frontmatter
|
||||
4. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder*
|
||||
@@ -711,37 +753,46 @@ class IntegrationBase(ABC):
|
||||
7. Replace ``__SPECKIT_COMMAND_<NAME>__`` with invocation strings
|
||||
"""
|
||||
# 1. Extract script command from frontmatter
|
||||
script_command = ""
|
||||
script_pattern = re.compile(
|
||||
rf"^\s*{re.escape(script_type)}:\s*(.+)$", re.MULTILINE
|
||||
)
|
||||
script_commands: dict[str, str] = {}
|
||||
script_pattern = re.compile(r"^\s*([A-Za-z0-9_-]+):\s*(.+)$")
|
||||
# Find the scripts: block
|
||||
in_frontmatter = False
|
||||
in_scripts = False
|
||||
for line in content.splitlines():
|
||||
if line.strip() == "scripts:":
|
||||
if line == "---":
|
||||
if in_frontmatter:
|
||||
break
|
||||
in_frontmatter = True
|
||||
continue
|
||||
if not in_frontmatter:
|
||||
continue
|
||||
if line == "scripts:":
|
||||
in_scripts = True
|
||||
continue
|
||||
if in_scripts and line and not line[0].isspace():
|
||||
in_scripts = False
|
||||
break
|
||||
if in_scripts:
|
||||
m = script_pattern.match(line)
|
||||
if m:
|
||||
script_command = m.group(1).strip()
|
||||
break
|
||||
script_commands[m.group(1)] = m.group(2).strip()
|
||||
|
||||
selected_script_type = (
|
||||
IntegrationBase.select_script_variant(script_type, script_commands)
|
||||
if script_commands
|
||||
else ""
|
||||
)
|
||||
|
||||
script_command = script_commands.get(selected_script_type, "")
|
||||
|
||||
# 2. Replace {SCRIPT}
|
||||
if script_command:
|
||||
# For the Python script type, prefix the resolved interpreter so
|
||||
# the command is portable (``.py`` files are not directly
|
||||
# executable on Windows).
|
||||
if script_type == "py":
|
||||
interpreter = IntegrationBase.resolve_python_interpreter(project_root)
|
||||
# Quote the interpreter if it contains whitespace (e.g. an
|
||||
# absolute ``sys.executable`` path under Windows
|
||||
# ``Program Files``) so it isn't split into multiple args.
|
||||
if any(ch.isspace() for ch in interpreter):
|
||||
interpreter = f'"{interpreter}"'
|
||||
script_command = f"{interpreter} {script_command}"
|
||||
if selected_script_type == "py":
|
||||
script_command = IntegrationBase.build_python_invocation(
|
||||
script_command, project_root
|
||||
)
|
||||
content = content.replace("{SCRIPT}", script_command)
|
||||
|
||||
# 3. Strip scripts: section from frontmatter
|
||||
|
||||
@@ -402,8 +402,13 @@ def install_shared_infra(
|
||||
# Track every shared path the current bundle produces so we can detect
|
||||
# manifest entries the core no longer ships (stale-script cleanup, #3076).
|
||||
seen_rels: set[str] = set()
|
||||
scripts_scanned = False
|
||||
variant_dir = {"sh": "bash", "py": "python"}.get(script_type, "powershell")
|
||||
scanned_variant_dirs: set[str] = set()
|
||||
shell_variant = "powershell" if os.name == "nt" else "bash"
|
||||
variant_dirs = (
|
||||
("python", shell_variant)
|
||||
if script_type == "py"
|
||||
else ("bash" if script_type == "sh" else "powershell",)
|
||||
)
|
||||
|
||||
def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
|
||||
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
|
||||
@@ -458,69 +463,69 @@ def install_shared_infra(
|
||||
if scripts_src.is_dir():
|
||||
dest_scripts = project_path / ".specify" / "scripts"
|
||||
if _ensure_or_bucket_dir(dest_scripts):
|
||||
variant_src = scripts_src / variant_dir
|
||||
if variant_src.is_dir():
|
||||
for variant_dir in variant_dirs:
|
||||
variant_src = scripts_src / variant_dir
|
||||
if not variant_src.is_dir():
|
||||
continue
|
||||
dest_variant = dest_scripts / variant_dir
|
||||
if _ensure_or_bucket_dir(dest_variant):
|
||||
for src_path in variant_src.rglob("*"):
|
||||
if not src_path.is_file():
|
||||
continue
|
||||
# Python bytecode caches are local artifacts, not
|
||||
# workflow scripts — never install them.
|
||||
if "__pycache__" in src_path.parts:
|
||||
continue
|
||||
# Mark scanned only once a real source file is seen. An
|
||||
# empty (or symlink-skipped) variant keeps this False, so
|
||||
# stale-cleanup is skipped — otherwise it would treat every
|
||||
# tracked script as obsolete and delete it. (The safety
|
||||
# hinge is this flag, not ``seen_rels``, which also holds
|
||||
# template paths populated later.)
|
||||
scripts_scanned = True
|
||||
if not _ensure_or_bucket_dir(dest_variant):
|
||||
continue
|
||||
for src_path in variant_src.rglob("*"):
|
||||
if not src_path.is_file():
|
||||
continue
|
||||
# Python bytecode caches are local artifacts, not
|
||||
# workflow scripts — never install them.
|
||||
if "__pycache__" in src_path.parts:
|
||||
continue
|
||||
# Mark scanned only once a real source file is seen. An
|
||||
# empty (or symlink-skipped) variant stays untracked, so
|
||||
# stale-cleanup cannot treat its managed scripts as obsolete.
|
||||
scanned_variant_dirs.add(variant_dir)
|
||||
|
||||
rel_path = src_path.relative_to(variant_src)
|
||||
dst_path = dest_variant / rel_path
|
||||
rel = dst_path.relative_to(project_path).as_posix()
|
||||
seen_rels.add(rel)
|
||||
if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False):
|
||||
continue
|
||||
write, bucket = _decide_overwrite(rel, dst_path)
|
||||
if not write:
|
||||
if bucket == "preserved":
|
||||
preserved_user_files.append(rel)
|
||||
else:
|
||||
skipped_files.append(rel)
|
||||
# Record the existing-on-disk file in the manifest so a
|
||||
# fresh manifest run against an already-populated
|
||||
# ``.specify/`` tree does not silently drop it (#2107).
|
||||
# ``prior_hashes`` is the function-scope snapshot taken
|
||||
# at entry, so this membership check is O(1) and avoids
|
||||
# the repeated ``dict(self._files)`` copy that
|
||||
# ``manifest.files`` performs on every access.
|
||||
if dst_path.is_file() and rel not in prior_hashes:
|
||||
try:
|
||||
manifest.record_existing(rel, recovered=True)
|
||||
except (OSError, ValueError) as exc:
|
||||
# Tolerate races / permission issues / non-file
|
||||
# collisions so one weird path does not abort
|
||||
# the whole install.
|
||||
console.print(
|
||||
f"[yellow]⚠[/yellow] could not record {rel} in manifest: {exc}"
|
||||
)
|
||||
continue
|
||||
rel_path = src_path.relative_to(variant_src)
|
||||
dst_path = dest_variant / rel_path
|
||||
rel = dst_path.relative_to(project_path).as_posix()
|
||||
seen_rels.add(rel)
|
||||
if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False):
|
||||
continue
|
||||
write, bucket = _decide_overwrite(rel, dst_path)
|
||||
if not write:
|
||||
if bucket == "preserved":
|
||||
preserved_user_files.append(rel)
|
||||
else:
|
||||
skipped_files.append(rel)
|
||||
# Record the existing-on-disk file in the manifest so a
|
||||
# fresh manifest run against an already-populated
|
||||
# ``.specify/`` tree does not silently drop it (#2107).
|
||||
# ``prior_hashes`` is the function-scope snapshot taken
|
||||
# at entry, so this membership check is O(1) and avoids
|
||||
# the repeated ``dict(self._files)`` copy that
|
||||
# ``manifest.files`` performs on every access.
|
||||
if dst_path.is_file() and rel not in prior_hashes:
|
||||
try:
|
||||
manifest.record_existing(rel, recovered=True)
|
||||
except (OSError, ValueError) as exc:
|
||||
# Tolerate races / permission issues / non-file
|
||||
# collisions so one weird path does not abort
|
||||
# the whole install.
|
||||
console.print(
|
||||
f"[yellow]⚠[/yellow] could not record {rel} in manifest: {exc}"
|
||||
)
|
||||
continue
|
||||
|
||||
if not _ensure_or_bucket_dir(dst_path.parent):
|
||||
continue
|
||||
content = src_path.read_text(encoding="utf-8")
|
||||
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
||||
content = _resolve_dynamic_command_refs(content, invoke_separator)
|
||||
planned_copies.append(
|
||||
(
|
||||
dst_path,
|
||||
rel,
|
||||
content.encode("utf-8"),
|
||||
src_path.stat().st_mode & 0o777,
|
||||
)
|
||||
if not _ensure_or_bucket_dir(dst_path.parent):
|
||||
continue
|
||||
content = src_path.read_text(encoding="utf-8")
|
||||
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
||||
content = _resolve_dynamic_command_refs(content, invoke_separator)
|
||||
planned_copies.append(
|
||||
(
|
||||
dst_path,
|
||||
rel,
|
||||
content.encode("utf-8"),
|
||||
src_path.stat().st_mode & 0o777,
|
||||
)
|
||||
)
|
||||
|
||||
templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root)
|
||||
if templates_src.is_dir():
|
||||
@@ -618,14 +623,16 @@ def install_shared_infra(
|
||||
# agent-context extension. Left behind, such an orphan can crash when it
|
||||
# sources a refreshed ``common.sh`` (#3076). Only run when the script source
|
||||
# was actually scanned (so a missing/empty source never triggers mass
|
||||
# deletion), scoped to the active variant, and only for *managed* copies —
|
||||
# deletion), scoped to the selected variants, and only for *managed* copies —
|
||||
# a user-customized file (hash diverges), a symlink, or a recovered entry is
|
||||
# preserved by ``_is_managed``.
|
||||
if scripts_scanned:
|
||||
if scanned_variant_dirs:
|
||||
stale_removed: list[str] = []
|
||||
script_prefix = f".specify/scripts/{variant_dir}/"
|
||||
script_prefixes = tuple(
|
||||
f".specify/scripts/{variant_dir}/" for variant_dir in scanned_variant_dirs
|
||||
)
|
||||
for rel in list(prior_hashes):
|
||||
if rel in seen_rels or not rel.startswith(script_prefix):
|
||||
if rel in seen_rels or not rel.startswith(script_prefixes):
|
||||
continue
|
||||
# Guard corrupted/hand-edited manifest keys BEFORE any filesystem
|
||||
# access: absolute, ``..``, or (on Windows) drive-relative keys such
|
||||
|
||||
@@ -11,6 +11,7 @@ handoffs:
|
||||
scripts:
|
||||
sh: scripts/bash/setup-plan.sh --json
|
||||
ps: scripts/powershell/setup-plan.ps1 -Json
|
||||
py: scripts/python/setup_plan.py --json
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
@@ -12,6 +12,7 @@ handoffs:
|
||||
scripts:
|
||||
sh: scripts/bash/setup-tasks.sh --json
|
||||
ps: scripts/powershell/setup-tasks.ps1 -Json
|
||||
py: scripts/python/setup_tasks.py --json
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Tests for IntegrationOption, IntegrationBase, MarkdownIntegration, and primitives."""
|
||||
|
||||
import shlex
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -495,19 +497,41 @@ class TestProcessTemplatePyScriptType:
|
||||
assert ".specify/scripts/bash/check-prerequisites.sh --json" in result
|
||||
assert "python" not in result
|
||||
|
||||
def test_body_scripts_example_does_not_override_frontmatter(self):
|
||||
content = (
|
||||
"---\n"
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/real.sh --json\n"
|
||||
"---\n"
|
||||
"Run {SCRIPT} now.\n"
|
||||
"```yaml\n"
|
||||
"scripts:\n"
|
||||
" sh: examples/not-the-command.sh\n"
|
||||
"```\n"
|
||||
)
|
||||
|
||||
result = IntegrationBase.process_template(content, "agent", "sh")
|
||||
|
||||
assert ".specify/scripts/bash/real.sh --json" in result
|
||||
assert "examples/not-the-command.sh" in result
|
||||
|
||||
def test_py_quotes_interpreter_with_spaces(self, monkeypatch):
|
||||
# An interpreter path containing whitespace (e.g. Windows
|
||||
# ``Program Files``) must be quoted so it isn't split into args.
|
||||
interpreter = r"C:\Program Files\Python\python.exe"
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable",
|
||||
r"C:\Program Files\Python\python.exe",
|
||||
interpreter,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.os", SimpleNamespace(name="posix")
|
||||
)
|
||||
result = IntegrationBase.process_template(self.CONTENT, "agent", "py")
|
||||
assert (
|
||||
'"C:\\Program Files\\Python\\python.exe" '
|
||||
f"{shlex.quote(interpreter)} "
|
||||
".specify/scripts/python/check-prerequisites.py --json"
|
||||
) in result
|
||||
|
||||
@@ -529,6 +553,39 @@ class TestProcessTemplatePyScriptType:
|
||||
)
|
||||
assert ".venv/bin/python .specify/scripts/python/check-prerequisites.py" in result
|
||||
|
||||
def test_setup_py_falls_back_to_platform_shell(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
template = tmp_path / "fallback.md"
|
||||
template.write_text(
|
||||
"---\n"
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/check-prerequisites.sh --json\n"
|
||||
" ps: scripts/powershell/check-prerequisites.ps1 -Json\n"
|
||||
"---\n"
|
||||
"Run {SCRIPT} now.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
integration = StubIntegration()
|
||||
monkeypatch.setattr(
|
||||
integration, "list_command_templates", lambda: [template]
|
||||
)
|
||||
|
||||
created = integration.setup(
|
||||
tmp_path,
|
||||
IntegrationManifest("stub", tmp_path),
|
||||
script_type="py",
|
||||
)
|
||||
|
||||
rendered = created[0].read_text(encoding="utf-8")
|
||||
expected = (
|
||||
".specify/scripts/powershell/check-prerequisites.ps1"
|
||||
if sys.platform == "win32"
|
||||
else ".specify/scripts/bash/check-prerequisites.sh"
|
||||
)
|
||||
assert "{SCRIPT}" not in rendered
|
||||
assert expected in rendered
|
||||
|
||||
|
||||
class TestInstallScriptsPython:
|
||||
def _make_integration_with_scripts(self, monkeypatch, tmp_path):
|
||||
|
||||
@@ -15,6 +15,22 @@ from tests.conftest import strip_ansi
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
["init", "--help"],
|
||||
["integration", "install", "--help"],
|
||||
["integration", "switch", "--help"],
|
||||
["integration", "upgrade", "--help"],
|
||||
],
|
||||
)
|
||||
def test_script_help_includes_python_variant(args):
|
||||
result = runner.invoke(app, args)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "sh, ps, or py" in " ".join(strip_ansi(result.output).split())
|
||||
|
||||
|
||||
def _init_project(tmp_path, integration="copilot", integration_options=None):
|
||||
"""Helper: init a spec-kit project with the given integration."""
|
||||
project = tmp_path / "proj"
|
||||
|
||||
133
tests/parity_helpers.py
Normal file
133
tests/parity_helpers.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Shared helpers for the core-script Python parity tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
BASH_DIR = PROJECT_ROOT / "scripts" / "bash"
|
||||
PS_DIR = PROJECT_ROOT / "scripts" / "powershell"
|
||||
PY_DIR = PROJECT_ROOT / "scripts" / "python"
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
WINDOWS_POWERSHELL = (
|
||||
(shutil.which("powershell.exe") or shutil.which("powershell"))
|
||||
if os.name == "nt"
|
||||
else None
|
||||
)
|
||||
POWERSHELL_EXE = "pwsh" if HAS_PWSH else WINDOWS_POWERSHELL
|
||||
HAS_POWERSHELL = POWERSHELL_EXE is not None
|
||||
|
||||
|
||||
def make_repo(tmp_path: Path, name: str = "proj") -> Path:
|
||||
repo = tmp_path / name
|
||||
(repo / ".specify").mkdir(parents=True)
|
||||
return repo
|
||||
|
||||
|
||||
def install_scripts(repo: Path, script: str) -> None:
|
||||
"""Install the bash/powershell/python twins of a kebab-case script name."""
|
||||
py_name = script.replace("-", "_")
|
||||
|
||||
bash_dir = repo / ".specify" / "scripts" / "bash"
|
||||
bash_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(BASH_DIR / "common.sh", bash_dir / "common.sh")
|
||||
shutil.copy(BASH_DIR / f"{script}.sh", bash_dir / f"{script}.sh")
|
||||
|
||||
ps_dir = repo / ".specify" / "scripts" / "powershell"
|
||||
ps_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(PS_DIR / "common.ps1", ps_dir / "common.ps1")
|
||||
shutil.copy(PS_DIR / f"{script}.ps1", ps_dir / f"{script}.ps1")
|
||||
|
||||
py_dir = repo / ".specify" / "scripts" / "python"
|
||||
py_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(PY_DIR / "common.py", py_dir / "common.py")
|
||||
shutil.copy(PY_DIR / f"{py_name}.py", py_dir / f"{py_name}.py")
|
||||
|
||||
|
||||
def bash_cmd(repo: Path, script: str, *args: str) -> list[str]:
|
||||
return ["bash", str(repo / ".specify" / "scripts" / "bash" / f"{script}.sh"), *args]
|
||||
|
||||
|
||||
def py_cmd(repo: Path, script: str, *args: str) -> list[str]:
|
||||
py_name = script.replace("-", "_")
|
||||
return [
|
||||
sys.executable,
|
||||
str(repo / ".specify" / "scripts" / "python" / f"{py_name}.py"),
|
||||
*args,
|
||||
]
|
||||
|
||||
|
||||
def ps_cmd(repo: Path, script: str, *args: str) -> list[str]:
|
||||
assert POWERSHELL_EXE, "no PowerShell available; guard the test with HAS_POWERSHELL"
|
||||
return [
|
||||
POWERSHELL_EXE,
|
||||
"-NoProfile",
|
||||
"-File",
|
||||
str(repo / ".specify" / "scripts" / "powershell" / f"{script}.ps1"),
|
||||
*args,
|
||||
]
|
||||
|
||||
|
||||
def clean_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
for key in list(env):
|
||||
if key.startswith("SPECIFY_"):
|
||||
env.pop(key)
|
||||
return env
|
||||
|
||||
|
||||
def run(
|
||||
cmd: list[str], repo: Path, env: dict[str, str] | None = None
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env if env is not None else clean_env(),
|
||||
)
|
||||
|
||||
|
||||
def json_stdout(result: subprocess.CompletedProcess[str]) -> object:
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def write_feature_json(
|
||||
repo: Path, feature_directory: str = "specs/001-my-feature"
|
||||
) -> None:
|
||||
(repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_directory}, separators=(",", ":"))
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def normalize_repo_paths(text: str, repo: Path) -> str:
|
||||
"""Replace the repo path with a placeholder so two-repo runs compare equal."""
|
||||
repo_paths = sorted({str(repo), str(repo.resolve())}, key=len, reverse=True)
|
||||
for repo_path in repo_paths:
|
||||
text = text.replace(repo_path, "<REPO>")
|
||||
return text.replace("\r\n", "\n")
|
||||
|
||||
|
||||
def normalize_script_names(text: str, repo: Path, script: str) -> str:
|
||||
"""Replace per-runtime script paths (argv[0] in usage/help output)."""
|
||||
py_name = script.replace("-", "_")
|
||||
bash_script = str(repo / ".specify" / "scripts" / "bash" / f"{script}.sh")
|
||||
py_script = str(repo / ".specify" / "scripts" / "python" / f"{py_name}.py")
|
||||
return text.replace(bash_script, "<SCRIPT>").replace(py_script, "<SCRIPT>")
|
||||
|
||||
|
||||
def normalize_status_text(text: str) -> str:
|
||||
return (
|
||||
text.replace(" ✓ ", " [OK] ")
|
||||
.replace(" ✗ ", " [FAIL] ")
|
||||
.replace("\r\n", "\n")
|
||||
)
|
||||
@@ -296,6 +296,39 @@ def test_python_normal_mode_persists_feature_json(prereq_repo: Path) -> None:
|
||||
assert data["feature_directory"] == "specs/002-other"
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_persisted_feature_json_is_lexical_when_specs_is_symlink(
|
||||
prereq_repo: Path, tmp_path: Path
|
||||
) -> None:
|
||||
"""A symlinked specs/ dir must persist "specs/NNN" like Bash does with its
|
||||
lexical prefix strip — resolve() would escape the repo and store a
|
||||
machine-specific absolute path."""
|
||||
real_specs = tmp_path / "real-specs"
|
||||
feat = real_specs / "002-other"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
repo = prereq_repo.resolve()
|
||||
try:
|
||||
(repo / "specs").symlink_to(real_specs, target_is_directory=True)
|
||||
except OSError:
|
||||
pytest.skip("symlinks not supported on this platform")
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = str(repo / "specs" / "002-other")
|
||||
feature_json = repo / ".specify" / "feature.json"
|
||||
|
||||
bash = _run(_bash_cmd(prereq_repo, "--json"), prereq_repo, env=env)
|
||||
assert bash.returncode == 0, bash.stderr
|
||||
bash_persisted = json.loads(feature_json.read_text(encoding="utf-8"))
|
||||
feature_json.unlink()
|
||||
|
||||
py = _run(_py_cmd(prereq_repo, "--json"), prereq_repo, env=env)
|
||||
assert py.returncode == 0, py.stderr
|
||||
py_persisted = json.loads(feature_json.read_text(encoding="utf-8"))
|
||||
|
||||
assert py_persisted == bash_persisted
|
||||
assert py_persisted["feature_directory"] == "specs/002-other"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("args", "expected"),
|
||||
[
|
||||
|
||||
@@ -10,12 +10,18 @@ and ``process_template`` turns them into a valid Python invocation
|
||||
existence check below enforces that ordering.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
from tests.parity_helpers import HAS_POWERSHELL, POWERSHELL_EXE
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
TEMPLATES_DIR = REPO_ROOT / "templates" / "commands"
|
||||
@@ -77,6 +83,122 @@ def test_template_renders_python_invocation(name: str):
|
||||
), f"{name} did not render a Python invocation"
|
||||
|
||||
|
||||
def test_py_missing_variant_rejects_opposite_shell_only():
|
||||
opposite_variant = "sh" if os.name == "nt" else "ps"
|
||||
opposite_command = (
|
||||
"scripts/bash/setup-plan.sh --json"
|
||||
if opposite_variant == "sh"
|
||||
else "scripts/powershell/setup-plan.ps1 -Json"
|
||||
)
|
||||
content = """---
|
||||
scripts:
|
||||
{variant}: {command}
|
||||
---
|
||||
Run {{SCRIPT}} now.
|
||||
""".format(variant=opposite_variant, command=opposite_command)
|
||||
|
||||
with pytest.raises(ValueError, match="No runnable script variant"):
|
||||
IntegrationBase.process_template(content, "agent", "py")
|
||||
|
||||
|
||||
def test_missing_script_preference_keeps_available_shell(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.platform.system", lambda: "Windows"
|
||||
)
|
||||
|
||||
selected = IntegrationBase.select_script_variant(
|
||||
None, {"sh": "scripts/bash/setup-plan.sh --json"}
|
||||
)
|
||||
|
||||
assert selected == "sh"
|
||||
|
||||
|
||||
def test_spaced_python_interpreter_uses_powershell_call_operator(monkeypatch):
|
||||
interpreter = r"C:\Program Files\Py$thon's\python.exe"
|
||||
quoted_interpreter = interpreter.replace("'", "''")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable",
|
||||
interpreter,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.os", SimpleNamespace(name="nt")
|
||||
)
|
||||
|
||||
content = "---\nscripts:\n py: scripts/python/setup_plan.py --json\n---\n{SCRIPT}\n"
|
||||
result = IntegrationBase.process_template(content, "agent", "py")
|
||||
|
||||
assert (
|
||||
f"& '{quoted_interpreter}' "
|
||||
".specify/scripts/python/setup_plan.py --json"
|
||||
) in result
|
||||
|
||||
|
||||
def test_spaced_python_interpreter_uses_posix_shell_quoting(monkeypatch):
|
||||
interpreter = "/opt/Python $HOME's/bin/python"
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable",
|
||||
interpreter,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.os", SimpleNamespace(name="posix")
|
||||
)
|
||||
|
||||
content = "---\nscripts:\n py: scripts/python/setup_plan.py --json\n---\n{SCRIPT}\n"
|
||||
result = IntegrationBase.process_template(content, "agent", "py")
|
||||
|
||||
assert (
|
||||
f"{shlex.quote(interpreter)} "
|
||||
".specify/scripts/python/setup_plan.py --json"
|
||||
) in result
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_spaced_python_interpreter_invocation_runs_in_powershell(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
interpreter_dir = tmp_path / "Python With Spaces"
|
||||
interpreter_dir.mkdir()
|
||||
if os.name == "nt":
|
||||
interpreter = interpreter_dir / "python.cmd"
|
||||
interpreter.write_text(f'@"{sys.executable}" %*\n', encoding="utf-8")
|
||||
else:
|
||||
interpreter = interpreter_dir / "python"
|
||||
interpreter.write_text(
|
||||
f'#!/bin/sh\nexec "{sys.executable}" "$@"\n', encoding="utf-8"
|
||||
)
|
||||
interpreter.chmod(0o755)
|
||||
|
||||
(tmp_path / "probe.py").write_text("print('ok')\n", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable", str(interpreter)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.os", SimpleNamespace(name="nt")
|
||||
)
|
||||
|
||||
content = "---\nscripts:\n py: probe.py\n---\n{SCRIPT}\n"
|
||||
command = IntegrationBase.process_template(content, "agent", "py").splitlines()[-1]
|
||||
result = subprocess.run(
|
||||
[POWERSHELL_EXE, "-NoProfile", "-Command", command],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "ok"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", PY_TEMPLATES)
|
||||
def test_sh_rendering_unchanged(name: str):
|
||||
# Negative: adding py: lines must not leak into sh rendering.
|
||||
@@ -102,6 +224,9 @@ def test_install_shared_infra_copies_python_scripts(tmp_path):
|
||||
console=Console(quiet=True),
|
||||
force=False,
|
||||
)
|
||||
dest = tmp_path / ".specify" / "scripts" / "python"
|
||||
assert (dest / "check_prerequisites.py").is_file()
|
||||
assert not (tmp_path / ".specify" / "scripts" / "powershell").exists()
|
||||
scripts_dir = tmp_path / ".specify" / "scripts"
|
||||
assert (scripts_dir / "python" / "check_prerequisites.py").is_file()
|
||||
shell_variant = "powershell" if os.name == "nt" else "bash"
|
||||
other_variant = "bash" if os.name == "nt" else "powershell"
|
||||
assert (scripts_dir / shell_variant).is_dir()
|
||||
assert not (scripts_dir / other_variant).exists()
|
||||
|
||||
837
tests/test_create_new_feature_python_parity.py
Normal file
837
tests/test_create_new_feature_python_parity.py
Normal file
@@ -0,0 +1,837 @@
|
||||
"""Parity tests for the Python create-new-feature port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.python import create_new_feature
|
||||
from scripts.python.common import persist_feature_json
|
||||
from tests.conftest import requires_bash
|
||||
from tests.parity_helpers import (
|
||||
HAS_POWERSHELL,
|
||||
bash_cmd,
|
||||
install_scripts,
|
||||
json_stdout,
|
||||
make_repo,
|
||||
normalize_repo_paths,
|
||||
normalize_script_names,
|
||||
ps_cmd,
|
||||
py_cmd,
|
||||
run,
|
||||
)
|
||||
|
||||
SCRIPT = "create-new-feature"
|
||||
TEMPLATE_BODY = "# Spec Template\n\nBody.\n"
|
||||
|
||||
|
||||
def _setup_repo(tmp_path: Path, name: str = "proj") -> Path:
|
||||
repo = make_repo(tmp_path, name)
|
||||
install_scripts(repo, SCRIPT)
|
||||
templates = repo / ".specify" / "templates"
|
||||
templates.mkdir(parents=True)
|
||||
(templates / "spec-template.md").write_text(TEMPLATE_BODY, encoding="utf-8")
|
||||
return repo
|
||||
|
||||
|
||||
def _normalized_error_text(stderr: str, repo: Path) -> str:
|
||||
stderr = re.sub(r"\x1b\[[0-9;]*m", "", stderr)
|
||||
stderr = re.sub(r"(?m)^\s*\|\s?", "", stderr)
|
||||
stderr = normalize_repo_paths(stderr, repo).replace("-Number", "--number")
|
||||
return " ".join(stderr.split())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path: Path) -> Path:
|
||||
return _setup_repo(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo_pair(tmp_path: Path) -> tuple[Path, Path]:
|
||||
return _setup_repo(tmp_path, "proj-a"), _setup_repo(tmp_path, "proj-b")
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize(
|
||||
"description",
|
||||
[
|
||||
"Add user authentication system",
|
||||
"I want to add the new API rate limiting feature for users",
|
||||
"Fix UI for DB sync",
|
||||
"a to the of",
|
||||
],
|
||||
ids=["plain", "stop_words", "acronyms", "all_stop_words_fallback"],
|
||||
)
|
||||
def test_python_branch_name_generation_matches_bash(
|
||||
repo: Path, description: str
|
||||
) -> None:
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", description), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", description), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert json_stdout(py) == json_stdout(bash)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
("--json", "--dry-run", "--number", "7", "add rate limiting"),
|
||||
("--json", "--dry-run", "--number", "010", "add rate limiting"),
|
||||
],
|
||||
ids=["explicit_number", "leading_zero_number"],
|
||||
)
|
||||
def test_python_number_flag_matches_bash(repo: Path, args: tuple[str, ...]) -> None:
|
||||
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert json_stdout(py) == json_stdout(bash)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_sequential_numbering_matches_bash(repo: Path) -> None:
|
||||
for name in ("001-first", "0005-fourdigit", "20260101-120000-stamp", "12-short"):
|
||||
(repo / "specs" / name).mkdir(parents=True)
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", "add rate limiting"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", "add rate limiting"), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert json_stdout(py) == json_stdout(bash)
|
||||
assert json_stdout(py)["FEATURE_NUM"] == "006"
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_all_variants_timestamp_mode_match_shape(repo: Path) -> None:
|
||||
args = ("--json", "--dry-run", "--timestamp", "--short-name", "user-auth", "x")
|
||||
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
||||
results = [bash, py]
|
||||
if HAS_POWERSHELL:
|
||||
results.append(
|
||||
run(
|
||||
ps_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"-Json",
|
||||
"-DryRun",
|
||||
"-Timestamp",
|
||||
"-ShortName",
|
||||
"user-auth",
|
||||
"x",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
)
|
||||
|
||||
assert all(result.returncode == 0 for result in results)
|
||||
# Timestamps may straddle a second boundary, so compare shape and suffix.
|
||||
for result in results:
|
||||
data = json_stdout(result)
|
||||
assert re.fullmatch(r"\d{8}-\d{6}-user-auth", data["BRANCH_NAME"])
|
||||
assert data["BRANCH_NAME"].startswith(data["FEATURE_NUM"])
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_timestamp_number_warning_matches(repo: Path) -> None:
|
||||
args = (
|
||||
"--json",
|
||||
"--dry-run",
|
||||
"--timestamp",
|
||||
"--number",
|
||||
"5",
|
||||
"--short-name",
|
||||
"ua",
|
||||
"x",
|
||||
)
|
||||
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
||||
ps = run(
|
||||
ps_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"-Json",
|
||||
"-DryRun",
|
||||
"-Timestamp",
|
||||
"-Number",
|
||||
"5",
|
||||
"-ShortName",
|
||||
"ua",
|
||||
"x",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert json_stdout(ps)
|
||||
assert (
|
||||
py.stderr
|
||||
== bash.stderr
|
||||
== ps.stderr.replace("-Number", "--number").replace(
|
||||
"-Timestamp", "--timestamp"
|
||||
)
|
||||
== "[specify] Warning: --number is ignored when --timestamp is used\n"
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_invalid_number_fails_cleanly(repo: Path) -> None:
|
||||
args = ("--json", "--dry-run", "--number", "abc", "add rate limiting")
|
||||
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
||||
ps = run(
|
||||
ps_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"-Json",
|
||||
"-DryRun",
|
||||
"-Number",
|
||||
"abc",
|
||||
"add rate limiting",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
expected = "Error: --number must be an unsigned integer, got 'abc'"
|
||||
for result in (bash, ps, py):
|
||||
assert expected in _normalized_error_text(result.stderr, repo)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_negative_number_fails_cleanly(repo: Path) -> None:
|
||||
args = ("--json", "--dry-run", "--number", "-1", "add rate limiting")
|
||||
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
||||
ps = run(
|
||||
ps_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"-Json",
|
||||
"-DryRun",
|
||||
"-Number",
|
||||
"-1",
|
||||
"add rate limiting",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
expected = "Error: --number must be an unsigned integer, got '-1'"
|
||||
for result in (bash, ps, py):
|
||||
assert expected in _normalized_error_text(result.stderr, repo)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
@pytest.mark.parametrize("digit_count", [244, 5000])
|
||||
def test_all_variants_oversized_number_fails_cleanly(
|
||||
repo: Path, digit_count: int
|
||||
) -> None:
|
||||
number = "9" * digit_count
|
||||
bash = run(
|
||||
bash_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"--json",
|
||||
"--dry-run",
|
||||
"--number",
|
||||
number,
|
||||
"add rate limiting",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
ps = run(
|
||||
ps_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"-Json",
|
||||
"-DryRun",
|
||||
"-Number",
|
||||
number,
|
||||
"add rate limiting",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
py = run(
|
||||
py_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"--json",
|
||||
"--dry-run",
|
||||
"--number",
|
||||
number,
|
||||
"add rate limiting",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
expected = (
|
||||
f"Error: --number must be between 0 and {2**63 - 1}, got '{number}'"
|
||||
)
|
||||
for result in (bash, ps, py):
|
||||
assert expected in _normalized_error_text(result.stderr, repo)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_branch_truncation_match(repo: Path) -> None:
|
||||
args = ("--json", "--dry-run", "--short-name", "a" * 300, "x")
|
||||
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
||||
ps = run(
|
||||
ps_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"-Json",
|
||||
"-DryRun",
|
||||
"-ShortName",
|
||||
"a" * 300,
|
||||
"x",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert bash.stderr == ps.stderr == py.stderr
|
||||
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
||||
assert len(json_stdout(py)["BRANCH_NAME"]) == 244
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_full_run_matches_bash(repo_pair: tuple[Path, Path]) -> None:
|
||||
repo_a, repo_b = repo_pair
|
||||
description = "Add user authentication system"
|
||||
|
||||
bash = run(bash_cmd(repo_a, SCRIPT, "--json", description), repo_a)
|
||||
py = run(py_cmd(repo_b, SCRIPT, "--json", description), repo_b)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert normalize_repo_paths(py.stdout, repo_b) == normalize_repo_paths(
|
||||
bash.stdout, repo_a
|
||||
)
|
||||
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
|
||||
bash.stderr, repo_a
|
||||
)
|
||||
|
||||
branch = json_stdout(py)["BRANCH_NAME"]
|
||||
for repo in repo_pair:
|
||||
spec = repo / "specs" / branch / "spec.md"
|
||||
assert spec.read_text(encoding="utf-8") == TEMPLATE_BODY
|
||||
assert (repo_b / ".specify" / "feature.json").read_bytes() == (
|
||||
repo_a / ".specify" / "feature.json"
|
||||
).read_bytes()
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_missing_template_warning_matches_bash(
|
||||
repo_pair: tuple[Path, Path],
|
||||
) -> None:
|
||||
repo_a, repo_b = repo_pair
|
||||
for repo in repo_pair:
|
||||
(repo / ".specify" / "templates" / "spec-template.md").unlink()
|
||||
|
||||
bash = run(bash_cmd(repo_a, SCRIPT, "--json", "add rate limiting"), repo_a)
|
||||
py = run(py_cmd(repo_b, SCRIPT, "--json", "add rate limiting"), repo_b)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
|
||||
bash.stderr, repo_a
|
||||
)
|
||||
branch = json_stdout(py)["BRANCH_NAME"]
|
||||
for repo in repo_pair:
|
||||
assert (repo / "specs" / branch / "spec.md").read_text(encoding="utf-8") == ""
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_existing_directory_error_matches_bash(
|
||||
repo_pair: tuple[Path, Path],
|
||||
) -> None:
|
||||
repo_a, repo_b = repo_pair
|
||||
description = "add rate limiting"
|
||||
|
||||
assert (
|
||||
run(
|
||||
bash_cmd(repo_a, SCRIPT, "--json", "--number", "1", description), repo_a
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
assert (
|
||||
run(
|
||||
py_cmd(repo_b, SCRIPT, "--json", "--number", "1", description), repo_b
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
|
||||
bash = run(bash_cmd(repo_a, SCRIPT, "--json", "--number", "1", description), repo_a)
|
||||
py = run(py_cmd(repo_b, SCRIPT, "--json", "--number", "1", description), repo_b)
|
||||
|
||||
assert py.returncode == bash.returncode == 1
|
||||
assert py.stdout == bash.stdout == ""
|
||||
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
|
||||
bash.stderr, repo_a
|
||||
)
|
||||
|
||||
bash_retry = run(
|
||||
bash_cmd(
|
||||
repo_a,
|
||||
SCRIPT,
|
||||
"--json",
|
||||
"--number",
|
||||
"1",
|
||||
"--allow-existing-branch",
|
||||
description,
|
||||
),
|
||||
repo_a,
|
||||
)
|
||||
py_retry = run(
|
||||
py_cmd(
|
||||
repo_b,
|
||||
SCRIPT,
|
||||
"--json",
|
||||
"--number",
|
||||
"1",
|
||||
"--allow-existing-branch",
|
||||
description,
|
||||
),
|
||||
repo_b,
|
||||
)
|
||||
assert py_retry.returncode == bash_retry.returncode == 0
|
||||
assert normalize_repo_paths(py_retry.stdout, repo_b) == normalize_repo_paths(
|
||||
bash_retry.stdout, repo_a
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
(),
|
||||
(" ",),
|
||||
("--short-name",),
|
||||
("--number",),
|
||||
],
|
||||
ids=["missing_description", "whitespace_description", "short_name_no_value", "number_no_value"],
|
||||
)
|
||||
def test_python_argument_errors_match_bash(repo: Path, args: tuple[str, ...]) -> None:
|
||||
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 1
|
||||
assert py.stdout == bash.stdout == ""
|
||||
assert normalize_script_names(py.stderr, repo, SCRIPT) == normalize_script_names(
|
||||
bash.stderr, repo, SCRIPT
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_help_matches_bash(repo: Path) -> None:
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--help"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--help"), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert normalize_script_names(py.stdout, repo, SCRIPT) == normalize_script_names(
|
||||
bash.stdout, repo, SCRIPT
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_persists_relative_feature_json(repo: Path) -> None:
|
||||
py = run(py_cmd(repo, SCRIPT, "--json", "add rate limiting"), repo)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
branch = json_stdout(py)["BRANCH_NAME"]
|
||||
feature_json = (repo / ".specify" / "feature.json").read_text(encoding="utf-8")
|
||||
assert feature_json == f'{{"feature_directory":"specs/{branch}"}}\n'
|
||||
|
||||
|
||||
def test_persist_feature_json_avoids_platform_newline_translation(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
def windows_write_text(path: Path, data: str, **kwargs) -> int:
|
||||
encoding = kwargs.get("encoding") or "utf-8"
|
||||
return path.write_bytes(data.replace("\n", "\r\n").encode(encoding))
|
||||
|
||||
monkeypatch.setattr(Path, "write_text", windows_write_text)
|
||||
|
||||
persist_feature_json(tmp_path, "specs/001-test")
|
||||
|
||||
assert (tmp_path / ".specify" / "feature.json").read_bytes() == (
|
||||
b'{"feature_directory":"specs/001-test"}\n'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
@pytest.mark.parametrize(
|
||||
("py_args", "ps_args"),
|
||||
[
|
||||
(
|
||||
("--json", "--dry-run", "Add user authentication system"),
|
||||
("-Json", "-DryRun", "Add user authentication system"),
|
||||
),
|
||||
(
|
||||
("--json", "--dry-run", "--short-name", "My Fancy Name", "x"),
|
||||
("-Json", "-DryRun", "-ShortName", "My Fancy Name", "x"),
|
||||
),
|
||||
(
|
||||
("--json", "--dry-run", "--number", "7", "add rate limiting"),
|
||||
("-Json", "-DryRun", "-Number", "7", "add rate limiting"),
|
||||
),
|
||||
],
|
||||
ids=["plain", "short_name", "number"],
|
||||
)
|
||||
def test_python_json_output_matches_powershell(
|
||||
repo: Path, py_args: tuple[str, ...], ps_args: tuple[str, ...]
|
||||
) -> None:
|
||||
ps = run(ps_cmd(repo, SCRIPT, *ps_args), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, *py_args), repo)
|
||||
|
||||
assert py.returncode == ps.returncode == 0
|
||||
assert json_stdout(py) == json_stdout(ps)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
@pytest.mark.parametrize("number", ["-1", "+1"], ids=["negative", "positive_sign"])
|
||||
def test_all_variants_reject_signed_number(repo: Path, number: str) -> None:
|
||||
bash = run(
|
||||
bash_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", number, "x"),
|
||||
repo,
|
||||
)
|
||||
ps = run(
|
||||
ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "-Number", number, "x"),
|
||||
repo,
|
||||
)
|
||||
py = run(
|
||||
py_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", number, "x"),
|
||||
repo,
|
||||
)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
expected = f"Error: --number must be an unsigned integer, got '{number}'"
|
||||
for result in (bash, ps, py):
|
||||
assert expected in _normalized_error_text(result.stderr, repo)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
@pytest.mark.parametrize("timestamp", [False, True], ids=["numbered", "timestamp"])
|
||||
def test_all_variants_treat_empty_number_as_omitted(
|
||||
repo: Path, timestamp: bool
|
||||
) -> None:
|
||||
bash_args = ["--json", "--dry-run", "--number", ""]
|
||||
ps_args = ["-Json", "-DryRun", "-Number", ""]
|
||||
py_args = ["--json", "--dry-run", "--number", ""]
|
||||
if timestamp:
|
||||
bash_args.append("--timestamp")
|
||||
ps_args.append("-Timestamp")
|
||||
py_args.append("--timestamp")
|
||||
bash_args.append("x")
|
||||
ps_args.append("x")
|
||||
py_args.append("x")
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, *bash_args), repo)
|
||||
ps = run(ps_cmd(repo, SCRIPT, *ps_args), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, *py_args), repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert bash.stderr == ps.stderr == py.stderr == ""
|
||||
if not timestamp:
|
||||
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
@pytest.mark.parametrize(
|
||||
("number", "returncode"),
|
||||
[
|
||||
(str(2**63 - 1), 0),
|
||||
(str(2**63), 1),
|
||||
],
|
||||
ids=["int64_max", "int64_overflow"],
|
||||
)
|
||||
def test_all_variants_share_int64_number_range(
|
||||
repo: Path, number: str, returncode: int
|
||||
) -> None:
|
||||
bash = run(
|
||||
bash_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", number, "x"),
|
||||
repo,
|
||||
)
|
||||
ps = run(
|
||||
ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "-Number", number, "x"),
|
||||
repo,
|
||||
)
|
||||
py = run(
|
||||
py_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", number, "x"),
|
||||
repo,
|
||||
)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == returncode
|
||||
if returncode == 0:
|
||||
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
||||
else:
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
expected = f"Error: --number must be between 0 and {2**63 - 1}, got '{number}'"
|
||||
for result in (bash, ps, py):
|
||||
assert expected in _normalized_error_text(result.stderr, repo)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_reject_exhausted_auto_number_range(repo: Path) -> None:
|
||||
(repo / "specs" / f"{2**63 - 1}-existing").mkdir(parents=True)
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", "x"), repo)
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "x"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", "x"), repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
expected = f"Error: feature number must be between 0 and {2**63 - 1}, got '{2**63}'"
|
||||
for result in (bash, ps, py):
|
||||
assert expected in _normalized_error_text(result.stderr, repo)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
@pytest.mark.parametrize("prefix", [2**63, 2**64 + 5])
|
||||
def test_all_variants_ignore_out_of_range_existing_prefix(
|
||||
repo: Path, prefix: int
|
||||
) -> None:
|
||||
(repo / "specs" / f"{prefix}-existing").mkdir(parents=True)
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", "x"), repo)
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "x"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", "x"), repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
||||
assert json_stdout(py)["FEATURE_NUM"] == "001"
|
||||
|
||||
|
||||
def test_python_ignores_unconvertibly_large_existing_prefix() -> None:
|
||||
class Entry:
|
||||
name = f"{'9' * 5000}-existing"
|
||||
|
||||
@staticmethod
|
||||
def is_dir() -> bool:
|
||||
return True
|
||||
|
||||
class SpecsDir:
|
||||
@staticmethod
|
||||
def is_dir() -> bool:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def iterdir() -> list[Entry]:
|
||||
return [Entry()]
|
||||
|
||||
assert create_new_feature._get_highest_from_specs(SpecsDir()) == 0
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_text_mode_match(repo: Path) -> None:
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--dry-run", "--number", "7", "x"), repo)
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-DryRun", "-Number", "7", "x"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--dry-run", "--number", "7", "x"), repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert bash.stderr == ps.stderr == py.stderr == ""
|
||||
assert (
|
||||
normalize_repo_paths(bash.stdout, repo)
|
||||
== normalize_repo_paths(ps.stdout, repo)
|
||||
== normalize_repo_paths(py.stdout, repo)
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_non_dry_text_mode_match(tmp_path: Path) -> None:
|
||||
bash_repo = _setup_repo(tmp_path, "bash")
|
||||
ps_repo = _setup_repo(tmp_path, "powershell")
|
||||
py_repo = _setup_repo(tmp_path, "python")
|
||||
|
||||
bash = run(
|
||||
bash_cmd(bash_repo, SCRIPT, "--number", "7", "x"), bash_repo
|
||||
)
|
||||
ps = run(
|
||||
ps_cmd(ps_repo, SCRIPT, "-Number", "7", "x"), ps_repo
|
||||
)
|
||||
py = run(py_cmd(py_repo, SCRIPT, "--number", "7", "x"), py_repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert (
|
||||
normalize_repo_paths(bash.stdout, bash_repo)
|
||||
== normalize_repo_paths(py.stdout, py_repo)
|
||||
)
|
||||
assert (
|
||||
normalize_repo_paths(bash.stderr, bash_repo)
|
||||
== normalize_repo_paths(py.stderr, py_repo)
|
||||
)
|
||||
ps_stdout = normalize_repo_paths(ps.stdout, ps_repo)
|
||||
ps_stderr = normalize_repo_paths(ps.stderr, ps_repo)
|
||||
assert "$env:SPECIFY_FEATURE = '007-x'" in ps_stdout
|
||||
assert (
|
||||
"$env:SPECIFY_FEATURE_DIRECTORY = '<REPO>/specs/007-x'" in ps_stdout
|
||||
)
|
||||
assert "$env:SPECIFY_FEATURE = '007-x'" in ps_stderr
|
||||
assert (
|
||||
"$env:SPECIFY_FEATURE_DIRECTORY = '<REPO>/specs/007-x'" in ps_stderr
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_persist_hints_match_bash_for_spaced_repo_path(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Paths with spaces must be quoted identically (shlex.quote format) so
|
||||
the side-by-side text/stderr comparison holds."""
|
||||
bash_repo = _setup_repo(tmp_path, "my proj a")
|
||||
py_repo = _setup_repo(tmp_path, "my proj b")
|
||||
|
||||
bash = run(bash_cmd(bash_repo, SCRIPT, "--number", "7", "x"), bash_repo)
|
||||
py = run(py_cmd(py_repo, SCRIPT, "--number", "7", "x"), py_repo)
|
||||
|
||||
assert bash.returncode == py.returncode == 0, bash.stderr + py.stderr
|
||||
assert normalize_repo_paths(bash.stdout, bash_repo) == normalize_repo_paths(
|
||||
py.stdout, py_repo
|
||||
)
|
||||
assert normalize_repo_paths(bash.stderr, bash_repo) == normalize_repo_paths(
|
||||
py.stderr, py_repo
|
||||
)
|
||||
assert "export SPECIFY_FEATURE_DIRECTORY='<REPO>/specs/007-x'" in (
|
||||
normalize_repo_paths(py.stderr, py_repo)
|
||||
)
|
||||
|
||||
|
||||
def test_python_powershell_persistence_assignments_escape_quotes() -> None:
|
||||
assert create_new_feature._persistence_assignments(
|
||||
"007-x", r"C:\repo\O'Brien", powershell=True
|
||||
) == (
|
||||
"$env:SPECIFY_FEATURE = '007-x'",
|
||||
"$env:SPECIFY_FEATURE_DIRECTORY = 'C:\\repo\\O''Brien'",
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_persist_symlinked_specs_path_lexically(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repos = [
|
||||
_setup_repo(tmp_path, "bash"),
|
||||
_setup_repo(tmp_path, "powershell"),
|
||||
_setup_repo(tmp_path, "python"),
|
||||
]
|
||||
for current in repos:
|
||||
specs_target = tmp_path / f"{current.name}-specs"
|
||||
specs_target.mkdir()
|
||||
try:
|
||||
(current / "specs").symlink_to(
|
||||
specs_target, target_is_directory=True
|
||||
)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("Symlinks are not available in this environment")
|
||||
|
||||
bash = run(
|
||||
bash_cmd(repos[0], SCRIPT, "--json", "--number", "7", "x"),
|
||||
repos[0],
|
||||
)
|
||||
ps = run(
|
||||
ps_cmd(repos[1], SCRIPT, "-Json", "-Number", "7", "x"),
|
||||
repos[1],
|
||||
)
|
||||
py = run(
|
||||
py_cmd(repos[2], SCRIPT, "--json", "--number", "7", "x"),
|
||||
repos[2],
|
||||
)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
expected = '{"feature_directory":"specs/007-x"}'
|
||||
for current in repos:
|
||||
assert (
|
||||
current / ".specify" / "feature.json"
|
||||
).read_text(encoding="utf-8").strip() == expected
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_allow_existing_branch(repo: Path) -> None:
|
||||
feature_dir = repo / "specs" / "001-x"
|
||||
feature_dir.mkdir(parents=True)
|
||||
spec_file = feature_dir / "spec.md"
|
||||
spec_file.write_text("existing\n", encoding="utf-8")
|
||||
|
||||
bash = run(
|
||||
bash_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"--json",
|
||||
"--number",
|
||||
"1",
|
||||
"--allow-existing-branch",
|
||||
"x",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
ps = run(
|
||||
ps_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"-Json",
|
||||
"-Number",
|
||||
"1",
|
||||
"-AllowExistingBranch",
|
||||
"x",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
py = run(
|
||||
py_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"--json",
|
||||
"--number",
|
||||
"1",
|
||||
"--allow-existing-branch",
|
||||
"x",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
||||
assert spec_file.read_text(encoding="utf-8") == "existing\n"
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_existing_directory_failure_diagnostics(repo: Path) -> None:
|
||||
(repo / "specs" / "001-x").mkdir(parents=True)
|
||||
expected = (
|
||||
"Error: Feature directory '<REPO>/specs/001-x' already exists. "
|
||||
"Please use a different feature name or specify a different number "
|
||||
"with --number."
|
||||
)
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json", "--number", "1", "x"), repo)
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-Json", "-Number", "1", "x"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json", "--number", "1", "x"), repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
for result in (bash, ps, py):
|
||||
assert expected in _normalized_error_text(result.stderr, repo)
|
||||
@@ -3118,7 +3118,9 @@ Run {SCRIPT}
|
||||
"""Without init metadata, Windows fallback should prefer ps scripts over sh."""
|
||||
import yaml
|
||||
|
||||
monkeypatch.setattr("specify_cli.agents.platform.system", lambda: "Windows")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.platform.system", lambda: "Windows"
|
||||
)
|
||||
|
||||
ext_dir = temp_dir / "ext-script-windows-fallback"
|
||||
ext_dir.mkdir()
|
||||
|
||||
316
tests/test_setup_plan_python_parity.py
Normal file
316
tests/test_setup_plan_python_parity.py
Normal file
@@ -0,0 +1,316 @@
|
||||
"""Parity tests for the Python setup-plan port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
from tests.parity_helpers import (
|
||||
HAS_POWERSHELL,
|
||||
POWERSHELL_EXE,
|
||||
bash_cmd,
|
||||
clean_env,
|
||||
install_scripts,
|
||||
json_stdout,
|
||||
make_repo,
|
||||
normalize_repo_paths,
|
||||
ps_cmd,
|
||||
py_cmd,
|
||||
run,
|
||||
write_feature_json,
|
||||
)
|
||||
|
||||
SCRIPT = "setup-plan"
|
||||
TEMPLATE_BODY = "# Plan Template\n\nBody.\n"
|
||||
|
||||
|
||||
def _setup_repo(tmp_path: Path, name: str = "proj", template: bool = True) -> Path:
|
||||
repo = make_repo(tmp_path, name)
|
||||
install_scripts(repo, SCRIPT)
|
||||
write_feature_json(repo)
|
||||
(repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
if template:
|
||||
templates = repo / ".specify" / "templates"
|
||||
templates.mkdir(parents=True)
|
||||
(templates / "plan-template.md").write_text(TEMPLATE_BODY, encoding="utf-8")
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path: Path) -> Path:
|
||||
return _setup_repo(tmp_path)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_fresh_copy_matches_bash(tmp_path: Path) -> None:
|
||||
repo_a = _setup_repo(tmp_path, "proj-a")
|
||||
repo_b = _setup_repo(tmp_path, "proj-b")
|
||||
|
||||
bash = run(bash_cmd(repo_a, SCRIPT, "--json"), repo_a)
|
||||
py = run(py_cmd(repo_b, SCRIPT, "--json"), repo_b)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert normalize_repo_paths(py.stdout, repo_b) == normalize_repo_paths(
|
||||
bash.stdout, repo_a
|
||||
)
|
||||
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
|
||||
bash.stderr, repo_a
|
||||
)
|
||||
for repo in (repo_a, repo_b):
|
||||
plan = repo / "specs" / "001-my-feature" / "plan.md"
|
||||
assert plan.read_text(encoding="utf-8") == TEMPLATE_BODY
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize("args", [("--json",), ()], ids=["json", "text"])
|
||||
def test_python_existing_plan_matches_bash(repo: Path, args: tuple[str, ...]) -> None:
|
||||
plan = repo / "specs" / "001-my-feature" / "plan.md"
|
||||
plan.write_text("# existing\n", encoding="utf-8")
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stdout == bash.stdout
|
||||
assert py.stderr == bash.stderr
|
||||
assert plan.read_text(encoding="utf-8") == "# existing\n"
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_ignore_extra_arguments(tmp_path: Path) -> None:
|
||||
repos = [
|
||||
_setup_repo(tmp_path, "bash"),
|
||||
_setup_repo(tmp_path, "powershell"),
|
||||
_setup_repo(tmp_path, "python"),
|
||||
]
|
||||
|
||||
bash = run(bash_cmd(repos[0], SCRIPT, "--json", "--bogus"), repos[0])
|
||||
ps = run(ps_cmd(repos[1], SCRIPT, "-Json", "--bogus"), repos[1])
|
||||
py = run(py_cmd(repos[2], SCRIPT, "--json", "--bogus"), repos[2])
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert normalize_repo_paths(bash.stdout, repos[0]) == normalize_repo_paths(
|
||||
ps.stdout, repos[1]
|
||||
) == normalize_repo_paths(py.stdout, repos[2])
|
||||
assert normalize_repo_paths(bash.stderr, repos[0]) == normalize_repo_paths(
|
||||
ps.stderr, repos[1]
|
||||
) == normalize_repo_paths(py.stderr, repos[2])
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_missing_template_matches_bash(tmp_path: Path) -> None:
|
||||
repo_a = _setup_repo(tmp_path, "proj-a", template=False)
|
||||
repo_b = _setup_repo(tmp_path, "proj-b", template=False)
|
||||
|
||||
bash = run(bash_cmd(repo_a, SCRIPT, "--json"), repo_a)
|
||||
py = run(py_cmd(repo_b, SCRIPT, "--json"), repo_b)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
|
||||
bash.stderr, repo_a
|
||||
)
|
||||
for repo in (repo_a, repo_b):
|
||||
plan = repo / "specs" / "001-my-feature" / "plan.md"
|
||||
assert plan.read_text(encoding="utf-8") == ""
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize(
|
||||
"registry",
|
||||
[
|
||||
'{"presets": {"alpha": {"priority": "high"}, "beta": {"priority": 1}}}',
|
||||
'{"presets": {"alpha": {"priority": 2}, "beta": {"priority": 1}, "gamma": {"priority": null}}}',
|
||||
"[]",
|
||||
'{"presets":[]}',
|
||||
'{"presets":null}',
|
||||
],
|
||||
ids=[
|
||||
"mixed_priorities",
|
||||
"null_priority",
|
||||
"list_root",
|
||||
"list_presets",
|
||||
"null_presets",
|
||||
],
|
||||
)
|
||||
def test_all_variants_broken_registry_falls_back_to_dir_scan(
|
||||
tmp_path: Path, registry: str
|
||||
) -> None:
|
||||
"""Malformed registries fall back to the alphabetical directory scan."""
|
||||
repos = [
|
||||
_setup_repo(tmp_path, "bash", template=False),
|
||||
_setup_repo(tmp_path, "powershell", template=False),
|
||||
_setup_repo(tmp_path, "python", template=False),
|
||||
]
|
||||
for repo in repos:
|
||||
presets = repo / ".specify" / "presets"
|
||||
for name, body in (
|
||||
(".hidden", "# hidden\n"),
|
||||
("beta", "# beta plan\n"),
|
||||
("alpha", "# alpha plan\n"),
|
||||
):
|
||||
(presets / name / "templates").mkdir(parents=True)
|
||||
(presets / name / "templates" / "plan-template.md").write_text(
|
||||
body, encoding="utf-8"
|
||||
)
|
||||
(presets / ".registry").write_text(
|
||||
registry, encoding="utf-8"
|
||||
)
|
||||
|
||||
bash = run(bash_cmd(repos[0], SCRIPT, "--json"), repos[0])
|
||||
py = run(py_cmd(repos[2], SCRIPT, "--json"), repos[2])
|
||||
results = [(bash, repos[0]), (py, repos[2])]
|
||||
if HAS_POWERSHELL:
|
||||
results.insert(
|
||||
1,
|
||||
(run(ps_cmd(repos[1], SCRIPT, "-Json"), repos[1]), repos[1]),
|
||||
)
|
||||
|
||||
assert all(result.returncode == 0 for result, _ in results)
|
||||
assert len(
|
||||
{
|
||||
normalize_repo_paths(result.stdout, repo)
|
||||
for result, repo in results
|
||||
}
|
||||
) == 1
|
||||
assert len(
|
||||
{
|
||||
normalize_repo_paths(result.stderr, repo)
|
||||
for result, repo in results
|
||||
}
|
||||
) == 1
|
||||
for _, repo in results:
|
||||
plan = repo / "specs" / "001-my-feature" / "plan.md"
|
||||
assert plan.read_text(encoding="utf-8") == "# alpha plan\n"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_powershell_broken_registry_fallback_sorts_directories(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repo = _setup_repo(tmp_path, "powershell", template=False)
|
||||
presets = repo / ".specify" / "presets"
|
||||
for name in ("alpha", "beta"):
|
||||
templates = presets / name / "templates"
|
||||
templates.mkdir(parents=True)
|
||||
(templates / "plan-template.md").write_text(
|
||||
f"# {name} plan\n", encoding="utf-8"
|
||||
)
|
||||
(presets / ".registry").write_text("{broken", encoding="utf-8")
|
||||
|
||||
common = repo / ".specify" / "scripts" / "powershell" / "common.ps1"
|
||||
common_ps = str(common).replace("'", "''")
|
||||
alpha_ps = str(presets / "alpha").replace("'", "''")
|
||||
beta_ps = str(presets / "beta").replace("'", "''")
|
||||
repo_ps = str(repo).replace("'", "''")
|
||||
command = f"""
|
||||
. '{common_ps}'
|
||||
function Get-ChildItem {{
|
||||
@(
|
||||
[PSCustomObject]@{{ Name = 'beta'; FullName = '{beta_ps}' }}
|
||||
[PSCustomObject]@{{ Name = 'alpha'; FullName = '{alpha_ps}' }}
|
||||
)
|
||||
}}
|
||||
Resolve-Template -TemplateName 'plan-template' -RepoRoot '{repo_ps}'
|
||||
"""
|
||||
result = run(
|
||||
[POWERSHELL_EXE, "-NoProfile", "-Command", command],
|
||||
repo,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stderr == ""
|
||||
assert Path(result.stdout.strip()).read_text(encoding="utf-8") == (
|
||||
"# alpha plan\n"
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
@pytest.mark.parametrize(
|
||||
"context", ["missing", "invalid_json", "invalid_utf8", "invalid_init_dir"]
|
||||
)
|
||||
def test_all_variants_feature_context_error_matches(
|
||||
tmp_path: Path, context: str
|
||||
) -> None:
|
||||
repo = make_repo(tmp_path)
|
||||
install_scripts(repo, SCRIPT)
|
||||
env = None
|
||||
if context == "invalid_json":
|
||||
(repo / ".specify" / "feature.json").write_text(
|
||||
"{not json", encoding="utf-8"
|
||||
)
|
||||
elif context == "invalid_utf8":
|
||||
(repo / ".specify" / "feature.json").write_bytes(b"\xff")
|
||||
elif context == "invalid_init_dir":
|
||||
env = clean_env()
|
||||
env["SPECIFY_INIT_DIR"] = str(tmp_path / "missing")
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json"), repo, env)
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-Json"), repo, env)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json"), repo, env)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
assert bash.stderr == ps.stderr == py.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
@pytest.mark.parametrize(
|
||||
"registry",
|
||||
[
|
||||
'{"presets":{"alpha":{"enabled":false,"priority":1}}}',
|
||||
'{"presets":{"alpha":"invalid"}}',
|
||||
],
|
||||
ids=["disabled", "invalid_metadata"],
|
||||
)
|
||||
def test_all_variants_ignore_inactive_preset_template(
|
||||
tmp_path: Path, registry: str
|
||||
) -> None:
|
||||
repos = [
|
||||
_setup_repo(tmp_path, "bash"),
|
||||
_setup_repo(tmp_path, "powershell"),
|
||||
_setup_repo(tmp_path, "python"),
|
||||
]
|
||||
for current in repos:
|
||||
preset_templates = (
|
||||
current / ".specify" / "presets" / "alpha" / "templates"
|
||||
)
|
||||
preset_templates.mkdir(parents=True)
|
||||
(preset_templates / "plan-template.md").write_text(
|
||||
"# Disabled preset\n", encoding="utf-8"
|
||||
)
|
||||
(current / ".specify" / "presets" / ".registry").write_text(
|
||||
registry, encoding="utf-8"
|
||||
)
|
||||
|
||||
bash = run(bash_cmd(repos[0], SCRIPT, "--json"), repos[0])
|
||||
ps = run(ps_cmd(repos[1], SCRIPT, "-Json"), repos[1])
|
||||
py = run(py_cmd(repos[2], SCRIPT, "--json"), repos[2])
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert normalize_repo_paths(bash.stdout, repos[0]) == normalize_repo_paths(
|
||||
ps.stdout, repos[1]
|
||||
) == normalize_repo_paths(py.stdout, repos[2])
|
||||
assert normalize_repo_paths(bash.stderr, repos[0]) == normalize_repo_paths(
|
||||
ps.stderr, repos[1]
|
||||
) == normalize_repo_paths(py.stderr, repos[2])
|
||||
for current in repos:
|
||||
assert (
|
||||
current / "specs" / "001-my-feature" / "plan.md"
|
||||
).read_text(encoding="utf-8") == TEMPLATE_BODY
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_python_json_output_matches_powershell(repo: Path) -> None:
|
||||
plan = repo / "specs" / "001-my-feature" / "plan.md"
|
||||
plan.write_text("# existing\n", encoding="utf-8")
|
||||
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-Json"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json"), repo)
|
||||
|
||||
assert py.returncode == ps.returncode == 0
|
||||
assert json_stdout(py) == json_stdout(ps)
|
||||
207
tests/test_setup_tasks_python_parity.py
Normal file
207
tests/test_setup_tasks_python_parity.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""Parity tests for the Python setup-tasks port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
from tests.parity_helpers import (
|
||||
HAS_POWERSHELL,
|
||||
bash_cmd,
|
||||
clean_env,
|
||||
install_scripts,
|
||||
json_stdout,
|
||||
make_repo,
|
||||
normalize_status_text,
|
||||
ps_cmd,
|
||||
py_cmd,
|
||||
run,
|
||||
write_feature_json,
|
||||
)
|
||||
|
||||
SCRIPT = "setup-tasks"
|
||||
|
||||
|
||||
def _setup_repo(tmp_path: Path) -> Path:
|
||||
repo = make_repo(tmp_path)
|
||||
install_scripts(repo, SCRIPT)
|
||||
write_feature_json(repo)
|
||||
feature = repo / "specs" / "001-my-feature"
|
||||
feature.mkdir(parents=True)
|
||||
(feature / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
(feature / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
templates = repo / ".specify" / "templates"
|
||||
templates.mkdir(parents=True)
|
||||
(templates / "tasks-template.md").write_text("# Tasks Template\n", encoding="utf-8")
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path: Path) -> Path:
|
||||
return _setup_repo(tmp_path)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_json_output_matches_bash(repo: Path) -> None:
|
||||
feature = repo / "specs" / "001-my-feature"
|
||||
(feature / "research.md").write_text("# research\n", encoding="utf-8")
|
||||
(feature / "data-model.md").write_text("# model\n", encoding="utf-8")
|
||||
(feature / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
|
||||
(feature / "contracts" / "v1").mkdir(parents=True)
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json"), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert json_stdout(py) == json_stdout(bash)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_text_output_matches_bash(repo: Path) -> None:
|
||||
feature = repo / "specs" / "001-my-feature"
|
||||
(feature / "research.md").write_text("# research\n", encoding="utf-8")
|
||||
(feature / "contracts").mkdir() # present but empty -> reported missing
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT), repo)
|
||||
py = run(py_cmd(repo, SCRIPT), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert normalize_status_text(py.stdout) == normalize_status_text(bash.stdout)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_override_template_wins_matches_bash(repo: Path) -> None:
|
||||
overrides = repo / ".specify" / "templates" / "overrides"
|
||||
overrides.mkdir(parents=True)
|
||||
(overrides / "tasks-template.md").write_text("# Override\n", encoding="utf-8")
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json"), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert json_stdout(py) == json_stdout(bash)
|
||||
assert json_stdout(py)["TASKS_TEMPLATE"].endswith("overrides/tasks-template.md")
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize(
|
||||
"missing",
|
||||
["plan.md", "spec.md", "tasks-template"],
|
||||
ids=["missing_plan", "missing_spec", "missing_tasks_template"],
|
||||
)
|
||||
def test_python_error_output_matches_bash(repo: Path, missing: str) -> None:
|
||||
if missing == "tasks-template":
|
||||
(repo / ".specify" / "templates" / "tasks-template.md").unlink()
|
||||
else:
|
||||
(repo / "specs" / "001-my-feature" / missing).unlink()
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json"), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 1
|
||||
assert py.stdout == bash.stdout == ""
|
||||
assert py.stderr == bash.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_unknown_option_matches_bash(repo: Path) -> None:
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--bogus"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--bogus"), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 1
|
||||
assert py.stdout == bash.stdout == ""
|
||||
assert py.stderr == bash.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_powershell_unknown_option_matches_siblings(repo: Path) -> None:
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--bogus"), repo)
|
||||
ps = run(ps_cmd(repo, SCRIPT, "--bogus"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--bogus"), repo)
|
||||
|
||||
assert ps.returncode == bash.returncode == py.returncode == 1
|
||||
assert ps.stdout == bash.stdout == py.stdout == ""
|
||||
assert ps.stderr == bash.stderr == py.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_help_beats_unknown_option_matches_bash(repo: Path) -> None:
|
||||
"""--help must win over a later unknown option and exit 0."""
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--help", "--bogus"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--help", "--bogus"), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert "Usage" in py.stdout and "Usage" in bash.stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_powershell_help_beats_unknown_option(repo: Path) -> None:
|
||||
"""-Help must win over unknown-argument validation like the siblings."""
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-Help", "--bogus"), repo)
|
||||
|
||||
assert ps.returncode == 0, ps.stderr
|
||||
assert ps.stderr == ""
|
||||
assert "Usage" in ps.stdout
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
@pytest.mark.parametrize(
|
||||
"context", ["missing", "invalid_json", "invalid_utf8", "invalid_init_dir"]
|
||||
)
|
||||
def test_all_variants_feature_context_error_matches(
|
||||
tmp_path: Path, context: str
|
||||
) -> None:
|
||||
repo = make_repo(tmp_path)
|
||||
install_scripts(repo, SCRIPT)
|
||||
env = None
|
||||
if context == "invalid_json":
|
||||
(repo / ".specify" / "feature.json").write_text(
|
||||
"{not json", encoding="utf-8"
|
||||
)
|
||||
elif context == "invalid_utf8":
|
||||
(repo / ".specify" / "feature.json").write_bytes(b"\xff")
|
||||
elif context == "invalid_init_dir":
|
||||
env = clean_env()
|
||||
env["SPECIFY_INIT_DIR"] = str(tmp_path / "missing")
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json"), repo, env)
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-Json"), repo, env)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json"), repo, env)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
assert bash.stderr == ps.stderr == py.stderr
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_python_json_output_matches_powershell(repo: Path) -> None:
|
||||
feature = repo / "specs" / "001-my-feature"
|
||||
(feature / "research.md").write_text("# research\n", encoding="utf-8")
|
||||
(feature / "contracts" / "v1").mkdir(parents=True)
|
||||
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-Json"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json"), repo)
|
||||
|
||||
assert py.returncode == ps.returncode == 0
|
||||
assert json_stdout(py) == json_stdout(ps)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_missing_template_error_matches_all_variants(repo: Path) -> None:
|
||||
(repo / ".specify" / "templates" / "tasks-template.md").unlink()
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json"), repo)
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-Json"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json"), repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
assert bash.stderr == ps.stderr == py.stderr
|
||||
152
tests/test_skill_placeholder_py.py
Normal file
152
tests/test_skill_placeholder_py.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""resolve_skill_placeholders must support the py script variant (#3280)."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli._init_options import save_init_options
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
FRONTMATTER = {
|
||||
"scripts": {
|
||||
"sh": "scripts/bash/setup-plan.sh --json",
|
||||
"ps": "scripts/powershell/setup-plan.ps1 -Json",
|
||||
"py": "scripts/python/setup_plan.py --json",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _resolve(tmp_path: Path, script: str | None, monkeypatch) -> str:
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.IntegrationBase._interpreter_runs",
|
||||
staticmethod(lambda path: True),
|
||||
)
|
||||
if script:
|
||||
save_init_options(tmp_path, {"script": script})
|
||||
return CommandRegistrar.resolve_skill_placeholders(
|
||||
"codex", FRONTMATTER, "Run {SCRIPT} now.", tmp_path
|
||||
)
|
||||
|
||||
|
||||
def test_py_variant_prefixes_interpreter(tmp_path, monkeypatch):
|
||||
body = _resolve(tmp_path, "py", monkeypatch)
|
||||
assert "python3 .specify/scripts/python/setup_plan.py --json" in body
|
||||
assert "{SCRIPT}" not in body
|
||||
|
||||
|
||||
def test_sh_variant_is_not_prefixed(tmp_path, monkeypatch):
|
||||
body = _resolve(tmp_path, "sh", monkeypatch)
|
||||
assert ".specify/scripts/bash/setup-plan.sh --json" in body
|
||||
assert "python3" not in body
|
||||
|
||||
|
||||
def test_py_interpreter_with_spaces_uses_powershell_call_operator(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
interpreter = r"C:\Program Files\Py$thon's\python.exe"
|
||||
quoted_interpreter = interpreter.replace("'", "''")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable",
|
||||
interpreter,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.os", SimpleNamespace(name="nt")
|
||||
)
|
||||
save_init_options(tmp_path, {"script": "py"})
|
||||
body = CommandRegistrar.resolve_skill_placeholders(
|
||||
"codex", FRONTMATTER, "Run {SCRIPT} now.", tmp_path
|
||||
)
|
||||
assert f"& '{quoted_interpreter}' " in body
|
||||
|
||||
|
||||
def test_missing_py_variant_falls_back_to_available_script(tmp_path, monkeypatch):
|
||||
"""script=py with a template that only ships sh/ps must not leave {SCRIPT} unresolved."""
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||
)
|
||||
save_init_options(tmp_path, {"script": "py"})
|
||||
frontmatter = {
|
||||
"scripts": {
|
||||
"sh": "scripts/bash/setup-plan.sh --json",
|
||||
"ps": "scripts/powershell/setup-plan.ps1 -Json",
|
||||
}
|
||||
}
|
||||
body = CommandRegistrar.resolve_skill_placeholders(
|
||||
"codex", frontmatter, "Run {SCRIPT} now.", tmp_path
|
||||
)
|
||||
assert "{SCRIPT}" not in body
|
||||
assert "setup-plan" in body
|
||||
|
||||
|
||||
def test_py_install_includes_python_and_fallback_scripts(tmp_path, monkeypatch):
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||
)
|
||||
_install_shared_infra(tmp_path, "py", force=True)
|
||||
|
||||
assert (tmp_path / ".specify/scripts/python/setup_plan.py").is_file()
|
||||
assert (tmp_path / ".specify/scripts/python/setup_tasks.py").is_file()
|
||||
|
||||
save_init_options(tmp_path, {"script": "py"})
|
||||
frontmatter = {
|
||||
"scripts": {
|
||||
"sh": "scripts/bash/check-prerequisites.sh --json",
|
||||
"ps": "scripts/powershell/check-prerequisites.ps1 -Json",
|
||||
}
|
||||
}
|
||||
body = CommandRegistrar.resolve_skill_placeholders(
|
||||
"codex", frontmatter, "Run {SCRIPT} now.", tmp_path
|
||||
)
|
||||
fallback = (
|
||||
".specify/scripts/bash/check-prerequisites.sh"
|
||||
if "scripts/bash/" in body
|
||||
else ".specify/scripts/powershell/check-prerequisites.ps1"
|
||||
)
|
||||
assert (tmp_path / fallback).is_file()
|
||||
|
||||
|
||||
def test_py_rejects_one_sided_opposite_platform_fallback(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
from specify_cli import _install_shared_infra
|
||||
from specify_cli import shared_infra
|
||||
|
||||
class WindowsOs:
|
||||
name = "nt"
|
||||
|
||||
def __getattr__(self, attr):
|
||||
return getattr(os, attr)
|
||||
|
||||
monkeypatch.setattr(shared_infra, "os", WindowsOs())
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.platform.system", lambda: "Windows"
|
||||
)
|
||||
_install_shared_infra(tmp_path, "py", force=True)
|
||||
|
||||
save_init_options(tmp_path, {"script": "py"})
|
||||
frontmatter = {
|
||||
"scripts": {
|
||||
"sh": "scripts/bash/check-prerequisites.sh --json",
|
||||
}
|
||||
}
|
||||
with pytest.raises(ValueError, match="No runnable script variant"):
|
||||
CommandRegistrar.resolve_skill_placeholders(
|
||||
"codex", frontmatter, "Run {SCRIPT} now.", tmp_path
|
||||
)
|
||||
|
||||
assert not (
|
||||
tmp_path / ".specify/scripts/bash/check-prerequisites.sh"
|
||||
).exists()
|
||||
Reference in New Issue
Block a user