This commit is contained in:
2026-06-23 05:02:15 +08:00
commit e6f1776d4f
264 changed files with 54215 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
// Package state embeds the issue state JSON schema and exposes a Validate
// helper for spx subcommands.
//
// The schema lives in the repo root under schemas/ so non-Go tooling
// (TypeScript / docs) can pick it up too; we embed it at build time so the
// shipped binary doesn't depend on the source tree.
package state
import (
_ "embed"
"encoding/json"
"fmt"
"strings"
"sync"
"github.com/santhosh-tekuri/jsonschema/v5"
)
//go:embed schema.json
var schemaBytes []byte
var (
compiled *jsonschema.Schema
compiledOnce sync.Once
compiledErr error
)
// SchemaBytes returns the embedded schema document. Useful for tooling that
// wants to dump or re-publish the schema.
func SchemaBytes() []byte {
out := make([]byte, len(schemaBytes))
copy(out, schemaBytes)
return out
}
// compile parses the embedded schema once and caches it.
func compile() (*jsonschema.Schema, error) {
compiledOnce.Do(func() {
c := jsonschema.NewCompiler()
c.Draft = jsonschema.Draft7
// Use a synthetic URL — the real $id in the schema is just a marker.
const url = "memory://state-json.schema.json"
if err := c.AddResource(url, strings.NewReader(string(schemaBytes))); err != nil {
compiledErr = fmt.Errorf("加载内嵌 schema 失败: %w", err)
return
}
sch, err := c.Compile(url)
if err != nil {
compiledErr = fmt.Errorf("编译内嵌 schema 失败: %w", err)
return
}
compiled = sch
})
return compiled, compiledErr
}
// Validate parses jsonBytes as JSON and validates the resulting value against
// the embedded state schema. Returns nil on success and a descriptive error
// (listing failing fields and rules) on validation failure.
func Validate(jsonBytes []byte) error {
sch, err := compile()
if err != nil {
return err
}
var doc any
if err := json.Unmarshal(jsonBytes, &doc); err != nil {
return fmt.Errorf("state JSON 解析失败: %w", err)
}
if err := sch.Validate(doc); err != nil {
return formatValidationError(err)
}
return nil
}
// ValidateValue validates an already-parsed value (e.g. the merged map).
func ValidateValue(v any) error {
sch, err := compile()
if err != nil {
return err
}
if err := sch.Validate(v); err != nil {
return formatValidationError(err)
}
return nil
}
// formatValidationError flattens a jsonschema validation tree into a
// human-readable, multi-line error listing each failing field and rule.
func formatValidationError(err error) error {
ve, ok := err.(*jsonschema.ValidationError)
if !ok {
return fmt.Errorf("state JSON 校验失败: %w", err)
}
var lines []string
collectValidationLines(ve, &lines)
if len(lines) == 0 {
return fmt.Errorf("state JSON 校验失败: %s", ve.Error())
}
return fmt.Errorf("state JSON 校验失败:\n - %s", strings.Join(lines, "\n - "))
}
// collectValidationLines walks the validation tree and emits one line per leaf
// failure: "<json-pointer>: <message>".
func collectValidationLines(ve *jsonschema.ValidationError, out *[]string) {
if ve == nil {
return
}
if len(ve.Causes) == 0 {
loc := ve.InstanceLocation
if loc == "" {
loc = "(root)"
}
*out = append(*out, fmt.Sprintf("%s: %s", loc, ve.Message))
return
}
for _, c := range ve.Causes {
collectValidationLines(c, out)
}
}
+91
View File
@@ -0,0 +1,91 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://github.com/cruldra/superpowers-vscode/schemas/state-json.schema.json",
"title": "Superpowers VSCode issue state JSON",
"description": "持久化在 issue 最后一条 comment 里的 JSON blobKanbanPanel 用来恢复 issue 在看板上的列、关联的 spec/plan/PR/branch/worktree、以及各个会话 id 等运行时状态。所有字段均可选,缺失字段由 issueLoader 用默认值兜底。",
"type": "object",
"additionalProperties": false,
"properties": {
"column": {
"type": "string",
"description": "看板列 id;缺失时 issueLoader 按 issue.state 兜底为 todo/done。",
"enum": ["todo", "in-progress", "review", "done"]
},
"sessionId": {
"type": "string",
"description": "讨论/头脑风暴会话的 Claude Code session id,用于 resume 已有对话。",
"minLength": 1
},
"implementSessionId": {
"type": "string",
"description": "实施阶段 (implement) 的 Claude Code session id,与讨论 sessionId 隔离。",
"minLength": 1
},
"reviewSessionId": {
"type": "string",
"description": "审查阶段的会话 idv1 存 codex thread id),用于 webhook synchronize 时 resume。",
"minLength": 1
},
"testSessionId": {
"type": "string",
"description": "测试阶段的 Claude Code session idPR 合并后手动启动的测试会话,用于 resume。",
"minLength": 1
},
"profilePath": {
"type": "string",
"description": "创建工单时使用的 Claude settings 配置文件绝对路径,resume 时作为 --settings 传入。",
"minLength": 1
},
"specFile": {
"type": "string",
"description": "本工单的 spec 文档路径,相对 workspace,必须形如 `dir/.../name.md`。",
"minLength": 1,
"pattern": "^[^\\s]+/[^\\s]+\\.md$"
},
"planFile": {
"type": "string",
"description": "本工单的 plan 文档路径,相对 workspace,必须形如 `dir/.../name.md`。",
"minLength": 1,
"pattern": "^[^\\s]+/[^\\s]+\\.md$"
},
"prDiffFile": {
"type": "string",
"description": "本工单的 PR 变更摘要文件路径,相对 workspace,必须位于 docs/pr-diff/ 下。",
"minLength": 1,
"pattern": "^docs/pr-diff/[^\\s]+\\.md$"
},
"pr": {
"type": "string",
"description": "本工单关联的 PR number(字符串形式,webhook 触发后写入)。",
"minLength": 1
},
"prMerged": {
"type": "boolean",
"description": "关联 PR 是否已合并;优先以 PR API 实时结果为准,此字段是 fallback。"
},
"branch": {
"type": "string",
"description": "实施分支名,例如 `feature/<hash>`。",
"minLength": 1
},
"worktreePath": {
"type": "string",
"description": "实施 worktree 的 workspace 相对路径。",
"minLength": 1
},
"implementStatus": {
"type": "string",
"description": "实施流程生命周期状态。",
"enum": ["running", "done", "failed"]
},
"color": {
"type": "string",
"description": "Terminal/tab 配色(一个 terminal.ansi* ThemeColor key),首次开会话时固化,后续所有会话复用同一色。",
"minLength": 1
},
"autoReview": {
"type": "boolean",
"description": "本工单是否启用自动审查(覆盖全局 autoReview 设置)。"
}
}
}
+88
View File
@@ -0,0 +1,88 @@
package state
import (
"bytes"
"os"
"path/filepath"
"runtime"
"testing"
)
// TestSchemaMirrorInSync makes sure the embedded copy under internal/state/
// matches the canonical schemas/state-json.schema.json at the repo root.
// The two-file layout exists so plain `go build` keeps working (embed needs
// the file inside the package tree) while non-Go tooling can still consume
// the root schemas/ directory. Drift between the two would be silent without
// this check.
func TestSchemaMirrorInSync(t *testing.T) {
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("无法定位测试文件路径")
}
pkgDir := filepath.Dir(thisFile)
canonical := filepath.Join(pkgDir, "..", "..", "..", "schemas", "state-json.schema.json")
canonicalBytes, err := os.ReadFile(canonical)
if err != nil {
t.Fatalf("读取根目录 schema 失败: %v", err)
}
if !bytes.Equal(canonicalBytes, schemaBytes) {
t.Fatalf("internal/state/schema.json 与 schemas/state-json.schema.json 内容不一致;请 `make sync-schema` 同步")
}
}
func TestValidateAcceptsEmpty(t *testing.T) {
if err := Validate([]byte(`{}`)); err != nil {
t.Fatalf("空对象应当通过校验,得到: %v", err)
}
}
func TestValidateRejectsBadColumn(t *testing.T) {
err := Validate([]byte(`{"column":"todoo"}`))
if err == nil {
t.Fatal("非法 column 应当被拒绝")
}
}
func TestValidateRejectsUnknownField(t *testing.T) {
err := Validate([]byte(`{"weirdField":1}`))
if err == nil {
t.Fatal("未声明字段应当被 additionalProperties:false 拒绝")
}
}
func TestValidateRejectsBadSpecFile(t *testing.T) {
err := Validate([]byte(`{"specFile":"..."}`))
if err == nil {
t.Fatal("占位 `...` 应当被 pattern 拒绝")
}
}
func TestValidateRejectsBadPrDiffFile(t *testing.T) {
err := Validate([]byte(`{"prDiffFile":"docs/superpowers/plans/foo.md"}`))
if err == nil {
t.Fatal("非 docs/pr-diff/*.md 路径应当被 prDiffFile pattern 拒绝")
}
}
func TestValidateAcceptsFullState(t *testing.T) {
full := `{
"column":"in-progress",
"sessionId":"abc",
"implementSessionId":"impl-1",
"reviewSessionId":"rev-1",
"profilePath":"/home/x/.claude/settings.json",
"specFile":"docs/superpowers/specs/foo.md",
"planFile":"docs/superpowers/plans/foo.md",
"prDiffFile":"docs/pr-diff/pr-42-issue-7.md",
"pr":"42",
"prMerged":true,
"branch":"feature/abc",
"worktreePath":".worktrees/foo",
"implementStatus":"done",
"color":"terminal.ansiBlue",
"autoReview":true
}`
if err := Validate([]byte(full)); err != nil {
t.Fatalf("完整合法 state 应当通过校验,得到: %v", err)
}
}