clanker-discipline

clanker-discipline

捕捉 AI 編碼代理產生的狀態膨脹、雜亂模型與變異模糊性。用於審查狀態類型、布林標記、可選欄位模型或可變資料模式時使用。

22星標
0分支
更新於 2026/3/23
SKILL.md
唯讀
名稱
clanker-discipline
描述

捕捉 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 鏈,其中每個分支回傳相似的形狀?將其做成表格。
  • [ ] 是否有從未被建構的無效型別變體?刪除它們。