编码智能体改完代码后,最常见的浪费不是模型不够聪明,而是把 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
 */

约定三条使用规则:

  1. 只把 severity === 'error' 送进修补循环;警告留给人审,避免智能体为了“清零”大面积改风格。
  2. file 一律变成绝对路径,再在写入前用 path.relative 转成仓库相对路径,避免 Windows 盘符和符号链接把同一文件认成两个。
  3. 用 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 是否只含真实错误、路径是否相对仓库根目录。智能体只看这个结构,不再看整段报错。无效重试会少很多,上下文也不会被日志淹没。