Files
superwork/vscode/src/codeReader/commentRewrite.test.ts
T
cruldra c9f60f5b30 ✨ feat(vscode): 重写注释留会话记录、能接着说,允许画原型图
每块的重写记录存进 .spx/comment-rewrites.json(和问答分开,改名、删文件跟着走),块下面同问答区:灰字历史、当前这一轮、输入框;输入框里的话接着这块最近一次重写的会话说,交回新正文照样写回,写回失败也记下原因。
重写跑在 claude --restricted 下:只放开读搜、OpenPencil 的 op、建 prototypes 目录、改草稿目录,写注释的插件单独加载;超时 15 分钟。
进行中的、出错被叫停留着的那一轮记在扩展端,切走标签页再切回来照样接上(问 AI 同一套)。
2026-10-12 13:45:37 +11:00

357 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
})
})