只读侧接上 LSP 和符号搜索,并不等于写入侧也安全。很多代码智能体改 TypeScript 时仍在做文本级补丁:替换同名标识符、算错行号、弄坏导入,最后留下无法编译的半成品。把修改收口成可枚举的 ts-morph 操作,并在落盘前做语法与局部类型检查,失败就拒绝写入,比再给模型加一句「请保持代码可运行」要可靠。

字符串补丁在写入侧会踩哪些坑

智能体读代码时,用语言服务跳转定义、找引用,已经比全文检索稳得多。真正出问题的往往是写入:工具层仍把「改文件」实现成 apply_patch 或一次 search_replace。模型看到的是行文本,不是符号。

典型事故并不罕见。文件里同时有 greet 和 greetTwice,朴素替换 greet 会把后者改成废标识符;两个模块都导出 format,补丁打在错误的那一处;插入几行时漏了 import,或重复加了同一条;括号、泛型、装饰器被截断,语法已经坏了,却照样写盘。事后编译器报错,再让模型开下一轮「修复循环」,成本高,而且中间状态会污染工作树。

这些不是「模型不够听话」,是工具边界放错了。文本补丁没有符号作用域,也没有类型信息。TypeScript 的权威表示是 AST 和 checker,不是补丁里的上下文行。写入侧若继续当纯文本编辑器,只读侧的 LSP 再准也救不了落盘结果。

把写入收口成可枚举操作

与其让模型输出任意 diff,不如把允许的修改收成一张短表。对日常重构来说,下面三类已经能覆盖很大一部分「改业务代码」需求:

  • 按符号重命名(函数、类、方法、变量),引用由语言服务更新
  • 确保某条 named import 存在,已存在则不重复添加
  • 按结构插入函数(名字、参数、返回类型、语句列表),而不是往某个行号塞字符串

模型只选择操作和参数,不手写文件级补丁。函数体仍可能是生成出来的语句,这一点不必假装已经「完全结构化」;但符号边界、导入和声明位置不再靠猜。工具层负责执行、校验、决定是否 save。校验失败就回滚内存中的 AST,磁盘保持原样。

这要求 TypeScript 文件被真正加载进同一个 Project(含 tsconfig.json 和会走到的源文件)。只解析单文件、不跟类型,检查会漏报。ts-morph 是 TypeScript Compiler API 的一层,不是通用多语言方案;Java/Go/Python 需要各自的结构化编辑器,本文不外推。

用 ts-morph 做三类结构化编辑

下面脚本可直接用 npx tsx 运行(先 npm i ts-morph)。示例用内存文件系统,避免改你的仓库;逻辑与真实 tsconfig 项目相同,差别只在 Project 的构造参数。

重命名符号,而不是替换文本

import { Project, ts } from "ts-morph";

const project = new Project({
  useInMemoryFileSystem: true,
  compilerOptions: {
    strict: true,
    target: ts.ScriptTarget.ES2022,
    module: ts.ModuleKind.ESNext,
  },
});

project.createSourceFile(
  "/src/greet.ts",
  `
export function greet(name: string): string {
  return "hello " + name;
}

export function greetTwice(name: string): string {
  return greet(name) + " | " + greet(name);
}
`.trimStart(),
);

const sourceFile = project.getSourceFileOrThrow("/src/greet.ts");
sourceFile.getFunctionOrThrow("greet").rename("sayHello");
console.log(sourceFile.getFullText());

rename 走语言服务的重命名位置,只会改符号 greet 及其引用,不会把 greetTwice 切碎。同一段文本若做全局字符串替换,这正是最容易改错的地方。重命名默认不改注释和字符串字面量;需要时再显式传入选项,不要默认打开。

补导入与插入函数

import { Project, SourceFile, ts } from "ts-morph";

const project = new Project({
  useInMemoryFileSystem: true,
  compilerOptions: {
    strict: true,
    target: ts.ScriptTarget.ES2022,
    module: ts.ModuleKind.ESNext,
  },
});

project.createSourceFile(
  "/src/math.ts",
  `export function add(a: number, b: number): number {\n  return a + b;\n}\n`,
);
project.createSourceFile("/src/app.ts", `export const version = 1;\n`);

function ensureNamedImport(
  sourceFile: SourceFile,
  moduleSpecifier: string,
  name: string,
): void {
  const decl = sourceFile
    .getImportDeclarations()
    .find((d) => d.getModuleSpecifierValue() === moduleSpecifier);

  if (!decl) {
    sourceFile.addImportDeclaration({
      moduleSpecifier,
      namedImports: [name],
    });
    return;
  }

  const already = decl.getNamedImports().some((n) => n.getName() === name);
  if (!already) decl.addNamedImport(name);
}

const app = project.getSourceFileOrThrow("/src/app.ts");
ensureNamedImport(app, "./math", "add");

if (app.getFunction("sum3")) {
  throw new Error("function sum3 already exists");
}

app.addFunction({
  isExported: true,
  name: "sum3",
  parameters: [
    { name: "a", type: "number" },
    { name: "b", type: "number" },
    { name: "c", type: "number" },
  ],
  returnType: "number",
  statements: ["return add(add(a, b), c);"],
});

console.log(app.getFullText());

导入是声明合并,不是「在文件头插入一行」。插入函数前先查重名,避免把原实现盖掉。addFunction 追加在源文件末尾,位置确定,不依赖模型数行号。

