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

218
SKILL.md Normal file
View File

@@ -0,0 +1,218 @@
---
name: ks-zl
description: Use when 用户输入 /ks-ai 或 /ks-zl要求把项目功能、当前会话中的需求/方案/业务规则,或简短功能描述蒸馏成可复用、可交付给 AI 还原功能的标准化文档。
---
# ks-zl
## 定位
把功能、需求、边界、业务规则、方案和参考材料蒸馏成标准化文档资产,目标是让后续 AI 能基于文档尽量 1:1 还原功能。
本 skill 自带最小写入规范、模板和校验脚本,不依赖固定蒸馏库。它只负责沉淀文档资产,不实现源项目功能,不修改源项目业务代码。
核心原则蒸馏结果以功能为中心不以来源项目或技术栈为中心。Java admin、Go service、Python app、前端页面、数据库表和接口路径都只能作为来源证据或参考材料需求本体必须描述跨技术栈可还原的业务能力。
短文档原则:默认产物是“功能规格卡片”,不是 PRD、设计文档或项目说明书。只写功能、流程、表、字段、字典、规则、验收和必要来源不写长篇背景、价值论证、技术选型、实现调用链或泛化说明。
## 默认输出位置
1. 用户明确给出输出路径时,写入该路径。
2. 用户未给出输出路径时,优先写入当前工作区。
3. 当前工作区不是合适的落点,且无法从上下文判断目标位置时,再询问用户。
如果输出路径已有自己的 `AGENTS.md``README.md``templates/` 或校验脚本,先读取它们以理解目录、索引、历史模板和可用校验;写入规范默认仍以本 skill 为准。
本地优先级判定:
- 本地 `AGENTS.md``README.md` 明确声明“写入使用 `ks-zl`”或“不维护独立写入规范”时,按本 skill 写入;本地 `templates/``scripts/` 只作为辅助资产按需使用。
- 只有本地规则明确要求某个模板、章节、路径或校验脚本时,才覆盖本 skill 的默认写入规范。
- 仅存在 `templates/` 或校验脚本,不等于本地写入规范优先;不要因为历史模板存在就反向覆盖本 skill 的需求包结构。
## 来源类型
### 源项目功能提取
用户示例:
```text
/ks-ai 帮我把 xxx 功能蒸馏到 C:\path\to\output
```
处理方式:
- 在当前源项目中定位 `xxx` 功能相关代码、文档、配置、数据库、API、页面、任务、权限、异常和测试。
- 只读取必要文件;先通过文件名、路由、模块名、接口名、菜单名、表名、关键词缩小范围。
- 提取功能目标、角色和权限、业务流程、输入输出、状态流转、边界、异常、依赖、验收方式和可复用业务规则。
- 将 Java、Spring、MyBatis、Gin、Django、SQL、接口路径、代码目录等实现细节放入 `来源依据``references/`;除非用户明确要求且它本身就是业务约束,不得写成确定需求。
- 如果没有可读取的源项目上下文,只根据用户描述创建需求草稿,并把缺失信息写入 `待确认``decisions.md``待确认事项`
- 将强依赖参考材料复制或整理到需求包 `references/`,不要只保留源项目绝对路径。
### 当前会话蒸馏
用户示例:
```text
/ks-ai 蒸馏当前会话到 C:\path\to\output
```
处理方式:
- 汇总当前会话中已经明确的功能需求、方案、业务规则、边界、决策和未确认事项。
- 只把用户确认或对话中明确成立的内容写成确定规则。
- 模糊、冲突或未确认内容写入 `待确认``decisions.md``待确认事项`
- 不要求存在源项目。
### 简短描述蒸馏
用户示例:
```text
/ks-ai xxx 功能需要 xxxx
```
处理方式:
- 把用户描述作为需求初稿。
- 不编造缺失业务规则。
- 能确定的写入主需求;不能确定的写入待确认事项。
- 不要求存在源项目。
## 输出类型选择
### 优先使用需求包
当目标是“以后在新项目中让 AI 根据文档 1:1 还原功能”时,创建或更新需求包,而不是单篇解决方案文档。
默认需求包结构:
```text
<output-root>/<capability-domain>/requirement-packages/<slug>/
requirement.md
decisions.md
acceptance.md
references/
```
`capability-domain` 默认从功能领域判断,例如 `access-control``workflow``inventory``reporting``billing``content-management``notification``integration``admin-operations`。不要用来源项目名、语言、框架或技术层目录作为需求包主目录。`slug` 使用小写英文、数字和连字符。若输出路径已有 `skill-requirements``feature-requirements` 需求包目录,沿用既有命名;不要创建并行规范。
新建需求包时,使用 `templates/requirement-package/``requirement.md` 至少写清:
- `功能`
- `流程`
- `数据表`
- `字典`
- `业务规则`
- `验收`
- `移植说明`
- `来源依据`
- `待确认`
- `Related`
`decisions.md``acceptance.md` 是审计/补充旁路文件,默认保持简短。能写进 `requirement.md` 的验收不要在 `acceptance.md` 重复扩写;只有冲突、覆盖、闭环缺口或用户批准记录需要进入 `decisions.md`
### 使用解决方案文档
只有当内容是故障、根因、排查经验、技术问题或可复用解决办法,而不是完整功能需求时,才使用单篇解决方案文档。
解决方案文档使用 `templates/solution-template.md`,默认放在:
```text
<output-root>/<domain>/<slug>.md
```
## 命名规范
- 目录名和文件名默认使用 ASCII 小写 kebab-case`a-z``0-9``-`;不使用中文、空格、下划线、日期前缀或版本号,除非目标路径本地规则明确要求。
- `capability-domain` 必须是可检索的功能域或能力域,例如 `access-control``workflow``inventory``reporting``billing``content-management``notification``integration``admin-operations`
- 禁止用技术层、语言、框架、来源项目名作为需求包主目录,例如 `backend``frontend``infra``devops``java``go``python``spring``gin``django``admin-service`
- 需求包目录默认是 `requirement-packages`;若输出路径已有 `skill-requirements``feature-requirements`,沿用既有目录。
- `slug` 从功能目标生成,使用 2-6 个英文关键词,优先表达对象和能力,例如 `permission-menu-sync`;不要使用 `new-feature``misc``test` 这类泛名。
- 同一主题已存在时更新原包;同名但语义不同且确需新建时,在 slug 末尾追加区分对象,例如 `permission-menu-sync-admin`
- 参考材料文件名也使用 kebab-case按用途命名例如 `api-contract.md``ui-flow.md``source-tree.md``database-schema.md`
## 查找规范
查找目标路径已有内容时:
- 先读目标路径的 `AGENTS.md``README.md``templates/` 和校验脚本,只读取存在且和当前任务相关的文件;区分“读取/定位规则”“辅助资产”和“明确写入规范”。
-`rg --files` 查找 `requirement.md``decisions.md``acceptance.md``status: requirement-draft``status: distilled`、候选 slug 和中文/英文关键词。
- 先匹配路径和标题,再读候选包的 `功能``流程``数据表``字典``业务规则`;目标能力相同则更新原包,不新建。
- 只有目标能力、边界或还原目标明显不同,才创建新需求包。
- 若目标路径有 `_indexes/`,更新已有索引;没有 `_indexes/` 时不创建索引系统。
查找源项目功能时:
- 先用用户给出的功能名、路由、模块名、接口名、菜单名、表名、任务名、权限标识和关键词缩小范围。
- 优先读取入口文件、路由/API 定义、数据模型、配置、测试和最近相关文档;不要默认全仓库扫描。
- 每次扩展阅读范围都要能说明它和目标功能的关系。
- 读取源项目材料后,先抽象成功能概念,再写需求;不要按源码目录、类名、接口路径或表结构组织需求本体。
## 新增和更新需求工作流
处理任何新增、补充或更新需求时,先完成判断,再写入确定内容:
1. 定位候选需求包:按查找规范找同 slug、同标题、同目标能力、同业务对象或同验收目标的需求包。
2. 判断关系:标记为 `new``additive``conflict``replacement``split-needed`
3. 检查闭环:确认 `功能``流程``数据表``字典``业务规则``验收``移植说明` 是否足够让后续 AI 还原;缺失则记录到 `闭环检查``待确认`
4. 检查冲突:如果新需求与旧需求的已确认规则、边界、验收、数据对象或参考材料冲突,禁止直接覆盖旧内容。
5. 给出方案:对每个冲突或闭环缺口,给用户 2-3 个互斥方案,并明确推荐一个最优方案和推荐理由。
6. 等待确认:涉及覆盖、删除、替换、重解释旧确定规则时,必须先得到用户明确同意,再修改旧规则。
7. 记录决策:把冲突、闭环检查、候选方案、推荐项和用户批准记录写入 `decisions.md`
8. 再写文档:只有已确认的内容写入确定章节;未确认内容只写入 `待确认事项``待确认`
默认推荐策略:
- 冲突但用户没有明确说新需求取代旧需求:推荐“保留旧确定规则,把新内容作为待确认冲突记录”。
- 用户明确要求新需求覆盖旧需求:推荐“覆盖前先展示差异和影响,获得明确批准后替换”。
- 需求不闭环:推荐“先补齐最小闭环问题,再写确定需求”;如果用户要求先保存,只能保存为 draft并把缺口写入待确认。
- 多个目标能力混在一起:推荐“拆成多个需求包”,不要把无关能力塞进同一包。
## 需求写入规范
- 主文档写短规格,默认不超过 120 个非空行;超出时必须拆分功能、裁剪重复内容或把证据移入 `references/`
- 禁止新增 `背景``价值``目标``技术方案``详细设计``接口设计``架构设计` 这类容易扩写的一级章节;必要证据放进 `来源依据``references/`
- 不写“为了提升体验”“系统需要支持完整能力”这类空泛句;每一条都必须能落到流程、表字段、字典值、规则或验收。
- `功能` 只写这个功能做什么、谁触发、产生什么结果,最多 5 条。
- `流程` 只写主流程和影响实现/验收的关键分支,不写类名、方法名、调用链或目录结构。
- `数据表` 必须写需要新增或复用的逻辑表;每张表只列业务字段、含义、类型/值域、必填和规则。不需要表时写 `不新增表,复用:...``不涉及表`
- `字典` 必须写需要新增或复用的字典和值域;不需要字典时写 `不新增字典`
- `业务规则` 只写确定规则、边界、异常和禁止行为;不要把来源项目的代码结构写成规则。
- `验收` 只写可判断结果的检查项,避免和 `流程` 重复。
- `移植说明` 只写两类内容:跨技术栈必须保留的业务语义、来源实现仅作参考的内容。
- `来源依据` 只列关键依据,不复制大段源码或旧文档。
- `待确认` 只放会影响功能还原或实现决策的问题;没有则写 `无`
## 参考材料规则
- `references/` 是强依赖材料,后续还原功能时必须随需求包一起交给 AI。
- 外部绝对路径只能记录来源,不能作为后续还原时必须读取的唯一依据。
- 复制参考材料前必须脱敏和裁剪删除真实密码、token、私钥、客户数据、真实公网 IP、内部地址、域名和账号。
- 参考材料过大时,先放结构快照、关键片段、规则摘要和文件清单;不要无差别复制整个项目。
- `requirement.md` 必须说明每份参考材料的用途。
- `Related` 只放弱关联知识,不替代 `references/`
## 查找和落点
1. 解析用户指定的输出路径;未指定时使用当前工作区。
2. 读取输出路径的本地规则和辅助资产;只有本地明确规定写入格式时才优先遵守,否则使用本 skill 的写入规范和模板。
3. 需求包按功能域判断落点,例如 `access-control``workflow``inventory``reporting`;只有单篇技术解决方案文档才按技术问题分类。
4. 先查找输出路径中是否已有相关文档或需求包。
5. 如果已有,更新原文档或原需求包;不要创建重复包。
6. 如果没有,按默认结构创建新文档或新需求包。
7. 如果输出路径有 `_indexes/`,按本地规则更新索引;没有 `_indexes/` 时不强制创建索引。
## 写入标准
- 中文为主体语言,技术术语保留英文原文。
- 保留可执行的边界和验收,不写空泛总结。
- 不把未确认事项写成确定结论。
- 不把实现细节伪造成业务需求代码、接口、SQL、页面细节只有在用户或源材料明确时才写且默认放入证据或参考材料不作为跨技术栈必须还原的规则。
- 如果从源项目提取功能,必须在 `来源依据``待确认` 中区分“已观察事实”“推断”“待确认”。
- 当前会话和简短描述蒸馏必须能在没有源项目的情况下完成。
- 完成后先运行与当前写入规范匹配的校验:本地规则明确要求本地校验脚本时先运行本地脚本;随后运行本 skill 自带校验作为需求包结构、敏感信息和短规格的基线检查。
```powershell
powershell -ExecutionPolicy Bypass -File <skill-root>\scripts\validate-distillation-output.ps1 -Root <output-root>
```
## 完成反馈
回复用户时说明:
- 创建或更新了哪个文档/需求包路径。
- 提取了哪些核心内容。
- 复制或整理了哪些 `references/`
- 校验命令和结果。
- 仍有哪些 `待确认` 或 pending 决策。

