Community寫作與編輯github.com

jessegall/commandments-writing-detectors

如何撰寫你自己的戒律(commandment)——一種專屬於專案的規則,從此能審查每一個檔案。在撰寫或修改 `.commandments/custom/` 下的規則前、使用者要求新規則/檢查/偵測器時、當 `commandments make` 引導你到這裡時,或當你準備使用正規表達式檢查程式碼時,請先閱讀此文件。

commandments-writing-detectors 是什麼?

此技能(SKILL)教導開發者如何為 AI 代理(例如 Claude Code, Cursor, Codex)撰寫專屬的專案層級規則,這些規則被稱為「戒律」(commandments)。這些規則是儲存在 `.commandments/custom/` 目錄下的 JSON 檔案,透過一個統一的引擎來評估,該引擎使用「選擇器」(selectors)、「保留條件」(where steps)和「拒絕條件」(reject steps)來定義一段程式碼何時違反規則。它支援多種語言的節點類型(如呼叫、建構、函式),並允許規則組合(從其他規則建構規則),以及使用 `either`、`not`、`all` 和 `any` 等邏輯運算子。此技能提供了一份詳盡的檢查清單(checks),涵蓋了節點的身分、名稱、修飾詞、參考的類型、所在位置和註解,讓開發者能夠精確地描述任何程式碼風格或架構約束。支援 PHP、Python、C# 等多種語言,並與他們的抽象語法樹(AST)橋接器(bridges)整合。

相容平台✓Claude Code~Codex CLI~Cursor
npx skills add https://github.com/jessegall/code-commandments/tree/HEAD/skills/writing-detectors

Installed? Explore more 寫作與編輯 skills: steipete/notion, langchain-ai/langchain, bytedance/podcast-generation · View all 6 →

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

撰寫你自己的戒律

內建的規則並非天花板。你在審查中反覆重申的慣例,就是一條你還沒寫下來的規則。一次寫好,它從此審查每一個檔案。

規則就是引擎要回答的一個問題

你自己的規則本質上是二進位執行檔所要處理的資料——一個放在 .commandments/custom/ 目錄下的 .json 檔案,可以放在該目錄或其下的任何子目錄中。因此,一個專案可以為其不同部分的程式碼分組管理規則(例如 custom/core/、custom/viewer/),而這些規則都向同一個引擎提問,就像內建規則一樣。規則由其檔案名稱(無論位於何處)來命名,因此將規則移動到資料夾時,配置中對它的所有引用仍然有效;而兩個同名的檔案都會被拒絕,不會有其中一個悄悄勝出的情況。規則指定了它所審查的引擎、它發現的罪狀(sin),以及一個查詢:一個選擇器(selector)用來打開它,然後是保留節點的 where 步驟和丟棄節點的 reject 步驟。每個步驟只做一次檢查;一條規則的好壞取決於每個步驟所問的問題。

{
    "engine": "php",
    "sin": {
        "name": "raw-sql",
        "description": "在呼叫點直接內嵌 SQL。",
        "rule": "查詢應透過擁有該資料表的 Repository 進行。",
        "skill": "no-raw-sql"
    },
    "find": {
        "select": "call",
        "where": [
            {"resolves": "Illuminate\\Support\\Facades\\DB", "of": "child:class"},
            {"nameIn": ["select", "statement", "unprepared"]}
        ],
        "reject": [
            {"file": "*Repository.php"}
        ]
    }
}

選擇器是一個中性的種類,在每種語言中讀法都相同——call、construction、function、type-declaration、assignment、loop、branch、return、throw、catch、comparison、literal、member-access、null-safe、identifier、import、parameter、block、bail-out、expression-statement、self-reference——或者 kind:<Kind>,這是語言自己的節點種類(如 kind:Expr_MethodCall),當沒有中性的種類能描述它時使用。

規則是從其他規則建構出來的。 你已經寫過一次的檢查可以被命名,無須再次書寫:{"rule": "ActionClass"} 在該名稱的規則也找到同一個節點時通過;而在 find 中,如果使用 "from": "ActionClass" 來取代 "select",則會縮小該規則找到的範圍。這是一個規則讀取另一個規則的方式。這兩種方式都能找到 .commandments/custom/ 中相同引擎的任何規則,而每個部分仍然是一條獨立的規則,會依其自身條件被列出和執行。一個未被任何規則宣告的名稱、指向另一個引擎的規則以及會繞回自身的循環鏈,在規則被讀取時就會被拒絕,不會進行任何評估。

無需第二條規則即可使用 either 和 not。 {"any": [{"is": "loop"}, {"is": "branch"}]} 在其中一個步驟通過時通過;{"all": [...]} 在所有步驟都通過時通過;而 {"not": {...}} 在其步驟不通過時通過——這就是 where 如何表達僅能由 reject 表達的條件。

一個步驟的一次檢查——所有可用的檢查,由工具本身產生:

它是什麼以及它叫什麼

檢查保留節點的時機
{"is": "loop"}節點符合該中性種類
{"kind": "Expr_StaticCall"}節點是該語言自己的種類,可以寫成語言本身的形式或詞彙表拼寫的形式:Expr_StaticCall 和 static-call 是同一種結構。拼寫方式是從種類衍生出來的,因此編輯器自己的自動完成列表會顯示語言真正產生的內容,而兩個種類共用的拼寫方式則不屬於任何一個
{"name": "select"}節點的名稱——呼叫或存取的名稱是其子節點的名稱——等於此值
{"nameIn": ["select", "statement"]}節點的名稱是其中一個值
{"nameLike": "get*"}節點的名稱符合 glob 模式,* 代表任意長度的字元,? 代表一個字元
`{"nameMatches": "^(getset)[A-Z]"}`
{"nameMood": "question"}節點的名稱屬於該語言語氣:bare-verb 是純動詞(holds, ships),third-person 是敘述式動詞(entersTestMode),而 question 以問詞開頭(is, has, can, should)。它只讀取語氣,而不是規則自己寫的詞彙列表。這是此處唯一一個評估名稱的檢查,而且它只回答名稱是否讀起來像某種語氣:讓語氣成為罪狀的原因,在於它與事物實際所做的事情相反,因此請將其與成員回答的內容或它是否改變了什麼配對使用,就像內建規則所做的那樣
{"nameCase": "snake"}節點的名稱(去除開頭的 $ 或 _)是 camel、pascal、snake、upper 或 kebab 大小寫
{"hasModifier": "static"}節點帶有該修飾詞
{"hasFlag": "byRef"}節點帶有該語言樹設定的旗標
{"operator": "*"}節點的運算子完全等於此值:* 和 ?? 區分兩種二元運算式,+= 是賦值,! 是一元運算,extends 是繼承子句
{"operatorIn": ["*", "/"]}節點的運算子是其中一個值

它所說的內容

檢查保留節點的時機
{"text": "select * from users"}字面值的文字完全等於此值
{"textLike": "*where id*"}字面值的文字符合 glob 模式
{"textMatches": "(?i)^select"}字面值的文字符合正規表達式
{"commentIs": "history"}註解是校準後的散文閱讀結果之一:history 敘述程式碼為何被修改,而非其功能;strawman 為了一個沒人會犯的錯誤辯護;restates 則重複了它所說明的程式碼本身已經表達的內容。這些閱讀結果是散文套件自己的,透過其在每種語言中的測試來衡量,且這個集合是封閉的:一條規則會要求一個已經校準過的判斷,而不是要求與規則自己寫的詞彙相匹配的散文。如果對一個註解進行評估("select": "comment"),它會判斷該註解;如果對程式碼進行評估,它會判斷為該程式碼所寫的註解
{"docReference": {"resolves": false}}文檔註解中命名了已不存在的程式碼:它所做的引用(<see cref>, {@see}, @see, Sphinx :class:)無法解析到任何東西,且該專案本來應該擁有它。兩個引用會被忽略,這是此檢查與斷鏈掃描器之間的全部區別:一個是編譯器本來就無法解析的,另一個的擁有者宣告在掃描範圍之外,因為此處無法判斷該擁有者是否仍然擁有該成員。解析是橋接器自己的功能
{"commentLike": "*TODO*"}節點上的註解,或節點正上方連續註解中的一個,符合 glob 模式——而且,如果對註解本身進行評估("select": "comment"),則是該註解所說的內容,因此一個鍵可以讀取規則所觸及的任何註解
`{"commentMatches": "(?i)\b(TODOFIXME)\b"}`
{"docTag": "deprecated"}節點的文檔帶有該標籤:PHPDoc 和 JSDoc 中的 @tag,C# 中的 XML 元素,Python 文件字串中的 Sphinx 欄位/指令或 Google/NumPy 段落
{"documented": false}節點帶有文檔註解,或者如果為 false 則表示沒有

它所引用的內容

檢查保留節點的時機
{"resolves": "App\\Models\\User"}其名稱所引用的內容(已解析)等於該類別——或者,對於不命名任何東西的值(變數、屬性),則是它持有的類別:是那個確切的類別,而非其子類
{"resolvesLike": "App\\Models\\*"}其名稱所引用的內容(已解析)符合 glob 模式,或者值所持有的類別符合:{"resolvesLike": "*Service", "of": "child:var"} 是任何 *Service 的接收者
{"declaredBy": "App\\Models\\*"}它所讀取的成員是由一個符合 glob 模式的類別宣告的:由其接收者持有的類別開始,沿著其父類別向上追溯,直到找到宣告該成員的類別。這是該欄位屬於哪個類別,沒有名稱可以說明,如果接收者在掃描中沒有類型,或者沒有任何宣告該成員的類型,則結果為空——因此該步驟不會保留任何節點
{"answeredBy": false}宣告它所讀取內容的類型已經命名了它所問的問題——該類型的一個方法,其整個主體返回相同的內容,無論從哪個方向讀取——或者如果為 false 則表示沒有。這區分了「使用已存在的名稱」和「給它一個名稱」,而由命名該問題的方法本身提出的問題,絕非提出該問題的地方之一
{"sameAs": {"of": "closest:loop", "target": "child:iter"}}它與相同檔案中的另一個節點命名了相同的東西,該節點是透過從它追蹤 of 然後從那裡追蹤 target 到達的——這是規則無法問的唯一一個問題:在迴圈所遍歷的同一個集合上執行 remove,一個參數就是剛剛從中讀取的欄位。兩者必須寫法相同,否則會被視為相同的樹;如果路徑末端沒有節點,則不通過
{"declaredElsewhere": true}它所命名的內容宣告在與其所在檔案不同的檔案中,或者如果為 false 則表示不是。從宣告該欄位的檔案外部設定該欄位,繞過了該類別為改變自身而撰寫的方法,這是一條關於類型自身業務的規則所要問的問題;如果掃描中沒有宣告它所命名的內容,則結果為空,因此規則也能說明擁有者
{"type": "number"}運算式所具有的類型,如該語言所解讀:檢查器賦予它的類型、其形狀所表示的類型,或宣告所寫的類型——其中一個基本類型,而 resolves 只回答類別
{"typeLike": "Money*"}運算式所具有的類型符合 glob 模式
{"calls": {"name": "dd"}}它自己的呼叫——而非其內部巢狀函式的呼叫——通過該步驟
{"argument": {"at": 0, "is": "literal"}}該位置的引數通過該步驟;負數的 at 從末尾開始計算,-1 表示最後一個,如果該位置不存在則不通過。如果完全沒有 at,則任何通過該步驟的引數都足夠,這是規則詢問按名稱傳遞的引數的方式,其位置並非識別它的依據
{"constructs": "Date*"}一個建構式,或者對於其他任何東西,其自身的建構式,建立了一個由 glob 模式命名的類別的實例——在 Python 中,是類別的呼叫
{"unused": true}沒有任何東西引用它:對於函式或類型,從其自身之外,透過掃描所解析的名稱和呼叫以及該語言的呼叫圖;對於參數,透過在其函式中讀取其名稱;對於欄位或屬性,透過其接收者持有的類型所達到的宣告,因為在大多數語言中,欄位不引用任何符號。語言本身按名稱訪問的成員,以及覆寫的成員,都不屬於此:專案中沒有任何東西必須命名它們
{"referredFrom": {"is": "parameter"}}引用它的東西——呼叫、名稱、書寫的類型——通過該步驟。它是 calledFrom 詢問引用而非其所在檔案的方式,這區分了事物的兩面:一個被 dispatch 命名但未被任何監聽器的參數命名的事件類別,沒有人聽到它,而 {"not": {"referredFrom": …}} 正是說明了這一點
{"calledFrom": "app/Http/**"}引用它的東西——最常見的是呼叫——位於一個符合 glob 模式的檔案中,其讀取方式與 file 讀取相同

它所處的位置

檢查保留節點的時機
{"file": "*Repository.php"}從專案(其所處的儲存庫,否則是被評估的資料夾)到該節點的路徑,或其任何尾部,符合 glob 模式:* 保持在一個資料夾內,而 ** 可以跨越多個資料夾,就如同 .gitignore 中的用法
`{"fileMatches": "(^/)pages/.*(Create
{"namespaceLike": "App\\Http\\*"}宣告該節點的命名空間、套件或模組,符合 glob 模式
{"layer": "App\\Domain"}它位於專案所宣告的堆疊層(後端、Python、C#)中
{"topLevel": true}它位於所有函式(包括閉包)和所有類型之外——也就是在檔案載入時執行的程式碼
{"withinLoop": true}它位於一個迴圈內部,或者如果為 false 則表示不是
{"position": "first"}它是其同層節點中的 first、last 或 only(唯一節點同時也是第一個和最後一個)
{"field": "cond"}它在父節點的樹狀結構中填充了該插槽——語言為該插槽命名的名稱,例如 cond、body、else、args——這是其他檢查從未問過的位置軸:作為條件的呼叫,而不是作為引數傳遞的呼叫。這些名稱是其所屬語言的,因此以此撰寫的規則用於判斷該語言
{"role": "member"}它是那種東西,使用每個橋接器都書寫的詞彙:statement、expression、member(類型、函式、屬性或列舉案例的任何宣告)、type(書寫的類型)、pattern、markup 或 other。這是規則說「一個宣告」而不必為每種語言命名一個種類的方式,這是關於成員順序或成員關係的規則所需要的
{"descendant": {"is": "return"}}它內部的某個節點通過該步驟;如果使用 "within": "ownBody",則只會踏入它自身持有的節點,並在巢狀函式處停止,這區分了「保存」的方法和「將持有保存動作的閉包傳遞給佇列」的方法
{"child": {"is": "return"}}它內部一層的某個節點通過該步驟,這是 descendant 在任何深度都無法說明的:一個其本身主體返回的函式,而對於一個只在 if 內部返回的函式則不成立。如果使用 "field",則只會踏入填充該樹狀結構欄位的子節點,就像計數的 field 會縮小計數的範圍一樣
{"body": {"is": "branch"}}其主體中的某個陳述式通過該步驟,透過語言包裝主體的任何方式來讀取:C# 寫一個區塊陳述式,而 Python 寫裸陳述式,因此 child 對它們的回答不同,而這個檢查對它們的回答相同。這是一個關於迴圈或函式主體的規則如何為每種語言編寫一次的方式
{"inside": {"is": "catch"}}它上方的某個節點,直到檔案的根節點,通過該步驟
`{"all": [{"name": "store"}, {"hasModifier": "pub

…

[因篇幅限制而截斷]

相關技能