✨ 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:
1 parent
335cf9ca9f
commit
582ac4c987
49 files changed
+1730
-757
No files matched your search
@@ -1,16 +1,27 @@
|
||||
/**
|
||||
* 按注释规范重写一段注释:模型只给新正文,这里套回原来的定界符和缩进。
|
||||
* 「重写注释」:用写注释的技能给一块代码重写(没有就新写)讲它的普通注释。模型只读文件、按 JSON Schema 交回注释正文,
|
||||
* 这里套回原来的定界符和缩进(没有注释时用语言的行注释记号),由扩展写回文件。
|
||||
* 认 Python 的三引号、井号行注释,以及块注释和双斜杠行注释。认不出就不改。
|
||||
*/
|
||||
|
||||
import { commonIndent, splitLines } from './editText'
|
||||
|
||||
/** 贴给模型的代码最多这么多行,再长就截断并在提示里说明。 */
|
||||
export const COMMENT_CONTEXT_LINES = 120
|
||||
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`。 */
|
||||
@@ -49,18 +60,6 @@ function docShell(comment: string): DocShell | undefined {
|
||||
return { prefix, quote, inner: rest.slice(quote.length, -quote.length) }
|
||||
}
|
||||
|
||||
/** 三引号里的正文:去掉包着正文的那一层换行,再去掉各行共同多出来的缩进。 */
|
||||
function docProse(shell: DocShell): string {
|
||||
let inner = shell.inner
|
||||
if (inner.startsWith('\n'))
|
||||
inner = inner.slice(1)
|
||||
if (inner.endsWith('\n'))
|
||||
inner = inner.slice(0, -1)
|
||||
const lines = inner.split('\n')
|
||||
const extra = commonIndent(lines)
|
||||
return lines.map(line => line.startsWith(extra) ? line.slice(extra.length) : line).join('\n').trim()
|
||||
}
|
||||
|
||||
/** 收尾那一行、引号前面的空白。正文和引号写在同一行时没有。 */
|
||||
function closerIndent(comment: string, quote: string): string {
|
||||
const nl = comment.lastIndexOf('\n')
|
||||
@@ -101,11 +100,10 @@ function refillDoc(comment: string, body: string): string | undefined {
|
||||
interface LineShell {
|
||||
token: string
|
||||
spaced: boolean
|
||||
prose: string
|
||||
}
|
||||
|
||||
/** 一行 `#` 或 `//` 注释:记号、记号后面有没有空格、空格后面的字。 */
|
||||
function lineParts(line: string): { token: string, spaced: boolean, text: string } | undefined {
|
||||
/** 一行 `#` 或 `//` 注释:记号、记号后面有没有空格。 */
|
||||
function lineParts(line: string): LineShell | undefined {
|
||||
let end = 0
|
||||
if (line.startsWith('//')) {
|
||||
end = 2
|
||||
@@ -118,8 +116,7 @@ function lineParts(line: string): { token: string, spaced: boolean, text: string
|
||||
return undefined
|
||||
}
|
||||
const gap = line[end]
|
||||
const spaced = gap === ' ' || gap === '\t'
|
||||
return { token: line.slice(0, end), spaced, text: line.slice(spaced ? end + 1 : end) }
|
||||
return { token: line.slice(0, end), spaced: gap === ' ' || gap === '\t' }
|
||||
}
|
||||
|
||||
function lineShell(comment: string): LineShell | undefined {
|
||||
@@ -133,7 +130,7 @@ function lineShell(comment: string): LineShell | undefined {
|
||||
const token = rows[0]?.token
|
||||
if (!token || rows.some(item => item.token !== token))
|
||||
return undefined
|
||||
return { token, spaced: rows.some(item => item.spaced), prose: rows.map(item => item.text).join('\n') }
|
||||
return { token, spaced: rows.some(item => item.spaced) }
|
||||
}
|
||||
|
||||
function refillLine(comment: string, body: string): string | undefined {
|
||||
@@ -214,14 +211,6 @@ function refillBlock(comment: string, body: string): string | undefined {
|
||||
return `${open}\n${rendered.join('\n')}\n${closeIndent}*/`
|
||||
}
|
||||
|
||||
/** 去掉定界符之后的正文。认不出这种注释就是 undefined。 */
|
||||
export function commentProse(comment: string): string | undefined {
|
||||
const doc = docShell(comment)
|
||||
if (doc)
|
||||
return docProse(doc)
|
||||
return lineShell(comment)?.prose ?? blockProse(comment)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把新正文套回这段注释原来的壳。comment 是去掉共同缩进的原文。
|
||||
* 认不出定界符,或新正文是空的,就返回 undefined,调用方不写文件。
|
||||
@@ -233,92 +222,204 @@ export function refillComment(comment: string, body: string): string | 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 function isModuleComment(comment: string, earlierKinds: readonly string[]): boolean {
|
||||
if (earlierKinds.some(kind => kind !== 'comment'))
|
||||
return false
|
||||
if (comment.trimStart().startsWith('/**'))
|
||||
return true
|
||||
return docShell(comment) !== undefined
|
||||
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)
|
||||
}
|
||||
|
||||
export interface CommentPlace<T extends { anchor: string, children: readonly T[] }> {
|
||||
block: T
|
||||
siblings: readonly T[]
|
||||
index: number
|
||||
function str(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
/** 按记号在块树里找到这块,以及它和哪些块排在同一层。 */
|
||||
export function findPlaced<T extends { anchor: string, children: readonly T[] }>(blocks: readonly T[], anchor: string): CommentPlace<T> | undefined {
|
||||
const index = blocks.findIndex(block => block.anchor === anchor)
|
||||
const found = index >= 0 ? blocks[index] : undefined
|
||||
if (found)
|
||||
return { block: found, siblings: blocks, index }
|
||||
/** 写到一半的结构化结果(`{"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) {
|
||||
const inner = findPlaced(block.children, anchor)
|
||||
if (block.anchor === anchor)
|
||||
return block
|
||||
const inner = findBlock(block.children, anchor)
|
||||
if (inner)
|
||||
return inner
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function clip(text: string, what: string): { text: string, note: string } {
|
||||
const lines = splitLines(text)
|
||||
if (lines.length <= COMMENT_CONTEXT_LINES)
|
||||
return { text, note: '' }
|
||||
return {
|
||||
text: lines.slice(0, COMMENT_CONTEXT_LINES).join('\n'),
|
||||
note: `\n(${what}一共 ${lines.length} 行,这里只贴了前 ${COMMENT_CONTEXT_LINES} 行,后面截断了)`,
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommentRewriteInput {
|
||||
/** 去掉共同缩进的注释原文 */
|
||||
comment: string
|
||||
/** 整份文件,用来在是模块说明时拿掉这段注释 */
|
||||
source: string
|
||||
startLine: number
|
||||
endLine: number
|
||||
earlierKinds: readonly string[]
|
||||
/** 普通注释后面紧挨着的那一块的原文;没有就不填 */
|
||||
following?: string
|
||||
/** 说明挂在这块代码上(函数、类的 docstring)。有它就不当成文件顶上的说明,上下文用这块。 */
|
||||
attached?: string
|
||||
}
|
||||
|
||||
/** 发给模型的整段话。开头是技能命令,Claude Code 自己加载技能。注释认不出就不组。 */
|
||||
export function commentRewritePrompt(input: CommentRewriteInput): string | undefined {
|
||||
const body = commentProse(input.comment)
|
||||
if (body === undefined)
|
||||
return undefined
|
||||
const fileComment = input.attached === undefined && isModuleComment(input.comment, input.earlierKinds)
|
||||
const context = input.attached !== undefined
|
||||
? clip(input.attached, '这块代码')
|
||||
: fileComment
|
||||
? clip(splitLines(input.source).filter((_, index) => index < input.startLine || index > input.endLine).join('\n').trim(), '文件其余部分')
|
||||
: input.following
|
||||
? clip(input.following, '这块代码')
|
||||
: { text: '(后面没有紧挨着的代码)', note: '' }
|
||||
return [
|
||||
COMMENT_SKILL,
|
||||
'只按这个技能重写这一段注释。用中文。不要改代码,不要加技能没要求的中文名记号。只输出新注释的正文,不要定界符,不要解释。',
|
||||
`这段注释现在的正文:\n${body}`,
|
||||
`它说明的代码:\n${context.text}${context.note}`,
|
||||
].join('\n\n')
|
||||
}
|
||||
|
||||
/** 模型偶尔会套一层代码围栏。剥掉;剥完是空的就当没给。 */
|
||||
export function modelCommentBody(text: string): string | undefined {
|
||||
let body = text.trim()
|
||||
if (body.startsWith('```') && body.endsWith('```')) {
|
||||
const nl = body.indexOf('\n')
|
||||
if (nl >= 0)
|
||||
body = body.slice(nl + 1, -3).trim()
|
||||
}
|
||||
if (!body)
|
||||
return undefined
|
||||
return body
|
||||
}
|
||||
Reference in new issue
Block a user