commit b81d91917612c72f84ed3f0cec0357f4ef947c8d Author: wdh-home <243823965@qq.com> Date: Wed May 13 18:14:30 2026 +0800 Add ks-zl skill diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..7b85d33 --- /dev/null +++ b/SKILL.md @@ -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 +//requirement-packages// + 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 +//.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 \scripts\validate-distillation-output.ps1 -Root +``` + +## 完成反馈 + +回复用户时说明: +- 创建或更新了哪个文档/需求包路径。 +- 提取了哪些核心内容。 +- 复制或整理了哪些 `references/`。 +- 校验命令和结果。 +- 仍有哪些 `待确认` 或 pending 决策。 diff --git a/agents/openai.yaml b/agents/openai.yaml new file mode 100644 index 0000000..0c36403 --- /dev/null +++ b/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "KS 蒸馏" + short_description: "Distill features into concise reusable specs." + default_prompt: "/ks-ai 蒸馏当前会话到当前工作区" diff --git a/scripts/build-requirement-index.ps1 b/scripts/build-requirement-index.ps1 new file mode 100644 index 0000000..14f293e --- /dev/null +++ b/scripts/build-requirement-index.ps1 @@ -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(?.*?)\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*(?.*)\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*(?.*)\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*(?.+?)\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(?.*?)(?=^##\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" +} + + + diff --git a/scripts/search-requirements.ps1 b/scripts/search-requirements.ps1 new file mode 100644 index 0000000..3ace25e --- /dev/null +++ b/scripts/search-requirements.ps1 @@ -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 "" +} + + diff --git a/scripts/test-requirement-search.ps1 b/scripts/test-requirement-search.ps1 new file mode 100644 index 0000000..c0483c2 --- /dev/null +++ b/scripts/test-requirement-search.ps1 @@ -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 + } +} + + diff --git a/scripts/validate-distillation-output.ps1 b/scripts/validate-distillation-output.ps1 new file mode 100644 index 0000000..c5eeec2 --- /dev/null +++ b/scripts/validate-distillation-output.ps1 @@ -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(?.*?)(?=^## |\z)") + if ($match.Success) { + return $match.Groups["body"].Value + } + + return $null +} + +function Add-RequiredMarkdownSubsectionErrors { + param( + [string]$Content, + [string]$Section, + [string[]]$Subsections, + [string]$RelativePath + ) + + $body = Get-MarkdownSectionBody -Content $Content -Section $Section + if ($null -eq $body) { + return + } + + foreach ($subsection in $Subsections) { + if ($body -notmatch "(?m)^### $([regex]::Escape($subsection))[ `t`r]*$") { + Add-ValidationError "$RelativePath $Section is missing subsection '$subsection'." + } + } +} + +function Resolve-MarkdownLinkTarget { + param( + [string]$BaseDirectory, + [string]$LinkedPath + ) + + if ([string]::IsNullOrWhiteSpace($LinkedPath)) { + return $null + } + + if ($LinkedPath -match "^[a-z][a-z0-9+.-]*:") { + return $null + } + + $cleanPath = $LinkedPath.Trim() + $fragmentIndex = $cleanPath.IndexOf("#") + if ($fragmentIndex -ge 0) { + $cleanPath = $cleanPath.Substring(0, $fragmentIndex) + } + + if ([string]::IsNullOrWhiteSpace($cleanPath)) { + return $null + } + + return Join-Path $BaseDirectory ($cleanPath -replace "/", [System.IO.Path]::DirectorySeparatorChar) +} + +function Add-MarkdownLinkErrorIfNeeded { + param( + [string]$BaseDirectory, + [string]$RelativePath, + [string]$Scope, + [string]$LinkedPath + ) + + $targetPath = Resolve-MarkdownLinkTarget -BaseDirectory $BaseDirectory -LinkedPath $LinkedPath + + if ($null -eq $targetPath) { + return + } + + if (-not (Test-Path -LiteralPath $targetPath)) { + Add-ValidationError "$RelativePath $Scope links to missing file: $LinkedPath" + } +} + +function Add-MissingMarkdownLinkErrors { + param( + [string]$Content, + [string]$BaseDirectory, + [string]$RelativePath, + [string]$Scope + ) + + $seenLinks = New-Object System.Collections.Generic.HashSet[string] + + foreach ($match in [regex]::Matches($Content, '\[[^\]\r\n]+\]\(([^)\r\n]+?\.md(?:#[^)\r\n]*)?)\)')) { + $linkedPath = $match.Groups[1].Value + [void]$seenLinks.Add($linkedPath) + Add-MarkdownLinkErrorIfNeeded -BaseDirectory $BaseDirectory -RelativePath $RelativePath -Scope $Scope -LinkedPath $linkedPath + } + + foreach ($match in [regex]::Matches($Content, '(?.*?)\r?\n---") { + return $null + } + + $frontMatter = $Matches["frontmatter"] + $match = [regex]::Match($frontMatter, "(?m)^$([regex]::Escape($Key))\s*:\s*(?.+?)\s*$") + if ($match.Success) { + return $match.Groups["value"].Value.Trim().Trim('"').Trim("'") + } + + return $null +} + +function Add-PlaceholderErrors { + param( + [string]$Content, + [string]$RelativePath + ) + + if ($Content -match "<[^>`r`n]+>") { + Add-ValidationError "$RelativePath contains an unresolved angle-bracket placeholder." + } + + if ($Content -match "(?i)\bTODO\b") { + Add-ValidationError "$RelativePath contains TODO placeholder text." + } +} + +function Get-NonEmptyLineCount { + param([string]$Content) + + return @($Content -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count +} + +function Add-LineLengthErrors { + param( + [string]$Content, + [string]$RelativePath, + [int]$MaxLineChars = 180 + ) + + $lineNumber = 0 + $inFrontMatter = $false + foreach ($line in ($Content -split "`r?`n")) { + $lineNumber++ + if ($lineNumber -eq 1 -and $line -eq "---") { + $inFrontMatter = $true + continue + } + if ($inFrontMatter) { + if ($line -eq "---") { + $inFrontMatter = $false + } + continue + } + if ($line.TrimStart().StartsWith("|")) { + continue + } + if ($line.Length -gt $MaxLineChars) { + Add-ValidationError "$RelativePath line $lineNumber is too long: $($line.Length) chars, max $MaxLineChars. Split it into short requirement bullets." + } + } +} + +function Add-ForbiddenRequirementHeadingErrors { + param( + [string]$Content, + [string]$RelativePath + ) + + $forbiddenHeadings = @( + "背景", + "需求背景", + "价值", + "目标", + "技术方案", + "详细设计", + "接口设计", + "架构设计", + "实现方案" + ) + + foreach ($match in [regex]::Matches($Content, "(?m)^##\s+(?[^#\r\n]+?)\s*$")) { + $heading = $match.Groups["heading"].Value.Trim() + if ($forbiddenHeadings -contains $heading) { + Add-ValidationError "$RelativePath uses verbose heading '$heading'. Keep requirement.md as a short functional spec." + } + } +} + +function Add-SectionLineLimitErrors { + param( + [string]$Content, + [string]$RelativePath, + [hashtable]$Limits + ) + + foreach ($section in $Limits.Keys) { + $body = Get-MarkdownSectionBody -Content $Content -Section $section + if ($null -eq $body) { + continue + } + + $lineCount = Get-NonEmptyLineCount -Content $body + if ($lineCount -gt $Limits[$section]) { + Add-ValidationError "$RelativePath section '$section' is too long: $lineCount non-empty line(s), max $($Limits[$section]). Keep it concise or split the feature." + } + } +} + +function Add-RequirementPackageErrors { + param( + [System.IO.FileInfo]$File, + [string]$Content, + [string]$RelativePath + ) + + Add-FrontMatterErrors -Content $Content -RelativePath $RelativePath + Add-PlaceholderErrors -Content $Content -RelativePath $RelativePath + Add-LineLengthErrors -Content $Content -RelativePath $RelativePath + Add-ForbiddenRequirementHeadingErrors -Content $Content -RelativePath $RelativePath + + $nonEmptyLineCount = Get-NonEmptyLineCount -Content $Content + if ($nonEmptyLineCount -gt $MaxRequirementLines) { + Add-ValidationError "$RelativePath is too long: $nonEmptyLineCount non-empty line(s), max $MaxRequirementLines. Split the feature or remove non-essential prose." + } + + $slugPattern = "[a-z0-9]+(?:-[a-z0-9]+)*" + $allowedPackageDirs = "requirement-packages|skill-requirements|feature-requirements" + if ($RelativePath -notmatch "^$slugPattern/($allowedPackageDirs)/$slugPattern/requirement\.md$") { + Add-ValidationError "$RelativePath must use '///requirement.md'." + } + + $topDomain = ($RelativePath -split "/")[0] + $technicalDomains = @( + "backend", + "frontend", + "infra", + "devops", + "java", + "go", + "python", + "node", + "spring", + "gin", + "django", + "admin-service", + "admin-project" + ) + if ($technicalDomains -contains $topDomain) { + Add-ValidationError "$RelativePath uses technology or source-project domain '$topDomain'. Requirement packages must be organized by feature/capability domain." + } + + $pathParts = $RelativePath -split "/" + $packageKind = $pathParts[1] + $category = Get-FrontMatterValue -Content $Content -Key "category" + $expectedCategory = "$topDomain/$packageKind" + if ($null -ne $category -and $category -ne $expectedCategory) { + Add-ValidationError "$RelativePath metadata category must be '$expectedCategory' to match its package path." + } + + $requiredRequirementSections = @( + "功能", + "流程", + "数据表", + "字典", + "业务规则", + "验收", + "移植说明", + "来源依据", + "待确认", + "Related" + ) + + foreach ($section in $requiredRequirementSections) { + if (-not (Test-MarkdownSection -Content $Content -Section $section)) { + Add-ValidationError "$RelativePath requirement package is missing section '$section'." + } + } + + Add-SectionLineLimitErrors ` + -Content $Content ` + -RelativePath $RelativePath ` + -Limits @{ + "功能" = 8 + "流程" = 25 + "业务规则" = 30 + "验收" = 15 + "移植说明" = 6 + "来源依据" = 15 + "待确认" = 15 + } + + $dataTableBody = Get-MarkdownSectionBody -Content $Content -Section "数据表" + if ($null -ne $dataTableBody -and $dataTableBody -notmatch "\|" -and $dataTableBody -notmatch "不新增表|不涉及表|复用") { + Add-ValidationError "$RelativePath 数据表 must contain a field table or explicitly state no new/reused tables." + } + + $dictionaryBody = Get-MarkdownSectionBody -Content $Content -Section "字典" + if ($null -ne $dictionaryBody -and $dictionaryBody -notmatch "\|" -and $dictionaryBody -notmatch "不新增字典|不涉及字典|复用") { + Add-ValidationError "$RelativePath 字典 must contain value-domain rows or explicitly state no new/reused dictionaries." + } + + $portabilityBody = Get-MarkdownSectionBody -Content $Content -Section "移植说明" + if ($null -ne $portabilityBody -and ($portabilityBody -notmatch "跨技术栈|跨栈" -or $portabilityBody -notmatch "来源实现|参考")) { + Add-ValidationError "$RelativePath 移植说明 must mention both cross-stack preservation and source implementation as reference." + } + + $packageDir = $File.DirectoryName + $requiredPackageEntries = @("decisions.md", "acceptance.md", "references") + + foreach ($entry in $requiredPackageEntries) { + $entryPath = Join-Path $packageDir $entry + if (-not (Test-Path -LiteralPath $entryPath)) { + Add-ValidationError "$RelativePath requirement package is missing '$entry'." + } + } + + $decisionsPath = Join-Path $packageDir "decisions.md" + if (Test-Path -LiteralPath $decisionsPath) { + $decisionsContent = Get-Content -Raw -Encoding UTF8 -LiteralPath $decisionsPath + $decisionsRelativePath = Get-RelativePath $decisionsPath + Add-LineLengthErrors -Content $decisionsContent -RelativePath $decisionsRelativePath + $decisionsLineCount = Get-NonEmptyLineCount -Content $decisionsContent + if ($decisionsLineCount -gt $MaxSidecarLines) { + Add-ValidationError "$decisionsRelativePath is too long: $decisionsLineCount non-empty line(s), max $MaxSidecarLines. Keep decision records concise." + } + $requiredDecisionSections = @( + "已确认", + "待确认事项", + "冲突检查", + "闭环检查", + "方案记录", + "覆盖记录" + ) + + foreach ($section in $requiredDecisionSections) { + if (-not (Test-MarkdownSection -Content $decisionsContent -Section $section)) { + Add-ValidationError "$decisionsRelativePath is missing section '$section'." + } + } + + Add-RequiredMarkdownSubsectionErrors ` + -Content $decisionsContent ` + -Section "方案记录" ` + -Subsections @("推荐方案", "备选方案") ` + -RelativePath $decisionsRelativePath + + $approvalBody = Get-MarkdownSectionBody -Content $decisionsContent -Section "覆盖记录" + if ($null -ne $approvalBody -and $approvalBody -notmatch "(?m)^\s*-\s*允许覆盖\s*[::]") { + Add-ValidationError "$decisionsRelativePath 覆盖记录 must include '允许覆盖:'." + } + Add-PlaceholderErrors -Content $decisionsContent -RelativePath $decisionsRelativePath + } + + $acceptancePath = Join-Path $packageDir "acceptance.md" + if (Test-Path -LiteralPath $acceptancePath) { + $acceptanceContent = Get-Content -Raw -Encoding UTF8 -LiteralPath $acceptancePath + $acceptanceRelativePath = Get-RelativePath $acceptancePath + Add-LineLengthErrors -Content $acceptanceContent -RelativePath $acceptanceRelativePath + $acceptanceLineCount = Get-NonEmptyLineCount -Content $acceptanceContent + if ($acceptanceLineCount -gt $MaxSidecarLines) { + Add-ValidationError "$acceptanceRelativePath is too long: $acceptanceLineCount non-empty line(s), max $MaxSidecarLines. Keep acceptance sidecars concise." + } + $requiredAcceptanceSections = @( + "验收补充", + "边界补充", + "还原检查" + ) + + foreach ($section in $requiredAcceptanceSections) { + if (-not (Test-MarkdownSection -Content $acceptanceContent -Section $section)) { + Add-ValidationError "$acceptanceRelativePath is missing section '$section'." + } + } + Add-PlaceholderErrors -Content $acceptanceContent -RelativePath $acceptanceRelativePath + } + + $relatedBody = Get-MarkdownSectionBody -Content $Content -Section "Related" + if ($null -ne $relatedBody) { + Add-RelatedContentErrors -Content $relatedBody -RelativePath $RelativePath + Add-MissingMarkdownLinkErrors -Content $relatedBody -BaseDirectory $packageDir -RelativePath $RelativePath -Scope "Related" + } +} + +function Add-DistilledDocumentErrors { + param( + [System.IO.FileInfo]$File, + [string]$Content, + [string]$RelativePath + ) + + Add-FrontMatterErrors -Content $Content -RelativePath $RelativePath + Add-PlaceholderErrors -Content $Content -RelativePath $RelativePath + + $slugPattern = "[a-z0-9]+(?:-[a-z0-9]+)*" + if ($RelativePath -notmatch "^$slugPattern/$slugPattern\.md$") { + Add-ValidationError "$RelativePath must use '/.md'." + } + + $topDomain = ($RelativePath -split "/")[0] + $category = Get-FrontMatterValue -Content $Content -Key "category" + if ($null -ne $category -and $category -notmatch "^$([regex]::Escape($topDomain))(/|$)") { + Add-ValidationError "$RelativePath metadata category must start with '$topDomain' to match its document path." + } + + if ($File.Name -notmatch "^[a-z0-9]+(-[a-z0-9]+)*\.md$") { + Add-ValidationError "$RelativePath uses a non-slug file name." + } + + $requiredSections = @("Summary", "Keywords", "Environment", "Symptom", "Root Cause", "Solution", "Verification", "Related") + + foreach ($section in $requiredSections) { + if (-not (Test-MarkdownSection -Content $Content -Section $section)) { + Add-ValidationError "$RelativePath is missing section '$section'." + } + } + + $relatedBody = Get-MarkdownSectionBody -Content $Content -Section "Related" + if ($null -ne $relatedBody) { + Add-RelatedContentErrors -Content $relatedBody -RelativePath $RelativePath + Add-MissingMarkdownLinkErrors -Content $relatedBody -BaseDirectory $File.DirectoryName -RelativePath $RelativePath -Scope "Related" + } +} + +$excludedTopDirs = @(".git", "templates", "scripts") +$markdownFiles = Get-ChildItem -Path $rootPath -Recurse -File -Filter "*.md" -Force | + Where-Object { + $relativePath = Get-RelativePath $_.FullName + $topDir = ($relativePath -split "/")[0] + $excludedTopDirs -notcontains $topDir + } + +$distilledCount = 0 +$requirementPackageCount = 0 + +foreach ($file in $markdownFiles) { + $relativePath = Get-RelativePath $file.FullName + $content = Get-Content -Raw -Encoding UTF8 -LiteralPath $file.FullName + $isDistilled = $content -match "(?m)^status:\s*distilled\s*$" + $isRequirementPackage = $file.Name -eq "requirement.md" -and $content -match "(?m)^status:\s*requirement-draft\s*$" + + if ($isDistilled) { + $distilledCount++ + Add-DistilledDocumentErrors -File $file -Content $content -RelativePath $relativePath + } + + if ($isRequirementPackage) { + $requirementPackageCount++ + Add-RequirementPackageErrors -File $file -Content $content -RelativePath $relativePath + } + + foreach ($match in [regex]::Matches($content, "\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b")) { + $ip = $match.Value + if (-not (Test-AllowedExampleIp $ip)) { + Add-ValidationError "$relativePath contains a non-example IP address: $ip" + } + } + + $secretPatterns = @( + @{ Name = "private key"; Pattern = '-----BEGIN [A-Z ]*PRIVATE KEY-----' }, + @{ Name = "credential assignment"; Pattern = '(?i)\b(password|passwd|pwd|token|secret|api[_-]?key)\b\s*[:=]\s*\S{6,}' } + ) + + foreach ($pattern in $secretPatterns) { + if ($content -match $pattern.Pattern) { + Add-ValidationError "$relativePath contains a possible $($pattern.Name)." + } + } +} + +if (-not $AllowNoArtifacts -and ($distilledCount + $requirementPackageCount) -eq 0) { + Add-ValidationError "No distillation artifacts found. Expected at least one 'status: distilled' document or 'status: requirement-draft' package. Use -AllowNoArtifacts only when intentionally checking an empty skeleton." +} + +if ($errors.Count -gt 0) { + Write-Host "Validation failed with $($errors.Count) issue(s):" + foreach ($validationError in $errors) { + Write-Host "- $validationError" + } + exit 1 +} + +Write-Host "Validation passed. Checked $($markdownFiles.Count) Markdown file(s), including $distilledCount distilled document(s) and $requirementPackageCount requirement package(s)." + diff --git a/templates/requirement-package/acceptance.md b/templates/requirement-package/acceptance.md new file mode 100644 index 0000000..e3f1d99 --- /dev/null +++ b/templates/requirement-package/acceptance.md @@ -0,0 +1,10 @@ +# acceptance + +## 验收补充 +- 无;以 `requirement.md` 的 `验收` 为准。 + +## 边界补充 +- 无 + +## 还原检查 +- 后续 AI 必须按功能语义还原,不得把来源项目技术栈、目录结构或框架写成必须约束。 diff --git a/templates/requirement-package/decisions.md b/templates/requirement-package/decisions.md new file mode 100644 index 0000000..d026119 --- /dev/null +++ b/templates/requirement-package/decisions.md @@ -0,0 +1,34 @@ +# decisions + +## 已确认 +- 无 + +## 待确认事项 +- 无 + +## 冲突检查 +- 状态:none +- 旧需求:无 +- 新需求:无 +- 影响:无 + +## 闭环检查 +- 状态:closed-loop +- 缺口:无 +- 对还原影响:无 + +## 方案记录 +### 推荐方案 +- 方案:无 +- 理由:无 + +### 备选方案 +- 方案:无 +- 取舍:无 + +## 覆盖记录 +- 允许覆盖:否 +- 确认人:无 +- 确认时间:无 +- 覆盖范围:无 +- 确认依据:无 diff --git a/templates/requirement-package/references/README.md b/templates/requirement-package/references/README.md new file mode 100644 index 0000000..a5f0656 --- /dev/null +++ b/templates/requirement-package/references/README.md @@ -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. diff --git a/templates/requirement-package/requirement.md b/templates/requirement-package/requirement.md new file mode 100644 index 0000000..23a9ef7 --- /dev/null +++ b/templates/requirement-package/requirement.md @@ -0,0 +1,51 @@ +--- +title: +category: +tags: + - requirement-package +status: requirement-draft +updated: +source: +--- + +# + +## 功能 +- <1-5 条:做什么、谁触发、产生什么业务结果> + +## 流程 +1. <主流程步骤> +2. <关键分支或异常;没有则删除本条> + +## 数据表 +### +- 用途:<为什么需要这张表;不新增表时写复用对象> + +| 字段 | 含义 | 类型/值域 | 必填 | 规则 | +| --- | --- | --- | --- | --- | +| | | | 是/否 | | + +## 字典 +### +| 值 | 含义 | 规则 | +| --- | --- | --- | +| | | | + +## 业务规则 +- <确定规则、边界、异常或禁止行为> + +## 验收 +- <可判断的结果检查项> + +## 移植说明 +- 跨技术栈必须保留:<业务语义/状态/规则> +- 来源实现仅作参考:<代码结构/接口/框架/表结构等参考内容> + +## 来源依据 +- <用户确认、当前会话或源项目材料中的关键依据> + +## 待确认 +- 无 + +## Related +- None yet. diff --git a/templates/solution-template.md b/templates/solution-template.md new file mode 100644 index 0000000..5a371a6 --- /dev/null +++ b/templates/solution-template.md @@ -0,0 +1,37 @@ +--- +title: +category: +tags: + - keyword-1 + - keyword-2 + - 中文关键词 +status: distilled +updated: +source: +--- + +# + +## Summary +用一句话说明这篇文档解决什么问题。 + +## Keywords +keyword-1, keyword-2, keyword-3, 中文关键词 + +## Environment +填写相关的 OS、runtime、framework、service 或 deployment context。 + +## Symptom +用短句描述表面现象或触发问题。 + +## Root Cause +说明真正的原因或关键判断依据。 + +## Solution +清晰写出最终采用的解决方案。 + +## Verification +列出验证结果的方法。 + +## Related +- None yet.