diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4f87b19..28f2730 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,5 +1,5 @@ { - "name": "spec-driftcheck", + "name": "acceptance-spec", "owner": { "name": "yhuan123", "url": "https://github.com/yhuan123" diff --git a/README.md b/README.md index 6201934..6af526f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ driftcheck init --plugin-name gitlab --spec-repo AlaudaDevops/gitlab-chart # 结构校验:REQ/Scenario 格式、模糊词 lint、anchors 路径与 CRD 有效性、 # 反向 CRD 哨兵(已定义但未被 anchors 登记的 CRD → crd-uncovered,豁免用 ignoreCRDs) -driftcheck check --spec-dir spec --work-dir /tmp/repos --local-repo-root . +driftcheck check --spec-dir spec --work-dir /tmp/repos --local-repo-root . # --format json 输出结构化 findings # PR 漂移提醒:变更文件命中锚点 → 发/幂等更新 PR 评论(无 GITHUB_TOKEN 时只打印) driftcheck notice --repo-name --changed-files changed.txt --spec-dir spec \ @@ -28,6 +28,7 @@ ghcr.io/yhuan123/spec-driftcheck:latest # 含 git + driftcheck 二进制,供 - 流程权威:[docs/playbook.md](docs/playbook.md)(7 步,含每步完成判据;平台无关,任何 AI agent 可执行) - Claude Code 用户:把 [skills/spec-bootstrap](skills/spec-bootstrap) 复制/软链到 `~/.claude/skills/` 后说"给 xxx 插件建 spec 体系" +- 漂移自动修复(Codex 起草修复 PR,人审合并):见 playbook 第 6 步第 5 项 ## 开发 diff --git a/docs/playbook.md b/docs/playbook.md index be99992..352221a 100644 --- a/docs/playbook.md +++ b/docs/playbook.md @@ -72,6 +72,7 @@ driftcheck init --plugin-name <插件名> --spec-repo [--out s 2. 各组件仓库 PAC namespace 建 secret:`kubectl create secret generic spec-drift-github-token --from-literal=token=`(bot 需各仓库 PR 评论权限)。 3. 各组件仓库 `.tekton/` 加 PipelineRun 引用 `spec-drift-notice` Task(模板在 `spec/sync/tasks/`,只改 `repo-name` param)。建议先 1 个仓库试点。 4. 主仓库所在集群 apply `spec/sync/tasks/scheduled-drift-check.yaml`(每日定时全量兜底)。 +5. (可选,GitHub 托管仓库)启用漂移自动修复:复制 `spec/sync/workflows/spec-drift-autofix.yaml` 到主仓库 `.github/workflows/`,repo secrets 配置 `OPENAI_API_KEY`。每日全量 check 发现漂移后由 Codex 起草修复,经护栏(只许改 spec/、禁改 drift-check.yaml)与 check 质量门后,向固定分支 `spec-drift-autofix` 提幂等 PR,**合并权在人**。启用后集群侧 `scheduled-drift-check.yaml` 可不再部署(离线集群仍用它)。 - **完成判据**:见第 7 步试点。 ## 第 7 步:试点验证 @@ -81,6 +82,7 @@ driftcheck init --plugin-name <插件名> --spec-repo [--out s 1. 机器人评论出现,列出正确的能力域与关联 REQ; 2. 再 push 一次,评论**幂等更新**(同一条评论,不重复发); 3. 改动不命中锚点的 PR **不**收到评论(负样本)。 +4. (启用 autofix 时)人为制造漂移(如临时从某 anchors.yaml 删一个已登记 CRD 并合入 main)→ 手动 `workflow_dispatch` 触发 → 验证:修复 PR 出现且内容正确;全绿时触发 → 不开 PR;漂移未合并前连跑两次 → 同一 PR 被更新不重复;漂移含"消失的 CRD"→ PR body 出现 ⚠️ 高亮。 - **完成判据**:三项全过 → 推广其余组件仓库(每仓库只改 `repo-name`)。 --- @@ -111,3 +113,5 @@ driftcheck notice --repo-name --changed-files /tmp/changed.txt --spec - anchors 路径写太宽会误报刷屏,写太窄会漏报——从测试目录/patches/CRD types 起步,按误报率迭代; - 评论 bot 的 token 权限:对组件仓库需要 issues/PR 写权限; - 定时任务依赖集群已有 ScheduledTrigger CRD(tektoncd-enhancement 提供);无此 CRD 的集群可改用 CronJob 形态自行改写。 +- autofix workflow 的 cron 是 UTC(`0 18 * * *` = 北京时间 02:00),改时区要换算; +- autofix 的质量门只覆盖机器可判定层(格式/锚点/CRD 存在性),**语义对错完全靠 PR 人审**——review 时重点看 ⚠️ 高亮的疑似移除项与 `` 草稿 REQ。 diff --git a/internal/report/report.go b/internal/report/report.go index 7b2d49f..e9fbfe2 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -2,6 +2,7 @@ package report import ( + "encoding/json" "fmt" "strings" @@ -26,3 +27,12 @@ func Render(findings []runner.Finding) string { b.WriteString("\n处理方式:更新 spec/对应 REQ,或在 spec/sync/drift-check.yaml 中附原因临时豁免。\n") return b.String() } + +// RenderJSON 输出 findings 的 JSON 数组(零 findings 输出 [],供下游 jq 消费)。 +func RenderJSON(findings []runner.Finding) (string, error) { + if len(findings) == 0 { + return "[]", nil + } + data, err := json.MarshalIndent(findings, "", " ") + return string(data), err +} diff --git a/internal/report/report_test.go b/internal/report/report_test.go new file mode 100644 index 0000000..1881a36 --- /dev/null +++ b/internal/report/report_test.go @@ -0,0 +1,47 @@ +package report + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/yhuan123/spec-driftcheck/internal/runner" +) + +// TestRenderJSON_Empty:零 findings 输出 "[]"(不是 "null",下游 jq 依赖数组语义)。 +func TestRenderJSON_Empty(t *testing.T) { + out, err := RenderJSON(nil) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(out) != "[]" { + t.Fatalf("want [], got %q", out) + } +} + +// TestRenderJSON_Fields:字段名与 drift-check.yaml 的 reqId 惯例一致,可逆序列化。 +func TestRenderJSON_Fields(t *testing.T) { + out, err := RenderJSON([]runner.Finding{ + {Capability: "D1-demo", ReqID: "REQ-D1-01", Check: "crd-defined", Detail: "未找到 CRD"}, + {Capability: "", ReqID: "", Check: "crd-uncovered", Detail: "NewKind 未登记"}, + }) + if err != nil { + t.Fatal(err) + } + var got []map[string]string + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("输出应是合法 JSON 数组: %v\n%s", err, out) + } + if len(got) != 2 { + t.Fatalf("want 2 entries, got %d", len(got)) + } + first := got[0] + for k, want := range map[string]string{ + "capability": "D1-demo", "reqId": "REQ-D1-01", + "check": "crd-defined", "detail": "未找到 CRD", + } { + if first[k] != want { + t.Errorf("first[%q] = %q, want %q", k, first[k], want) + } + } +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index cd79e78..c4eb89a 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -17,10 +17,10 @@ import ( // Finding 是一处漂移。 type Finding struct { - Capability string - ReqID string // anchors/CRD 级问题为空;fuzzy-word 可留空(见 Detail) - Check string // spec-structure | fuzzy-word | anchor-path | crd-defined | crd-uncovered - Detail string + Capability string `json:"capability"` + ReqID string `json:"reqId"` // anchors/CRD 级问题为空;fuzzy-word 可留空(见 Detail) + Check string `json:"check"` // spec-structure | fuzzy-word | anchor-path | crd-defined | crd-uncovered + Detail string `json:"detail"` } type ignoreFile struct { diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index c772e89..18d9649 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -42,7 +42,12 @@ func Render(outDir string, p Params) ([]string, error) { if err != nil { return err } - tmpl, err := template.New(rel).Parse(string(raw)) + t := template.New(rel) + if strings.HasPrefix(rel, "sync/workflows/") { + // GHA workflow 含 ${{ }},与默认定界符冲突,改用 [[ ]]。 + t = t.Delims("[[", "]]") + } + tmpl, err := t.Parse(string(raw)) if err != nil { return fmt.Errorf("解析模板 %s: %w", rel, err) } diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go index c51197c..5b59241 100644 --- a/internal/scaffold/scaffold_test.go +++ b/internal/scaffold/scaffold_test.go @@ -48,7 +48,7 @@ func TestRender_SubstitutesParams(t *testing.T) { if err != nil { t.Fatal(err) } - if strings.Contains(string(data), "{{") { + if strings.Contains(string(data), "{{.") || strings.Contains(string(data), "[[.") { t.Errorf("%s 含未渲染的模板占位符", f) } } @@ -61,6 +61,30 @@ func TestRender_SubstitutesParams(t *testing.T) { } } +// TestRender_WorkflowKeepsGHAExpressions:workflow 模板用 [[ ]] 定界符渲染, +// GHA 的 ${{ }} 原样保留,Go 模板变量被替换。 +func TestRender_WorkflowKeepsGHAExpressions(t *testing.T) { + root := t.TempDir() + specDir := filepath.Join(root, "spec") + if _, err := Render(specDir, testParams); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(specDir, "sync/workflows/spec-drift-autofix.yaml")) + if err != nil { + t.Fatal(err) + } + s := string(data) + if !strings.Contains(s, "${{ secrets.OPENAI_API_KEY }}") { + t.Error("GHA secrets 表达式应原样保留") + } + if !strings.Contains(s, testParams.ToolImage) { + t.Error("ToolImage 应被渲染") + } + if strings.Contains(s, "[[") { + t.Error("不应残留 [[ ]] 模板占位符") + } +} + // TestRender_RefusesOverwrite 断言:目标文件已存在时报错,不覆盖。 func TestRender_RefusesOverwrite(t *testing.T) { root := t.TempDir() @@ -72,3 +96,22 @@ func TestRender_RefusesOverwrite(t *testing.T) { t.Fatal("重复渲染应报错拒绝覆盖") } } + +// TestRender_AutofixPrompt:prompt 模板渲染且含关键纪律。 +func TestRender_AutofixPrompt(t *testing.T) { + root := t.TempDir() + specDir := filepath.Join(root, "spec") + if _, err := Render(specDir, testParams); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(specDir, "sync/autofix-prompt.md")) + if err != nil { + t.Fatal(err) + } + s := string(data) + for _, want := range []string{"demo-plugin", "drift-check.yaml", "planned", "/tmp/pr-body.md"} { + if !strings.Contains(s, want) { + t.Errorf("prompt 应含 %q", want) + } + } +} diff --git a/internal/scaffold/templates/sync/autofix-prompt.md.tmpl b/internal/scaffold/templates/sync/autofix-prompt.md.tmpl new file mode 100644 index 0000000..289b720 --- /dev/null +++ b/internal/scaffold/templates/sync/autofix-prompt.md.tmpl @@ -0,0 +1,65 @@ +# spec 漂移自动修复任务书 + +你是 {{.PluginName}} 插件业务价值 spec 体系的维护 agent。当前目录是主仓库 +({{.SpecRepo}})的工作副本,`spec/` 下是 spec 体系。本任务书末尾附有本次 +`driftcheck check` 发现的漂移 findings(JSON 数组)。你的任务:修改 `spec/` +下的文件消除全部漂移,使 `driftcheck check --spec-dir spec --local-repo-root .` +重新通过(exit 0)。 + +## 硬性约束(违反任何一条,修复会被流水线整体拒绝) + +1. 只允许改动 `spec/` 目录下的文件; +2. 禁止修改 `spec/sync/drift-check.yaml`——不得用 ignore/ignoreCRDs 豁免来"修绿"; +3. 修完把修复说明写入 `/tmp/pr-body.md`(格式见文末)。 + +## spec 格式契约(摘录) + +- REQ 头:`### REQ-D-<两位序号>: <标题> (P0|P1|P2[, planned])`, + `planned` = 功能尚未交付; +- 每个 REQ 至少一个 `#### Scenario:`,每个 Scenario 必须有 + `- GIVEN` / `- WHEN` / `- THEN` 行(`- AND` 可选); +- REQ 主句用 EARS 句式(WHEN/WHILE/IF…THEN + SHALL),一个 REQ 只说一件事; +- GWT 行禁用模糊词:合理、正常、适当、尽快、友好——时限/数量/状态值必须明确; +- CRD 字段写全限定路径。 + +## 按 finding 类型的处置规则 + +**总原则:不确定时,宁可保留现状 + 在 pr-body 提问,也不要做猜测性删除。** +删除 REQ 永远是人的决定,你只负责把失效的锚点指针对齐代码现实,并把存疑处显眼地交给人。 + +- `crd-defined`(anchors 登记的 CRD 在仓库中找不到):先在仓库中找改名痕迹 + (相似 kind、git 历史、CRD manifest 目录)。 + - 确认**改名** → 同步更新该能力域 anchors.yaml 与 spec.md 中相关 REQ 的措辞; + - 确认**消失** → 仅从 anchors.yaml 移除该 CRD 登记(使 check 恢复绿), + **保留对应 REQ 不要删除**;在该 REQ 标题下加一行 + ``, + 并在 `/tmp/pr-body.md` 用 ⚠️ 高亮列出,请人工裁决是误删代码还是应删 REQ。 +- `crd-uncovered`(仓库新 CRD 未被 anchors 登记):在语义最接近的能力域登记 + 该 CRD,并基于 CRD manifest 的字段起草一个**草稿 REQ**: + - 优先级暂填 `P2`(占位,待人工核定),**不要标 `planned`**——该 CRD 已存在 + 即功能已交付,而 `planned` 仅用于未交付能力; + - REQ 标题下加一行 ``, + REQ 须含至少一个完整 GWT Scenario; + - 没有语义接近的能力域时,登记到 anchors 后在 pr-body 中说明"建议人工评估 + 是否新建能力域"。 +- `anchor-path`(glob 无匹配文件):在仓库中探测目录改名/移动后的新位置并更 + 新 glob(保持目录级 `dir/**` 粒度);确认路径已不存在 → 删除该 glob 并在 + pr-body 说明。 +- `spec-structure` / `fuzzy-word`:按格式契约直接修复措辞,不改变 REQ 语义。 + +## /tmp/pr-body.md 格式 + +``` +## spec 漂移自动修复 + +### 本次漂移 + + +### 逐条修复说明 +<每条 finding 一行:做了什么、为什么> + +### ⚠️ 需要人工重点确认 +<逐条列出:疑似移除的 CRD/REQ(待裁决删除或恢复代码)、autofix 起草的草稿 REQ(待核定优先级);无则写"无"> +``` + +修完务必自验:`driftcheck check --spec-dir spec --local-repo-root .` 须 exit 0。 diff --git a/internal/scaffold/templates/sync/workflows/spec-drift-autofix.yaml.tmpl b/internal/scaffold/templates/sync/workflows/spec-drift-autofix.yaml.tmpl new file mode 100644 index 0000000..b80ee71 --- /dev/null +++ b/internal/scaffold/templates/sync/workflows/spec-drift-autofix.yaml.tmpl @@ -0,0 +1,101 @@ +# spec 漂移自动修复:每日全量 check → Codex 起草修复 → 护栏与质量门 → 幂等 PR。 +# 接入:复制本文件到主仓库 .github/workflows/,并在 repo secrets 配置 OPENAI_API_KEY。 +# 设计:generator-verifier 分离——agent 是不可信生成器,driftcheck check 是确定性验证器。 +name: spec-drift-autofix +on: + schedule: + - cron: "0 18 * * *" # 02:00 Asia/Shanghai + workflow_dispatch: {} +concurrency: + group: spec-drift-autofix + cancel-in-progress: true +permissions: + contents: write + pull-requests: write +jobs: + autofix: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: extract driftcheck binary + # 工具镜像是 alpine 基底,不能作 container job(JS actions 的 node 不兼容 musl), + # 故提取静态二进制到 runner。 + run: | + docker create --name dc [[.ToolImage]] + sudo docker cp dc:/usr/local/bin/driftcheck /usr/local/bin/driftcheck + docker rm dc + driftcheck 2>&1 | head -1 || true + - name: drift check + id: check + run: | + set +e + driftcheck check --spec-dir spec --work-dir /tmp/driftcheck-repos \ + --local-repo-root . --format json > /tmp/findings.json + code=$? + set -e + # 基础设施错误(克隆失败等):非零退出且无 findings 输出 → 直接红,与漂移路径区分。 + if [ "$code" -ne 0 ] && [ ! -s /tmp/findings.json ]; then + echo "driftcheck 基础设施错误,详见上方日志" >&2 + exit "$code" + fi + echo "drifted=$([ "$code" -ne 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + cat /tmp/findings.json + - name: compose prompt + if: steps.check.outputs.drifted == 'true' + run: | + cat spec/sync/autofix-prompt.md > /tmp/prompt.md + printf '\n## 本次漂移 findings(JSON)\n\n```json\n' >> /tmp/prompt.md + cat /tmp/findings.json >> /tmp/prompt.md + printf '\n```\n' >> /tmp/prompt.md + - name: codex autofix + if: steps.check.outputs.drifted == 'true' + uses: openai/codex-action@v1 + with: + openai-api-key: ${{ secrets.OPENAI_API_KEY }} + prompt-file: /tmp/prompt.md + sandbox: workspace-write + - name: guardrails + if: steps.check.outputs.drifted == 'true' + # 确定性护栏,不信任 prompt 约束:改动只许落在 spec/ 内,且禁改豁免配置。 + # 用 git status --porcelain 覆盖未追踪新文件 + 暂存 + 未暂存改动。 + run: | + changed=$(git status --porcelain) + echo "agent 改动:"; echo "$changed" + paths=$(echo "$changed" | cut -c4-) + outside=$(echo "$paths" | grep -v '^$' | grep -v '^spec/' || true) + if [ -n "$outside" ]; then + echo "护栏违规:改动超出 spec/:" >&2; echo "$outside" >&2; exit 1 + fi + if echo "$paths" | grep -q '^spec/sync/drift-check.yaml$'; then + echo "护栏违规:禁止修改 spec/sync/drift-check.yaml(豁免是人的特权)" >&2; exit 1 + fi + - name: verify green + if: steps.check.outputs.drifted == 'true' + # 确定性质量门:agent 修完必须全绿,否则不开 PR。 + run: | + rm -rf /tmp/driftcheck-repos + driftcheck check --spec-dir spec --work-dir /tmp/driftcheck-repos --local-repo-root . + - name: ensure PR body + if: steps.check.outputs.drifted == 'true' + # agent 按任务书把修复说明写到 /tmp/pr-body.md;缺失时退化为原始 findings。 + run: | + if [ ! -s /tmp/pr-body.md ]; then + { + echo '## spec 漂移自动修复' + echo + echo '⚠️ agent 未生成修复说明,请对照原始 findings 逐项审查改动:' + echo + echo '```json' + cat /tmp/findings.json + echo '```' + } > /tmp/pr-body.md + fi + - name: create or update PR + if: steps.check.outputs.drifted == 'true' + uses: peter-evans/create-pull-request@v7 + with: + branch: spec-drift-autofix + title: "spec: 漂移自动修复(autofix)" + commit-message: "spec: 漂移自动修复(autofix)" + body-path: /tmp/pr-body.md + delete-branch: true diff --git a/main.go b/main.go index 5a30faa..c9f0546 100644 --- a/main.go +++ b/main.go @@ -76,17 +76,29 @@ func runCheck(args []string) error { specDir := fs.String("spec-dir", "", "spec 目录(含 capabilities/ 与 sync/)") workDir := fs.String("work-dir", "/tmp/driftcheck-repos", "跨仓库克隆工作目录") localRoot := fs.String("local-repo-root", "", "local 仓库(tektoncd-operator)根目录") + format := fs.String("format", "text", "输出格式:text|json") if err := fs.Parse(args); err != nil { return err } if *specDir == "" || *localRoot == "" { return fmt.Errorf("--spec-dir 与 --local-repo-root 必填") } + if *format != "text" && *format != "json" { + return fmt.Errorf("未知 --format %q(支持 text|json)", *format) + } findings, err := runner.Run(*specDir, *workDir, *localRoot) if err != nil { return err } - fmt.Print(report.Render(findings)) + if *format == "json" { + out, err := report.RenderJSON(findings) + if err != nil { + return err + } + fmt.Println(out) + } else { + fmt.Print(report.Render(findings)) + } if len(findings) > 0 { os.Exit(1) }