越界 / 不理想

一个类型错误演变成了架构改动

One type error became an architecture change

为了修一个类型错误,智能体改了接口,接着改了所有实现类,一个只涉及单文件的需求变成了多文件的重新设计。

适用于: Claude Code · Cursor · Codex · Copilot agent

症状

diff 的重心是一个你没提过的接口或类型文件,机械性的改动波及了很多模块。

为什么会这样

从编译器的角度看,在根部改类型是最“彻底”的修法。Night Forge 把这种连锁重构列为三种常见错误编辑模式之一。

示例

摘自 Night Forge 的文章。

text
Asked: fix a type error in OrderService.ts
Agent: changed the Repository<T> interface, then updated all 9 implementers

复制即用的修复

按你用的智能体挑选文件。规则片段用英文写,放进任何仓库都能用。

规则

Claude Code:加入 CLAUDE.md

CLAUDE.md
## Fix locally first
- Fix type and compile errors at the call site when possible (narrow, convert, or adapt locally).
- Do not change a shared interface, base class or exported type to fix a single caller.
  If that seems necessary, stop and propose it with the list of affected implementers.
- Keep the diff within the files you listed before starting.
规则

Codex、Copilot 编码智能体等:加入 AGENTS.md

AGENTS.md
## Fix locally first
- Fix type and compile errors at the call site when possible (narrow, convert, or adapt locally).
- Do not change a shared interface, base class or exported type to fix a single caller.
  If that seems necessary, stop and propose it with the list of affected implementers.
- Keep the diff within the files you listed before starting.
Cursor 规则

Cursor:项目规则

.cursor/rules/cascading-refactor.mdc
---
description: "AgentFails: One type error became an architecture change"
alwaysApply: true
---

## Fix locally first
- Fix type and compile errors at the call site when possible (narrow, convert, or adapt locally).
- Do not change a shared interface, base class or exported type to fix a single caller.
  If that seems necessary, stop and propose it with the list of affected implementers.
- Keep the diff within the files you listed before starting.
CI / 脚本

Diff 预算检查(提交前或在 CI 中运行)

改编自 dev.to(yureki_lab)的 diff budget 文章。读取真实的 git diff,所以通过 shell 做的修改也逃不掉。用法:python3 scripts/diff_budget.py origin/main 6 150

scripts/diff_budget.py
#!/usr/bin/env python3
"""Fail if the diff vs BASE touches files outside .agent-scope or exceeds the budget."""
import fnmatch, os, subprocess, sys

base = sys.argv[1] if len(sys.argv) > 1 else "HEAD"
max_files = int(sys.argv[2]) if len(sys.argv) > 2 else 6
max_lines = int(sys.argv[3]) if len(sys.argv) > 3 else 150

allowed = []
if os.path.exists(".agent-scope"):
    allowed = [l.strip() for l in open(".agent-scope") if l.strip() and not l.startswith("#")]

out = subprocess.run(["git", "diff", "--numstat", base], capture_output=True, text=True, check=True).stdout
untracked = subprocess.run(["git", "ls-files", "--others", "--exclude-standard"],
                           capture_output=True, text=True, check=True).stdout.split()

files, lines, outside = 0, 0, []
rows = [r.split("\t") for r in out.strip().splitlines()] + [["0", "0", p] for p in untracked]
for added, deleted, path in rows:
    files += 1
    if added != "-":
        lines += int(added) + int(deleted)
    if allowed and not any(fnmatch.fnmatch(path, p) for p in allowed):
        outside.append(path)

problems = []
if outside:
    problems.append("out-of-scope files: " + ", ".join(outside))
if files > max_files:
    problems.append(f"{files} files changed (budget {max_files})")
if lines > max_lines:
    problems.append(f"{lines} lines changed (budget {max_lines})")

if problems:
    print("OVER BUDGET: " + "; ".join(problems))
    sys.exit(1)
print(f"within budget: {files} files, {lines} lines")
钩子

Claude Code PreToolUse 钩子:拦截声明范围之外的编辑

改编自 dev.to(yureki_lab)的 diff budget 设计。在仓库根目录的 .agent-scope 中每行写一个允许的 glob。退出码 2 会阻止该工具调用,并把 stderr 反馈给智能体。它只覆盖文件编辑工具,shell 写入仍需靠 diff 检查兜底。

.claude/hooks/scope_guard.py
#!/usr/bin/env python3
"""PreToolUse hook: block Edit/Write/MultiEdit outside the globs in .agent-scope."""
import fnmatch, json, os, sys

call = json.load(sys.stdin)
if call.get("tool_name") not in ("Edit", "Write", "MultiEdit"):
    sys.exit(0)

root = os.environ.get("CLAUDE_PROJECT_DIR") or call.get("cwd") or os.getcwd()
scope_file = os.path.join(root, ".agent-scope")
if not os.path.exists(scope_file):
    sys.exit(0)  # no scope declared for this task -> allow

allowed = [l.strip() for l in open(scope_file) if l.strip() and not l.startswith("#")]
target = os.path.relpath(call["tool_input"]["file_path"], root)

if any(fnmatch.fnmatch(target, pat) for pat in allowed):
    sys.exit(0)

print(
    f"BLOCKED: {target} is outside this task's scope ({', '.join(allowed)}).\n"
    "If this edit is required, stop and ask the user to widen .agent-scope, with a one-line reason.\n"
    "If it is just an improvement you noticed, add it to FOLLOWUPS.md and keep going.",
    file=sys.stderr,
)
sys.exit(2)

来源与致谢

相关条目

每周翻车

每周一个真实的智能体翻车案例和它的修复。免费、简短、不刷屏。