4
agents/openai.yaml Normal file
View File

@@ -0,0 +1,4 @@
interface:
display_name: "KS 蒸馏"
short_description: "Distill features into concise reusable specs."
default_prompt: "/ks-ai 蒸馏当前会话到当前工作区"

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

View File

@@ -0,0 +1,10 @@
# acceptance
## 验收补充
- 无;以 `requirement.md``验收` 为准。
## 边界补充
-
## 还原检查
- 后续 AI 必须按功能语义还原,不得把来源项目技术栈、目录结构或框架写成必须约束。

View File

@@ -0,0 +1,34 @@
# decisions
## 已确认
-
## 待确认事项
-
## 冲突检查
- 状态none
- 旧需求:无
- 新需求:无
- 影响:无
## 闭环检查
- 状态closed-loop
- 缺口:无
- 对还原影响:无
## 方案记录
### 推荐方案
- 方案:无
- 理由:无
### 备选方案
- 方案:无
- 取舍:无
## 覆盖记录
- 允许覆盖:否
- 确认人:无
- 确认时间:无
- 覆盖范围:无
- 确认依据:无

View File

@@ -0,0 +1,3 @@
# references
This directory stores sanitized, trimmed, self-contained reference materials required to reconstruct the feature. Do not rely on external absolute paths as the only source of truth.

