Add ks-zl skill

This commit is contained in:
2026-05-13 18:14:30 +08:00
commit b81d919176
11 changed files with 1492 additions and 0 deletions

View 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"
}

View 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 ""
}

View 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
}
}

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