param( [string]$Root = (Get-Location).Path, [switch]$AllowNoArtifacts, [int]$MaxRequirementLines = 120, [int]$MaxSidecarLines = 80 ) $ErrorActionPreference = "Stop" if (-not (Test-Path -LiteralPath $Root)) { Write-Host "Validation failed: root path does not exist: $Root" exit 1 } $rootPath = (Resolve-Path -LiteralPath $Root).Path.TrimEnd([char[]]"\/") $errors = New-Object System.Collections.Generic.List[string] function Add-ValidationError { param([string]$Message) [void]$errors.Add($Message) } function Get-RelativePath { param([string]$Path) $fullPath = (Resolve-Path -LiteralPath $Path).Path if ($fullPath.StartsWith($rootPath, [System.StringComparison]::OrdinalIgnoreCase)) { return ($fullPath.Substring($rootPath.Length).TrimStart([char[]]"\/") -replace "\\", "/") } return ($fullPath -replace "\\", "/") } function Test-AllowedExampleIp { param([string]$Ip) $parts = $Ip.Split(".") | ForEach-Object { [int]$_ } if ($parts[0] -eq 127) { return $true } if ($parts[0] -eq 10 -and $parts[1] -eq 0 -and $parts[2] -eq 0) { return $true } if ($parts[0] -eq 192 -and $parts[1] -eq 0 -and $parts[2] -eq 2) { return $true } if ($parts[0] -eq 198 -and $parts[1] -eq 51 -and $parts[2] -eq 100) { return $true } if ($parts[0] -eq 203 -and $parts[1] -eq 0 -and $parts[2] -eq 113) { return $true } return $false } function Test-MarkdownSection { param( [string]$Content, [string]$Section ) return $Content -match "(?m)^## $([regex]::Escape($Section))[ `t`r]*$" } function Get-MarkdownSectionBody { param( [string]$Content, [string]$Section ) $match = [regex]::Match($Content, "(?ms)^## $([regex]::Escape($Section))[ `t]*\r?\n(?.*?)(?=^## |\z)") if ($match.Success) { return $match.Groups["body"].Value } return $null } function Add-RequiredMarkdownSubsectionErrors { param( [string]$Content, [string]$Section, [string[]]$Subsections, [string]$RelativePath ) $body = Get-MarkdownSectionBody -Content $Content -Section $Section if ($null -eq $body) { return } foreach ($subsection in $Subsections) { if ($body -notmatch "(?m)^### $([regex]::Escape($subsection))[ `t`r]*$") { Add-ValidationError "$RelativePath $Section is missing subsection '$subsection'." } } } function Resolve-MarkdownLinkTarget { param( [string]$BaseDirectory, [string]$LinkedPath ) if ([string]::IsNullOrWhiteSpace($LinkedPath)) { return $null } if ($LinkedPath -match "^[a-z][a-z0-9+.-]*:") { return $null } $cleanPath = $LinkedPath.Trim() $fragmentIndex = $cleanPath.IndexOf("#") if ($fragmentIndex -ge 0) { $cleanPath = $cleanPath.Substring(0, $fragmentIndex) } if ([string]::IsNullOrWhiteSpace($cleanPath)) { return $null } return Join-Path $BaseDirectory ($cleanPath -replace "/", [System.IO.Path]::DirectorySeparatorChar) } function Add-MarkdownLinkErrorIfNeeded { param( [string]$BaseDirectory, [string]$RelativePath, [string]$Scope, [string]$LinkedPath ) $targetPath = Resolve-MarkdownLinkTarget -BaseDirectory $BaseDirectory -LinkedPath $LinkedPath if ($null -eq $targetPath) { return } if (-not (Test-Path -LiteralPath $targetPath)) { Add-ValidationError "$RelativePath $Scope links to missing file: $LinkedPath" } } function Add-MissingMarkdownLinkErrors { param( [string]$Content, [string]$BaseDirectory, [string]$RelativePath, [string]$Scope ) $seenLinks = New-Object System.Collections.Generic.HashSet[string] foreach ($match in [regex]::Matches($Content, '\[[^\]\r\n]+\]\(([^)\r\n]+?\.md(?:#[^)\r\n]*)?)\)')) { $linkedPath = $match.Groups[1].Value [void]$seenLinks.Add($linkedPath) Add-MarkdownLinkErrorIfNeeded -BaseDirectory $BaseDirectory -RelativePath $RelativePath -Scope $Scope -LinkedPath $linkedPath } foreach ($match in [regex]::Matches($Content, '(?.*?)\r?\n---") { return $null } $frontMatter = $Matches["frontmatter"] $match = [regex]::Match($frontMatter, "(?m)^$([regex]::Escape($Key))\s*:\s*(?.+?)\s*$") if ($match.Success) { return $match.Groups["value"].Value.Trim().Trim('"').Trim("'") } return $null } function Add-PlaceholderErrors { param( [string]$Content, [string]$RelativePath ) if ($Content -match "<[^>`r`n]+>") { Add-ValidationError "$RelativePath contains an unresolved angle-bracket placeholder." } if ($Content -match "(?i)\bTODO\b") { Add-ValidationError "$RelativePath contains TODO placeholder text." } } function Get-NonEmptyLineCount { param([string]$Content) return @($Content -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count } function Add-LineLengthErrors { param( [string]$Content, [string]$RelativePath, [int]$MaxLineChars = 180 ) $lineNumber = 0 $inFrontMatter = $false foreach ($line in ($Content -split "`r?`n")) { $lineNumber++ if ($lineNumber -eq 1 -and $line -eq "---") { $inFrontMatter = $true continue } if ($inFrontMatter) { if ($line -eq "---") { $inFrontMatter = $false } continue } if ($line.TrimStart().StartsWith("|")) { continue } if ($line.Length -gt $MaxLineChars) { Add-ValidationError "$RelativePath line $lineNumber is too long: $($line.Length) chars, max $MaxLineChars. Split it into short requirement bullets." } } } function Add-ForbiddenRequirementHeadingErrors { param( [string]$Content, [string]$RelativePath ) $forbiddenHeadings = @( "背景", "需求背景", "价值", "目标", "技术方案", "详细设计", "接口设计", "架构设计", "实现方案" ) foreach ($match in [regex]::Matches($Content, "(?m)^##\s+(?[^#\r\n]+?)\s*$")) { $heading = $match.Groups["heading"].Value.Trim() if ($forbiddenHeadings -contains $heading) { Add-ValidationError "$RelativePath uses verbose heading '$heading'. Keep requirement.md as a short functional spec." } } } function Add-SectionLineLimitErrors { param( [string]$Content, [string]$RelativePath, [hashtable]$Limits ) foreach ($section in $Limits.Keys) { $body = Get-MarkdownSectionBody -Content $Content -Section $section if ($null -eq $body) { continue } $lineCount = Get-NonEmptyLineCount -Content $body if ($lineCount -gt $Limits[$section]) { Add-ValidationError "$RelativePath section '$section' is too long: $lineCount non-empty line(s), max $($Limits[$section]). Keep it concise or split the feature." } } } function Add-RequirementPackageErrors { param( [System.IO.FileInfo]$File, [string]$Content, [string]$RelativePath ) Add-FrontMatterErrors -Content $Content -RelativePath $RelativePath Add-PlaceholderErrors -Content $Content -RelativePath $RelativePath Add-LineLengthErrors -Content $Content -RelativePath $RelativePath Add-ForbiddenRequirementHeadingErrors -Content $Content -RelativePath $RelativePath $nonEmptyLineCount = Get-NonEmptyLineCount -Content $Content if ($nonEmptyLineCount -gt $MaxRequirementLines) { Add-ValidationError "$RelativePath is too long: $nonEmptyLineCount non-empty line(s), max $MaxRequirementLines. Split the feature or remove non-essential prose." } $slugPattern = "[a-z0-9]+(?:-[a-z0-9]+)*" $allowedPackageDirs = "requirement-packages|skill-requirements|feature-requirements" if ($RelativePath -notmatch "^$slugPattern/($allowedPackageDirs)/$slugPattern/requirement\.md$") { Add-ValidationError "$RelativePath must use '///requirement.md'." } $topDomain = ($RelativePath -split "/")[0] $technicalDomains = @( "backend", "frontend", "infra", "devops", "java", "go", "python", "node", "spring", "gin", "django", "admin-service", "admin-project" ) if ($technicalDomains -contains $topDomain) { Add-ValidationError "$RelativePath uses technology or source-project domain '$topDomain'. Requirement packages must be organized by feature/capability domain." } $pathParts = $RelativePath -split "/" $packageKind = $pathParts[1] $category = Get-FrontMatterValue -Content $Content -Key "category" $expectedCategory = "$topDomain/$packageKind" if ($null -ne $category -and $category -ne $expectedCategory) { Add-ValidationError "$RelativePath metadata category must be '$expectedCategory' to match its package path." } $requiredRequirementSections = @( "功能", "流程", "数据表", "字典", "业务规则", "验收", "移植说明", "来源依据", "待确认", "Related" ) foreach ($section in $requiredRequirementSections) { if (-not (Test-MarkdownSection -Content $Content -Section $section)) { Add-ValidationError "$RelativePath requirement package is missing section '$section'." } } Add-SectionLineLimitErrors ` -Content $Content ` -RelativePath $RelativePath ` -Limits @{ "功能" = 8 "流程" = 25 "业务规则" = 30 "验收" = 15 "移植说明" = 6 "来源依据" = 15 "待确认" = 15 } $dataTableBody = Get-MarkdownSectionBody -Content $Content -Section "数据表" if ($null -ne $dataTableBody -and $dataTableBody -notmatch "\|" -and $dataTableBody -notmatch "不新增表|不涉及表|复用") { Add-ValidationError "$RelativePath 数据表 must contain a field table or explicitly state no new/reused tables." } $dictionaryBody = Get-MarkdownSectionBody -Content $Content -Section "字典" if ($null -ne $dictionaryBody -and $dictionaryBody -notmatch "\|" -and $dictionaryBody -notmatch "不新增字典|不涉及字典|复用") { Add-ValidationError "$RelativePath 字典 must contain value-domain rows or explicitly state no new/reused dictionaries." } $portabilityBody = Get-MarkdownSectionBody -Content $Content -Section "移植说明" if ($null -ne $portabilityBody -and ($portabilityBody -notmatch "跨技术栈|跨栈" -or $portabilityBody -notmatch "来源实现|参考")) { Add-ValidationError "$RelativePath 移植说明 must mention both cross-stack preservation and source implementation as reference." } $packageDir = $File.DirectoryName $requiredPackageEntries = @("decisions.md", "acceptance.md", "references") foreach ($entry in $requiredPackageEntries) { $entryPath = Join-Path $packageDir $entry if (-not (Test-Path -LiteralPath $entryPath)) { Add-ValidationError "$RelativePath requirement package is missing '$entry'." } } $decisionsPath = Join-Path $packageDir "decisions.md" if (Test-Path -LiteralPath $decisionsPath) { $decisionsContent = Get-Content -Raw -Encoding UTF8 -LiteralPath $decisionsPath $decisionsRelativePath = Get-RelativePath $decisionsPath Add-LineLengthErrors -Content $decisionsContent -RelativePath $decisionsRelativePath $decisionsLineCount = Get-NonEmptyLineCount -Content $decisionsContent if ($decisionsLineCount -gt $MaxSidecarLines) { Add-ValidationError "$decisionsRelativePath is too long: $decisionsLineCount non-empty line(s), max $MaxSidecarLines. Keep decision records concise." } $requiredDecisionSections = @( "已确认", "待确认事项", "冲突检查", "闭环检查", "方案记录", "覆盖记录" ) foreach ($section in $requiredDecisionSections) { if (-not (Test-MarkdownSection -Content $decisionsContent -Section $section)) { Add-ValidationError "$decisionsRelativePath is missing section '$section'." } } Add-RequiredMarkdownSubsectionErrors ` -Content $decisionsContent ` -Section "方案记录" ` -Subsections @("推荐方案", "备选方案") ` -RelativePath $decisionsRelativePath $approvalBody = Get-MarkdownSectionBody -Content $decisionsContent -Section "覆盖记录" if ($null -ne $approvalBody -and $approvalBody -notmatch "(?m)^\s*-\s*允许覆盖\s*[::]") { Add-ValidationError "$decisionsRelativePath 覆盖记录 must include '允许覆盖:'." } Add-PlaceholderErrors -Content $decisionsContent -RelativePath $decisionsRelativePath } $acceptancePath = Join-Path $packageDir "acceptance.md" if (Test-Path -LiteralPath $acceptancePath) { $acceptanceContent = Get-Content -Raw -Encoding UTF8 -LiteralPath $acceptancePath $acceptanceRelativePath = Get-RelativePath $acceptancePath Add-LineLengthErrors -Content $acceptanceContent -RelativePath $acceptanceRelativePath $acceptanceLineCount = Get-NonEmptyLineCount -Content $acceptanceContent if ($acceptanceLineCount -gt $MaxSidecarLines) { Add-ValidationError "$acceptanceRelativePath is too long: $acceptanceLineCount non-empty line(s), max $MaxSidecarLines. Keep acceptance sidecars concise." } $requiredAcceptanceSections = @( "验收补充", "边界补充", "还原检查" ) foreach ($section in $requiredAcceptanceSections) { if (-not (Test-MarkdownSection -Content $acceptanceContent -Section $section)) { Add-ValidationError "$acceptanceRelativePath is missing section '$section'." } } Add-PlaceholderErrors -Content $acceptanceContent -RelativePath $acceptanceRelativePath } $relatedBody = Get-MarkdownSectionBody -Content $Content -Section "Related" if ($null -ne $relatedBody) { Add-RelatedContentErrors -Content $relatedBody -RelativePath $RelativePath Add-MissingMarkdownLinkErrors -Content $relatedBody -BaseDirectory $packageDir -RelativePath $RelativePath -Scope "Related" } } function Add-DistilledDocumentErrors { param( [System.IO.FileInfo]$File, [string]$Content, [string]$RelativePath ) Add-FrontMatterErrors -Content $Content -RelativePath $RelativePath Add-PlaceholderErrors -Content $Content -RelativePath $RelativePath $slugPattern = "[a-z0-9]+(?:-[a-z0-9]+)*" if ($RelativePath -notmatch "^$slugPattern/$slugPattern\.md$") { Add-ValidationError "$RelativePath must use '/.md'." } $topDomain = ($RelativePath -split "/")[0] $category = Get-FrontMatterValue -Content $Content -Key "category" if ($null -ne $category -and $category -notmatch "^$([regex]::Escape($topDomain))(/|$)") { Add-ValidationError "$RelativePath metadata category must start with '$topDomain' to match its document path." } if ($File.Name -notmatch "^[a-z0-9]+(-[a-z0-9]+)*\.md$") { Add-ValidationError "$RelativePath uses a non-slug file name." } $requiredSections = @("Summary", "Keywords", "Environment", "Symptom", "Root Cause", "Solution", "Verification", "Related") foreach ($section in $requiredSections) { if (-not (Test-MarkdownSection -Content $Content -Section $section)) { Add-ValidationError "$RelativePath is missing section '$section'." } } $relatedBody = Get-MarkdownSectionBody -Content $Content -Section "Related" if ($null -ne $relatedBody) { Add-RelatedContentErrors -Content $relatedBody -RelativePath $RelativePath Add-MissingMarkdownLinkErrors -Content $relatedBody -BaseDirectory $File.DirectoryName -RelativePath $RelativePath -Scope "Related" } } $excludedTopDirs = @(".git", "templates", "scripts") $markdownFiles = Get-ChildItem -Path $rootPath -Recurse -File -Filter "*.md" -Force | Where-Object { $relativePath = Get-RelativePath $_.FullName $topDir = ($relativePath -split "/")[0] $excludedTopDirs -notcontains $topDir } $distilledCount = 0 $requirementPackageCount = 0 foreach ($file in $markdownFiles) { $relativePath = Get-RelativePath $file.FullName $content = Get-Content -Raw -Encoding UTF8 -LiteralPath $file.FullName $isDistilled = $content -match "(?m)^status:\s*distilled\s*$" $isRequirementPackage = $file.Name -eq "requirement.md" -and $content -match "(?m)^status:\s*requirement-draft\s*$" if ($isDistilled) { $distilledCount++ Add-DistilledDocumentErrors -File $file -Content $content -RelativePath $relativePath } if ($isRequirementPackage) { $requirementPackageCount++ Add-RequirementPackageErrors -File $file -Content $content -RelativePath $relativePath } foreach ($match in [regex]::Matches($content, "\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b")) { $ip = $match.Value if (-not (Test-AllowedExampleIp $ip)) { Add-ValidationError "$relativePath contains a non-example IP address: $ip" } } $secretPatterns = @( @{ Name = "private key"; Pattern = '-----BEGIN [A-Z ]*PRIVATE KEY-----' }, @{ Name = "credential assignment"; Pattern = '(?i)\b(password|passwd|pwd|token|secret|api[_-]?key)\b\s*[:=]\s*\S{6,}' } ) foreach ($pattern in $secretPatterns) { if ($content -match $pattern.Pattern) { Add-ValidationError "$relativePath contains a possible $($pattern.Name)." } } } if (-not $AllowNoArtifacts -and ($distilledCount + $requirementPackageCount) -eq 0) { Add-ValidationError "No distillation artifacts found. Expected at least one 'status: distilled' document or 'status: requirement-draft' package. Use -AllowNoArtifacts only when intentionally checking an empty skeleton." } if ($errors.Count -gt 0) { Write-Host "Validation failed with $($errors.Count) issue(s):" foreach ($validationError in $errors) { Write-Host "- $validationError" } exit 1 } Write-Host "Validation passed. Checked $($markdownFiles.Count) Markdown file(s), including $distilledCount distilled document(s) and $requirementPackageCount requirement package(s)."