Add ks-zl skill
This commit is contained in:
200
scripts/build-requirement-index.ps1
Normal file
200
scripts/build-requirement-index.ps1
Normal file
@@ -0,0 +1,200 @@
|
||||
param(
|
||||
[string]$Root = (Get-Location).Path,
|
||||
[string]$OutputPath,
|
||||
[switch]$Quiet
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Root)) {
|
||||
throw "Root path does not exist: $Root"
|
||||
}
|
||||
|
||||
$rootPath = (Resolve-Path -LiteralPath $Root).Path.TrimEnd([char[]]"\/")
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||
$OutputPath = Join-Path (Join-Path $rootPath "_indexes") "requirements-index.json"
|
||||
}
|
||||
|
||||
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 Get-FrontMatter {
|
||||
param([string]$Content)
|
||||
|
||||
$match = [regex]::Match($Content, "(?s)\A---\r?\n(?<body>.*?)\r?\n---")
|
||||
if ($match.Success) {
|
||||
return $match.Groups["body"].Value
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
function Get-MetadataValue {
|
||||
param(
|
||||
[string]$FrontMatter,
|
||||
[string]$Key
|
||||
)
|
||||
|
||||
$match = [regex]::Match($FrontMatter, "(?m)^$([regex]::Escape($Key))\s*:\s*(?<value>.*)\s*$")
|
||||
if ($match.Success) {
|
||||
return $match.Groups["value"].Value.Trim().Trim('"').Trim("'")
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
function Get-MetadataList {
|
||||
param(
|
||||
[string]$FrontMatter,
|
||||
[string]$Key
|
||||
)
|
||||
|
||||
$lines = $FrontMatter -split "`r?`n"
|
||||
$values = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
for ($i = 0; $i -lt $lines.Count; $i++) {
|
||||
if ($lines[$i] -match "^$([regex]::Escape($Key))\s*:\s*(?<inline>.*)\s*$") {
|
||||
$inline = $Matches["inline"].Trim()
|
||||
if ($inline.StartsWith("[") -and $inline.EndsWith("]")) {
|
||||
$inline.Trim("[]").Split(",") |
|
||||
ForEach-Object { $_.Trim().Trim('"').Trim("'") } |
|
||||
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
|
||||
ForEach-Object { [void]$values.Add($_) }
|
||||
}
|
||||
|
||||
for ($j = $i + 1; $j -lt $lines.Count; $j++) {
|
||||
if ($lines[$j] -match "^\s*-\s*(?<value>.+?)\s*$") {
|
||||
[void]$values.Add($Matches["value"].Trim().Trim('"').Trim("'"))
|
||||
continue
|
||||
}
|
||||
|
||||
if ($lines[$j] -match "^\S[^:]*\s*:") {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return @($values)
|
||||
}
|
||||
|
||||
function Get-MarkdownSectionBody {
|
||||
param(
|
||||
[string]$Content,
|
||||
[string]$Section
|
||||
)
|
||||
|
||||
$match = [regex]::Match($Content, "(?ms)^##\s+$([regex]::Escape($Section))[ `t]*\r?\n(?<body>.*?)(?=^##\s+|\z)")
|
||||
if ($match.Success) {
|
||||
return $match.Groups["body"].Value
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
function ConvertTo-PlainText {
|
||||
param([string]$Value)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return (($Value -replace "`r?`n", " ") -replace "\s+", " ").Trim()
|
||||
}
|
||||
|
||||
function Get-PackageInfo {
|
||||
param([string]$RelativePath)
|
||||
|
||||
$parts = $RelativePath -split "/"
|
||||
$domain = ""
|
||||
$packageType = ""
|
||||
$slug = ""
|
||||
|
||||
if ($parts.Count -ge 4) {
|
||||
$domain = $parts[0]
|
||||
$packageType = $parts[1]
|
||||
$slug = $parts[2]
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
domain = $domain
|
||||
packageType = $packageType
|
||||
slug = $slug
|
||||
}
|
||||
}
|
||||
|
||||
$excludedTopDirs = @(".git", "templates", "scripts")
|
||||
$requirementFiles = Get-ChildItem -Path $rootPath -Recurse -File -Filter "requirement.md" -Force |
|
||||
Where-Object {
|
||||
$relativePath = Get-RelativePath $_.FullName
|
||||
$topDir = ($relativePath -split "/")[0]
|
||||
$excludedTopDirs -notcontains $topDir
|
||||
}
|
||||
|
||||
$items = New-Object System.Collections.Generic.List[object]
|
||||
|
||||
foreach ($file in $requirementFiles) {
|
||||
$relativePath = Get-RelativePath $file.FullName
|
||||
$content = Get-Content -Raw -Encoding UTF8 -LiteralPath $file.FullName
|
||||
|
||||
if ($content -notmatch "(?m)^status:\s*requirement-draft\s*$") {
|
||||
continue
|
||||
}
|
||||
|
||||
$frontMatter = Get-FrontMatter -Content $content
|
||||
$packageInfo = Get-PackageInfo -RelativePath $relativePath
|
||||
$tags = @(Get-MetadataList -FrontMatter $frontMatter -Key "tags")
|
||||
|
||||
[void]$items.Add([pscustomobject][ordered]@{
|
||||
schemaVersion = 1
|
||||
type = "requirement-package"
|
||||
title = Get-MetadataValue -FrontMatter $frontMatter -Key "title"
|
||||
category = Get-MetadataValue -FrontMatter $frontMatter -Key "category"
|
||||
tags = $tags
|
||||
status = Get-MetadataValue -FrontMatter $frontMatter -Key "status"
|
||||
updated = Get-MetadataValue -FrontMatter $frontMatter -Key "updated"
|
||||
source = Get-MetadataValue -FrontMatter $frontMatter -Key "source"
|
||||
domain = $packageInfo.domain
|
||||
packageType = $packageInfo.packageType
|
||||
slug = $packageInfo.slug
|
||||
relativePath = $relativePath
|
||||
fullPath = $file.FullName
|
||||
feature = ConvertTo-PlainText (Get-MarkdownSectionBody -Content $content -Section "功能")
|
||||
flow = ConvertTo-PlainText (Get-MarkdownSectionBody -Content $content -Section "流程")
|
||||
dataTables = ConvertTo-PlainText (Get-MarkdownSectionBody -Content $content -Section "数据表")
|
||||
dictionaries = ConvertTo-PlainText (Get-MarkdownSectionBody -Content $content -Section "字典")
|
||||
businessRules = ConvertTo-PlainText (Get-MarkdownSectionBody -Content $content -Section "业务规则")
|
||||
acceptance = ConvertTo-PlainText (Get-MarkdownSectionBody -Content $content -Section "验收")
|
||||
portability = ConvertTo-PlainText (Get-MarkdownSectionBody -Content $content -Section "移植说明")
|
||||
sourceEvidence = ConvertTo-PlainText (Get-MarkdownSectionBody -Content $content -Section "来源依据")
|
||||
pending = ConvertTo-PlainText (Get-MarkdownSectionBody -Content $content -Section "待确认")
|
||||
related = ConvertTo-PlainText (Get-MarkdownSectionBody -Content $content -Section "Related")
|
||||
})
|
||||
}
|
||||
|
||||
$outputDirectory = Split-Path -Parent $OutputPath
|
||||
if (-not [string]::IsNullOrWhiteSpace($outputDirectory) -and -not (Test-Path -LiteralPath $outputDirectory)) {
|
||||
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
|
||||
}
|
||||
|
||||
$itemArray = @($items.ToArray())
|
||||
$json = ConvertTo-Json -InputObject $itemArray -Depth 8
|
||||
Set-Content -LiteralPath $OutputPath -Value $json -Encoding UTF8
|
||||
|
||||
if (-not $Quiet) {
|
||||
Write-Host "Requirement index built. Indexed $($items.Count) package(s): $OutputPath"
|
||||
}
|
||||
|
||||
|
||||
|
||||
204
scripts/search-requirements.ps1
Normal file
204
scripts/search-requirements.ps1
Normal file
@@ -0,0 +1,204 @@
|
||||
param(
|
||||
[string]$Root = (Get-Location).Path,
|
||||
[string]$IndexPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Query,
|
||||
[int]$Top = 10,
|
||||
[switch]$Json,
|
||||
[switch]$Rebuild
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Query)) {
|
||||
throw "Query must not be empty."
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Root)) {
|
||||
throw "Root path does not exist: $Root"
|
||||
}
|
||||
|
||||
$rootPath = (Resolve-Path -LiteralPath $Root).Path.TrimEnd([char[]]"\/")
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($IndexPath)) {
|
||||
$IndexPath = Join-Path (Join-Path $rootPath "_indexes") "requirements-index.json"
|
||||
}
|
||||
|
||||
if ($Rebuild -or -not (Test-Path -LiteralPath $IndexPath)) {
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$buildScript = Join-Path $scriptDir "build-requirement-index.ps1"
|
||||
& $buildScript -Root $rootPath -OutputPath $IndexPath -Quiet
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $IndexPath)) {
|
||||
throw "Index file does not exist: $IndexPath"
|
||||
}
|
||||
|
||||
function Get-QueryTerms {
|
||||
param([string]$Value)
|
||||
|
||||
$normalized = $Value.ToLowerInvariant()
|
||||
$terms = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
foreach ($part in [regex]::Split($normalized, "[\s,;,;、]+")) {
|
||||
$trimmed = $part.Trim()
|
||||
if (-not [string]::IsNullOrWhiteSpace($trimmed)) {
|
||||
[void]$terms.Add($trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
return @($terms)
|
||||
}
|
||||
|
||||
function Test-ContainsText {
|
||||
param(
|
||||
[string]$Haystack,
|
||||
[string]$Needle
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Haystack) -or [string]::IsNullOrWhiteSpace($Needle)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
return $Haystack.ToLowerInvariant().Contains($Needle)
|
||||
}
|
||||
|
||||
function Get-TextValue {
|
||||
param($Value)
|
||||
|
||||
if ($null -eq $Value) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if ($Value -is [array]) {
|
||||
return (@($Value) -join " ")
|
||||
}
|
||||
|
||||
return [string]$Value
|
||||
}
|
||||
|
||||
function Add-Score {
|
||||
param(
|
||||
[int]$Current,
|
||||
[string]$FieldValue,
|
||||
[string]$Term,
|
||||
[int]$Weight,
|
||||
[string]$FieldName,
|
||||
[System.Collections.Generic.List[string]]$MatchedFields
|
||||
)
|
||||
|
||||
if (Test-ContainsText -Haystack $FieldValue -Needle $Term) {
|
||||
[void]$MatchedFields.Add($FieldName)
|
||||
return ($Current + $Weight)
|
||||
}
|
||||
|
||||
return $Current
|
||||
}
|
||||
|
||||
$terms = @(Get-QueryTerms -Value $Query)
|
||||
if ($terms.Count -eq 0) {
|
||||
throw "Query must contain at least one searchable term."
|
||||
}
|
||||
|
||||
$rawItems = Get-Content -Raw -Encoding UTF8 -LiteralPath $IndexPath | ConvertFrom-Json
|
||||
$items = @()
|
||||
if ($null -ne $rawItems) {
|
||||
if ($rawItems -is [array]) {
|
||||
$items = @($rawItems)
|
||||
}
|
||||
else {
|
||||
$items = @($rawItems)
|
||||
}
|
||||
}
|
||||
$results = New-Object System.Collections.Generic.List[object]
|
||||
|
||||
foreach ($item in $items) {
|
||||
$score = 0
|
||||
$matchedTerms = New-Object System.Collections.Generic.HashSet[string]
|
||||
$matchedFields = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
foreach ($term in $terms) {
|
||||
$termScore = 0
|
||||
|
||||
$termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.title) -Term $term -Weight 80 -FieldName "title" -MatchedFields $matchedFields
|
||||
$termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.slug) -Term $term -Weight 60 -FieldName "slug" -MatchedFields $matchedFields
|
||||
$termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.tags) -Term $term -Weight 55 -FieldName "tags" -MatchedFields $matchedFields
|
||||
$termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.domain) -Term $term -Weight 35 -FieldName "domain" -MatchedFields $matchedFields
|
||||
$termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.category) -Term $term -Weight 25 -FieldName "category" -MatchedFields $matchedFields
|
||||
$termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.feature) -Term $term -Weight 45 -FieldName "feature" -MatchedFields $matchedFields
|
||||
$termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.businessRules) -Term $term -Weight 40 -FieldName "businessRules" -MatchedFields $matchedFields
|
||||
$termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.acceptance) -Term $term -Weight 25 -FieldName "acceptance" -MatchedFields $matchedFields
|
||||
$termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.flow) -Term $term -Weight 18 -FieldName "flow" -MatchedFields $matchedFields
|
||||
$termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.dataTables) -Term $term -Weight 12 -FieldName "dataTables" -MatchedFields $matchedFields
|
||||
$termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.dictionaries) -Term $term -Weight 12 -FieldName "dictionaries" -MatchedFields $matchedFields
|
||||
$termScore = Add-Score -Current $termScore -FieldValue (Get-TextValue $item.sourceEvidence) -Term $term -Weight 8 -FieldName "sourceEvidence" -MatchedFields $matchedFields
|
||||
|
||||
if ($termScore -gt 0) {
|
||||
[void]$matchedTerms.Add($term)
|
||||
$score += $termScore
|
||||
}
|
||||
}
|
||||
|
||||
if ($matchedTerms.Count -eq $terms.Count) {
|
||||
$score += 30
|
||||
}
|
||||
|
||||
if ($score -gt 0) {
|
||||
[void]$results.Add([pscustomobject][ordered]@{
|
||||
score = $score
|
||||
title = $item.title
|
||||
domain = $item.domain
|
||||
packageType = $item.packageType
|
||||
slug = $item.slug
|
||||
relativePath = $item.relativePath
|
||||
fullPath = $item.fullPath
|
||||
tags = @($item.tags)
|
||||
updated = $item.updated
|
||||
matchedTerms = @($matchedTerms)
|
||||
matchedFields = @($matchedFields | Select-Object -Unique)
|
||||
feature = $item.feature
|
||||
businessRules = $item.businessRules
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
$resultArray = @($results.ToArray())
|
||||
$ranked = @($resultArray |
|
||||
Sort-Object `
|
||||
@{ Expression = "score"; Descending = $true },
|
||||
@{ Expression = "updated"; Descending = $true },
|
||||
@{ Expression = "relativePath"; Descending = $false } |
|
||||
Select-Object -First $Top)
|
||||
|
||||
if ($Json) {
|
||||
if ($ranked.Count -eq 0) {
|
||||
Write-Output "[]"
|
||||
}
|
||||
elseif ($ranked.Count -eq 1) {
|
||||
Write-Output "[$($ranked[0] | ConvertTo-Json -Depth 8)]"
|
||||
}
|
||||
else {
|
||||
ConvertTo-Json -InputObject $ranked -Depth 8
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if ($ranked.Count -eq 0) {
|
||||
Write-Host "No matching requirement packages found."
|
||||
return
|
||||
}
|
||||
|
||||
foreach ($result in $ranked) {
|
||||
Write-Host "[$($result.score)] $($result.title) ($($result.domain)/$($result.slug))"
|
||||
Write-Host " path: $($result.relativePath)"
|
||||
if ($result.tags.Count -gt 0) {
|
||||
Write-Host " tags: $(@($result.tags) -join ', ')"
|
||||
}
|
||||
Write-Host " matched: $(@($result.matchedTerms) -join ', ') in $(@($result.matchedFields) -join ', ')"
|
||||
if (-not [string]::IsNullOrWhiteSpace($result.feature)) {
|
||||
Write-Host " feature: $($result.feature)"
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
|
||||
134
scripts/test-requirement-search.ps1
Normal file
134
scripts/test-requirement-search.ps1
Normal file
@@ -0,0 +1,134 @@
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$buildScript = Join-Path $scriptDir "build-requirement-index.ps1"
|
||||
$searchScript = Join-Path $scriptDir "search-requirements.ps1"
|
||||
|
||||
$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("ks-zl-search-test-" + [System.Guid]::NewGuid().ToString("N"))
|
||||
New-Item -ItemType Directory -Path $tempRoot | Out-Null
|
||||
|
||||
function New-TestRequirementPackage {
|
||||
param(
|
||||
[string]$Domain,
|
||||
[string]$Slug,
|
||||
[string]$Title,
|
||||
[string[]]$Tags,
|
||||
[string]$Feature,
|
||||
[string]$Rules,
|
||||
[string]$Acceptance
|
||||
)
|
||||
|
||||
$packageDir = Join-Path $tempRoot (Join-Path $Domain (Join-Path "requirement-packages" $Slug))
|
||||
New-Item -ItemType Directory -Path (Join-Path $packageDir "references") -Force | Out-Null
|
||||
New-Item -ItemType File -Path (Join-Path $packageDir "decisions.md") -Force | Out-Null
|
||||
New-Item -ItemType File -Path (Join-Path $packageDir "acceptance.md") -Force | Out-Null
|
||||
|
||||
$tagLines = ($Tags | ForEach-Object { " - $_" }) -join "`n"
|
||||
$content = @(
|
||||
"---"
|
||||
"title: $Title"
|
||||
"category: $Domain/requirement-packages"
|
||||
"tags:"
|
||||
$tagLines
|
||||
"status: requirement-draft"
|
||||
"updated: 2026-05-13"
|
||||
"source: test"
|
||||
"---"
|
||||
""
|
||||
"# $Title"
|
||||
""
|
||||
"## 功能"
|
||||
"- $Feature"
|
||||
""
|
||||
"## 流程"
|
||||
"1. 发起请求。"
|
||||
""
|
||||
"## 数据表"
|
||||
"不涉及表"
|
||||
""
|
||||
"## 字典"
|
||||
"不新增字典"
|
||||
""
|
||||
"## 业务规则"
|
||||
"- $Rules"
|
||||
""
|
||||
"## 验收"
|
||||
"- $Acceptance"
|
||||
""
|
||||
"## 移植说明"
|
||||
"- 跨技术栈必须保留业务语义。"
|
||||
"- 来源实现仅作参考。"
|
||||
""
|
||||
"## 来源依据"
|
||||
"- 测试样例。"
|
||||
""
|
||||
"## 待确认"
|
||||
"- 无"
|
||||
""
|
||||
"## Related"
|
||||
"- None yet."
|
||||
) -join "`n"
|
||||
|
||||
Set-Content -LiteralPath (Join-Path $packageDir "requirement.md") -Value $content -Encoding UTF8
|
||||
}
|
||||
|
||||
try {
|
||||
New-TestRequirementPackage `
|
||||
-Domain "order" `
|
||||
-Slug "refund-approval" `
|
||||
-Title "退款审批" `
|
||||
-Tags @("refund", "approval", "order") `
|
||||
-Feature "提交退款申请后按审批规则生成审批任务。" `
|
||||
-Rules "退款金额超过阈值时必须进入人工审批。" `
|
||||
-Acceptance "搜索退款审批时该功能排在最前。"
|
||||
|
||||
New-TestRequirementPackage `
|
||||
-Domain "inventory" `
|
||||
-Slug "stock-sync" `
|
||||
-Title "库存同步" `
|
||||
-Tags @("stock", "sync") `
|
||||
-Feature "同步外部库存数量。" `
|
||||
-Rules "库存同步失败时记录重试。" `
|
||||
-Acceptance "搜索库存时可命中该功能。"
|
||||
|
||||
$indexPath = Join-Path $tempRoot "_indexes\requirements-index.json"
|
||||
& $buildScript -Root $tempRoot -OutputPath $indexPath | Out-Null
|
||||
|
||||
if (-not (Test-Path -LiteralPath $indexPath)) {
|
||||
throw "Expected index file was not created."
|
||||
}
|
||||
|
||||
$index = Get-Content -Raw -Encoding UTF8 -LiteralPath $indexPath | ConvertFrom-Json
|
||||
if (@($index).Count -ne 2) {
|
||||
throw "Expected 2 indexed requirement packages, got $(@($index).Count)."
|
||||
}
|
||||
|
||||
$refundResults = & $searchScript -Root $tempRoot -IndexPath $indexPath -Query "退款 审批" -Top 1 -Json |
|
||||
ConvertFrom-Json
|
||||
|
||||
if (@($refundResults).Count -ne 1) {
|
||||
throw "Expected one search result for refund query."
|
||||
}
|
||||
|
||||
if ($refundResults[0].slug -ne "refund-approval") {
|
||||
throw "Expected refund-approval to be the top result, got '$($refundResults[0].slug)'."
|
||||
}
|
||||
|
||||
$inventoryResults = & $searchScript -Root $tempRoot -IndexPath $indexPath -Query "库存" -Top 1 -Json |
|
||||
ConvertFrom-Json
|
||||
|
||||
if ($inventoryResults[0].slug -ne "stock-sync") {
|
||||
throw "Expected stock-sync to be the top inventory result, got '$($inventoryResults[0].slug)'."
|
||||
}
|
||||
|
||||
Write-Host "Requirement search tests passed."
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $tempRoot) {
|
||||
Remove-Item -LiteralPath $tempRoot -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
597
scripts/validate-distillation-output.ps1
Normal file
597
scripts/validate-distillation-output.ps1
Normal file
@@ -0,0 +1,597 @@
|
||||
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(?<body>.*?)(?=^## |\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, '(?<![\w./-])(?:\.{1,2}/)?[A-Za-z0-9_./-]+\.md(?:#[A-Za-z0-9_.-]+)?')) {
|
||||
$linkedPath = $match.Value
|
||||
if (-not $seenLinks.Contains($linkedPath)) {
|
||||
Add-MarkdownLinkErrorIfNeeded -BaseDirectory $BaseDirectory -RelativePath $RelativePath -Scope $Scope -LinkedPath $linkedPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Add-RelatedContentErrors {
|
||||
param(
|
||||
[string]$Content,
|
||||
[string]$RelativePath
|
||||
)
|
||||
|
||||
$trimmedContent = $Content.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($trimmedContent)) {
|
||||
Add-ValidationError "$RelativePath Related section is empty."
|
||||
return
|
||||
}
|
||||
|
||||
$hasNoneYet = $Content -match "(?m)^\s*-\s+None yet\.\s*$"
|
||||
$hasMarkdownReference = $Content -match '\[[^\]\r\n]+\]\([^)\r\n]+?\.md(?:#[^)\r\n]*)?\)'
|
||||
$hasBareReference = $Content -match '(?<![\w./-])(?:\.{1,2}/)?[A-Za-z0-9_./-]+\.md(?:#[A-Za-z0-9_.-]+)?'
|
||||
|
||||
if (-not $hasNoneYet -and -not $hasMarkdownReference -and -not $hasBareReference) {
|
||||
Add-ValidationError "$RelativePath Related section must contain '- None yet.' or at least one Markdown document link."
|
||||
}
|
||||
|
||||
if ($hasNoneYet -and ($hasMarkdownReference -or $hasBareReference)) {
|
||||
Add-ValidationError "$RelativePath Related section must not combine '- None yet.' with document links."
|
||||
}
|
||||
}
|
||||
|
||||
function Add-FrontMatterErrors {
|
||||
param(
|
||||
[string]$Content,
|
||||
[string]$RelativePath
|
||||
)
|
||||
|
||||
$requiredMetadataKeys = @("title", "category", "tags", "status", "updated", "source")
|
||||
|
||||
if ($Content -notmatch "(?s)\A---\r?\n(.*?)\r?\n---") {
|
||||
Add-ValidationError "$RelativePath is missing YAML metadata."
|
||||
return
|
||||
}
|
||||
|
||||
$frontMatter = $Matches[1]
|
||||
foreach ($key in $requiredMetadataKeys) {
|
||||
if ($frontMatter -notmatch "(?m)^$([regex]::Escape($key))\s*:") {
|
||||
Add-ValidationError "$RelativePath metadata is missing '$key'."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-FrontMatterValue {
|
||||
param(
|
||||
[string]$Content,
|
||||
[string]$Key
|
||||
)
|
||||
|
||||
if ($Content -notmatch "(?s)\A---\r?\n(?<frontmatter>.*?)\r?\n---") {
|
||||
return $null
|
||||
}
|
||||
|
||||
$frontMatter = $Matches["frontmatter"]
|
||||
$match = [regex]::Match($frontMatter, "(?m)^$([regex]::Escape($Key))\s*:\s*(?<value>.+?)\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+(?<heading>[^#\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 '<domain>/<requirement-packages|skill-requirements|feature-requirements>/<slug>/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 '<domain>/<slug>.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)."
|
||||
|
||||
Reference in New Issue
Block a user