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)."