落盘前做语法与局部类型检查

ts-morph 的改动默认停在内存里,调用 save / saveSync 才写盘。正确顺序是:改 AST → 取诊断 → 通过才保存。失败则用改前文本恢复,不要留下半成品。

getPreEmitDiagnostics() 会包含解析错误和类型错误。单文件诊断已经比「整仓 tsc」便宜,但仍取决于 Project 是否加载了被引用的文件。只检查当前文件、且过滤到本次插入区间,可以把噪声限制在这次编辑附近;文件级诊断(例如配置问题)没有起止位置,建议一律视为失败,宁可拒写。

import { Project, SourceFile, Diagnostic, ts } from "ts-morph";

type EditResult =
  | { ok: true; text: string }
  | { ok: false; reason: string };

function formatDiagnostics(diagnostics: Diagnostic[]): string {
  return diagnostics
    .map((d) => {
      const msg = ts.flattenDiagnosticMessageText(d.getMessageText(), "\n");
      const sf = d.getSourceFile();
      const start = d.getStart();
      if (!sf || start == null) return msg;
      const { line, column } = sf.getLineAndColumnAtPos(start);
      return `${sf.getFilePath()}:${line}:${column}: ${msg}`;
    })
    .join("\n");
}

function diagnosticsOverlapping(
  sourceFile: SourceFile,
  rangeStart: number,
  rangeEnd: number,
): Diagnostic[] {
  return sourceFile.getPreEmitDiagnostics().filter((d) => {
    const start = d.getStart();
    const length = d.getLength();
    if (start == null || length == null) return true;
    return start < rangeEnd && start + length > rangeStart;
  });
}

function applyStructuredEdit(
  sourceFile: SourceFile,
  mutate: () => void,
): EditResult {
  const before = sourceFile.getFullText();
  const mark = sourceFile.getEnd();
  try {
    mutate();
    const afterEnd = sourceFile.getEnd();
    const diags = diagnosticsOverlapping(
      sourceFile,
      Math.min(mark, afterEnd),
      Math.max(mark, afterEnd),
    );
    if (diags.length > 0) {
      sourceFile.replaceWithText(before);
      return { ok: false, reason: formatDiagnostics(diags) };
    }
    return { ok: true, text: sourceFile.getFullText() };
  } catch (e) {
    sourceFile.replaceWithText(before);
    return { ok: false, reason: e instanceof Error ? e.message : String(e) };
  }
}

const project = new Project({
  useInMemoryFileSystem: true,
  compilerOptions: {
    strict: true,
    target: ts.ScriptTarget.ES2022,
    module: ts.ModuleKind.ESNext,
  },
});

const sourceFile = project.createSourceFile(
  "/src/app.ts",
  `export function add(a: number, b: number): number {\n  return a + b;\n}\n`,
);

const rejected = applyStructuredEdit(sourceFile, () => {
  sourceFile.addFunction({
    name: "broken",
    statements: ["return missingSymbol;"],
  });
});

console.log(rejected.ok); // false
console.log(sourceFile.getFullText()); // 仍是原始 add,未污染

插入引用了不存在的 missingSymbol 后,局部诊断会失败,replaceWithText 把文件拽回原状。真实项目里通过后再 sourceFile.saveSync()。不要在诊断失败时保存「也许能跑」的版本。

局部检查不是测试,也替代不了全量 tsc。它只把「这次编辑已经让当前文件在现有 Project 下无法通过 checker」挡在工具边界。泛型推断、跨项目引用、any 逃逸,仍可能漏过。这是预期取舍,不是完整 CI。

接到现有工具层:失败则拒绝写入

把上一节的执行器包成智能体工具时,对外只暴露操作枚举,不暴露通用补丁。例如:

  • renameFunction({ file, from, to })
  • ensureNamedImport({ file, moduleSpecifier, name })
  • insertFunction({ file, name, parameters, returnType, statements })

实现上先用 tsConfigFilePath 加载现有工程,再 getSourceFileOrThrow。找不到符号、目标名冲突、诊断非空,都返回错误信息给模型,并且不调用 save。模型可以换参数重试,工作树始终是可编译的旧状态(至少不被这次工具调用写坏)。

和「系统提示里写请保持可运行」的差别在于:可编译被收成工具的后置条件,而不是生成策略。读仍用 LSP 或符号搜索;写只走这张表。表以外的改动(大段重构、改配置、改非 TS 文件)应显式降级或交给人,而不是悄悄退回字符串补丁。

加载范围要老实:没进 Project 的引用,rename 和类型检查都会盲。node_modules 过大时按你们现有工程的 tsconfig 走即可,不必为智能体单独发明一套解析规则。内存占用和 getPreEmitDiagnostics 耗时随工程变大,这是 Compiler API 的成本,需要的话再对单次工具调用加超时,而不是去掉检查。

总结

代码智能体的读写不对称:读可以靠 LSP,写却长期停在文本补丁,于是同名标识符、导入和半成品编译错误会反复出现。用 ts-morph 把 TypeScript 修改收成重命名、补导入、插函数这类可枚举操作,在内存里改 AST,用 getPreEmitDiagnostics 做语法与局部类型检查,通过才落盘,失败则恢复并拒绝。函数体仍可能是生成文本,检查也无法代替全量测试;但它把「能通过当前 Project 的 checker」从事后报错前移到了工具边界。先把写入侧收口,再考虑要不要给模型多写一句提醒。