clanker-discipline

clanker-discipline

捕获AI编码智能体产生的状态膨胀、大杂烩模型和变更歧义。在审查状态类型、布尔标志、可选字段模型或可变数据模式时使用。

22Star
0Fork
更新于 2026/3/23
SKILL.md
readonly只读
name
clanker-discipline
description

捕获AI编码智能体产生的状态膨胀、大杂烩模型和变更歧义。在审查状态类型、布尔标志、可选字段模型或可变数据模式时使用。

Clanker Discipline

在编写或审查状态类型、数据模型以及管理应用程序状态的函数时,应用这些规则。智能体倾向于添加标志、可选字段和特殊情况,这些会累积成无人预期的状态——在它落地之前捕获它。

当你发现违规时,彻底重构。目标是干净、可维护的代码,而不是最小的差异。移除标志,重塑类型,重构函数。现在更大的差异比以后层层叠加变通方案要好。


1. 推导,而非存储

每添加一个布尔值,理论状态空间就翻倍。当一个值可以从已有数据推导出来时,不要存储它。最好的推导来源是事件流:一个记录已发生事件的日志。

之前:缓存的标志

一个智能体被要求仅在助手自然完成时显示页脚。它发明了四个标志:

type ThreadState = {
  wasInterrupted: boolean;
  didAssistantFinish: boolean;
  didAssistantError: boolean;
  wasToolCallOnly: boolean;
};

function shouldShowFooter(state: ThreadState): boolean {
  return state.didAssistantFinish
    && !state.wasInterrupted
    && !state.didAssistantError
    && !state.wasToolCallOnly;
}

四个字段回答一个问题,其他地方还有四个变更点来保持它们同步。

之后:从证据推导

function shouldShowFooter(events: SessionEvent[]): boolean {
  const latest = getLatestAssistantMessage(events);
  if (!latest) return false;
  return latest.completed && !latest.error && latest.finish !== 'tool-calls';
}

答案现在从已经存在的事件中计算得出。

何时不推导

  • 领域确实有一个具有有序转换的状态机。结账步骤不是缓存的结论;它本身就是状态。
  • 字段包含无法重新推导的时序或外部数据(来自异步过程的时间戳、下游需要的API响应)。
  • 推导比存储值更复杂。

如果不能推导,则封装

如果可变状态必须存在,将其限制在尽可能小的作用域内。闭包比类字段更好:

// 坏:状态对整个类可见
class Writer {
  private debounceTimeout: ReturnType<typeof setTimeout> | null = null;
  queueSend(text: string) { /* 可以接触 debounceTimeout */ }
  flushNow() { /* 可以接触 debounceTimeout */ }
  somethingElse() { /* 也可以接触 debounceTimeout */ }
}

// 好:状态被困在闭包中
function createDebouncedAction(callback: () => void, delayMs = 300) {
  let timeout: ReturnType<typeof setTimeout> | null = null;
  return {
    trigger() {
      clearTimeout(timeout!);
      timeout = setTimeout(() => { timeout = null; callback(); }, delayMs);
    },
    clear() {
      if (timeout) { clearTimeout(timeout); timeout = null; }
    },
  };
}

闭包外部无法接触定时器。

调试的回报

当状态从证据推导时,调试变成数据输入、答案输出:

test('footer is hidden for aborted runs', () => {
  const events = loadEvents('./fixtures/aborted-session.jsonl');
  expect(shouldShowFooter(events)).toBe(false);
});

无需模拟或时序重现。错误要么在事件中,要么在纯函数中。


2. 使错误状态不可能

每个可选字段都是代码库其余部分每次接触该数据时必须回答的问题。

使用联合类型而非可选包

// 坏:当 status 为 'idle' 时,gateway/transactionId 应该存在吗?类型没有说明。
type PaymentState = {
  status: 'idle' | 'processing' | 'settled';
  gateway?: 'stripe' | 'paypal';
  transactionId?: string;
  initiatedAt?: string;
  settledAt?: string;
};

// 好:每个状态精确携带它需要的字段。
type PaymentState =
  | { status: 'idle' }
  | { status: 'processing'; gateway: 'stripe' | 'paypal'; transactionId: string; initiatedAt: string }
  | { status: 'settled'; gateway: 'stripe' | 'paypal'; transactionId: string; settledAt: string };

使用 null 而非哨兵值

// 坏:'none' 不是一个动作。它是动作的缺失。
type PendingAction = 'none' | 'confirm-address' | 'select-shipping';

// 好
type PendingAction = 'confirm-address' | 'select-shipping';
type OrderState = { pendingAction: PendingAction | null };

使用分阶段组合而非大杂烩

// 坏:20+ 个可选字段。每个消费者都做 profile.firstName ?? defaults.firstName。
type UserProfile = {
  firstName?: string;
  lastName?: string;
  email?: string;
  phone?: string;
  company?: string;
  jobTitle?: string;
  billingAddress?: string;
  cardLast4?: string;
  // ... 更多
};

// 好:检查一个可选字段而不是八个。当 identity 存在时,其所有字段都存在。
type UserProfile = {
  identity?: { firstName: string; lastName: string; email: string };
  billing?: { address: string; cardLast4: string };
};

