✨ feat(vscode): handoff 清单类型与校验
Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { handoffAttachmentName, parseHandoffManifest } from './handoffManifest'
|
||||
|
||||
const good = {
|
||||
version: 1,
|
||||
issue: 42,
|
||||
from: 'chw',
|
||||
createdAt: '2026-08-26T00:00:00Z',
|
||||
branch: 'feature/x',
|
||||
sessions: { implementSessionId: 'a' },
|
||||
profiles: {},
|
||||
claude: [{ id: 'a', kind: 'implement' }],
|
||||
codex: [],
|
||||
}
|
||||
|
||||
describe('handoffAttachmentName', () => {
|
||||
it('固定命名', () => {
|
||||
expect(handoffAttachmentName(42)).toBe('spx-handoff-issue-42.tgz')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseHandoffManifest', () => {
|
||||
it('合法清单原样返回', () => {
|
||||
expect(parseHandoffManifest(JSON.stringify(good), 42)).toEqual(good)
|
||||
})
|
||||
it('issue 不匹配 → 抛错', () => {
|
||||
expect(() => parseHandoffManifest(JSON.stringify(good), 43)).toThrow(/42/)
|
||||
})
|
||||
it('version 不是 1 → 抛错', () => {
|
||||
expect(() => parseHandoffManifest(JSON.stringify({ ...good, version: 2 }), 42)).toThrow(/version/)
|
||||
})
|
||||
it('claude 条目 kind 非法 → 抛错', () => {
|
||||
expect(() => parseHandoffManifest(JSON.stringify({ ...good, claude: [{ id: 'a', kind: 'x' }] }), 42)).toThrow(/claude/)
|
||||
})
|
||||
it('不是 JSON → 抛错', () => {
|
||||
expect(() => parseHandoffManifest('nope', 42)).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
export type ClaudeSessionKind = 'brainstorm' | 'implement' | 'test'
|
||||
|
||||
export const CLAUDE_SESSION_FIELDS = [
|
||||
{ field: 'sessionId', kind: 'brainstorm' },
|
||||
{ field: 'implementSessionId', kind: 'implement' },
|
||||
{ field: 'testSessionId', kind: 'test' },
|
||||
] as const satisfies ReadonlyArray<{ field: 'sessionId' | 'implementSessionId' | 'testSessionId', kind: ClaudeSessionKind }>
|
||||
|
||||
export interface HandoffSessions {
|
||||
sessionId?: string
|
||||
implementSessionId?: string
|
||||
testSessionId?: string
|
||||
reviewSessionId?: string
|
||||
}
|
||||
|
||||
export interface HandoffProfiles {
|
||||
profilePath?: string
|
||||
brainstormProfilePath?: string
|
||||
testProfilePath?: string
|
||||
}
|
||||
|
||||
export interface HandoffManifest {
|
||||
version: 1
|
||||
issue: number
|
||||
from: string
|
||||
createdAt: string
|
||||
branch?: string
|
||||
sessions: HandoffSessions
|
||||
profiles: HandoffProfiles
|
||||
claude: Array<{ id: string, kind: ClaudeSessionKind }>
|
||||
codex: Array<{ id: string, relPath: string }>
|
||||
}
|
||||
|
||||
export const HANDOFF_MANIFEST_FILE = 'handoff.json'
|
||||
|
||||
export function handoffAttachmentName(issueNumber: number): string {
|
||||
return `spx-handoff-issue-${issueNumber}.tgz`
|
||||
}
|
||||
|
||||
const KINDS: ReadonlySet<string> = new Set<ClaudeSessionKind>(['brainstorm', 'implement', 'test'])
|
||||
|
||||
function optString(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v.length > 0 ? v : undefined
|
||||
}
|
||||
|
||||
export function parseHandoffManifest(json: string, expectedIssue: number): HandoffManifest {
|
||||
const raw = JSON.parse(json) as Record<string, unknown>
|
||||
if (!raw || typeof raw !== 'object')
|
||||
throw new Error('handoff.json 不是对象')
|
||||
if (raw.version !== 1)
|
||||
throw new Error(`handoff.json version 不支持:${String(raw.version)}`)
|
||||
if (raw.issue !== expectedIssue)
|
||||
throw new Error(`handoff.json 属于工单 #${String(raw.issue)},不是 #${expectedIssue}`)
|
||||
const from = optString(raw.from)
|
||||
const createdAt = optString(raw.createdAt)
|
||||
if (!from || !createdAt)
|
||||
throw new Error('handoff.json 缺少 from / createdAt')
|
||||
const sessionsRaw = (raw.sessions ?? {}) as Record<string, unknown>
|
||||
const profilesRaw = (raw.profiles ?? {}) as Record<string, unknown>
|
||||
const claudeRaw = Array.isArray(raw.claude) ? raw.claude : []
|
||||
const codexRaw = Array.isArray(raw.codex) ? raw.codex : []
|
||||
const claude = claudeRaw.map((e) => {
|
||||
const o = e as Record<string, unknown>
|
||||
const id = optString(o.id)
|
||||
const kind = optString(o.kind)
|
||||
if (!id || !kind || !KINDS.has(kind))
|
||||
throw new Error(`handoff.json claude 条目非法:${JSON.stringify(e)}`)
|
||||
return { id, kind: kind as ClaudeSessionKind }
|
||||
})
|
||||
const codex = codexRaw.map((e) => {
|
||||
const o = e as Record<string, unknown>
|
||||
const id = optString(o.id)
|
||||
const relPath = optString(o.relPath)
|
||||
if (!id || !relPath || relPath.startsWith('/') || relPath.includes('..'))
|
||||
throw new Error(`handoff.json codex 条目非法:${JSON.stringify(e)}`)
|
||||
return { id, relPath }
|
||||
})
|
||||
const sessions: HandoffSessions = {}
|
||||
for (const k of ['sessionId', 'implementSessionId', 'testSessionId', 'reviewSessionId'] as const) {
|
||||
const v = optString(sessionsRaw[k])
|
||||
if (v)
|
||||
sessions[k] = v
|
||||
}
|
||||
const profiles: HandoffProfiles = {}
|
||||
for (const k of ['profilePath', 'brainstormProfilePath', 'testProfilePath'] as const) {
|
||||
const v = optString(profilesRaw[k])
|
||||
if (v)
|
||||
profiles[k] = v
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
issue: expectedIssue,
|
||||
from,
|
||||
createdAt,
|
||||
...(optString(raw.branch) ? { branch: optString(raw.branch) } : {}),
|
||||
sessions,
|
||||
profiles,
|
||||
claude,
|
||||
codex,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user