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

426 lines
18 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.
/**
* 「重写注释」:用写注释的技能给一块代码重写(没有就新写)讲它的普通注释。模型只读文件、按 JSON Schema 交回注释正文,
* 这里套回原来的定界符和缩进(没有注释时用语言的行注释记号),由扩展写回文件。
* 认 Python 的三引号、井号行注释,以及块注释和双斜杠行注释。认不出就不改。
*/
import type { CommentTarget } from './blockView'
import { describeToolUse } from '../cc/claudeProgress'
import { commonIndent } from './editText'
/** 跟在对话框里输入这个命令一样,由 Claude Code 自己加载写注释的技能。 */
export const COMMENT_SKILL = '/ecc:writing-code-comments'
/** 模型交回的结构(`--json-schema`):body 是注释正文,不带注释符号。 */
export const COMMENT_SCHEMA = JSON.stringify({
type: 'object',
properties: { body: { type: 'string' } },
required: ['body'],
additionalProperties: false,
})
/** Claude Code 交结构化结果时调的工具:它的输入就是按 COMMENT_SCHEMA 写的结果。 */
const STRUCTURED_TOOL = 'StructuredOutput'
const DOUBLE_DOC = '"""'
const SINGLE_DOC = '\'' + '\'' + '\''
/** 写在三引号前面的字符串前缀,长的排前面,免得 `r` 吃掉 `rf`。 */
const DOC_PREFIXES = ['rf', 'fr', 'rb', 'br', 'r', 'b', 'u', 'f']
/** 一块注释占的几行,去掉共同缩进。和块编辑框里的字一样。 */
export function commentSource(lines: readonly string[]): string {
const width = commonIndent([...lines]).length
return lines.map(line => line.slice(Math.min(width, line.length))).join('\n')
}
interface DocShell {
prefix: string
quote: string
/** 引号里面的原文,含换行 */
inner: string
}
function docShell(comment: string): DocShell | undefined {
const trimmed = comment.trimEnd()
const lower = trimmed.toLowerCase()
let prefix = ''
for (const candidate of DOC_PREFIXES) {
if (!lower.startsWith(candidate))
continue
const after = trimmed.slice(candidate.length)
if (after.startsWith(DOUBLE_DOC) || after.startsWith(SINGLE_DOC)) {
prefix = trimmed.slice(0, candidate.length)
break
}
}
const rest = trimmed.slice(prefix.length)
const quote = rest.startsWith(DOUBLE_DOC) ? DOUBLE_DOC : rest.startsWith(SINGLE_DOC) ? SINGLE_DOC : undefined
if (!quote || rest.length < quote.length * 2 || !rest.endsWith(quote))
return undefined
return { prefix, quote, inner: rest.slice(quote.length, -quote.length) }
}
/** 收尾那一行、引号前面的空白。正文和引号写在同一行时没有。 */
function closerIndent(comment: string, quote: string): string {
const nl = comment.lastIndexOf('\n')
if (nl < 0)
return ''
const last = comment.slice(nl + 1)
const at = last.lastIndexOf(quote)
if (at < 0 || last.slice(at + quote.length).trim() !== '')
return ''
return last.slice(0, at)
}
function refillDoc(comment: string, body: string): string | undefined {
const shell = docShell(comment)
if (!shell)
return undefined
const single = !shell.inner.includes('\n')
if (single && !body.includes('\n'))
return `${shell.prefix}${shell.quote}${body}${shell.quote}`
let inner = shell.inner
if (inner.startsWith('\n'))
inner = inner.slice(1)
if (inner.endsWith('\n'))
inner = inner.slice(0, -1)
const extra = commonIndent(inner.split('\n'))
const closeIndent = closerIndent(comment, shell.quote)
const rendered = body.split('\n').map(line => line ? extra + line : '')
// 原来正文就写在开头引号那一行时,第一行仍写在那里
if (shell.inner.length > 0 && !shell.inner.startsWith('\n')) {
const [first, ...rest] = rendered
if (rest.length === 0)
return `${shell.prefix}${shell.quote}${first ?? ''}${shell.quote}`
return `${shell.prefix}${shell.quote}${first ?? ''}\n${rest.join('\n')}\n${closeIndent}${shell.quote}`
}
return `${shell.prefix}${shell.quote}\n${rendered.join('\n')}\n${closeIndent}${shell.quote}`
}
interface LineShell {
token: string
spaced: boolean
}
/** 一行 `#` 或 `//` 注释:记号、记号后面有没有空格。 */
function lineParts(line: string): LineShell | undefined {
let end = 0
if (line.startsWith('//')) {
end = 2
}
else if (line.startsWith('#')) {
while (line[end] === '#')
end++
}
else {
return undefined
}
const gap = line[end]
return { token: line.slice(0, end), spaced: gap === ' ' || gap === '\t' }
}
function lineShell(comment: string): LineShell | undefined {
const lines = comment.split('\n')
if (lines.length === 0 || lines.some(line => line.length === 0))
return undefined
const parsed = lines.map(lineParts)
if (parsed.some(item => !item))
return undefined
const rows = parsed.flatMap(item => item ? [item] : [])
const token = rows[0]?.token
if (!token || rows.some(item => item.token !== token))
return undefined
return { token, spaced: rows.some(item => item.spaced) }
}
function refillLine(comment: string, body: string): string | undefined {
const shell = lineShell(comment)
if (!shell)
return undefined
return body.split('\n').map(line => line ? shell.token + (shell.spaced ? ' ' : '') + line : shell.token).join('\n')
}
function blockOpener(comment: string): '/**' | '/*' | undefined {
if (comment.startsWith('/**'))
return '/**'
if (comment.startsWith('/*'))
return '/*'
return undefined
}
/** 块注释中间一行 ` * 正文` 里的正文。不是星号行就 undefined。 */
function starText(line: string): string | undefined {
const trimmed = line.trimStart()
if (!trimmed.startsWith('*') || trimmed.startsWith('*/'))
return undefined
const rest = trimmed.slice(1)
if (rest.startsWith(' ') || rest.startsWith('\t'))
return rest.slice(1)
return rest
}
/** 写回时套在每行正文前面的 ` * `,沿用原来那一行的缩进和空格。 */
function starPrefix(line: string): string | undefined {
const trimmed = line.trimStart()
if (!trimmed.startsWith('*') || trimmed.startsWith('*/'))
return undefined
const indent = line.slice(0, line.length - trimmed.length)
const rest = trimmed.slice(1)
const gap = rest.startsWith(' ') || rest.startsWith('\t') ? rest[0] : ''
return `${indent}*${gap}`
}
function blockProse(comment: string): string | undefined {
const open = blockOpener(comment)
if (!open || !comment.trimEnd().endsWith('*/'))
return undefined
if (!comment.includes('\n'))
return comment.trimEnd().slice(open.length, -2).trim()
const lines = comment.split('\n')
const first = lines[0] ?? ''
const last = lines[lines.length - 1] ?? ''
if (!first.startsWith(open) || last.trim() !== '*/')
return undefined
const body: string[] = []
const head = first.slice(open.length).trim()
if (head)
body.push(head)
for (const line of lines.slice(1, -1)) {
const text = starText(line)
body.push(text ?? line.trim())
}
while (body[0] === '')
body.shift()
while (body.length > 0 && body[body.length - 1] === '')
body.pop()
return body.join('\n')
}
function refillBlock(comment: string, body: string): string | undefined {
const open = blockOpener(comment)
if (!open || blockProse(comment) === undefined)
return undefined
if (!comment.includes('\n') && !body.includes('\n'))
return `${open} ${body} */`
const lines = comment.split('\n')
const prefix = lines.slice(1, -1).map(starPrefix).find(item => item !== undefined) ?? ' * '
const rendered = body.split('\n').map(line => line ? `${prefix}${line}` : prefix.trimEnd())
const closeIndent = (lines[lines.length - 1] ?? '').trim() === '*/'
? (lines[lines.length - 1] ?? '').slice(0, (lines[lines.length - 1] ?? '').indexOf('*/'))
: ' '
return `${open}\n${rendered.join('\n')}\n${closeIndent}*/`
}
/**
* 把新正文套回这段注释原来的壳。comment 是去掉共同缩进的原文。
* 认不出定界符,或新正文是空的,就返回 undefined,调用方不写文件。
*/
export function refillComment(comment: string, body: string): string | undefined {
const text = body.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim()
if (!text)
return undefined
return refillDoc(comment, text) ?? refillLine(comment, text) ?? refillBlock(comment, text)
}
/** 发给模型的整句话:技能命令后面跟代码块在哪,`路径:起-止`(路径相对 claude 的工作目录,行号 1 起,连同讲它的注释),别的不说。startLine、endLine 0 起。 */
export function commentRewritePrompt(path: string, startLine: number, endLine: number): string {
return `${COMMENT_SKILL} ${path}:${startLine + 1}-${endLine + 1}`
}
/**
* 从 claude 的 stream-json 输出里读出来的:text 是注释正文到现在为止写出来的(结构化结果逐字长出来),activity 是正在做的事(「读取 routes.py」),
* result 是答完了(body 是结构化结果里的正文,没按 COMMENT_SCHEMA 交回、正文是空的时没有),error 是 Claude Code 自己报的失败。
*/
export type CommentEvent
= | { kind: 'text', text: string }
| { kind: 'activity', label: string }
| { kind: 'result', body?: string }
| { kind: 'error', message: string }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function str(value: unknown): string {
return typeof value === 'string' ? value : ''
}
/** 写到一半的结构化结果(`{"body": "…`)里已经写出来的正文;还没写到正文、转义写到一半解不开时没有。 */
const PARTIAL_BODY = /^\s*\{\s*"body"\s*:\s*"((?:[^"\\]|\\.)*)/
function partialBody(json: string): string | undefined {
const match = PARTIAL_BODY.exec(json)
if (!match)
return undefined
try {
return JSON.parse(`"${match[1].replace(/\\u[0-9a-f]{0,3}$/i, '')}"`) as string
}
catch {
return undefined
}
}
/** 正文:结构化结果里的 body,去掉首尾空白是空的不算。 */
function bodyOf(output: unknown): string | undefined {
return isRecord(output) && typeof output.body === 'string' && output.body.trim() !== '' ? output.body : undefined
}
/**
* 一次重写一个解析器(它记着结构化结果写到哪了):`claude -p --output-format stream-json --verbose --include-partial-messages --json-schema`
* 的每行 → CommentEvent。结构化结果是模型调 StructuredOutput 工具交的,它的输入逐段(input_json_delta)来,读出正文给进度;
* 读文件、搜索这些工具调用是 activity;结果以 result 行的 structured_output 为准,模型只回了一段话(没调这个工具)就没有正文。
*/
export function createCommentParser(): (line: string) => CommentEvent[] {
/** 正在写的结构化结果(工具输入的 JSON 原文);不在写时没有 */
let json: string | undefined
let text = ''
const grow = (body: string | undefined): CommentEvent[] => {
if (body === undefined || body === text)
return []
text = body
return [{ kind: 'text', text }]
}
const streamEvent = (event: Record<string, unknown>): CommentEvent[] => {
if (event.type === 'content_block_start' && isRecord(event.content_block)) {
json = event.content_block.type === 'tool_use' && event.content_block.name === STRUCTURED_TOOL ? '' : undefined
return []
}
if (event.type === 'content_block_delta' && json !== undefined && isRecord(event.delta) && event.delta.type === 'input_json_delta') {
json += str(event.delta.partial_json)
return grow(partialBody(json))
}
if (event.type === 'content_block_stop')
json = undefined
return []
}
const assistant = (content: unknown[]): CommentEvent[] => content.flatMap((block): CommentEvent[] => {
if (!isRecord(block) || block.type !== 'tool_use' || typeof block.name !== 'string')
return []
const input = isRecord(block.input) ? block.input : {}
return block.name === STRUCTURED_TOOL ? grow(bodyOf(input)) : [{ kind: 'activity', label: describeToolUse(block.name, input) }]
})
return (raw) => {
let line: unknown
try {
line = JSON.parse(raw)
}
catch {
return []
}
// 子代理的消息(parent_tool_use_id 不空)不是这次的结果
if (!isRecord(line) || (line.parent_tool_use_id !== undefined && line.parent_tool_use_id !== null))
return []
if (line.type === 'stream_event')
return isRecord(line.event) ? streamEvent(line.event) : []
if (line.type === 'assistant')
return isRecord(line.message) && Array.isArray(line.message.content) ? assistant(line.message.content) : []
if (line.type !== 'result')
return []
if (line.is_error === true) {
const errors = Array.isArray(line.errors) ? line.errors.filter(item => typeof item === 'string').join(';') : ''
return [{ kind: 'error', message: str(line.result).trim() || errors || str(line.subtype) || '没说原因' }]
}
const body = bodyOf(line.structured_output)
return [{ kind: 'result', ...(body !== undefined && { body }) }]
}
}
/**
* 要改文件的哪儿(行号 0 起):replace 换掉 startLine..endLine 这几行,text 去掉了共同缩进(写回时每行补回第一行的缩进,同块编辑);
* insert 在 line 这一行上面插进几行,text 已经带着缩进、不带结尾的换行。
*/
export type CommentEdit
= | { kind: 'replace', startLine: number, endLine: number, text: string }
| { kind: 'insert', line: number, text: string }
/**
* 新正文落到文件里的样子:换掉原来那段注释时套回它的定界符(几段注释中间隔着空行时照第一段的写法写成一段);
* 块还没有注释时用语言的行注释记号新写一段,缩进同块的第一行。认不出定界符、没有行注释记号、正文是空的时没有。
*/
export function commentEdit(target: CommentTarget, lineAt: (line: number) => string, body: string, lineComment: string | undefined): CommentEdit | undefined {
if (target.kind === 'replace') {
const comment = commentSource(Array.from({ length: target.endLine - target.startLine + 1 }, (_, i) => lineAt(target.startLine + i)))
const text = refillComment(comment, body) ?? refillComment(comment.split(/\n[ \t]*\n/)[0], body)
return text === undefined ? undefined : { kind: 'replace', startLine: target.startLine, endLine: target.endLine, text }
}
if (!lineComment)
return undefined
const indent = /^[ \t]*/.exec(lineAt(target.line))?.[0] ?? ''
const text = refillComment(`${lineComment} _`, body)
return text === undefined ? undefined : { kind: 'insert', line: target.line, text: text.split('\n').map(line => indent + line).join('\n') }
}
/** 写回文件的结果:written 写进去了,unchanged 和文件里一样不用写,missing 行号已经不在,rejected 编辑器没接受。 */
export type WriteResult = 'written' | 'unchanged' | 'missing' | 'rejected'
/** 一次重写的结果:done 已经写进文件,failed 没写并带给人看的原因,stopped 是被叫停、没写。 */
export type RewriteOutcome
= | { status: 'done' }
| { status: 'stopped' }
| { status: 'failed', error: string }
/** 给哪块代码重写注释:「重写注释」写到哪,块的起止行(0 起,连同讲它的注释),文件相对 claude 工作目录的路径(`/` 分隔),语言的行注释记号。 */
export interface CommentRewriteJob {
target: CommentTarget
startLine: number
endLine: number
path: string
lineComment?: string
}
/** 重写时要用的:读文件现在的行,问模型(拿到结构化结果里的正文,没有正文时 undefined;失败时抛错),出错时给人看的话,写回文件。 */
export interface CommentRewriteDeps {
lineAt: (line: number) => string
lineCount: () => number
ask: (prompt: string) => Promise<string | undefined>
failure: (err: unknown) => string
write: (edit: CommentEdit) => Promise<WriteResult>
signal: AbortSignal
}
/**
* 重写一块代码的注释:问模型(提示只有技能命令和代码块在哪,见 commentRewritePrompt),拿结构化结果里的正文套回去写进文件。
* 没交回结构化的正文、这块代码在重写期间变了、写不回去都算失败,文件不动。
*/
export async function rewriteBlockComment(job: CommentRewriteJob, deps: CommentRewriteDeps): Promise<RewriteOutcome> {
const failed = (error: string): RewriteOutcome => ({ status: 'failed', error })
const rangeText = (): string | undefined => job.endLine < deps.lineCount()
? Array.from({ length: job.endLine - job.startLine + 1 }, (_, i) => deps.lineAt(job.startLine + i)).join('\n')
: undefined
const snapshot = job.startLine >= 0 && job.endLine >= job.startLine ? rangeText() : undefined
if (snapshot === undefined)
return failed('这块代码的位置已经变了,没有写回。')
let body: string | undefined
try {
body = await deps.ask(commentRewritePrompt(job.path, job.startLine, job.endLine))
}
catch (err) {
return deps.signal.aborted ? { status: 'stopped' } : failed(deps.failure(err))
}
if (deps.signal.aborted)
return { status: 'stopped' }
if (body === undefined)
return failed('模型没有按要求交回注释正文,没有改文件。')
if (rangeText() !== snapshot)
return failed('这块代码在重写期间变了,没有写回。')
const edit = commentEdit(job.target, deps.lineAt, body, job.lineComment)
if (!edit)
return failed('这段注释的定界符认不出来,没有改文件。')
const wrote = await deps.write(edit)
if (wrote === 'written' || wrote === 'unchanged')
return { status: 'done' }
return failed(wrote === 'rejected' ? '编辑器没接受这次修改,没有写回。' : '这块代码在重写期间变了,没有写回。')
}
/** 按记号在块树里找到这块。 */
export function findBlock<T extends { anchor: string, children: readonly T[] }>(blocks: readonly T[], anchor: string): T | undefined {
for (const block of blocks) {
if (block.anchor === anchor)
return block
const inner = findBlock(block.children, anchor)
if (inner)
return inner
}
return undefined
}