为相同的基本类型添加品牌

// 坏:接受 UserId 的函数会愉快地接受 TeamId。
type UserId = string;
type TeamId = string;

// 好
type UserId = string & { readonly __brand: 'user' };
type TeamId = string & { readonly __brand: 'team' };

删除死变体

如果一个类型有一个从未被构造的变体,删除它。一个 status: 'open' | 'completed''completed' 从未被设置,表明一个不存在的生命周期。


3. 强制执行函数契约

永远不要给纯函数添加副作用

当一个纯函数悄悄获得副作用时,每个调用点都继承了它未要求的行为。如果一个函数需要副作用,将它们提取到单独的组织器中。

  • 语义函数 是小的、纯的、自描述的。所有输入进,所有输出出,没有隐藏效果。
  • 实用函数 是组织器。它们组合语义函数并包含混乱的领域胶水。

之前:语义函数演变为实用函数

function handleWebhook(state, eventType, payload, receivedAt): WebhookResult {
  switch (eventType) {
    case 'payment.captured': {
      const receipt = buildReceipt(payload);            // 数据创建
      state.order.paymentStatus = 'captured';           // 变更
      state.order.receipt = receipt;                     // 变更
      state.user.lastPurchaseAt = receivedAt;           // 变更
      state.user.lifetimeSpend += receipt.amount;        // 变更
      clearPendingAction(state);                         // 副作用
      const notifications = buildPaymentNotifs(state);   // 通知
      state.notifications.push(...notifications);        // 变更
      recalculateDashboard(state);                       // 推导
      return { state, output: receipt, notifications };
    }
    // ... 另外 12 个 case,相同模式
  }
}

之后:由语义函数组合而成

function handlePaymentCaptured(state: AppState, payload: PaymentPayload, receivedAt: string): WebhookResult {
  const receipt = buildReceipt(payload);
  const updatedOrder = applyPaymentToOrder(state.order, receipt);
  const updatedUser = applyPurchaseToUser(state.user, receipt, receivedAt);
  const notifications = buildPaymentNotifs(state, receipt);

  return {
    state: { ...state, order: updatedOrder, user: updatedUser },
    output: receipt,
    notifications,
  };
}

选择一个变更契约

如果一个函数变更其输入,返回 void。如果它返回值,先克隆。永远不要变更输入并返回同一个引用——调用者无法判断是使用返回值还是原始值。

// 坏:既变更又返回同一个对象
function withPendingAction(state: AppState, action: string): AppState {
  state.pendingAction = action;
  return state;
}

// 好:变更,返回 void
function applyPendingAction(state: AppState, action: string): void {
  state.pendingAction = action;
}

// 也好:克隆,返回新对象
function withPendingAction(state: AppState, action: string): AppState {
  return { ...state, pendingAction: action };
}

4. 数据优于过程

当一个长的 if 链从每个分支返回相似的形状时,逻辑是编码为代码的查找表。将其转换为数据。

之前:if 链

function getStepInfo(step: string): StepInfo | null {
  if (step === 'verify-email') {
    return { tone: 'action', title: 'Verify your email', detail: 'Check your inbox' };
  }
  if (step === 'add-payment') {
    return { tone: 'action', title: 'Add payment method', detail: 'Enter card details' };
  }
  if (step === 'review-order') {
    return { tone: 'confirm', title: 'Review your order', detail: 'Check totals' };
  }
  // ... 另外 10 个分支
  return null;
}

之后:声明式表格

const STEP_INFO: Array<{
  match: (step: string) => boolean;
  info: StepInfo;
}> = [
  { match: (s) => s === 'verify-email', info: { tone: 'action', title: 'Verify your email', detail: 'Check your inbox' } },
  { match: (s) => s === 'add-payment',  info: { tone: 'action', title: 'Add payment method', detail: 'Enter card details' } },
  { match: (s) => s === 'review-order', info: { tone: 'confirm', title: 'Review your order', detail: 'Check totals' } },
  // 数据,而非代码
];

function getStepInfo(step: string): StepInfo | null {
  return STEP_INFO.find(({ match }) => match(step))?.info ?? null;
}

更容易扫描、扩展和测试。智能体添加新步骤时添加数据条目,而不是控制流中的分支。

何时不转换

如果分支有不同的控制流——不仅仅是不同的返回值——保持为代码。表格将输入映射到输出;它无法表达“调用 X 然后有条件地调用 Y”。


检查清单

审查代码(你自己的或智能体的)时:

  • [ ] 任何新字段可以从现有状态推导吗?推导它。
  • [ ] 可变状态是否超出其最小作用域?将其困在闭包中。
  • [ ] 是否有模型允许本应不可能的字段组合?使用联合类型。
  • [ ] 是否有哨兵值('none''unknown'-1)而 null 可以工作?使用 null。
  • [ ] 是否有相同的基本类型别名用于不同的领域概念?添加品牌或消除。
  • [ ] 是否有函数既变更输入又返回它?选择一个契约。
  • [ ] 语义函数是否已经长出副作用?提取它们。
  • [ ] 是否有 if 链每个分支返回相似形状?将其变成表格。
  • [ ] 是否有从未构造的死类型变体?删除它们。