Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "spec-driftcheck",
"name": "acceptance-spec",
"owner": {
"name": "yhuan123",
"url": "https://github.com/yhuan123"
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <repo-key> --changed-files changed.txt --spec-dir spec \
Expand All @@ -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 项

## 开发

Expand Down
4 changes: 4 additions & 0 deletions docs/playbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ driftcheck init --plugin-name <插件名> --spec-repo <owner/主仓库> [--out s
2. 各组件仓库 PAC namespace 建 secret:`kubectl create secret generic spec-drift-github-token --from-literal=token=<bot-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 步:试点验证
Expand All @@ -81,6 +82,7 @@ driftcheck init --plugin-name <插件名> --spec-repo <owner/主仓库> [--out s
1. 机器人评论出现,列出正确的能力域与关联 REQ;
2. 再 push 一次,评论**幂等更新**(同一条评论,不重复发);
3. 改动不命中锚点的 PR **不**收到评论(负样本)。
4. (启用 autofix 时)人为制造漂移(如临时从某 anchors.yaml 删一个已登记 CRD 并合入 main)→ 手动 `workflow_dispatch` 触发 → 验证:修复 PR 出现且内容正确;全绿时触发 → 不开 PR;漂移未合并前连跑两次 → 同一 PR 被更新不重复;漂移含"消失的 CRD"→ PR body 出现 ⚠️ 高亮。
- **完成判据**:三项全过 → 推广其余组件仓库(每仓库只改 `repo-name`)。

---
Expand Down Expand Up @@ -111,3 +113,5 @@ driftcheck notice --repo-name <repo-key> --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 时重点看 ⚠️ 高亮的疑似移除项与 `<!-- draft by autofix -->` 草稿 REQ。
10 changes: 10 additions & 0 deletions internal/report/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package report

import (
"encoding/json"
"fmt"
"strings"

Expand All @@ -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
}
47 changes: 47 additions & 0 deletions internal/report/report_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
8 changes: 4 additions & 4 deletions internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion internal/scaffold/scaffold.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
45 changes: 44 additions & 1 deletion internal/scaffold/scaffold_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -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()
Expand All @@ -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)
}
}
}
65 changes: 65 additions & 0 deletions internal/scaffold/templates/sync/autofix-prompt.md.tmpl
Original file line number Diff line number Diff line change
@@ -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<n>-<两位序号>: <标题> (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 标题下加一行
`<!-- 疑似移除:CRD <名> 已从仓库消失,待人工确认删除本 REQ 或恢复代码 -->`,
并在 `/tmp/pr-body.md` 用 ⚠️ 高亮列出,请人工裁决是误删代码还是应删 REQ。
- `crd-uncovered`(仓库新 CRD 未被 anchors 登记):在语义最接近的能力域登记
该 CRD,并基于 CRD manifest 的字段起草一个**草稿 REQ**:
- 优先级暂填 `P2`(占位,待人工核定),**不要标 `planned`**——该 CRD 已存在
即功能已交付,而 `planned` 仅用于未交付能力;
- REQ 标题下加一行 `<!-- draft by autofix:待人工核定优先级与措辞 -->`,
REQ 须含至少一个完整 GWT Scenario;
- 没有语义接近的能力域时,登记到 anchors 后在 pr-body 中说明"建议人工评估
是否新建能力域"。
- `anchor-path`(glob 无匹配文件):在仓库中探测目录改名/移动后的新位置并更
新 glob(保持目录级 `dir/**` 粒度);确认路径已不存在 → 删除该 glob 并在
pr-body 说明。
- `spec-structure` / `fuzzy-word`:按格式契约直接修复措辞,不改变 REQ 语义。

## /tmp/pr-body.md 格式

```
## spec 漂移自动修复

### 本次漂移
<findings 的 markdown 表格:能力域 | REQ | 类型 | 详情>

### 逐条修复说明
<每条 finding 一行:做了什么、为什么>

### ⚠️ 需要人工重点确认
<逐条列出:疑似移除的 CRD/REQ(待裁决删除或恢复代码)、autofix 起草的草稿 REQ(待核定优先级);无则写"无">
```

修完务必自验:`driftcheck check --spec-dir spec --local-repo-root .` 须 exit 0。
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading