workflows/default/hooks/verify/03-check-md-refs.ps1
|
param( [string]$TaskId, [string]$Category, [switch]$StagedOnly, [string]$RepoRoot ) # Validate .bot/recipes/ and .bot/workflows/.../recipes/ path references # in markdown, JSON, and YAML source files against the actual source tree. # # At source time, runtime paths like .bot/recipes/agents/implementer/AGENT.md # map to workflows/default/recipes/agents/implementer/AGENT.md (or any # workflow/stack that provides the file). $issues = @() $totalRefs = 0 $validRefs = 0 $skippedRefs = 0 $filesScanned = 0 # Resolve repo root if (-not $RepoRoot) { $RepoRoot = try { (git rev-parse --show-toplevel 2>$null) } catch { $null } } if (-not $RepoRoot) { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))) } $RepoRoot = (Resolve-Path $RepoRoot).Path # ── Phase 1: Build file index ────────────────────────────────────────────── # Map runtime keys → source paths. A runtime key is the path after stripping .bot/ # e.g., "recipes/agents/implementer/AGENT.md" $fileIndex = @{} # runtime-key → @(source-paths) function Add-ToIndex { param([string]$RuntimeKey, [string]$SourcePath) $key = $RuntimeKey -replace '\\', '/' if (-not $fileIndex.ContainsKey($key)) { $fileIndex[$key] = [System.Collections.Generic.List[string]]::new() } $fileIndex[$key].Add($SourcePath) } # Scan workflows/*/recipes/ and workflows/*/workspace/ $workflowsDir = Join-Path $RepoRoot "workflows" if (Test-Path $workflowsDir) { Get-ChildItem $workflowsDir -Directory | ForEach-Object { $wfName = $_.Name $recipesDir = Join-Path $_.FullName "recipes" if (Test-Path $recipesDir) { Get-ChildItem $recipesDir -File -Recurse | ForEach-Object { $relToRecipes = $_.FullName.Substring($recipesDir.Length + 1) -replace '\\', '/' # Maps to .bot/recipes/{relToRecipes} Add-ToIndex -RuntimeKey "recipes/$relToRecipes" -SourcePath $_.FullName # Also maps to .bot/workflows/{wfName}/recipes/{relToRecipes} Add-ToIndex -RuntimeKey "workflows/$wfName/recipes/$relToRecipes" -SourcePath $_.FullName } } } } # Scan stacks/*/recipes/ $stacksDir = Join-Path $RepoRoot "stacks" if (Test-Path $stacksDir) { Get-ChildItem $stacksDir -Directory | ForEach-Object { $recipesDir = Join-Path $_.FullName "recipes" if (Test-Path $recipesDir) { Get-ChildItem $recipesDir -File -Recurse | ForEach-Object { $relToRecipes = $_.FullName.Substring($recipesDir.Length + 1) -replace '\\', '/' Add-ToIndex -RuntimeKey "recipes/$relToRecipes" -SourcePath $_.FullName } } } } # ── Phase 2: Determine files to scan ─────────────────────────────────────── $scanExtensions = @('.md', '.json', '.yaml', '.yml') $scanDirs = @('workflows', 'stacks') # If no workflows/ or stacks/ dirs exist, this is a target project, not the dotbot source repo. # Skip validation — the hook only validates source-level references. $hasSourceDirs = $scanDirs | Where-Object { Test-Path (Join-Path $RepoRoot $_) } if (-not $hasSourceDirs) { @{ success = $true script = "03-check-md-refs.ps1" message = "Skipped — no workflows/ or stacks/ directory (not a dotbot source repo)" details = @{ files_scanned = 0; references_found = 0; references_valid = 0; references_skipped = 0; references_broken = 0; scan_mode = if ($StagedOnly) { 'staged' } else { 'full' } } failures = @() } | ConvertTo-Json -Depth 10 return } if ($StagedOnly) { $stagedFiles = git diff --cached --name-only --diff-filter=ACM 2>$null $filesToScan = @($stagedFiles | Where-Object { $file = $_ $ext = [System.IO.Path]::GetExtension($file) ($ext -in $scanExtensions) -and ($scanDirs | Where-Object { $file.StartsWith("$_/") -or $file.StartsWith("$_\") }) } | ForEach-Object { Join-Path $RepoRoot $_ }) } else { $filesToScan = @() foreach ($dir in $scanDirs) { $fullDir = Join-Path $RepoRoot $dir if (Test-Path $fullDir) { $filesToScan += @(Get-ChildItem $fullDir -File -Recurse | Where-Object { $_.Extension -in $scanExtensions } | ForEach-Object { $_.FullName }) } } } # ── Phase 3: Scan and validate references ────────────────────────────────── # Regex to capture .bot/recipes/... or .bot/workflows/.../recipes/... paths $refPattern = '\.bot/((?:recipes|workflows)/[^\s"''`\]\)>]+\.(?:md|json|yaml|yml|ps1|psm1))' foreach ($file in $filesToScan) { if (-not (Test-Path $file)) { continue } $filesScanned++ $relFile = $file.Substring($RepoRoot.Length + 1) -replace '\\', '/' $lines = Get-Content $file -ErrorAction SilentlyContinue if (-not $lines) { continue } $inCodeBlock = $false $isMdFile = $relFile -match '\.md$' for ($i = 0; $i -lt $lines.Count; $i++) { $line = $lines[$i] # Track fenced code blocks in markdown files (``` or ~~~) if ($isMdFile -and $line -match '^\s*(`{3,}|~{3,})') { $inCodeBlock = -not $inCodeBlock continue } # Skip references inside code blocks — they are examples, not real refs if ($inCodeBlock) { continue } $matches = [regex]::Matches($line, $refPattern) foreach ($m in $matches) { $totalRefs++ $runtimeKey = $m.Groups[1].Value # Skip template variables: {var}, {{VAR}}, {{TASK.xxx}} if ($runtimeKey -match '\{[^}]*\}') { $skippedRefs++ continue } # Skip glob patterns if ($runtimeKey -match '\*') { $skippedRefs++ continue } # Look up in index if ($fileIndex.ContainsKey($runtimeKey)) { $validRefs++ } else { # Broken reference — try to suggest a fix $leafName = Split-Path $runtimeKey -Leaf $suggestions = @($fileIndex.Keys | Where-Object { $_ -like "*/$leafName" -or $_ -eq $leafName }) $issue = @{ file = $relFile line = $i + 1 reference = ".bot/$runtimeKey" issue = "Broken reference: .bot/$runtimeKey" severity = "error" } if ($suggestions.Count -gt 0) { $issue['suggestion'] = "Did you mean: .bot/$($suggestions[0])" } $issues += $issue } } } } # ── Phase 4: Output ──────────────────────────────────────────────────────── $details = @{ files_scanned = $filesScanned references_found = $totalRefs references_valid = $validRefs references_skipped = $skippedRefs references_broken = $issues.Count scan_mode = if ($StagedOnly) { 'staged' } else { 'full' } } if ($StagedOnly -and $issues.Count -gt 0) { [Console]::Error.WriteLine("") [Console]::Error.WriteLine("dotbot reference check: $($issues.Count) broken reference(s) in staged files:") foreach ($v in $issues) { $msg = " $($v.file):$($v.line) - $($v.reference)" if ($v.suggestion) { $msg += " ($($v.suggestion))" } [Console]::Error.WriteLine($msg) } [Console]::Error.WriteLine("") [Console]::Error.WriteLine("Fix the broken references before committing.") } @{ success = ($issues.Count -eq 0) script = "03-check-md-refs.ps1" message = if ($issues.Count -eq 0) { "All $totalRefs path references valid ($skippedRefs skipped)" } else { "$($issues.Count) broken reference(s) found out of $totalRefs" } details = $details failures = @($issues) } | ConvertTo-Json -Depth 10 |