✨ feat(spx): issue marker 支持第三种类型 prototype
- --type 三选一 spec|plan|prototype,marker 行写 <!-- spx:prototype=路径 -->,
与 spec/plan 共用同一套增量更新逻辑;类型→state 字段的映射收成一张表
- 同步写 state JSON 的 prototypeFile(pattern 要求 dir/.../x.json)
- 顺手补上 b62e58d 漏掉的 prototypeSessionId / prototypeProfilePath:
schema 与 knownStateFields 都没有它们,带原型会话的工单一 merge
就会被 additionalProperties 拒掉
Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
This commit is contained in:
1 parent
814e0e8f5a
commit
def5f4e88c
5 files changed
+89
-8
No files matched your search
@@ -111,6 +111,20 @@ func buildRootCmd() *cobra.Command {
|
|||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// markerTypeToStateField maps a `spx issue marker --type` value to the state
|
||||||
|
// JSON field it mirrors. All three types share one incremental-update path;
|
||||||
|
// this table is the single source of truth — adding a type only touches it
|
||||||
|
// (plus markerTypesHint below, which is what the user sees).
|
||||||
|
var markerTypeToStateField = map[string]string{
|
||||||
|
"spec": "specFile",
|
||||||
|
"plan": "planFile",
|
||||||
|
"prototype": "prototypeFile",
|
||||||
|
}
|
||||||
|
|
||||||
|
// markerTypesHint is the human-readable enumeration used in help text and the
|
||||||
|
// "bad --type" error, kept next to the table so the two can't drift.
|
||||||
|
const markerTypesHint = "spec、plan 或 prototype"
|
||||||
|
|
||||||
func buildIssueCmd() *cobra.Command {
|
func buildIssueCmd() *cobra.Command {
|
||||||
issue := &cobra.Command{
|
issue := &cobra.Command{
|
||||||
Use: "issue",
|
Use: "issue",
|
||||||
@@ -215,17 +229,17 @@ func buildIssueCmd() *cobra.Command {
|
|||||||
|
|
||||||
markerCmd := &cobra.Command{
|
markerCmd := &cobra.Command{
|
||||||
Use: "marker",
|
Use: "marker",
|
||||||
Short: "增量更新现有工单的 spec/plan marker,并同步合并 specFile/planFile 进 state JSON",
|
Short: "增量更新现有工单的 spec/plan/prototype marker,并同步合并 specFile/planFile/prototypeFile 进 state JSON",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if imIssue <= 0 {
|
if imIssue <= 0 {
|
||||||
return fmt.Errorf("--issue 必须是正整数")
|
return fmt.Errorf("--issue 必须是正整数")
|
||||||
}
|
}
|
||||||
if imType != "spec" && imType != "plan" {
|
fieldName, ok := markerTypeToStateField[imType]
|
||||||
return fmt.Errorf("--type 必须是 spec 或 plan,得到 %q", imType)
|
if !ok {
|
||||||
|
return fmt.Errorf("--type 必须是 %s,得到 %q", markerTypesHint, imType)
|
||||||
}
|
}
|
||||||
// Fail fast on a bad path before we touch the network — the
|
// Fail fast on a bad path before we touch the network — the
|
||||||
// schema's specFile/planFile pattern doubles as input check.
|
// schema's pattern for this field doubles as input check.
|
||||||
fieldName := map[string]string{"spec": "specFile", "plan": "planFile"}[imType]
|
|
||||||
patch := map[string]any{fieldName: imValue}
|
patch := map[string]any{fieldName: imValue}
|
||||||
if err := state.ValidateValue(patch); err != nil {
|
if err := state.ValidateValue(patch); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -263,8 +277,8 @@ func buildIssueCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
markerCmd.Flags().IntVar(&imIssue, "issue", 0, "工单编号(必填)")
|
markerCmd.Flags().IntVar(&imIssue, "issue", 0, "工单编号(必填)")
|
||||||
markerCmd.Flags().StringVar(&imType, "type", "", "marker 类型:spec 或 plan(必填)")
|
markerCmd.Flags().StringVar(&imType, "type", "", "marker 类型:"+markerTypesHint+"(必填)")
|
||||||
markerCmd.Flags().StringVar(&imValue, "value", "", "marker 值(路径,必填,同步写入 state JSON 的 specFile/planFile)")
|
markerCmd.Flags().StringVar(&imValue, "value", "", "marker 值(路径,必填,同步写入 state JSON 的 specFile/planFile/prototypeFile)")
|
||||||
_ = markerCmd.MarkFlagRequired("issue")
|
_ = markerCmd.MarkFlagRequired("issue")
|
||||||
_ = markerCmd.MarkFlagRequired("type")
|
_ = markerCmd.MarkFlagRequired("type")
|
||||||
_ = markerCmd.MarkFlagRequired("value")
|
_ = markerCmd.MarkFlagRequired("value")
|
||||||
@@ -394,11 +408,14 @@ var knownStateFields = map[string]struct{}{
|
|||||||
"implementSessionId": {},
|
"implementSessionId": {},
|
||||||
"reviewSessionId": {},
|
"reviewSessionId": {},
|
||||||
"testSessionId": {},
|
"testSessionId": {},
|
||||||
|
"prototypeSessionId": {},
|
||||||
"profilePath": {},
|
"profilePath": {},
|
||||||
"brainstormProfilePath": {},
|
"brainstormProfilePath": {},
|
||||||
"testProfilePath": {},
|
"testProfilePath": {},
|
||||||
|
"prototypeProfilePath": {},
|
||||||
"specFile": {},
|
"specFile": {},
|
||||||
"planFile": {},
|
"planFile": {},
|
||||||
|
"prototypeFile": {},
|
||||||
"prDiffFile": {},
|
"prDiffFile": {},
|
||||||
"pr": {},
|
"pr": {},
|
||||||
"prMerged": {},
|
"prMerged": {},
|
||||||
|
|||||||
@@ -29,16 +29,38 @@ func TestUpsertMarkerReplacesWhenPresent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUpsertMarkerLeavesOtherTypesAlone(t *testing.T) {
|
func TestUpsertMarkerLeavesOtherTypesAlone(t *testing.T) {
|
||||||
body := "<!-- spx:plan=p.md -->\n<!-- spx:spec=old.md -->"
|
body := "<!-- spx:plan=p.md -->\n<!-- spx:prototype=docs/prototype/x/figma-plugin/manifest.json -->\n<!-- spx:spec=old.md -->"
|
||||||
got := UpsertMarker(body, "spec", "new.md")
|
got := UpsertMarker(body, "spec", "new.md")
|
||||||
if !strings.Contains(got, "<!-- spx:plan=p.md -->") {
|
if !strings.Contains(got, "<!-- spx:plan=p.md -->") {
|
||||||
t.Fatalf("plan marker should be preserved: %q", got)
|
t.Fatalf("plan marker should be preserved: %q", got)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(got, "<!-- spx:prototype=docs/prototype/x/figma-plugin/manifest.json -->") {
|
||||||
|
t.Fatalf("prototype marker should be preserved: %q", got)
|
||||||
|
}
|
||||||
if !strings.Contains(got, "<!-- spx:spec=new.md -->") {
|
if !strings.Contains(got, "<!-- spx:spec=new.md -->") {
|
||||||
t.Fatalf("spec marker should be updated: %q", got)
|
t.Fatalf("spec marker should be updated: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpsertMarkerPrototypeAppendsWhenAbsent(t *testing.T) {
|
||||||
|
got := UpsertMarker("hello body", "prototype", "docs/prototype/login/figma-plugin/manifest.json")
|
||||||
|
want := "hello body\n<!-- spx:prototype=docs/prototype/login/figma-plugin/manifest.json -->"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertMarkerPrototypeReplacesWhenPresent(t *testing.T) {
|
||||||
|
body := "intro\n<!-- spx:prototype=old/manifest.json -->\nmore"
|
||||||
|
got := UpsertMarker(body, "prototype", "new/manifest.json")
|
||||||
|
if strings.Contains(got, "old/manifest.json") {
|
||||||
|
t.Fatalf("old value should be gone: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "<!-- spx:prototype=new/manifest.json -->") {
|
||||||
|
t.Fatalf("new marker missing: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpsertMarkerEmptyBody(t *testing.T) {
|
func TestUpsertMarkerEmptyBody(t *testing.T) {
|
||||||
got := UpsertMarker("", "plan", "p.md")
|
got := UpsertMarker("", "plan", "p.md")
|
||||||
want := "<!-- spx:plan=p.md -->"
|
want := "<!-- spx:plan=p.md -->"
|
||||||
|
|||||||
@@ -31,6 +31,11 @@
|
|||||||
"description": "测试阶段的 Claude Code session id;PR 合并后手动启动的测试会话,用于 resume。",
|
"description": "测试阶段的 Claude Code session id;PR 合并后手动启动的测试会话,用于 resume。",
|
||||||
"minLength": 1
|
"minLength": 1
|
||||||
},
|
},
|
||||||
|
"prototypeSessionId": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "原型阶段的 Claude Code session id;画 Figma 原型的会话,用于 resume。",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
"profilePath": {
|
"profilePath": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "创建工单时使用的 Claude settings 配置文件绝对路径,resume 时作为 --settings 传入。",
|
"description": "创建工单时使用的 Claude settings 配置文件绝对路径,resume 时作为 --settings 传入。",
|
||||||
@@ -46,6 +51,11 @@
|
|||||||
"description": "测试会话使用的 Claude settings 配置文件绝对路径;与实施 profilePath 独立,空则用默认。",
|
"description": "测试会话使用的 Claude settings 配置文件绝对路径;与实施 profilePath 独立,空则用默认。",
|
||||||
"minLength": 1
|
"minLength": 1
|
||||||
},
|
},
|
||||||
|
"prototypeProfilePath": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "原型会话使用的 Claude settings 配置文件绝对路径;与实施 profilePath 独立,空则用默认。",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
"specFile": {
|
"specFile": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "本工单的 spec 文档路径,相对 workspace,必须形如 `dir/.../name.md`。",
|
"description": "本工单的 spec 文档路径,相对 workspace,必须形如 `dir/.../name.md`。",
|
||||||
@@ -58,6 +68,12 @@
|
|||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"pattern": "^[^\\s]+/[^\\s]+\\.md$"
|
"pattern": "^[^\\s]+/[^\\s]+\\.md$"
|
||||||
},
|
},
|
||||||
|
"prototypeFile": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "本工单的 Figma 原型插件 manifest.json 路径,仓库相对或绝对,必须形如 `dir/.../manifest.json`。",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^[^\\s]+/[^\\s]+\\.json$"
|
||||||
|
},
|
||||||
"prDiffFile": {
|
"prDiffFile": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "本工单的 PR 变更摘要文件路径,相对 workspace,必须位于 docs/pr-diff/ 下。",
|
"description": "本工单的 PR 变更摘要文件路径,相对 workspace,必须位于 docs/pr-diff/ 下。",
|
||||||
|
|||||||
@@ -71,11 +71,14 @@ func TestValidateAcceptsFullState(t *testing.T) {
|
|||||||
"implementSessionId":"impl-1",
|
"implementSessionId":"impl-1",
|
||||||
"reviewSessionId":"rev-1",
|
"reviewSessionId":"rev-1",
|
||||||
"testSessionId":"test-1",
|
"testSessionId":"test-1",
|
||||||
|
"prototypeSessionId":"proto-1",
|
||||||
"profilePath":"/home/x/.claude/settings.json",
|
"profilePath":"/home/x/.claude/settings.json",
|
||||||
"brainstormProfilePath":"/home/x/.claude/profiles/brainstorm.json",
|
"brainstormProfilePath":"/home/x/.claude/profiles/brainstorm.json",
|
||||||
"testProfilePath":"/home/x/.claude/profiles/test.json",
|
"testProfilePath":"/home/x/.claude/profiles/test.json",
|
||||||
|
"prototypeProfilePath":"/home/x/.claude/profiles/prototype.json",
|
||||||
"specFile":"docs/superpowers/specs/foo.md",
|
"specFile":"docs/superpowers/specs/foo.md",
|
||||||
"planFile":"docs/superpowers/plans/foo.md",
|
"planFile":"docs/superpowers/plans/foo.md",
|
||||||
|
"prototypeFile":"docs/prototype/login/figma-plugin/manifest.json",
|
||||||
"prDiffFile":"docs/pr-diff/pr-42-issue-7.md",
|
"prDiffFile":"docs/pr-diff/pr-42-issue-7.md",
|
||||||
"pr":"42",
|
"pr":"42",
|
||||||
"prMerged":true,
|
"prMerged":true,
|
||||||
@@ -91,6 +94,13 @@ func TestValidateAcceptsFullState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsBadPrototypeFile(t *testing.T) {
|
||||||
|
err := Validate([]byte(`{"prototypeFile":"docs/prototype/login/figma-plugin/manifest.md"}`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("非 .json 路径应当被 prototypeFile pattern 拒绝")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateAcceptsBrainstormProfilePathOnly(t *testing.T) {
|
func TestValidateAcceptsBrainstormProfilePathOnly(t *testing.T) {
|
||||||
if err := Validate([]byte(`{"brainstormProfilePath":"/home/x/.claude/profiles/brainstorm.json"}`)); err != nil {
|
if err := Validate([]byte(`{"brainstormProfilePath":"/home/x/.claude/profiles/brainstorm.json"}`)); err != nil {
|
||||||
t.Fatalf("仅含 brainstormProfilePath 应当通过校验,得到: %v", err)
|
t.Fatalf("仅含 brainstormProfilePath 应当通过校验,得到: %v", err)
|
||||||
|
|||||||
@@ -31,6 +31,11 @@
|
|||||||
"description": "测试阶段的 Claude Code session id;PR 合并后手动启动的测试会话,用于 resume。",
|
"description": "测试阶段的 Claude Code session id;PR 合并后手动启动的测试会话,用于 resume。",
|
||||||
"minLength": 1
|
"minLength": 1
|
||||||
},
|
},
|
||||||
|
"prototypeSessionId": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "原型阶段的 Claude Code session id;画 Figma 原型的会话,用于 resume。",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
"profilePath": {
|
"profilePath": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "创建工单时使用的 Claude settings 配置文件绝对路径,resume 时作为 --settings 传入。",
|
"description": "创建工单时使用的 Claude settings 配置文件绝对路径,resume 时作为 --settings 传入。",
|
||||||
@@ -46,6 +51,11 @@
|
|||||||
"description": "测试会话使用的 Claude settings 配置文件绝对路径;与实施 profilePath 独立,空则用默认。",
|
"description": "测试会话使用的 Claude settings 配置文件绝对路径;与实施 profilePath 独立,空则用默认。",
|
||||||
"minLength": 1
|
"minLength": 1
|
||||||
},
|
},
|
||||||
|
"prototypeProfilePath": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "原型会话使用的 Claude settings 配置文件绝对路径;与实施 profilePath 独立,空则用默认。",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
"specFile": {
|
"specFile": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "本工单的 spec 文档路径,相对 workspace,必须形如 `dir/.../name.md`。",
|
"description": "本工单的 spec 文档路径,相对 workspace,必须形如 `dir/.../name.md`。",
|
||||||
@@ -58,6 +68,12 @@
|
|||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"pattern": "^[^\\s]+/[^\\s]+\\.md$"
|
"pattern": "^[^\\s]+/[^\\s]+\\.md$"
|
||||||
},
|
},
|
||||||
|
"prototypeFile": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "本工单的 Figma 原型插件 manifest.json 路径,仓库相对或绝对,必须形如 `dir/.../manifest.json`。",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^[^\\s]+/[^\\s]+\\.json$"
|
||||||
|
},
|
||||||
"prDiffFile": {
|
"prDiffFile": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "本工单的 PR 变更摘要文件路径,相对 workspace,必须位于 docs/pr-diff/ 下。",
|
"description": "本工单的 PR 变更摘要文件路径,相对 workspace,必须位于 docs/pr-diff/ 下。",
|
||||||
|
|||||||
Reference in new issue
Block a user