View File

@@ -0,0 +1,51 @@
---
title: <feature-slug>
category: <capability-domain/requirement-packages>
tags:
- requirement-package
status: requirement-draft
updated: <YYYY-MM-DD>
source: <conversation|source-project|mixed>
---
# <feature-name>
## 功能
- <1-5 条:做什么、谁触发、产生什么业务结果>
## 流程
1. <主流程步骤>
2. <关键分支或异常;没有则删除本条>
## 数据表
### <table-name 或 不新增表>
- 用途:<为什么需要这张表;不新增表时写复用对象>
| 字段 | 含义 | 类型/值域 | 必填 | 规则 |
| --- | --- | --- | --- | --- |
| <field> | <meaning> | <type/domain> | 是/否 | <rule> |
## 字典
### <dict-code 或 不新增字典>
| 值 | 含义 | 规则 |
| --- | --- | --- |
| <value> | <meaning> | <rule> |
## 业务规则
- <确定规则、边界、异常或禁止行为>
## 验收
- <可判断的结果检查项>
## 移植说明
- 跨技术栈必须保留:<业务语义/状态/规则>
- 来源实现仅作参考:<代码结构/接口/框架/表结构等参考内容>
## 来源依据
- <用户确认、当前会话或源项目材料中的关键依据>
## 待确认
-
## Related
- None yet.

View File

@@ -0,0 +1,37 @@
---
title: <topic-symptom-resolution>
category: <domain/subdomain>
tags:
- keyword-1
- keyword-2
- 中文关键词
status: distilled
updated: <YYYY-MM-DD>
source: <conversation|source-project|sanitized>
---
# <topic-symptom-resolution>
## Summary
用一句话说明这篇文档解决什么问题。
## Keywords
keyword-1, keyword-2, keyword-3, 中文关键词
## Environment
填写相关的 OS、runtime、framework、service 或 deployment context。
## Symptom
用短句描述表面现象或触发问题。
## Root Cause
说明真正的原因或关键判断依据。
## Solution
清晰写出最终采用的解决方案。
## Verification
列出验证结果的方法。
## Related
- None yet.