编码智能体改完代码后,最常见的浪费不是模型不够聪明,而是把 tsc、ESLint、测试的整段终端日志原样塞回上下文。本文用 Node.js 把这些失败收成带文件、行列、规则码的诊断,再配上修改范围和轮次上限,做成可以停下来的修补内环。目标很具体:少喂日志、对准位置、避免无效重试。
整段日志进上下文,错位比漏信息更常见
编译器和测试运行器是为人类扫终端设计的。它们会带颜色、重复打印同一条错误、夹杂成功用例、堆栈、耗时和无关警告。智能体看到的是一段很长的字符串,于是开始“猜”:改哪一个文件、哪一行才是根因、这条 any 警告要不要管。
更糟的是,日志会把真正有用的信号稀释掉。一次 tsc 失败可能只有三条类型错误,但终端里还有配置回显和文件列表;一次测试失败可能只是一个断言,却附带完整 diff 和依赖栈。下一轮修补如果把这些原样贴回去,上下文又贵又吵,还容易诱发无关改动。
所以不要让智能体靠读报错猜。先在仓库里用脚本把失败收成诊断,再决定喂什么、改哪里、何时停。
先定一份最小诊断,而不是再包一层日志
内环只需要能定位补丁的字段。多了会变成第二种日志,少了智能体仍然只能猜。下面这份结构足够对接 tsc、ESLint 和测试:
/**
* @typedef {Object} Diagnostic
* @property {'tsc'|'eslint'|'test'} source
* @property {string|null} file 绝对路径;无法定位时为 null
* @property {number} line 1-based;未知为 0
* @property {number} column 1-based;未知为 0
* @property {string} code 如 TS2322、no-unused-vars、assertion
* @property {string} message 单条说明,不含整段终端装饰
* @property {'error'|'warning'} severity
*/
约定三条使用规则:
- 只把
severity === 'error'送进修补循环;警告留给人审,避免智能体为了“清零”大面积改风格。 file一律变成绝对路径,再在写入前用path.relative转成仓库相对路径,避免 Windows 盘符和符号链接把同一文件认成两个。- 用
source + file + line + column + code + message做签名。签名不变,就视为本轮没有进展。
诊断是给程序比对的,不是给模型欣赏的。后面所有解析器都朝这个形状靠拢。
三类失败怎么收成同一形状
能走官方 API 就不要正则扫终端。API 给出的行列是工具自己的语义,比“从红色文字里抠数字”稳。下面这个模块可直接放到 scripts/collect-diagnostics.mjs,在已有 tsconfig.json 和 ESLint 配置的仓库里运行。
import path from 'node:path';
import { spawn } from 'node:child_process';
import ts from 'typescript';
import { ESLint } from 'eslint';
function run(cmd, args, cwd) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
cwd,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
stdout += chunk;
});
child.stderr.on('data', (chunk) => {
stderr += chunk;
});
child.on('error', reject);
child.on('close', (code) => resolve({ code, stdout, stderr }));
});
}
export function collectTscDiagnostics(tsconfigPath) {
const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
if (configFile.error) {
const message = ts.flattenDiagnosticMessageText(
configFile.error.messageText,
'\n',
);
return [
{
source: 'tsc',
file: path.resolve(tsconfigPath),
line: 0,
column: 0,
code: `TS${configFile.error.code}`,
message,
severity: 'error',
},
];
}
const parsed = ts.parseJsonConfigFileContent(
configFile.config,
ts.sys,
path.dirname(tsconfigPath),
);
const program = ts.createProgram({
rootNames: parsed.fileNames,
options: { ...parsed.options, noEmit: true },
});
return ts.getPreEmitDiagnostics(program).map((diag) => {
const message = ts.flattenDiagnosticMessageText(diag.messageText, '\n');
if (!diag.file || diag.start === undefined) {
return {
source: 'tsc',
file: null,
line: 0,
column: 0,
code: `TS${diag.code}`,
message,
severity: 'error',
};
}
const pos = diag.file.getLineAndCharacterOfPosition(diag.start);
return {
source: 'tsc',
file: path.resolve(diag.file.fileName),
line: pos.line + 1,
column: pos.character + 1,
code: `TS${diag.code}`,
message,
severity: 'error',
};
});
}
export async function collectEslintDiagnostics(patterns) {
const eslint = new ESLint();
const results = await eslint.lintFiles(patterns);
const diagnostics = [];
for (const result of results) {
for (const msg of result.messages) {
diagnostics.push({
source: 'eslint',
file: path.resolve(result.filePath),
line: msg.line ?? 0,
column: msg.column ?? 0,
code: msg.ruleId ?? 'eslint',
message: msg.message,
severity: msg.severity === 2 ? 'error' : 'warning',
});
}
}
return diagnostics;
}
export async function collectJestJsonDiagnostics(cwd) {
const outputFile = path.join(cwd, '.repair-jest.json');
await run(
process.execPath,
[
'./node_modules/jest/bin/jest.js',
'--json',
`--outputFile=${outputFile}`,
'--watchAll=false',
],
cwd,
);
const { readFile } = await import('node:fs/promises');
let report;
try {
report = JSON.parse(await readFile(outputFile, 'utf8'));
} catch {
return [];
}
const diagnostics = [];
for (const suite of report.testResults ?? []) {
for (const assertion of suite.assertionResults ?? []) {
if (assertion.status !== 'failed') continue;
diagnostics.push({
source: 'test',
file: path.resolve(suite.name),
line: assertion.location?.line ?? 0,
column: assertion.location?.column ?? 0,
code: 'assertion',
message: String(assertion.failureMessages?.[0] ?? assertion.fullName)
.split('\n')[0]
.slice(0, 300),
severity: 'error',
});
}
}
return diagnostics;
}
几点刻意克制的选择:
- TypeScript 走
createProgram+getPreEmitDiagnostics,行列来自getLineAndCharacterOfPosition,这是编译器自己的 0-based 坐标,转成 1-based 再交给智能体。 - ESLint 走官方
ESLint.lintFiles。ruleId为空时(例如解析失败)仍保留一条诊断,但code退回'eslint',不要假装有一条具体规则。 - 测试优先吃 Jest 的
--json --outputFile。断言信息只取失败消息的第一行并截断到 300 字:补丁定位靠文件和行列,不靠把 diff 全文喂回去。 - 没有 Jest 时,不要发明一种“通用测试日志格式”。换成你实际在用的 JSON reporter,字段对上再写解析,比扫 ANSI 颜色可靠。
收集侧的输出应当是数组,而不是又一段漂亮打印。调试时可以 JSON.stringify(diagnostics, null, 2) 看,但送进模型前还要再收一层。
修补内环:范围、轮次、无进展就停
有了诊断,内环仍然可能失控:同一条错误改十轮、改到诊断里没出现的文件、把警告也修成新的类型错误。控制逻辑不需要智能,只需要硬上限。
import path from 'node:path';
import {
collectTscDiagnostics,
collectEslintDiagnostics,
collectJestJsonDiagnostics,
} from './collect-diagnostics.mjs';
function keyOf(d) {
return [d.source, d.file, d.line, d.column, d.code, d.message].join('|');
}
function toPayload(diagnostics, repoRoot) {
return diagnostics
.filter((d) => d.severity === 'error')
.map((d) => ({
source: d.source,
file: d.file ? path.relative(repoRoot, d.file) : null,
line: d.line,
column: d.column,
code: d.code,
message: d.message,
}));
}
async function collectAll(repoRoot) {
const tsc = collectTscDiagnostics(path.join(repoRoot, 'tsconfig.json'));
const eslint = await collectEslintDiagnostics(['src']);
const test = await collectJestJsonDiagnostics(repoRoot);
return [...tsc, ...eslint, ...test].filter((d) => d.severity === 'error');
}
export async function runRepairLoop({
repoRoot,
maxRounds = 3,
extraAllowedFiles = [],
applyPatch,
}) {
const extra = new Set(extraAllowedFiles.map((f) => path.resolve(repoRoot, f)));
let previous = new Set();
for (let round = 1; round <= maxRounds; round += 1) {
const diagnostics = await collectAll(repoRoot);
if (diagnostics.length === 0) {
return { stop: 'clean', round, remaining: [] };
}
const current = new Set(diagnostics.map(keyOf));
const unchanged =
current.size === previous.size &&
[...current].every((k) => previous.has(k));
if (round > 1 && unchanged) {
return { stop: 'no-progress', round, remaining: toPayload(diagnostics, repoRoot) };
}
previous = current;
const fromDiags = diagnostics
.map((d) => d.file)
.filter(Boolean)
.map((f) => path.resolve(f));
const allowed =
extra.size === 0
? new Set(fromDiags)
: new Set(fromDiags.filter((f) => extra.has(f)));
if (allowed.size === 0) {
return { stop: 'out-of-scope', round, remaining: toPayload(diagnostics, repoRoot) };
}
const payload = {
round,
maxRounds,
allowedFiles: [...allowed].map((f) => path.relative(repoRoot, f)),
diagnostics: toPayload(
diagnostics.filter((d) => d.file && allowed.has(path.resolve(d.file))),
repoRoot,
),
};
const touched = await applyPatch(payload);
const illegal = (touched ?? []).filter(
(f) => !allowed.has(path.resolve(repoRoot, f)),
);
if (illegal.length > 0) {
return { stop: 'illegal-write', round, remaining: payload.diagnostics, illegal };
}
}
const leftover = await collectAll(repoRoot);
return {
stop: leftover.length === 0 ? 'clean' : 'max-rounds',
round: maxRounds,
remaining: toPayload(leftover, repoRoot),
};
}
applyPatch 是你现有的编码智能体入口,本文不编造某个 LLM SDK。调用约定只有三条:入参是上面的 payload,不要再附带原始日志;返回本轮实际改过的相对路径;超出 allowedFiles 的写入直接停,而不是再“让模型解释一下”。
maxRounds 默认 3 只是示例,不是调参结论。类型修复往往一轮就能对准;测试失败若行列为 0,智能体只能改到文件级,更需要靠“签名不变就停”防止空转。
把循环接到一次性脚本时,先跑收集、打印 payload,确认没有把 node_modules 或生成文件算进 allowedFiles,再接入自动改写。
落地时别把解析器做成第二个智能体
解析失败是工程问题,不是再调用一次模型去“总结日志”。后者既贵,又会把不确定的自然语言说明写进下一轮提示。
几个容易踩的边界:
- 行列对不上时,宁可
line: 0并让范围停在文件级,也不要从堆栈里随便挑一行。测试框架的location有时指向 expect,有时指向被测函数,把它当成提示而不是唯一真相。 - 配置错误(找不到
tsconfig、ESLint 解析失败)应变成file指向配置、line为 0 的诊断,并尽快停循环。这类问题通常不该由改业务代码来修。 - 同一文件上先修会改变后续诊断的错误,例如 TS 语法错误会挡住后面的类型检查。内环已经是“收集 → 改 → 再收集”,不必在解析器里手工排序优先级。
- 不要把覆盖率、snapshot 全文、安装日志并进诊断。需要快照时,存文件、在 payload 里只留路径。
这套内环解决的是“对准和停止”,不是“一次修好所有失败”。剩下的失败原样交给人,比让智能体在过期日志上继续猜更安全。
总结
终端日志适合人读,不适合作为编码智能体的补丁坐标。用 TypeScript 编译器 API、ESLint 的 lintFiles 和测试的 JSON reporter,可以把失败收成带文件、行列、规则码的诊断;再以错误级诊断构造允许修改的文件集合,加上轮次上限和“签名不变就停”,就能得到一条可停止的修补内环。
直接落地时先让收集脚本在你的仓库里跑通,检查 payload 是否只含真实错误、路径是否相对仓库根目录。智能体只看这个结构,不再看整段报错。无效重试会少很多,上下文也不会被日志淹没。
评论