每块的重写记录存进 .spx/comment-rewrites.json(和问答分开,改名、删文件跟着走),块下面同问答区:灰字历史、当前这一轮、输入框;输入框里的话接着这块最近一次重写的会话说,交回新正文照样写回,写回失败也记下原因。 重写跑在 claude --restricted 下:只放开读搜、OpenPencil 的 op、建 prototypes 目录、改草稿目录,写注释的插件单独加载;超时 15 分钟。 进行中的、出错被叫停留着的那一轮记在扩展端,切走标签页再切回来照样接上(问 AI 同一套)。
357 lines
17 KiB
TypeScript
357 lines
17 KiB
TypeScript
import type { BlockView, CommentTarget } from './blockView'
|
||
import type { CommentEdit, CommentJob, CommentWriteDeps, WriteResult } from './commentRewrite'
|
||
import { describe, expect, it } from 'vitest'
|
||
import { toBlockViews } from './blockView'
|
||
import { COMMENT_SKILL, commentEdit, commentFollowUp, commentJob, commentRewritePrompt, commentSource, createCommentParser, findBlock, refillComment, writeCommentBody } from './commentRewrite'
|
||
import { commonIndent, replaceLines, splitLines } from './editText'
|
||
import { createPythonAdapter } from './languages/python'
|
||
import { createRustAdapter } from './languages/rust'
|
||
import { createTypeScriptAdapter } from './languages/typescript'
|
||
import { WASM_DIR, withBlocks } from './testSupport'
|
||
import { pythonZhTranslator } from './translators/pythonZh'
|
||
import { rustZhTranslator } from './translators/rustZh'
|
||
import { typescriptZhTranslator } from './translators/typescriptZh'
|
||
|
||
/** 和预览写回同一条路:正文套回壳,再按原来的缩进替换这几行。 */
|
||
function rewrite(file: string, start: number, end: number, body: string): string {
|
||
const lines = file.split('\n').slice(start, end + 1)
|
||
const filled = refillComment(commentSource(lines), body)
|
||
expect(filled, '认得出定界符').toBeTruthy()
|
||
return replaceLines(file, start, end, filled ?? '')
|
||
}
|
||
|
||
describe('重写注释后套回原来的壳', () => {
|
||
it('三引号模块说明:定界符还在,正文换了,文件别的部分不动', () => {
|
||
const file = [
|
||
'"""旧的模块说明。',
|
||
'',
|
||
'只做一件事。',
|
||
'"""',
|
||
'',
|
||
'def keep():',
|
||
' return 1',
|
||
'',
|
||
].join('\n')
|
||
expect(rewrite(file, 0, 3, '新的模块说明。\n\n换成了。')).toBe([
|
||
'"""新的模块说明。',
|
||
'',
|
||
'换成了。',
|
||
'"""',
|
||
'',
|
||
'def keep():',
|
||
' return 1',
|
||
'',
|
||
].join('\n'))
|
||
})
|
||
|
||
it('行注释 #:缩进和井号留着,只换这几行', () => {
|
||
const file = [
|
||
'def keep():',
|
||
' return 0',
|
||
'',
|
||
' # 旧说明',
|
||
' # 第二行',
|
||
' return 1',
|
||
'',
|
||
].join('\n')
|
||
const next = rewrite(file, 3, 4, '新说明')
|
||
expect(next).toBe([
|
||
'def keep():',
|
||
' return 0',
|
||
'',
|
||
' # 新说明',
|
||
' return 1',
|
||
'',
|
||
].join('\n'))
|
||
expect(commonIndent([' # 新说明'])).toBe(' ')
|
||
})
|
||
|
||
it('块注释 /** */:星号和收尾留着,前后的代码不动', () => {
|
||
const file = [
|
||
'export const KEEP = 1',
|
||
'',
|
||
'/**',
|
||
' * 旧说明',
|
||
' * 还有一行',
|
||
' */',
|
||
'export function stay(): number {',
|
||
' return KEEP',
|
||
'}',
|
||
'',
|
||
].join('\n')
|
||
expect(rewrite(file, 2, 5, '新说明')).toBe([
|
||
'export const KEEP = 1',
|
||
'',
|
||
'/**',
|
||
' * 新说明',
|
||
' */',
|
||
'export function stay(): number {',
|
||
' return KEEP',
|
||
'}',
|
||
'',
|
||
].join('\n'))
|
||
})
|
||
|
||
it('三斜杠和模块内文档注释:记号留着,不收成 //', () => {
|
||
expect(refillComment('/// 旧说明', '新说明')).toBe('/// 新说明')
|
||
expect(refillComment('//! 旧说明', '新说明')).toBe('//! 新说明')
|
||
})
|
||
|
||
it('双斜杠行注释同样只换正文', () => {
|
||
const file = [
|
||
'function keep() {',
|
||
' // 旧说明',
|
||
' return 1',
|
||
'}',
|
||
'',
|
||
].join('\n')
|
||
expect(rewrite(file, 1, 1, '新说明')).toBe([
|
||
'function keep() {',
|
||
' // 新说明',
|
||
' return 1',
|
||
'}',
|
||
'',
|
||
].join('\n'))
|
||
})
|
||
|
||
it('认不出定界符、正文是空的,都不给出替换', () => {
|
||
expect(refillComment('const x = 1', '新')).toBeUndefined()
|
||
expect(refillComment('# 旧', ' ')).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
const line = (value: unknown): string => JSON.stringify(value)
|
||
/** 模型调 StructuredOutput 交结果:工具输入逐段来,再是整条消息 */
|
||
function structuredLines(body: string): string[] {
|
||
const json = JSON.stringify({ body })
|
||
return [
|
||
line({ type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'tool_use', name: 'StructuredOutput', input: {} } } }),
|
||
line({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'input_json_delta', partial_json: json.slice(0, 12) } } }),
|
||
line({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'input_json_delta', partial_json: json.slice(12) } } }),
|
||
line({ type: 'stream_event', event: { type: 'content_block_stop' } }),
|
||
line({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'StructuredOutput', input: { body } }] } }),
|
||
]
|
||
}
|
||
const resultLine = (fields: Record<string, unknown>): string => line({ type: 'result', subtype: 'success', is_error: false, ...fields })
|
||
|
||
describe('重写注释的提示', () => {
|
||
it('点按钮时只有技能命令和代码块在哪:路径:起-止,行号 1 起', () => {
|
||
expect(commentRewritePrompt('src/acquisition/repository.py', 12, 20)).toBe(`${COMMENT_SKILL} src/acquisition/repository.py:13-21`)
|
||
expect(commentRewritePrompt('a.ts', 0, 0)).toBe('/ecc:writing-code-comments a.ts:1-1')
|
||
})
|
||
|
||
it('用户说了话:开新会话时跟在技能命令后面;接着会话说时前面是块现在在哪(写回注释后行号会变)', () => {
|
||
expect(commentRewritePrompt('a.ts', 2, 9, '加上原型图')).toBe('/ecc:writing-code-comments a.ts:3-10\n\n加上原型图')
|
||
expect(commentFollowUp('a.ts', 2, 9, '加上原型图')).toBe('a.ts:3-10\n\n加上原型图')
|
||
})
|
||
})
|
||
|
||
describe('读模型交回的结构化结果', () => {
|
||
it('正文跟着 StructuredOutput 的输入逐段长出来,读文件是正在做的事;结果以 structured_output 为准', () => {
|
||
const parse = createCommentParser()
|
||
const events = [
|
||
line({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/w/src/a.py' } }] } }),
|
||
...structuredLines('把 `a` 和 `b` 相加。'),
|
||
resultLine({ result: '{"body":"把 `a` 和 `b` 相加。"}', structured_output: { body: '把 `a` 和 `b` 相加。' }, session_id: 's1' }),
|
||
].flatMap(parse)
|
||
expect(events[0]).toEqual({ kind: 'activity', label: '读取 a.py' })
|
||
expect(events.filter(event => event.kind === 'text').map(event => 'text' in event && event.text)).toEqual(['把 `', '把 `a` 和 `b` 相加。'])
|
||
expect(events.at(-1)).toEqual({ kind: 'result', body: '把 `a` 和 `b` 相加。', sessionId: 's1' })
|
||
})
|
||
|
||
it('会话 id 以 result 行的为准,没有时用开头 init 行的', () => {
|
||
const parse = createCommentParser()
|
||
expect(parse(line({ type: 'system', subtype: 'init', session_id: 's0' }))).toEqual([])
|
||
expect(parse(resultLine({ structured_output: { body: '正文' } }))).toEqual([{ kind: 'result', body: '正文', sessionId: 's0' }])
|
||
})
|
||
|
||
it('模型只回了一段话、structured_output 是空的:结果里没有正文;Claude Code 报错时是失败', () => {
|
||
expect(createCommentParser()(resultLine({ result: '我改好了:把 a 和 b 相加。', session_id: 's1' }))).toEqual([{ kind: 'result', sessionId: 's1' }])
|
||
expect(createCommentParser()(resultLine({ result: '', structured_output: { body: ' ' }, session_id: 's1' }))).toEqual([{ kind: 'result', sessionId: 's1' }])
|
||
expect(createCommentParser()(line({ type: 'result', is_error: true, result: '额度用完了' }))).toEqual([{ kind: 'error', message: '额度用完了' }])
|
||
})
|
||
})
|
||
|
||
/** 内存里的一份文件:按行读,写回时同预览一样换掉几行(补回缩进)或在某行上面插几行。 */
|
||
function memoryFile(text: string): { deps: CommentWriteDeps, text: () => string, writes: CommentEdit[] } {
|
||
let current = text
|
||
const writes: CommentEdit[] = []
|
||
const lines = (): string[] => splitLines(current)
|
||
return {
|
||
text: () => current,
|
||
writes,
|
||
deps: {
|
||
lineAt: index => lines()[index],
|
||
lineCount: () => lines().length,
|
||
write: async (edit): Promise<WriteResult> => {
|
||
writes.push(edit)
|
||
if (edit.kind === 'replace') {
|
||
current = replaceLines(current, edit.startLine, edit.endLine, edit.text)
|
||
}
|
||
else {
|
||
const all = lines()
|
||
current = [...all.slice(0, edit.line), ...edit.text.split('\n'), ...all.slice(edit.line)].join('\n')
|
||
}
|
||
return 'written'
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
/** 用 Python 语言插件、中文直译把源码读成视图数据,按块的第一行(1 起,含讲它的注释)找到那块。 */
|
||
async function pythonBlock(source: string, line: number): Promise<BlockView> {
|
||
const py = createPythonAdapter(WASM_DIR)
|
||
const views = await withBlocks(py, 'python', source, blocks => toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator))
|
||
const all = (list: BlockView[]): BlockView[] => list.flatMap(view => [view, ...all(view.children)])
|
||
const found = all(views).find(view => view.startLine + 1 === line)
|
||
if (!found)
|
||
throw new Error(`第 ${line} 行没有块`)
|
||
return found
|
||
}
|
||
|
||
/** 开始重写时记下这块:写到哪、范围、这几行当时的原文。 */
|
||
function job(block: BlockView, source: string): CommentJob {
|
||
if (!block.commentTarget)
|
||
throw new Error('这块不能写注释')
|
||
const lines = splitLines(source)
|
||
const started = commentJob(block.commentTarget, block.startLine, block.endLine, index => lines[index], () => lines.length)
|
||
if (!started)
|
||
throw new Error('块不在文件里')
|
||
return started
|
||
}
|
||
|
||
const SOURCE = [
|
||
'import os',
|
||
'',
|
||
'# 旧的说明',
|
||
'',
|
||
'# 第二段',
|
||
'class Repo:',
|
||
' def find(self):',
|
||
' return os.sep',
|
||
'',
|
||
].join('\n')
|
||
|
||
describe('把交回的正文写回这块代码', () => {
|
||
it('块前面的注释(几段隔着空行的也算)换成交回的正文,套回 # 和缩进', async () => {
|
||
const repo = await pythonBlock(SOURCE, 3)
|
||
expect(repo.commentTarget).toEqual({ kind: 'replace', startLine: 2, endLine: 4 })
|
||
const file = memoryFile(SOURCE)
|
||
expect(await writeCommentBody(job(repo, SOURCE), '仓库:按名字找文件。', file.deps)).toBeUndefined()
|
||
expect(file.text()).toBe(SOURCE.replace('# 旧的说明\n\n# 第二段', '# 仓库:按名字找文件。'))
|
||
})
|
||
|
||
it('函数已经有 docstring、上面没有 #:换掉 docstring 的正文,不在 def 上面再插一行', async () => {
|
||
const source = 'class Repo:\n def find(self):\n """找。"""\n return 1\n'
|
||
const find = await pythonBlock(source, 2)
|
||
expect(find.commentTarget).toEqual({ kind: 'replace', startLine: 2, endLine: 2 })
|
||
const file = memoryFile(source)
|
||
expect(await writeCommentBody(job(find, source), '按名字找\n\n找不到时返回 1', file.deps)).toBeUndefined()
|
||
expect(file.text()).toBe('class Repo:\n def find(self):\n """按名字找\n\n 找不到时返回 1\n """\n return 1\n')
|
||
})
|
||
|
||
it('函数既没有 docstring 也没有前面的 #:在主体里插一段 docstring,不在 def 上面写 #', async () => {
|
||
const source = 'def find(self):\n return 1\n'
|
||
const find = await pythonBlock(source, 1)
|
||
expect(find.commentTarget).toEqual({ kind: 'insert', line: 1, shell: '"""_"""' })
|
||
const file = memoryFile(source)
|
||
expect(await writeCommentBody(job(find, source), '按名字找。', file.deps)).toBeUndefined()
|
||
expect(file.text()).toBe('def find(self):\n """按名字找。"""\n return 1\n')
|
||
})
|
||
|
||
it('声明和主体写在同一行:docstring 插进主体,不写在 def 上面', async () => {
|
||
const source = 'def find(): return 1\n'
|
||
const find = await pythonBlock(source, 1)
|
||
expect(find.commentTarget).toMatchObject({ kind: 'insert', line: 0, shell: '"""_"""' })
|
||
const file = memoryFile(source)
|
||
expect(await writeCommentBody(job(find, source), '按名字找。', file.deps)).toBeUndefined()
|
||
expect(file.text()).toBe('def find():\n """按名字找。"""\n return 1\n')
|
||
})
|
||
|
||
it('函数只有前面的 #、没有 docstring:仍换掉那段 #', async () => {
|
||
const source = '# 旧的说明\ndef find():\n return 1\n'
|
||
const find = await pythonBlock(source, 1)
|
||
expect(find.commentTarget).toEqual({ kind: 'replace', startLine: 0, endLine: 0 })
|
||
const file = memoryFile(source)
|
||
expect(await writeCommentBody(job(find, source), '按名字找。', file.deps)).toBeUndefined()
|
||
expect(file.text()).toBe('# 按名字找。\ndef find():\n return 1\n')
|
||
})
|
||
|
||
it('docstring 和前面的 # 都有:只换 docstring,# 留着', async () => {
|
||
const source = '# 旧的说明\ndef find():\n """找。"""\n return 1\n'
|
||
const find = await pythonBlock(source, 1)
|
||
expect(find.commentTarget).toEqual({ kind: 'replace', startLine: 2, endLine: 2 })
|
||
const file = memoryFile(source)
|
||
expect(await writeCommentBody(job(find, source), '新的说明。', file.deps)).toBeUndefined()
|
||
expect(file.text()).toBe('# 旧的说明\ndef find():\n """新的说明。"""\n return 1\n')
|
||
})
|
||
|
||
it('文件开头的说明卡片换的是它自己:范围就是这段注释', async () => {
|
||
const source = '"""旧的模块说明。"""\n\nimport os\n'
|
||
const header = await pythonBlock(source, 1)
|
||
expect(header).toMatchObject({ kind: 'comment', commentTarget: { kind: 'replace', startLine: 0, endLine: 0 } })
|
||
const file = memoryFile(source)
|
||
expect(await writeCommentBody(job(header, source), '新的模块说明。', file.deps)).toBeUndefined()
|
||
expect(file.text()).toBe('"""新的模块说明。"""\n\nimport os\n')
|
||
})
|
||
|
||
it('没交回结构化的正文:不写,说原因', async () => {
|
||
const repo = await pythonBlock(SOURCE, 3)
|
||
const silent = memoryFile(SOURCE)
|
||
expect(await writeCommentBody(job(repo, SOURCE), undefined, silent.deps)).toBe('模型没有按要求交回注释正文,没有改文件。')
|
||
expect(silent.writes).toEqual([])
|
||
})
|
||
|
||
it('重写期间这块代码变了、行号已经不在文件里:不写;编辑器没接受:说原因', async () => {
|
||
const repo = await pythonBlock(SOURCE, 3)
|
||
const file = memoryFile(SOURCE)
|
||
const edited = splitLines(SOURCE.replace('return os.sep', 'return "/"'))
|
||
expect(await writeCommentBody(job(repo, SOURCE), '新说明', { ...file.deps, lineAt: index => edited[index] })).toBe('这块代码在重写期间变了,没有写回。')
|
||
expect(await writeCommentBody(job(repo, SOURCE), '新说明', { ...file.deps, lineCount: () => 3 })).toBe('这块代码在重写期间变了,没有写回。')
|
||
expect(file.writes).toEqual([])
|
||
expect(await writeCommentBody(job(repo, SOURCE), '新说明', { ...file.deps, write: async () => 'rejected' })).toBe('编辑器没接受这次修改,没有写回。')
|
||
expect(commentJob({ kind: 'replace', startLine: 0, endLine: 0 }, 5, 9, () => '', () => 3)).toBeUndefined()
|
||
})
|
||
|
||
it('认不出定界符:不写,说原因', async () => {
|
||
const file = memoryFile('x = 1\n')
|
||
const started = commentJob({ kind: 'replace', startLine: 0, endLine: 0 }, 0, 0, () => 'x = 1', () => 2)
|
||
expect(started && await writeCommentBody(started, '说明', file.deps)).toBe('这段注释的定界符认不出来,没有改文件。')
|
||
})
|
||
|
||
it('typescript 已经有 JSDoc:换掉这段说明,不在声明上面再写 //', async () => {
|
||
const ts = createTypeScriptAdapter(WASM_DIR)
|
||
const source = '/** 读配置。 */\nexport function load() {}\n'
|
||
const [view] = await withBlocks(ts, 'typescript', source, blocks => toBlockViews(blocks, { source, language: ts, markers: [] }, typescriptZhTranslator))
|
||
expect(view.commentTarget).toEqual({ kind: 'replace', startLine: 0, endLine: 0 })
|
||
const edit = commentEdit(view.commentTarget as CommentTarget, index => splitLines(source)[index], '启动时读一次')
|
||
expect(edit).toEqual({ kind: 'replace', startLine: 0, endLine: 0, text: '/** 启动时读一次 */' })
|
||
})
|
||
|
||
it('typescript 还没有说明:在声明上面插 /** */,不写 //', async () => {
|
||
const ts = createTypeScriptAdapter(WASM_DIR)
|
||
const source = 'export function load() {}\n'
|
||
const [view] = await withBlocks(ts, 'typescript', source, blocks => toBlockViews(blocks, { source, language: ts, markers: [] }, typescriptZhTranslator))
|
||
expect(view.commentTarget).toEqual({ kind: 'insert', line: 0, shell: '/** _ */' })
|
||
const edit = commentEdit(view.commentTarget as CommentTarget, index => splitLines(source)[index], '启动时读一次')
|
||
expect(edit).toEqual({ kind: 'insert', line: 0, text: '/** 启动时读一次 */' })
|
||
})
|
||
|
||
it('rust 还没有说明:在项上面插 ///,不写 //', async () => {
|
||
const rust = createRustAdapter(WASM_DIR)
|
||
const source = 'fn load() {}\n'
|
||
const [view] = await withBlocks(rust, 'rust', source, blocks => toBlockViews(blocks, { source, language: rust, markers: [] }, rustZhTranslator))
|
||
expect(view.commentTarget).toEqual({ kind: 'insert', line: 0, shell: '/// _' })
|
||
const edit = commentEdit(view.commentTarget as CommentTarget, index => splitLines(source)[index], '启动时读一次')
|
||
expect(edit).toEqual({ kind: 'insert', line: 0, text: '/// 启动时读一次' })
|
||
})
|
||
})
|
||
|
||
describe('按记号找块', () => {
|
||
it('先序找,找不到是 undefined', () => {
|
||
const tree = [{ anchor: 'a', children: [{ anchor: 'b', children: [] }] }, { anchor: 'c', children: [] }]
|
||
expect(findBlock(tree, 'b')?.anchor).toBe('b')
|
||
expect(findBlock(tree, 'x')).toBeUndefined()
|
||
})
|
||
})
|