mcpField
C
clanker-discipline

clanker-discipline

github.com/gbasin开发227.5k 次安装31 周前更新
代码仓库

关于 clanker-discipline

面向 Agent 的代码审查技能,用于检查状态类型、数据模型和状态管理函数。它强调从现有数据推导状态、避免无效状态,并将纯逻辑与副作用分开。

主要功能

  1. 01从已有数据推导状态
  2. 02用类型排除无效状态
  3. 03限制可变状态的作用范围
  4. 04将纯函数与副作用分离
  5. 05用数据表达查找逻辑

适用场景

  1. 01审查应用状态变更
  2. 02重构含可选字段的模型
  3. 03检查会修改状态的函数

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/gbasin/clanker-discipline --skill clanker-discipline

安装后在 Claude Code 中输入/clanker-discipline确认已加载。

附带文件README.md

详细介绍

Clanker Discipline

编写或审查管理应用状态的状态类型、数据模型和函数时,请遵循这些规则。Agent 往往会添加标志、可选字段和特殊情况,最终堆叠出无人预期的状态——请在这些问题落地前将其发现。

发现违规时,请彻底重构。目标是打造整洁、易维护的代码,而不是尽量减少差异。移除标志,重塑类型,重构函数。现在多改一些代码,也好过层层叠加变通方案,导致问题日益复杂。


1. 推导,不存储

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

之前:缓存标志

有人要求 Agent 仅在助手自然结束时显示页脚。它因此创建了四个标志:

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 响应)。
  • 推导逻辑比存储该值更复杂。
如果无法推导,就封装

如果必须存在可变状态,就将其限制在尽可能小的作用域中。闭包优于类字段:

// Bad: state visible to the whole class
class Writer {
  private debounceTimeout: ReturnType<typeof setTimeout> | null = null;
  queueSend(text: string) { /* can touch debounceTimeout */ }
  flushNow() { /* can touch debounceTimeout */ }
  somethingElse() { /* can also touch debounceTimeout */ }
}

// Good: state trapped in a closure
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. 让错误状态无法存在

每个可选字段都会产生一个问题,代码库中的其他部分每次处理该数据时都必须回答。

用判别联合类型替代可选字段集合
// Bad: when status is 'idle', should gateway/transactionId exist? The type doesn't say.
type PaymentState = {
  status: 'idle' | 'processing' | 'settled';
  gateway?: 'stripe' | 'paypal';
  transactionId?: string;
  initiatedAt?: string;
  settledAt?: string;
};

// Good: each status carries exactly the fields it needs.
type PaymentState =
  | { status: 'idle' }
  | { status: 'processing'; gateway: 'stripe' | 'paypal'; transactionId: string; initiatedAt: string }
  | { status: 'settled'; gateway: 'stripe' | 'paypal'; transactionId: string; settledAt: string };
用 Null 替代哨兵值
// Bad: 'none' is not an action. It is the absence of one.
type PendingAction = 'none' | 'confirm-address' | 'select-shipping';

// Good
type PendingAction = 'confirm-address' | 'select-shipping';
type OrderState = { pendingAction: PendingAction | null };
用分阶段组合替代杂物袋
// Bad: 20+ optional fields. Every consumer does profile.firstName ?? defaults.firstName.
type UserProfile = {
  firstName?: string;
  lastName?: string;
  email?: string;
  phone?: string;
  company?: string;
  jobTitle?: string;
  billingAddress?: string;
  cardLast4?: string;
  // ... more
};

// Good: check one optional instead of eight. When identity exists, all its fields are present.
type UserProfile = {
  identity?: { firstName: string; lastName: string; email: string };
  billing?: { address: string; cardLast4: string };
};
为相同的基本类型添加品牌
// Bad: a function accepting UserId will happily take a TeamId.
type UserId = string;
type TeamId = string;

// Good
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);            // data creation
      state.order.paymentStatus = 'captured';           // mutation
      state.order.receipt = receipt;                     // mutation
      state.user.lastPurchaseAt = receivedAt;           // mutation
      state.user.lifetimeSpend += receipt.amount;        // mutation
      clearPendingAction(state);                         // side effect
      const notifications = buildPaymentNotifs(state);   // notification
      state.notifications.push(...notifications);        // mutation
      recalculateDashboard(state);                       // derivation
      return { state, output: receipt, notifications };
    }
    // ... 12 more cases, same pattern
  }
}
之后:由语义函数组合而成
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。如果函数会返回值,就先克隆。切勿修改输入后再返回同一个引用——调用者无法判断该使用返回值还是原始值。

// Bad: mutates AND returns the same object
function withPendingAction(state: AppState, action: string): AppState {
  state.pendingAction = action;
  return state;
}

// Good: mutate, return void
function applyPendingAction(state: AppState, action: string): void {
  state.pendingAction = action;
}

// Also good: clone, return new
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 more branches
  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' } },
  // data, not code
];

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

这样更易于浏览、扩展和测试。Agent 添加新步骤时,只需添加一条数据,而不是在控制流中增加一个分支。

不要转换的情况

如果各分支的控制流不同,而不只是返回值不同,就保留代码形式。表格可以将输入映射到输出,但无法表达“调用 X,然后有条件地调用 Y”。


检查清单

审查代码时(无论是你自己的代码还是 Agent 的代码):

  • 是否可以从现有状态推导出任何新字段?直接推导。
  • 可变状态是否超出了最小作用域?将其封装在闭包中。
  • 是否有模型允许不应存在的字段组合?使用判别联合类型。
  • 是否有可以用 null 替代的哨兵值('none'、'unknown'、-1)?使用 null。
  • 不同领域概念是否使用了完全相同的类型别名?添加品牌标记或将其删除。
  • 是否有函数既修改输入,又返回输入?选择一种契约。
  • 语义函数是否增加了副作用?将副作用提取出来。
  • 是否存在每个分支都返回类似结构的 if 链?将其改为表。
  • 是否有从未构造过的无用类型变体?删除它们。

评论

支持 Markdown