✨ feat(vscode): 代码阅读的注释挂到它讲的代码块上成「注释」一行,重写注释改用技能一句话加结构化结果

- 普通注释不再单独成卡片:同一层挂到后面最近的块(隔空行也算),后面没有挂到前面那块,整层没代码挂到父块;只有文件开头隔着空行的注释、模块 docstring、//!、@file 单独成「文件开头的说明」
- 各语言(Python、TypeScript、Rust、Shell、Dockerfile)建好树后统一调 attachComments;块的编辑范围连着挂上的注释,差异、跳转、钉住卡片都按它
- 宿主统一给每张卡片(语言卡片、翻译插件的属性表、库规则卡片、一行译文的语句卡片)插「注释」一行,排在名称、说明后面
- TypeScript 的 JSDoc 也能当常量、变量的说明;sqlx、React 不再单独处理前面的注释
- 「重写注释」每块都有:提示只发 /ecc:writing-code-comments 路径:起-止,模型只读、按 --json-schema 交 body,没交结构化正文就失败不写;扩展换掉块前面的注释或在块上面新写一段
This commit is contained in:
cruldra committed 2026-10-08 07:51:21 +08:00
1 parent 335cf9ca9f
commit 582ac4c987
49 files changed
+1730 -757

No files matched your search

+192 -57
View File
@@ -1,6 +1,14 @@
import type { BlockView, CommentTarget } from './blockView'
import type { CommentEdit, CommentRewriteDeps, CommentRewriteJob, WriteResult } from './commentRewrite'
import { describe, expect, it } from 'vitest'
import { COMMENT_SKILL, commentProse, commentRewritePrompt, commentSource, isModuleComment, modelCommentBody, refillComment } from './commentRewrite'
import { commonIndent, replaceLines } from './editText'
import { toBlockViews } from './blockView'
import { COMMENT_SKILL, commentEdit, commentRewritePrompt, commentSource, createCommentParser, findBlock, refillComment, rewriteBlockComment } from './commentRewrite'
import { commonIndent, replaceLines, splitLines } from './editText'
import { createPythonAdapter } from './languages/python'
import { createTypeScriptAdapter } from './languages/typescript'
import { WASM_DIR, withBlocks } from './testSupport'
import { pythonZhTranslator } from './translators/pythonZh'
import { typescriptZhTranslator } from './translators/typescriptZh'
/** 和预览写回同一条路:正文套回壳,再按原来的缩进替换这几行。 */
function rewrite(file: string, start: number, end: number, body: string): string {
@@ -103,66 +111,193 @@ describe('重写注释后套回原来的壳', () => {
expect(refillComment('const x = 1', '新')).toBeUndefined()
expect(refillComment('# 旧', ' ')).toBeUndefined()
})
it('给模型的正文不带定界符', () => {
expect(commentProse('"""旧说明"""')).toBe('旧说明')
expect(commentProse('# 旧说明\n# 第二行')).toBe('旧说明\n第二行')
expect(commentProse('/**\n * 旧说明\n */')).toBe('旧说明')
expect(commentProse('// 旧说明')).toBe('旧说明')
})
})
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('文件顶上的三引号:上下文是去掉这段之后的文件,太长会截掉并说明', () => {
const rest = Array.from({ length: 130 }, (_, index) => `line ${index}`).join('\n')
const prompt = commentRewritePrompt({
comment: '"""旧"""',
source: `"""旧"""\n${rest}`,
startLine: 0,
endLine: 0,
earlierKinds: [],
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')
})
})
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` 相加。' } }),
].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` 相加。' })
})
it('模型只回了一段话、structured_output 是空的:结果里没有正文;Claude Code 报错时是失败', () => {
expect(createCommentParser()(resultLine({ result: '我改好了:把 a 和 b 相加。' }))).toEqual([{ kind: 'result' }])
expect(createCommentParser()(resultLine({ result: '', structured_output: { body: ' ' } }))).toEqual([{ kind: 'result' }])
expect(createCommentParser()(line({ type: 'result', is_error: true, result: '额度用完了' }))).toEqual([{ kind: 'error', message: '额度用完了' }])
})
})
/** 内存里的一份文件:按行读,写回时同预览一样换掉几行(补回缩进)或在某行上面插几行。 */
function memoryFile(text: string): { deps: (ask: CommentRewriteDeps['ask']) => CommentRewriteDeps, text: () => string, writes: CommentEdit[] } {
let current = text
const writes: CommentEdit[] = []
const lines = (): string[] => splitLines(current)
return {
text: () => current,
writes,
deps: ask => ({
lineAt: index => lines()[index],
lineCount: () => lines().length,
ask,
failure: err => `没答成:${err instanceof Error ? err.message : String(err)}`,
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'
},
signal: new AbortController().signal,
}),
}
}
/** 用 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, path = 'src/a.py', lineComment = '#'): CommentRewriteJob {
if (!block.commentTarget)
throw new Error('这块不能写注释')
return { target: block.commentTarget, startLine: block.startLine, endLine: block.endLine, path, lineComment }
}
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)
const prompts: string[] = []
const outcome = await rewriteBlockComment(job(repo), file.deps(async (prompt) => {
prompts.push(prompt)
return '仓库:按名字找文件。'
}))
expect(outcome).toEqual({ status: 'done' })
expect(prompts).toEqual(['/ecc:writing-code-comments src/a.py:3-8'])
expect(file.text()).toBe(SOURCE.replace('# 旧的说明\n\n# 第二段', '# 仓库:按名字找文件。'))
})
it('还没有注释的块:用行注释记号新写一段,插在块上面,缩进同块的第一行;说明(docstring)不动', 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: 'insert', line: 1 })
const file = memoryFile(source)
expect(await rewriteBlockComment(job(find), file.deps(async () => '按名字找\n\n找不到时返回 1'))).toEqual({ status: 'done' })
expect(file.text()).toBe('class Repo:\n # 按名字找\n #\n # 找不到时返回 1\n def find(self):\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)
const prompts: string[] = []
await rewriteBlockComment(job(header), file.deps(async (prompt) => {
prompts.push(prompt)
return '新的模块说明。'
}))
expect(prompts).toEqual(['/ecc:writing-code-comments src/a.py:1-1'])
expect(file.text()).toBe('"""新的模块说明。"""\n\nimport os\n')
})
it('没交回结构化的正文、模型出错:失败,文件不动', async () => {
const repo = await pythonBlock(SOURCE, 3)
const silent = memoryFile(SOURCE)
expect(await rewriteBlockComment(job(repo), silent.deps(async () => undefined))).toEqual({ status: 'failed', error: '模型没有按要求交回注释正文,没有改文件。' })
expect(silent.writes).toEqual([])
const broken = memoryFile(SOURCE)
expect(await rewriteBlockComment(job(repo), broken.deps(async () => {
throw new Error('额度用完了')
}))).toEqual({ status: 'failed', error: '没答成:额度用完了' })
expect(broken.writes).toEqual([])
})
it('重写期间这块代码变了:失败,不写;被叫停:stopped,不写', async () => {
const repo = await pythonBlock(SOURCE, 3)
const file = memoryFile(SOURCE)
let edited = SOURCE
const deps = file.deps(async () => {
edited = SOURCE.replace('return os.sep', 'return "/"')
return '新说明'
})
expect(prompt?.startsWith(`${COMMENT_SKILL}\n`)).toBe(true)
expect(prompt).toContain('只按这个技能重写这一段注释')
expect(prompt).toContain('这段注释现在的正文:\n旧')
expect(prompt).not.toContain('"""')
expect(prompt).toContain('截断了')
expect(prompt).toContain('line 0')
expect(prompt).not.toContain('line 129')
})
it('普通注释只带后面紧挨着的那一块,不带整份文件', () => {
const prompt = commentRewritePrompt({
comment: '# 旧',
source: '整份文件不该出现',
startLine: 4,
endLine: 4,
earlierKinds: ['function'],
following: 'def stay():\n return 1',
})
expect(prompt).toContain('def stay():')
expect(prompt).not.toContain('整份文件不该出现')
expect(isModuleComment('# 旧', [])).toBe(false)
expect(isModuleComment('"""旧"""', [])).toBe(true)
expect(isModuleComment('"""旧"""', ['function'])).toBe(false)
})
it('函数上的说明用这块代码当上下文,不当成文件顶上的说明', () => {
const prompt = commentRewritePrompt({
comment: '"""旧说明"""',
source: 'def run():\n """旧说明"""\n return 1\n\ndef other():\n return 2',
startLine: 1,
endLine: 1,
earlierKinds: [],
attached: 'def run():\n return 1',
})
expect(prompt).toContain('def run():')
expect(prompt).toContain('return 1')
expect(prompt).not.toContain('def other()')
const changing: CommentRewriteDeps = { ...deps, lineAt: index => splitLines(edited)[index] }
expect(await rewriteBlockComment(job(repo), changing)).toEqual({ status: 'failed', error: '这块代码在重写期间变了,没有写回。' })
expect(file.writes).toEqual([])
const stop = new AbortController()
const stopped = memoryFile(SOURCE)
const result = await rewriteBlockComment(job(repo), { ...stopped.deps(async () => {
stop.abort()
return '新说明'
}), signal: stop.signal })
expect(result).toEqual({ status: 'stopped' })
expect(stopped.writes).toEqual([])
})
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: 'insert', line: 0 })
const edit = commentEdit(view.commentTarget as CommentTarget, index => splitLines(source)[index], '启动时读一次', ts.lineComment)
expect(edit).toEqual({ kind: 'insert', line: 0, text: '// 启动时读一次' })
})
})
it('模型套了代码围栏时只留里面的正文', () => {
expect(modelCommentBody('```\n新说明\n```')).toBe('新说明')
expect(modelCommentBody(' ')).toBeUndefined()
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()
})
})