只读侧接上 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」从事后报错前移到了工具边界。先把写入侧收口,再考虑要不要给模型多写一句提醒。
评论