common.ps1 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  1. #!/usr/bin/env pwsh
  2. # Common PowerShell functions analogous to common.sh
  3. # Find repository root by searching upward for .specify directory
  4. # This is the primary marker for spec-kit projects
  5. function Find-SpecifyRoot {
  6. param([string]$StartDir = (Get-Location).Path)
  7. # Normalize to absolute path to prevent issues with relative paths
  8. # Use -LiteralPath to handle paths with wildcard characters ([, ], *, ?)
  9. $resolved = Resolve-Path -LiteralPath $StartDir -ErrorAction SilentlyContinue
  10. $current = if ($resolved) { $resolved.Path } else { $null }
  11. if (-not $current) { return $null }
  12. while ($true) {
  13. if (Test-Path -LiteralPath (Join-Path $current ".specify") -PathType Container) {
  14. return $current
  15. }
  16. $parent = Split-Path $current -Parent
  17. if ([string]::IsNullOrEmpty($parent) -or $parent -eq $current) {
  18. return $null
  19. }
  20. $current = $parent
  21. }
  22. }
  23. # Resolve an explicit SPECIFY_INIT_DIR project override (the directory that
  24. # *contains* .specify/), for non-interactive / CI use -- e.g. running a Spec Kit
  25. # command against a member project from a monorepo root without cd.
  26. #
  27. # Precondition: $env:SPECIFY_INIT_DIR is set. Returns the validated project root,
  28. # or writes an error and exits 1. Strict by design: the path must exist and
  29. # contain .specify/, with no silent fallback. (An empty string is falsy, so the
  30. # caller's `if ($env:SPECIFY_INIT_DIR)` guard treats empty as unset.)
  31. #
  32. # This is the single resolver: bundled extensions inherit it by sourcing core
  33. # (e.g. the git extension's create-new-feature-branch) rather than duplicating it.
  34. function Resolve-SpecifyInitDir {
  35. $initDir = $env:SPECIFY_INIT_DIR
  36. # Normalize: relative paths resolve against the current directory.
  37. if (-not [System.IO.Path]::IsPathRooted($initDir)) {
  38. $initDir = Join-Path (Get-Location).Path $initDir
  39. }
  40. $resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue
  41. # Resolve-Path also succeeds for files, so check the resolved path is a
  42. # directory; otherwise a file value would slip through to the less accurate
  43. # "not a Spec Kit project" error below.
  44. if (-not $resolved -or -not (Test-Path -LiteralPath $resolved.Path -PathType Container)) {
  45. [Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)")
  46. exit 1
  47. }
  48. # Resolve-Path echoes back any trailing separator from the input; trim it so
  49. # the returned root matches the bash resolver, whose `cd && pwd` never yields
  50. # one. TrimEndingDirectorySeparator is a no-op on a bare root and on a path
  51. # that already has no trailing separator.
  52. $initRoot = [System.IO.Path]::TrimEndingDirectorySeparator($resolved.Path)
  53. if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
  54. [Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
  55. exit 1
  56. }
  57. return $initRoot
  58. }
  59. # Get repository root, prioritizing .specify directory
  60. # This prevents using a parent repository when spec-kit is initialized in a subdirectory
  61. function Get-RepoRoot {
  62. # Explicit project override wins (see Resolve-SpecifyInitDir).
  63. if ($env:SPECIFY_INIT_DIR) {
  64. return (Resolve-SpecifyInitDir)
  65. }
  66. # First, look for .specify directory (spec-kit's own marker)
  67. $specifyRoot = Find-SpecifyRoot
  68. if ($specifyRoot) {
  69. return $specifyRoot
  70. }
  71. # Final fallback to script location
  72. # Use -LiteralPath to handle paths with wildcard characters
  73. return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path
  74. }
  75. function Get-CurrentBranch {
  76. # Return feature name from explicit state only.
  77. # Feature state is set by SPECIFY_FEATURE (from create-new-feature or
  78. # the git extension) or implicitly via .specify/feature.json.
  79. if ($env:SPECIFY_FEATURE) {
  80. return $env:SPECIFY_FEATURE
  81. }
  82. # No explicit feature set - return empty to signal "unknown".
  83. return ""
  84. }
  85. # Persist a feature_directory value to .specify/feature.json.
  86. # Writes only when the file is missing or the value differs from what's stored.
  87. function Save-FeatureJson {
  88. param(
  89. [Parameter(Mandatory = $true)][string]$RepoRoot,
  90. [Parameter(Mandatory = $true)][string]$FeatureDirectory
  91. )
  92. # Strip repo root prefix if the value is absolute and under repo root.
  93. # Use case-insensitive comparison on Windows only (case-sensitive filesystems elsewhere).
  94. $prefix = $RepoRoot + [System.IO.Path]::DirectorySeparatorChar
  95. if ($null -ne $IsWindows) { $onWin = $IsWindows } else { $onWin = $true }
  96. if ($onWin) {
  97. $cmp = [System.StringComparison]::OrdinalIgnoreCase
  98. } else {
  99. $cmp = [System.StringComparison]::Ordinal
  100. }
  101. if ($FeatureDirectory.StartsWith($prefix, $cmp)) {
  102. $FeatureDirectory = $FeatureDirectory.Substring($prefix.Length)
  103. }
  104. $fjPath = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
  105. # Read current value and skip write when unchanged
  106. if (Test-Path -LiteralPath $fjPath -PathType Leaf) {
  107. try {
  108. $raw = Get-Content -LiteralPath $fjPath -Raw
  109. $cfg = $raw | ConvertFrom-Json
  110. if ($cfg.feature_directory -eq $FeatureDirectory) {
  111. return
  112. }
  113. } catch {
  114. # File is corrupt or unreadable - overwrite it
  115. }
  116. }
  117. # Ensure .specify/ directory exists
  118. $specifyDir = Join-Path $RepoRoot '.specify'
  119. if (-not (Test-Path -LiteralPath $specifyDir -PathType Container)) {
  120. New-Item -ItemType Directory -Path $specifyDir -Force | Out-Null
  121. }
  122. # Write feature.json
  123. $json = @{ feature_directory = $FeatureDirectory } | ConvertTo-Json -Compress
  124. $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
  125. [System.IO.File]::WriteAllText($fjPath, $json, $utf8NoBom)
  126. }
  127. function Get-FeaturePathsEnv {
  128. $repoRoot = Get-RepoRoot
  129. $currentBranch = Get-CurrentBranch
  130. # Resolve feature directory. Priority:
  131. # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
  132. # 2. .specify/feature.json "feature_directory" key (persisted by specify command)
  133. # 3. Error - no feature context available
  134. $featureJson = Join-Path $repoRoot '.specify/feature.json'
  135. if ($env:SPECIFY_FEATURE_DIRECTORY) {
  136. $featureDir = $env:SPECIFY_FEATURE_DIRECTORY
  137. # Normalize relative paths to absolute under repo root
  138. if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
  139. $featureDir = Join-Path $repoRoot $featureDir
  140. }
  141. # Persist to feature.json so future sessions without the env var still work
  142. Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
  143. } elseif (Test-Path $featureJson) {
  144. $featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
  145. try {
  146. $featureConfig = $featureJsonRaw | ConvertFrom-Json
  147. } catch {
  148. [Console]::Error.WriteLine("ERROR: Failed to parse .specify/feature.json: $_")
  149. exit 1
  150. }
  151. if ($featureConfig.feature_directory) {
  152. $featureDir = $featureConfig.feature_directory
  153. # Normalize relative paths to absolute under repo root
  154. if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
  155. $featureDir = Join-Path $repoRoot $featureDir
  156. }
  157. } else {
  158. [Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
  159. exit 1
  160. }
  161. } else {
  162. [Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
  163. exit 1
  164. }
  165. [PSCustomObject]@{
  166. REPO_ROOT = $repoRoot
  167. CURRENT_BRANCH = $currentBranch
  168. FEATURE_DIR = $featureDir
  169. FEATURE_SPEC = Join-Path $featureDir 'spec.md'
  170. IMPL_PLAN = Join-Path $featureDir 'plan.md'
  171. TASKS = Join-Path $featureDir 'tasks.md'
  172. RESEARCH = Join-Path $featureDir 'research.md'
  173. DATA_MODEL = Join-Path $featureDir 'data-model.md'
  174. QUICKSTART = Join-Path $featureDir 'quickstart.md'
  175. CONTRACTS_DIR = Join-Path $featureDir 'contracts'
  176. }
  177. }
  178. function Test-FileExists {
  179. param([string]$Path, [string]$Description)
  180. if (Test-Path -Path $Path -PathType Leaf) {
  181. Write-Output " [OK] $Description"
  182. return $true
  183. } else {
  184. Write-Output " [FAIL] $Description"
  185. return $false
  186. }
  187. }
  188. function Test-DirHasFiles {
  189. param([string]$Path, [string]$Description)
  190. if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } | Select-Object -First 1)) {
  191. Write-Output " [OK] $Description"
  192. return $true
  193. } else {
  194. Write-Output " [FAIL] $Description"
  195. return $false
  196. }
  197. }
  198. function Get-InvokeSeparator {
  199. param([string]$RepoRoot = (Get-RepoRoot))
  200. if ($null -eq $script:SpecKitInvokeSeparatorCache) {
  201. $script:SpecKitInvokeSeparatorCache = @{}
  202. }
  203. if ($script:SpecKitInvokeSeparatorCache.ContainsKey($RepoRoot)) {
  204. return $script:SpecKitInvokeSeparatorCache[$RepoRoot]
  205. }
  206. $separator = '.'
  207. $integrationJson = Join-Path $RepoRoot '.specify/integration.json'
  208. if (Test-Path -LiteralPath $integrationJson -PathType Leaf) {
  209. try {
  210. $state = Get-Content -LiteralPath $integrationJson -Raw | ConvertFrom-Json
  211. $key = if ($state.default_integration) { [string]$state.default_integration } elseif ($state.integration) { [string]$state.integration } else { '' }
  212. if ($key -and $state.integration_settings) {
  213. $settingProperty = $state.integration_settings.PSObject.Properties[$key]
  214. if ($settingProperty) {
  215. $setting = $settingProperty.Value
  216. if ($setting -and ($setting.invoke_separator -eq '.' -or $setting.invoke_separator -eq '-')) {
  217. $separator = [string]$setting.invoke_separator
  218. }
  219. }
  220. }
  221. } catch {
  222. $separator = '.'
  223. }
  224. }
  225. $script:SpecKitInvokeSeparatorCache[$RepoRoot] = $separator
  226. return $separator
  227. }
  228. function Format-SpecKitCommand {
  229. param(
  230. [Parameter(Mandatory = $true)][string]$CommandName,
  231. [string]$RepoRoot = (Get-RepoRoot)
  232. )
  233. $separator = Get-InvokeSeparator -RepoRoot $RepoRoot
  234. $name = $CommandName.TrimStart('/')
  235. if ($name.StartsWith('speckit.')) {
  236. $name = $name.Substring(8)
  237. } elseif ($name.StartsWith('speckit-')) {
  238. $name = $name.Substring(8)
  239. }
  240. $name = $name -replace '\.', $separator
  241. return "/speckit$separator$name"
  242. }
  243. # Find a usable Python 3 executable (python3, python, or py -3).
  244. # Returns the command/arguments as an array, or $null if none found.
  245. function Get-Python3Command {
  246. if (Get-Command python3 -ErrorAction SilentlyContinue) { return @('python3') }
  247. if (Get-Command python -ErrorAction SilentlyContinue) {
  248. $ver = & python --version 2>&1
  249. if ($ver -match 'Python 3') { return @('python') }
  250. }
  251. if (Get-Command py -ErrorAction SilentlyContinue) {
  252. $ver = & py -3 --version 2>&1
  253. if ($ver -match 'Python 3') { return @('py', '-3') }
  254. }
  255. return $null
  256. }
  257. # Resolve a template name to a file path using the priority stack:
  258. # 1. .specify/templates/overrides/
  259. # 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
  260. # 3. .specify/extensions/<ext-id>/templates/
  261. # 4. .specify/templates/ (core)
  262. function Resolve-Template {
  263. param(
  264. [Parameter(Mandatory=$true)][string]$TemplateName,
  265. [Parameter(Mandatory=$true)][string]$RepoRoot
  266. )
  267. $base = Join-Path $RepoRoot '.specify/templates'
  268. # Priority 1: Project overrides
  269. $override = Join-Path $base "overrides/$TemplateName.md"
  270. if (Test-Path $override) { return $override }
  271. # Priority 2: Installed presets (sorted by priority from .registry)
  272. $presetsDir = Join-Path $RepoRoot '.specify/presets'
  273. if (Test-Path $presetsDir) {
  274. $registryFile = Join-Path $presetsDir '.registry'
  275. $sortedPresets = @()
  276. if (Test-Path $registryFile) {
  277. try {
  278. $registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
  279. $presets = $registryData.presets
  280. if ($presets) {
  281. $sortedPresets = $presets.PSObject.Properties |
  282. Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
  283. Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
  284. ForEach-Object { $_.Name }
  285. }
  286. } catch {
  287. # Fallback: alphabetical directory order
  288. $sortedPresets = @()
  289. }
  290. }
  291. if ($sortedPresets.Count -gt 0) {
  292. foreach ($presetId in $sortedPresets) {
  293. $candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
  294. if (Test-Path $candidate) { return $candidate }
  295. }
  296. } else {
  297. # Fallback: alphabetical directory order
  298. foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
  299. $candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
  300. if (Test-Path $candidate) { return $candidate }
  301. }
  302. }
  303. }
  304. # Priority 3: Extension-provided templates
  305. $extDir = Join-Path $RepoRoot '.specify/extensions'
  306. if (Test-Path $extDir) {
  307. foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
  308. $candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
  309. if (Test-Path $candidate) { return $candidate }
  310. }
  311. }
  312. # Priority 4: Core templates
  313. $core = Join-Path $base "$TemplateName.md"
  314. if (Test-Path $core) { return $core }
  315. return $null
  316. }
  317. # Resolve a template name to composed content using composition strategies.
  318. # Reads strategy metadata from preset manifests and composes content
  319. # from multiple layers using prepend, append, or wrap strategies.
  320. function Resolve-TemplateContent {
  321. param(
  322. [Parameter(Mandatory=$true)][string]$TemplateName,
  323. [Parameter(Mandatory=$true)][string]$RepoRoot
  324. )
  325. $base = Join-Path $RepoRoot '.specify/templates'
  326. # Collect all layers (highest priority first)
  327. $layerPaths = @()
  328. $layerStrategies = @()
  329. # Priority 1: Project overrides (always "replace")
  330. $override = Join-Path $base "overrides/$TemplateName.md"
  331. if (Test-Path $override) {
  332. $layerPaths += $override
  333. $layerStrategies += 'replace'
  334. }
  335. # Priority 2: Installed presets (sorted by priority from .registry)
  336. $presetsDir = Join-Path $RepoRoot '.specify/presets'
  337. if (Test-Path $presetsDir) {
  338. $registryFile = Join-Path $presetsDir '.registry'
  339. $sortedPresets = @()
  340. if (Test-Path $registryFile) {
  341. try {
  342. $registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
  343. $presets = $registryData.presets
  344. if ($presets) {
  345. $sortedPresets = $presets.PSObject.Properties |
  346. Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
  347. Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
  348. ForEach-Object { $_.Name }
  349. }
  350. } catch {
  351. $sortedPresets = @()
  352. }
  353. }
  354. if ($sortedPresets.Count -gt 0) {
  355. $pyCmd = Get-Python3Command
  356. if (-not $pyCmd) {
  357. # Check if any preset has strategy fields that would be ignored
  358. foreach ($pid in $sortedPresets) {
  359. $mf = Join-Path $presetsDir "$pid/preset.yml"
  360. if ((Test-Path $mf) -and (Select-String -Path $mf -Pattern 'strategy:' -Quiet -ErrorAction SilentlyContinue)) {
  361. Write-Warning "No Python 3 found; preset composition strategies will be ignored"
  362. break
  363. }
  364. }
  365. }
  366. $yamlWarned = $false
  367. foreach ($presetId in $sortedPresets) {
  368. # Read strategy and file path from preset manifest
  369. $strategy = 'replace'
  370. $manifestFilePath = ''
  371. $manifest = Join-Path $presetsDir "$presetId/preset.yml"
  372. if ((Test-Path $manifest) -and $pyCmd) {
  373. try {
  374. # Use Python to parse YAML manifest for strategy and file path
  375. $pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() }
  376. $pyStderrFile = [System.IO.Path]::GetTempFileName()
  377. $stratResult = & $pyCmd[0] @pyArgs -c @"
  378. import sys
  379. try:
  380. import yaml
  381. except ImportError:
  382. print('yaml_missing', file=sys.stderr)
  383. print('replace\t')
  384. sys.exit(0)
  385. try:
  386. with open(sys.argv[1]) as f:
  387. data = yaml.safe_load(f)
  388. for t in data.get('provides', {}).get('templates', []):
  389. if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template':
  390. print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
  391. sys.exit(0)
  392. print('replace\t')
  393. except Exception:
  394. print('replace\t')
  395. "@ $manifest $TemplateName 2>$pyStderrFile
  396. if ($stratResult) {
  397. $parts = $stratResult.Trim() -split "`t", 2
  398. $strategy = $parts[0].ToLowerInvariant()
  399. if ($parts.Count -gt 1 -and $parts[1]) { $manifestFilePath = $parts[1] }
  400. }
  401. if (-not $yamlWarned -and (Test-Path $pyStderrFile) -and (Get-Content $pyStderrFile -Raw -ErrorAction SilentlyContinue) -match 'yaml_missing') {
  402. Write-Warning "PyYAML not available; composition strategies may be ignored"
  403. $yamlWarned = $true
  404. }
  405. Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue
  406. } catch {
  407. $strategy = 'replace'
  408. if ($pyStderrFile) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue }
  409. }
  410. }
  411. # Try manifest file path first, then convention path
  412. $candidate = $null
  413. if ($manifestFilePath) {
  414. # Reject absolute paths and parent traversal
  415. if ([System.IO.Path]::IsPathRooted($manifestFilePath) -or $manifestFilePath -match '\.\.[\\/]') {
  416. $manifestFilePath = ''
  417. }
  418. }
  419. if ($manifestFilePath) {
  420. $mf = Join-Path $presetsDir "$presetId/$manifestFilePath"
  421. if (Test-Path $mf) { $candidate = $mf }
  422. }
  423. if (-not $candidate) {
  424. $cf = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
  425. if (Test-Path $cf) { $candidate = $cf }
  426. }
  427. if ($candidate) {
  428. $layerPaths += $candidate
  429. $layerStrategies += $strategy
  430. }
  431. }
  432. } else {
  433. # Fallback: alphabetical directory order (no registry or parse failure)
  434. foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
  435. $candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
  436. if (Test-Path $candidate) {
  437. $layerPaths += $candidate
  438. $layerStrategies += 'replace'
  439. }
  440. }
  441. }
  442. }
  443. # Priority 3: Extension-provided templates (always "replace")
  444. $extDir = Join-Path $RepoRoot '.specify/extensions'
  445. if (Test-Path $extDir) {
  446. foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
  447. $candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
  448. if (Test-Path $candidate) {
  449. $layerPaths += $candidate
  450. $layerStrategies += 'replace'
  451. }
  452. }
  453. }
  454. # Priority 4: Core templates (always "replace")
  455. $core = Join-Path $base "$TemplateName.md"
  456. if (Test-Path $core) {
  457. $layerPaths += $core
  458. $layerStrategies += 'replace'
  459. }
  460. if ($layerPaths.Count -eq 0) { return $null }
  461. # If the top (highest-priority) layer is replace, it wins entirely --
  462. # lower layers are irrelevant regardless of their strategies.
  463. if ($layerStrategies[0] -eq 'replace') {
  464. return (Get-Content $layerPaths[0] -Raw)
  465. }
  466. # Check if any layer uses a non-replace strategy
  467. $hasComposition = $false
  468. foreach ($s in $layerStrategies) {
  469. if ($s -ne 'replace') { $hasComposition = $true; break }
  470. }
  471. if (-not $hasComposition) {
  472. return (Get-Content $layerPaths[0] -Raw)
  473. }
  474. # Find the effective base: scan from highest priority (index 0) downward
  475. # to find the nearest replace layer. Only compose layers above that base.
  476. $baseIdx = -1
  477. for ($i = 0; $i -lt $layerPaths.Count; $i++) {
  478. if ($layerStrategies[$i] -eq 'replace') {
  479. $baseIdx = $i
  480. break
  481. }
  482. }
  483. if ($baseIdx -lt 0) { return $null }
  484. $content = Get-Content $layerPaths[$baseIdx] -Raw
  485. for ($i = $baseIdx - 1; $i -ge 0; $i--) {
  486. $path = $layerPaths[$i]
  487. $strat = $layerStrategies[$i]
  488. $layerContent = Get-Content $path -Raw
  489. switch ($strat) {
  490. 'replace' { $content = $layerContent }
  491. 'prepend' { $content = "$layerContent`n`n$content" }
  492. 'append' { $content = "$content`n`n$layerContent" }
  493. 'wrap' {
  494. if (-not $layerContent.Contains('{CORE_TEMPLATE}')) {
  495. throw "Wrap strategy missing {CORE_TEMPLATE} placeholder"
  496. }
  497. $content = $layerContent.Replace('{CORE_TEMPLATE}', $content)
  498. }
  499. default { throw "Unknown strategy: $strat" }
  500. }
  501. }
  502. return $content
  503. }