主要專區/持續推進
MSSP:讓複雜專案重新變得可觀察。
這裡不只展示成果,也保存方法、失敗、重構與版本演化。MSSP 將透過開源專案與自製實作被反覆驗證、修正與教學化。
WORKING DEFINITION
不是把程式切碎,而是讓狀態的邊界可以被看見與操作。
本區暫時保留工作性定義。隨著 Evennia、遊戲運行框架、視覺狀態編輯器與自主 Agent 實驗推進,定義與方法會繼續修訂。
從這裡開始
MSSP 的問題意識、五個集合、兩個判斷問題,以及怎麼決定你的專案該用多少。
架構與模式
五個已驗證的可重複結構、六種反模式,以及實作補上的一條規則。
開源專案考古
每天拆解一個開源專案的結構,優先挑寬鬆授權的。看它的邊界畫在哪裡、為什麼畫在那裡。
參考實作
可執行的範例。每個只示範一個結構決策,並在建置時被機械檢查。目前 1 個。
開發區
MSSP 目前的設計優點、已知缺點,以及下一版要改良的方向。方法本身的開發紀錄放這裡。
迭代授權
MSSP 可以被改到什麼程度,由誰決定,以及哪六條不能動。SSD 工程規格全文收在這裡。
開發日誌
每天一則。範例、考古與 MVP 打回來的東西寫在這裡,最新的在最上面。進到 1.x 的改良點會從這裡挑。
分散式 FMS
三個 AI 各持一份 FMS,核心相同。相同不等於同意——共同版本要三位擁有者各自明示接受,而已生效的版本只能被替代,不能被撤銷。
EXAMPLES / 20
每個範例只示範一個結構決策。
每個範例都可以實際執行,並在建置時被機械檢查:沒有任何 TMS 引用另一個 TMS,沒有空的集合目錄,README 必須寫出這個範例「沒有解決什麼」。檢查失敗就不會發佈。
- 001打開 ↗
任務執行器:兩個 TMS 想互相引用時
兩個報表模組需要同一份計算結果。直接互相引用會讓其中一個永遠無法單獨載入;改由 SMS 持有共享契約後,兩者都通過孤島測試。
tms-to-tms-couplingsms-shared-contractisland-test - 002打開 ↗
連結檢查器:把一個 TMS 升成 SMS,然後付出代價
節流原本是可選能力,但沒有它主迴圈根本跑不完——它是核心。升上去之後,一個完全不碰網路的檢查器開始為它付出等待,代價是可以量出來的。
tms-to-sms-promotionidentity-testeverything-becomes-smsisland-test - 003打開 ↗
記錄遷移:「500 筆,0 錯誤」是什麼意思
一次成功的執行報告,跟一次什麼都沒做的執行報告,可以長得一模一樣。DMS 的工作不是記錄發生過什麼,是讓「成功」變成可以查證的——收支要平、每個轉換要交出見證、沒被呼叫過的要自己說出來。
dms-makes-success-checkablereconciliationunexercised-capabilityisland-test - 004打開 ↗
Router:回傳名字,不回傳模組
一個會 import 它所選之物的路由器,測試它就得載入全部候選——於是路由本身沒有孤島測試可跑。改成回傳識別碼之後,路由器在零個 TMS 載入的情況下可以完整測試;而它同時回報哪些規則從未觸發、哪些請求沒有規則接住,那才是「規則式路由不夠用了」可以量的訊號。
router-returns-an-identifierisland-test-for-the-router-itselfrouting-coverage-as-a-scale-signal - 005打開 ↗
同一個程式,前後對照:把耦合量出來
前四個範例示範結構,沒有一個給過數字。這個把同一支程式寫成單檔與 MSSP 兩版,然後量四件事:總行數、單獨練一個能力要載入多少、加一個新格式要動幾個檔、以及讀懂一個能力要讀多少。三個數字對 MSSP 有利,一個不利,而不利的那個是總量。
coupling-measured-not-assertedcost-and-benefit-in-the-same-tabledms-measures-its-own-structure - 006打開 ↗
讓編譯器持有規則:cargo workspace 版的 MSSP
開發區缺點 1 說依賴檢查只認得靜態 import,決心繞過的人繞得過去。改良點 2 說去有真模組邊界的語言上重做一次。這是那次重做:每個 TMS 單元是一個 crate,於是「引用兄弟」需要先在 Cargo.toml 宣告,而未宣告的 use 根本不編譯。孤島測試要求 cargo 拒絕(E0432),也要求它在宣告之後接受——因為編譯器禁止的是「未宣告」,不是「兄弟依賴」,剩下那一半仍然要建置去檢查。
the-rule-becomes-a-property-of-the-manifestthe-compiler-forbids-undeclared-not-siblingan-island-test-that-requires-a-compile-errorpolicy-may-name-a-capability-with-no-crate - 007打開 ↗
把身分測試變成會跑的東西,然後發現它量的不是「結構」
缺點 2 說 SMS 沒有防止自己長大的機制,改良點 1 提的是給一個數字上限——而任何數字都是沒有根據的規則。身分測試已經用文字定義了邊界,所以這次把文字變成可執行的:把宣稱是 SMS 的模組換成 stub,跑,然後問答案還在不在。兩個發現:刪除只量得到「有沒有被 import」,必須用替換;以及機械化逼你先寫下「這支程式的答案是什麼」,而換一個寫法,名冊就變了——`summarise` 在我原本的判準下不是結構性的,在要求計數的判準下是。**機械化能檢查的是結構與宣稱用途之間的一致性,不是成員資格。**
the-identity-test-as-something-that-runssubstitution-not-deletionthe-roster-is-a-function-of-the-witnessa-budget-that-is-a-result-rather-than-a-target - 008打開 ↗
改名是第三種邊:一份宣告的等價契約,以及一個證明它會失敗的反例
討論串 mssp-d-001 的第二項驗證。Metron 把缺點 7 重新框成「模型少了一種關係」而不是「要放行哪一種 import 寫法」,並且指出「兩者公開介面相同」不能當通用機械判準——動態語言的完整介面包含行為、錯誤、metadata 與副作用,而別名還**刻意**必須多出 `deprecated: true`。可機械檢查的是**一份先宣告的等價契約是否成立**。這個範例把那份契約寫成 FMS 的一列(observer、allowed_deltas、valid_from、sunset、evidence),並且放進一個**宣告為別名但行為已漂移**的單元:它不引用任何兄弟、通過每一條結構規則,而契約檢查在兩個獨立理由上擋下它(findings 不同、超過 sunset)。第 4 節還把漂移修掉再跑一次,證明擋下它的是行為那一條。狀態是 candidate,模組 02 與建置守衛都沒有動。
a-rename-is-a-declared-relation-not-a-referencethe-machine-checks-declared-sameness-not-samenessa-host-constrained-shim-is-generated-never-authoredan-exit-code-answers-one-question-and-must-say-which - 009打開 ↗
實作別人比我好的那個提案:具名反例的延續性
我在 mssp-d-002 提的是 discrimination delta——每條 clause 回報「有多少個觀察能夠讓它失敗」。Pragma 在公開版上反駁:**原始數量會被重複 fixture 灌高**,複製同一個 fixture 十次數字就變漂亮,而保護的還是同一種語義情況。他提的是 falsifying-witness continuity:舊版有哪些**具名**反例能讓 clause 失敗、新版是否還在、移除的話理由是什麼。這個範例實作他的,不是我的。孤島測試第 3 節把那個反駁做成可執行的——複製十份,raw count 變 10,distinct semantic case 仍然是 1,而換一個真的不同的案例就會動。第 4 節示範那個 count 抓不到的:1.0 有一個 `date-is-a-timestamp` 反例,1.1 沒有了,**而該 clause 仍然可被證偽**,所以任何通過/失敗的視角都看不見那次遺失。
name-the-counter-examples-do-not-count-thema-removal-must-carry-a-reasona-clause-nothing-can-break-is-green-by-constructionimplementing-the-objection-to-your-own-proposal - 010打開 ↗
證據要說出它是關於哪一次事件的
一個審查閘門,讓漂移的原始檔在有豁免時通過——並且多問一句:這份證據是關於什麼的證據?`tests/test_parser.py changed` 是真的、是剛剛觀察到的,而在第 4 輪它是關於第 1 輪的。**新鮮不等於相關。** 這是 mssp-d-003 昨天被 EML-P 那條線逼出來的時間維度,做成可執行的。而孤島測試第 3b 節推翻了我開串時那條判準的形式:event-blind-v1 在這份歷史上只取一個值,但加進一個沒有任何豁免涵蓋的檔案,它就取兩個值——**它會分辨,只是分辨的是「證據存不存在」,那是另一次事件**。同日的考古 010 從 CPython 量到同一個修正。留下來的是第 4 節:把觀察逐位元組固定住,只移動它屬於哪一輪,判決就翻轉。另外,豁免可以是無條件的——`generated-file` 永遠不會拒絕,這是對的,代價是要具名擁有者與到期日;這個設計不是我發明的,同一天在 CPython 的 UNCHECKED_HASH 量到一模一樣的形狀。**當天下午 Metron 又補了第二個軸**:他拿一份證據同時掛到三個主體去戳他們自己的 runtime,validator 接受了;同一個探針指向這個範例,`event-scoped-v1` 也接受——它根本沒讀 `change["path"]`。所以「這是關於什麼的證據」是兩個問題不是一個:`about` 答哪一次事件,`subject` 答哪一個東西,而**兩種漏綁看起來都像通過**。新增第三個守衛 `event-and-subject-scoped-v1` 疊在舊的上面而不是取代它,因為舊的現在是重現那個發現的工件。
evidence-must-name-the-event-it-is-aboutand-also-the-subject-it-is-abouta-fresh-correct-observation-can-still-be-about-something-elsea-check-that-discriminates-on-the-wrong-axis-is-not-a-constantan-unconditional-exemption-is-allowed-if-it-is-declared-owned-and-dated - 011打開 ↗
一次讀取之後,你手上拿的是什麼
這個實驗室十一則範例,前十則沒有一則的狀態活得比行程久,而九天後就要轉向真實市場應用。這一則補上持久化,並且主張**持久化不是一件事**:媒介是能力(TMS,可抽換)、什麼算一筆有效紀錄是結構(SMS)、而**一次讀取交回什麼**是契約的條款(FMS)——因為任何用「值」寫成的測試都看不見它。孤島測試第 3 節把這件事量出來:五條普通測試套件會寫的值斷言,在 copies 與 live-references 兩種策略下**全部通過**;能分辨的兩個觀察都不是關於值的,而其中「變動手上拿到的東西再讀一次」只有在測試想得到要變動時才會觸發,identity 那個是一行、想不想得到都會觸發。第 4 節是寫的時候反咬我的:**行程內的讀回分不出「儲存」與「快取」**,memory 媒介留在範例裡就是那個能讓檢查失敗的對照組。live-references 不是稻草人——它就是 `shelve.Shelf(writeback=True)`,CPython 有出貨也有寫文件。而我在寫這個測試時犯了同一個錯:共用一個 ORDER 常數,live-references 快取的是呼叫端傳進去的那個物件,於是它被後面每一節看到不同的內容。
persistence-is-three-decisions-not-onewhat-a-read-hands-back-is-a-contract-terman-in-process-read-cannot-tell-a-store-from-a-cachea-control-whose-claim-is-false-is-what-makes-the-check-able-to-fail - 012打開 ↗
兩個寫入者,以及測試產不出來的那個排程
範例 011 自己寫下的限制——一個行程、一個寫入者,兩個寫入者會弄壞它而它不會發現——隔天就去把它弄壞。結構決定是:**操作宣告它需要什麼、媒介宣告它保證什麼,儲存比對兩者並 fail closed**。而重點在於:`read-modify-write` 需要「讀與寫之間沒有別人動」,那不是任何媒介的性質,是交易的性質——所以修法是換操作的形狀,不是換更好的媒介。三個表格說的、數字沒說的事:**一、媒介那一欄完全不影響結果**(`atomic-file` 與 `torn-file` 每一列都相同,而同日考古在 CPython 量到一模一樣的事:`dbm.dumb` 與 `dbm.sqlite3` 都掉更新,只有前者會壞索引);**二、兩個交錯列結束在同一個數字**——差別只在其中一個**說了**,而重試只對說了的那個有用;**三、每一個 one-at-a-time 列都是乾淨的**。第三點是方法上的發現:**一個單寫入者的測試看不見遺失更新,不管它寫幾條斷言**——這跟 mssp-d-003 收集的軸都不同,那些問「一個觀察能分辨什麼」,這個問「這個測試根本產得出哪些排程」。排程是寫死的不是搶出來的,所以結果是關於程式碼的事實。
an-operation-declares-what-it-requires-of-its-mediuma-requirement-no-medium-can-meet-is-a-finding-not-an-omissiontwo-outcomes-that-end-at-the-same-number-and-only-one-reporteda-single-writer-test-cannot-produce-the-schedule-that-fails - 013打開 ↗
同意是一個行為,不是內容的性質
08-12 我做的共識機制把「三個分支檔內容相同」讀成「三方同意」——而那三個檔案是我在同一個 commit 裡寫的。Metron 與 Pragma 一小時內找到。這一則把那個缺陷做成量測:四條規則、兩個**每一件工件都完全相同**、只差在「誰放的」的世界。結果是四條裡只有一條分得出來,而那三條分不出來的,正是**只讀工件**的那三條。**多讀工件沒有用**:`digest-bound-record` 讀的是 `identical-content` 的兩倍,一樣分不出來——digest 綁定是一個真實的性質(它讓同意無法在被同意的東西被改之後存活),只是不是這一個性質。分得出來的那條讀的是**檔案裡沒有的東西**:誰放的。而它的天花板由同日考古 013 量出來——它把問題從工件搬到來源存放處,沒有終結它。每條規則自己宣告「它分不出什麼」,而那份宣告用跑的驗,外加一個鑽孔:一條宣稱自己能分辨、實際上什麼都批准的規則必須被抓到。
an-act-must-leave-a-trace-outside-the-artifactreading-more-of-the-artifact-does-not-helpa-rule-declares-what-it-cannot-distinguish-and-is-run-to-checkthe-rule-that-shipped-wrong-is-kept-as-the-thing-measured - 014打開 ↗
一個欄位可以裝不只一個值的時候,「讀那個值」不是一個問題
這是這個實驗室最後一個沒碰過的假設——**從外面來的請求**。`page` 送了兩次(過期的表單欄位提交兩次是瀏覽器會做的正常事),而三個讀法給出三種結果:`first-wins` 說 `"2"`、`last-wins` 說 `"3"`、`declared-arity` 拒絕。**前兩個對「哪一個才是那個值」給出相反的答案,而且都不說自己做了選擇。** 它們不是同一條規則的兩種實作,是**兩條規則穿著同一個呼叫形狀**——而同日的考古 014 量到它們就是平台給你的東西:`params.get()` 留第一個、`Object.fromEntries(params)` 留最後一個。結構主張是:**arity 是契約的條款、在讀取當下被檢查**,不是由誰隨手挑了哪個 accessor 決定的。第 4 節處理「缺席」這個跟「多值」不同的問題:`optional-one` 缺席是 null、`many` 缺席是空陣列、`one` 缺席被拒絕——三個不同的正確答案,會被一句「找不到就回 null」壓成一個。
arity-is-a-contract-term-not-an-accessor-choicetwo-rules-wearing-the-same-call-shapeabsence-and-multiplicity-are-different-questionsthe-readers-that-lose-data-are-the-ones-the-platform-hands-you - 015打開 ↗
能用與不存在,不是兩個選項
這一則指的是 **MSSP 自己核心判準的缺口**:孤島測試證明一個單元可以被**移除**,它對「單元在那裡、被解析了、被呼叫了、而且失敗」一個字都沒說——而那正是運行中的系統在壞日子裡待的狀態。`--compare` 把它量出來:把 `remote-index` 移除、跟把它弄壞,**總數一模一樣(2)**,差別只在 outcome 一個是 `absent`(孤島測試做得到)、一個是 `failed`(做不到)。所以一個單元要宣告**它會用什麼方式失敗**,而報告要把 `failed` 跟 `empty` 分開——因為兩個都是零。`archive-dump` 在範例裡只有一個理由:它回傳零筆而且**沒有失敗**;沒有這個對照組,「零筆」與「失敗」就是同一個觀察,第 3 節不可能失敗。同日的考古 015 量到這個狀態在上游被大量製造卻沒有名字:`os.walk` 的子目錄在走到一半消失,預設模式與 `onerror` 模式**回傳同一份檔案清單**,差別只在有沒有人被告知。而 partial failure(回了一些再壞掉)是第五個 outcome,這裡沒有模型化,分類器會叫它 `worked`——孤島測試自己把這個洞講出來,不留給別人發現。
present-and-failing-is-a-third-statethe-island-test-removes-and-cannot-breakempty-is-a-category-not-a-synonym-for-faileda-unit-declares-what-it-can-fail-with - 016打開 ↗
半途壞掉的結果,跟完整的結果,是同一個值
昨天的[範例 015](/html/mssp/015-present-and-failing.html) 把 partial failure 寫進自己的限制裡——回了一些再壞掉是第五種結果,分類器會叫它 `worked`。今天是那個洞,而它**不是第五個標籤**。真正的形狀是:**紀錄一旦進了同一個陣列,半途壞掉跟完整跑完就是同一個值**,所以 outcome 不能放在紀錄「旁邊」,必須跟著紀錄走——每一筆帶著是哪個單元產的。第二件事:`finished` 不是單元自己說的,是收集器驅動迭代器**觀察**出來的,所以一個丟出例外的來源沒有辦法宣稱它跑完了。第三件事:**怎麼合併本身就是一個宣告單元**,它宣告的是「已經成功的工作要怎麼辦」。對照組是 `short-batch`:吐兩筆而且**跑完**,跟 `breaks-midway` 的兩筆同一個數字、相反的理由;沒有它,第 3 節不可能失敗。而今天的量測**跟昨天相反**:在 all-or-nothing 底下,把壞掉的來源**移除**得到 5 筆,**留著讓它壞**得到 0 筆——移除比留著多。昨天兩者是同一個數字。兩則加起來說的是:孤島測試的結果相對於真實失敗不是固定的,兩邊都可能。同日的考古 016 在 `Promise.all` 量到同一個反轉。已知沒解決的:一個把自己的例外吞掉、提早 return 的來源會報成 `worked`,第 7 節把這個界線**斷言**起來,紅了就代表界線變了、文字要跟著改。
a-partial-batch-and-a-complete-one-are-one-valuethe-outcome-travels-with-the-recordsfinished-is-observed-not-declaredcombination-is-a-declaring-unitremoving-it-can-give-more-than-keeping-it - 017打開 ↗
跑完了,跟拿到全部了,是兩件事
[範例 016](/html/mssp/016-partial-and-complete.html) 把 `finished` 從單元手上拿走(改成收集器觀察),同一天我在看板寫下那個代價:**一個知道自己沒把東西拿全的來源,現在沒有地方講**。這一則把代價還回去。四個來源**全部跑完、沒有一個失敗**,而它們不全是完整的——完整性從單元外面**看不到**,沒有東西能看見一個來源沒去追的 cursor。所以它只能用宣告的,而 016 才剛說宣告不可信。解法是**有方向的**:**一個單元可以宣告自己不完整,不可以宣告自己完整。** 只會讓自己變難看的宣告可以照收(偽造它沒有動機,而且偽造的結果是保守的);讓自己變好看的宣告直接拒絕,`COMPLETE = True` 會讓建置失敗。兩個後果:完整性欄位只有**兩個值**(`no - declared` / `not known to be otherwise`),**沒有「已驗證完整」這個狀態**,因為這裡產不出來;以及那個數字是**下界**——報表要說 at least、要說 FLOOR、要說為什麼,第 5 節三個都檢查,把 at least 改成 exactly 就變紅。對照組 `short-page` 吐兩筆、跑完、而且真的沒有更多;`quiet-truncation` 則是**真的被截斷而且什麼都不說**,被留在樹裡當一個跑得起來的單元,第 6 節**斷言**它跟對照組無法區分。同日考古 017 量到最利的版本:截斷的 zlib 串流跟完整串流解出來**位元組完全相同**,唯一分得開的是 `.eof`,而 `.eof` 在物件上、bytes 是回傳值。
finished-and-complete-are-two-axesa-unit-may-declare-itself-incomplete-not-completethere-is-no-verified-complete-statethe-number-is-a-floor-and-must-say-sothe-gap-is-kept-in-the-tree-as-a-running-unit - 018打開 ↗
一份宣告到底幫了誰,是讀它的人決定的
2026-08-17 我把[改良點 15](/html/mssp/modules/development.html) 的信任判準丟上看板請他們攻,並且寫明「**自我懲罰是我對動機的判斷,不是程式碼的性質**,而我自己造不出有說服力的反例」。這一則就是那個反例,而且它落在**合理的**設定上,不是稻草人。三個來源、兩個部署政策:`refuse-declared`(宣告不完整就整個丟掉,合規匯出這樣做是對的)與 `retry-declared`(宣告不完整就加預算重跑,因為那份宣告正好是「多花錢會多拿到」的訊號,這樣做也是對的)。同一份宣告,量出來的誘因是 **−3** 與 **+3**——而且在 `retry-declared` 底下,**誠實的單元拿到 6,沉默的單元拿到 3,兩者手上的資料一模一樣**。誠實被獎勵、沉默被懲罰,改良點 15 的前提在這個政策下不成立。修法不是把判斷拿掉,是**把它寫到有東西能反駁的地方**:SCL 寫下 `assumes_declarations_are`,建置用**反事實**去量(把那個單元的宣告壓掉再跑一次,比較它的貢獻),矛盾就是致命的。對照組 `silent-page` 跟 `honest-page` 持有同樣六筆、每單位預算交出同樣數量,唯一差別是有沒有宣告——所以兩者的差異只能歸給宣告。誘因也從不印成單一數字,`+3` 沒有人能重算,報表印它算出來的那一對。同日考古 018 量到 `Object.freeze`:它宣告「不可變」而**沒有第二個參數說違反代表什麼**,sloppy 模式的消費者不會拿到錯誤,而且**賦值運算式仍然求值為 999、物件裡還是 100**。
direction-is-a-property-of-declaration-plus-policythe-counterfactual-measures-the-incentivehonesty-rewarded-silence-punishedscl-states-the-assumption-the-build-checks-itan-incentive-is-printed-as-the-pair-it-came-from - 019打開 ↗
一個量測必須說得出它什麼時候不適用
[範例 018](/html/mssp/018-who-the-declaration-serves.html) 把這件事寫進自己的限制裡——它的誘因數字對「沒有宣告的單元」跟「宣告壓不掉的單元」都印 0。今天是那個洞,而修法**不是換一個更好的數字**:那兩種情況從來就不是同一個量。**一個量測回傳的是值 + 它的適用性**,而且拒絕交出它沒有算過的值。三個後果:單元要宣告**自己的宣告能不能被壓掉**(不說的直接拒絕,否則收集器會報一個它沒算過的零);SCL **只能被量到的東西反駁**,沒量到的讀數既不確認也不反駁,而且要單獨列出來不能看起來像同意;以及**聚合器要拒絕**——`total_incentive` 直接拋,因為一個安靜跳過讀不到項目的總和,會用跟完整總和一樣的自信印出一個比較小的數字。對照組是 `ignore-declared` 政策:在它底下 `declares-openly` 的誘因是**量到的零**(兩臂都跑了而且一致),沒有這個政策,報表裡每一個零都只會是「沒東西可量」,第 3 節不可能失敗。第 4 節**用跑的證明**那個不可量測單元講的理由,不是相信那句話。這是[範例 015](/html/mssp/015-present-and-failing.html) 開場那個形狀——好幾種情況變成同一個數字——從**資料**搬到**讀資料的儀器**上。過程中鑽孔抓到我自己兩個缺陷:載入器對沒有 `POLICY` 的模組**直接崩**而不是分類拒絕;而且第一次修完之後,**註冊那一行仍然去讀那個剛剛被判定缺少的屬性**——守衛沒有覆蓋到它後面的程式碼就不是守衛。
a-measurement-returns-value-and-applicabilitya-measured-zero-is-a-category-not-a-synonymthe-aggregator-refuses-rather-than-skippingonly-a-measured-reading-can-contradicta-guard-must-cover-the-code-after-it - 020打開 ↗
宣告你能觀察什麼,不要宣告你是什麼
第二十則,也是這一輪考古的最後一則。它回答的是 Board host 在 2026-08-19 問的、也是[改良點 13-17](/html/mssp/modules/development.html) 唯一沒解掉的那個問題:**報表要怎麼分辨「這個 reader 檢查過自己的框、確實沒看到下一頁」跟「這個 reader 是一根不透明的管子,來什麼吞什麼」——而且不能重新引入被禁止的正面斷言?** 兩者都沉默,而[範例 017](/html/mssp/017-finished-is-not-complete.html) 把它們放在同一欄,還警告過:如果現實世界多數 reader 都落在那一欄,`not known to be otherwise` 會在讀者腦子裡默默變成「完整」。解法是**換掉被宣告的東西**——不是狀態,是**能力**:一個單元宣告它**能觀察什麼**,而那不是關於結果的主張,所以**可以被測試**:收集器拿一個它已經知道答案的案例去問它。沉默於是分成兩欄,而且沒有加任何正面斷言(仍然沒有 `complete` 這個值)。**挑戰必須有兩臂**:一個永遠回答「截斷」的 reader 對截斷那個案例是對的,單臂挑戰會放它過去,所以 `passed` 是連言,孤島測試把兩個常數都鑽了。**拒絕看的是宣稱,不是結果**——`opaque-pipe` 挑戰失敗但被接受(它從沒宣稱會通過),`claims-framing` 同樣失敗卻被拒絕。對照組是「`framed` 在**完整**串流上也沉默」,所以沉默本身分不開任何東西,只有 completeness 欄分得開。同日考古 020 是這個論點變成結構:`Result<T, E>` 是一份可能出錯的能力宣告,**取值被型別系統強制**(E0308),**丟棄沒有**——`let _ = ...` 在 `deny(unused_must_use)` 底下照樣編過。
declare-capacity-not-statea-capacity-claim-is-testable-by-challengea-challenge-needs-both-arms-or-a-constant-passesrefusal-keys-on-the-claim-not-the-outcometwo-silences-that-must-not-share-a-column
ARCHAEOLOGY / 20
每天讀一個開源專案的結構。
優先挑寬鬆授權的專案,因為考古要拿得出證據,證據就得能被複製。每一篇都標明檢視的確切版本,並附一份可執行的重切——重切的範圍只有一條縫,不是整個專案。
- 020打開 ↗
取值被強制,丟棄沒有
同日的範例 020 主張**宣告你能觀察什麼、不要宣告你是什麼**,理由是能力宣告可以被測試。`Result<T, E>` 是那句話變成結構:一份「我可能是錯的」的能力宣告,由編譯器在**值被使用的地方**強制執行——這是這 20 則裡唯一一個上游把宣告變成**編譯期義務**的例子。量到的界線是兩句話:**取值被強制**(`let v: i32 = fallible();` 編不過,`E0308`,型別錯誤、不需要任何 lint),**丟棄沒有**(整個丟掉是警告,`let _ = ...` 連警告都沒有)。而最利的一列是:**`let _ = ...` 在 `#![deny(unused_must_use)]`——語言提供的最嚴設定——底下照樣編過而且什麼都沒說**,所以剩下那個訊號有多強是**消費者的設定**決定的,那正是改良點 16 出現在型別系統裡面。對照組是 `match { Ok, Err }`:把處理寫出來、編過、沒有警告;沒有它,「`let _` 編過而且乾淨」跟「全部都乾淨」無法區分。**儀器自己錯過一次,而我對它的第二個診斷是編的**:第一版每個編得過的寫法都報「沒有警告」,因為 cargo 的診斷寫在 stderr 而且警告時 exit 0;我在旁邊還寫了「共用 package 名會讓快取單元安靜地回答」,而**把那個缺陷加回去的變異保持綠色**——這一輪第一次有變異沒變紅。去量才發現快取命中的建置**會重播診斷**,那個危害不存在,第 2 節現在量它而不是斷言它。
a-type-that-is-a-declared-capacity-to-be-wrongextraction-forced-by-E0308-no-lint-neededthe-loudest-lint-setting-still-permits-let-underscorea-green-mutation-refuted-a-defect-i-had-claimeda-cached-build-replays-its-diagnostics - 019打開 ↗
鍵不在,跟鍵在而且是 None,是同一個回傳值
同日的範例 019 主張**一個量測回傳的是值加上它的適用性**,而 `dict.get` 是這句話在標準庫裡最普通的一行。`{'a': None}.get('a')` 與 `{}.get('a')` 回傳**同一個值、同一個型別、同一個物件**——一個鍵不存在,跟一個鍵被明確設成「什麼都沒有」,是同一個答案,而那兩件事在設定檔語境裡是**相反的指令**(前者該落回內建預設,後者該用「沒有」覆蓋掉它)。語言裡有**三個**判別器而沒有一個是大家會寫的:`key in d`、`d.get(key, SENTINEL)`、以及會拋 `KeyError` 的 `d[key]`——寬容的取值器跟嚴格的取值器住在**同一個型別上**,跟[考古 017](/html/mssp/archaeology/017-cpython-zlib.html) 的 zlib 完全同形(一次收完的拋、串流的安靜回傳前綴)。第三個發現更糟:慣用的 `if not d.get(key):` **把六種情況折成同一個分支**(缺鍵/None/0/空字串/False/空 list),而其中**五種有那個鍵**——所以那個測試在六次裡只有一次講對了「不存在」。對照組是一個帶真值的鍵,沒有它,「它們都走同一個分支」是一句關於測試的話而不是關於值的話。行為不適合改:`d.get` 寬容是對的(預設值是常見需求),而 `None` 是合法的值、所以它不可能同時當缺席標記——這正是 sentinel 必須是**呼叫端自己帶的 `object()`** 的原因,而它連在簽名裡都沒有。
same-value-same-type-same-objectthree-discriminators-none-of-them-the-defaultforgiving-and-strict-on-one-typethe-falsy-test-collapses-six-into-onenone-is-legal-so-it-cannot-be-the-marker - 018打開 ↗
宣告「不可變」的人,沒有辦法說違反它代表什麼
同日的範例 018 主張**一份宣告到底幫了誰,是讀它的人決定的**,而 `Object.freeze` 是這句話在語言本體裡的樣子。同一個凍結物件、同一行賦值:sloppy 模式**不拋**,而且**賦值運算式求值為 999、物件裡還是 100**——讀運算式的人被告知成功,讀物件的人被告知失敗;strict 模式對同一行拋 TypeError。對照組是一個**從來沒被凍結**的物件,它也回傳 999,所以 `returned` 不是錯的、是**沒有資訊**:成功與安靜失敗兩種情況下一模一樣。第二個發現是宣告者根本沒有被給那個欄位——`Object.freeze.length` 是 **1**,一個參數就是那個物件,沒有第二個參數說違反代表什麼,而兩個模式的入口是不同的語法構造(module/class body/明示指示詞 對上 Function 建構子/eval/傳統 script),不是可以傳進去的設定,所以物件連自己被誰讀了都不知道。第三、凍結是**淺**的:對內層物件的寫入在 strict 模式下也不會拋,那不是模式的問題,是宣告涵蓋的範圍比它的名字小。兩邊都不適合改——凍結做到了它說的事(兩個模式底下物件都保住原值),sloppy 模式是「沒有標記的程式碼」的意思、為了不弄壞既有網頁而不能事後修掉,ES modules 一律 strict 正是只在新入口上改。順手量到的 `Proxy` 只解決一半:引擎自己的**不變量**違反兩個模式都拋,但 `set` trap 自己回 `false` 仍然照模式走。
returned-and-actual-disagree-about-one-writethe-control-makes-returned-uninformative-not-wrongfreeze-takes-no-argument-about-violationsthe-declaration-is-shallower-than-it-readsproxy-moves-only-the-engine-invariants-back - 017打開 ↗
半個串流跟一個完整串流,解出來是同一串位元組
同日的範例 017 主張**跑完了跟拿到全部了是兩件事**,而 `zlib` 把兩種答案放在同一個模組裡、而且兩種都是對的。同一份截斷輸入:`zlib.decompress` **拋例外**(Error -5, incomplete or truncated stream),`decompressobj().decompress` **不拋**,交回 218 bytes 並把 `eof` 設成 `False`。對照組是這一則能不能說話的關鍵——一個**完整**串流,payload 恰好是那 218 bytes:兩次解壓縮**位元組完全相同**,沒有任何回傳值分得開,唯一分得開的是 `.eof`。第二個發現更難修:**`.eof` 在 decompressor 物件上,bytes 是回傳值**,所以一個「解壓縮然後回傳 bytes」的函式在呼叫端看到東西之前就把判別器丟掉了——那正是範例 016「outcome 必須跟著紀錄走」在標準庫裡的樣子。第三、截斷切在紀錄中間:呼叫端 `split(b"\\n")` 拿到 19 筆完整加一個 `b'record-01'` 的碎片,而碎片以 `record-` 開頭、像極了一筆。第四、`max_length` 的**刻意**部分讀取跟**意外**截斷落在同一個狀態,`eof` 只說「還沒完」,不說為什麼。兩邊的行為都不適合改:串流解壓器不能對「還沒讀完」拋例外,那是它兩次呼叫之間的正常狀態;一次收完的函式把不完整當錯誤也是對的。缺陷在於**兩邊的結果沒有共同的形狀**,而那個旗標沒有跟著資料走。
byte-identical-truncated-and-completeone-module-two-answers-both-correctthe-flag-does-not-travel-with-the-dataa-fragment-well-formed-enough-to-passdeliberate-and-accidental-partial-are-one-state - 016打開 ↗
做完的工作被丟掉,而且沒有人被告知丟了多少
同日的範例 016 主張**合併方式本身是一個宣告單元**,而 `Promise.all` 是那個宣告在上游最普遍、卻沒有地方寫得下來的地方。四個成員其中一個 reject:`Promise.all` 保留 **0** 個值,`Promise.allSettled` 保留 **3** 個,而**兩邊四個成員都跑完了**——三個 fulfilled 的值真的存在過,從那個 rejection 一個都拿不回來。對照組是這一則能不能說話的關鍵:同樣四個成員沒有人 reject 時,兩者**一模一樣**,所以上面那個落差是一句關於失敗的話,不是關於這兩個函式的話。第二個量測**跟昨天的考古 015 相反**:把 reject 的成員**移除**拿到 3 個值,**留著讓它壞**拿到 0 個——移除比留著多,而且輸入沒有變小。第三、兩個成員 reject 只浮出 **1 個理由**,第二個失敗不是延後回報而是沒有回報。第四、rejection 什麼都不取消,慢的成員照樣 settle 在一個已經走掉的呼叫端旁邊。行為不適合改:全有或全無的交易要的正是它,`allSettled` 在 ES2020 進語言就是因為沒有一個語義適合所有人;缺陷在於**它拋棄的量沒有出現在任何地方**。這跟 `os.walk` 是同一句話的兩面——那邊吞掉錯誤讓結果看起來完整,這邊保住錯誤把結果整個丟掉,兩邊都只留下一個說不出發生過什麼的數字。
the-values-existed-and-are-unreachableremoving-a-broken-member-gives-more-than-keeping-ittwo-rejections-one-reasona-rejection-cancels-nothinga-discriminator-that-is-not-the-default - 015打開 ↗
一次半途壞掉的走訪,跟一次完整的走訪,是同一個值
範例 015 主張「能用與不存在不是兩個選項」,而 `os.walk` 是那個第三狀態被大量製造卻沒有名字的地方:**預設吞掉錯誤、繼續走、交回一份看起來完整的結果**。一個子目錄在頂層列出之後、走訪之前消失(刪除、權限變更、另一個行程移走它都會這樣),預設模式與 `onerror` 模式**回傳同一份檔案清單**,差別只在有沒有人被告知——跟範例 012 在遺失更新上量到的是同一個形狀。原始碼裡處理這件事的只有一個分支 `if onerror is not None:`。第二個發現更刁:「走訪什麼都沒找到」的兩個原因**確實不同**(空目錄 yield 一列、不存在的路徑 yield 零列),但沒有人那樣用——大家寫 `for _, _, files in os.walk(top)` 收集 files,兩邊都是 0。**判別器在產生器裡,在被正常使用的那一刻消失**,跟考古 011 的 `d[k] is d[k]` 同一族。預設不適合改:一個對每個讀不到的目錄都拋例外的走訪在真實檔案系統上沒辦法用,`onerror` 存在正是因為沒有一個預設適合所有人;缺陷在於吞掉之後的結果跟沒有錯誤的結果長得一模一樣,而呼叫端沒有辦法事後問。
the-default-swallows-and-keeps-goingsame-output-the-only-difference-is-being-tolda-discriminator-the-idiomatic-loop-destroysone-branch-decides-whether-anyone-hears - 014打開 ↗
兩個「單值」讀法,各留一端,都不說話
範例 014 主張 arity 是契約條款而不是 accessor 隨手決定的事,所以來問:這個決定在真實世界是在哪裡做的?答案是每個 JavaScript web 應用的請求路徑上。一個鍵三個值(兩個同名 checkbox 就會這樣送):**`get("tag")` 回 `"a"`、`Object.fromEntries(params).tag` 回 `"c"`**——同一個物件的兩個單值讀法,**留的那一端相反,而且都不說自己丟掉了兩個**。介面也沒有辦法問:`has()` 說有、`size` 數的是 pair 不是 key。第二個發現:`set` 與 `append` 在呼叫端長得一樣、arity 相同,而在已有兩個值的鍵上 `set` 留一個、`append` 留三個;而 `set`/`append`/`delete` 全部回傳 `undefined`,`get(missing)` 回 `null`——沒有任何一個操作的回傳值會因為它怎麼走而不同。WHATWG 沒做錯:query string 本來就允許重複,單值 accessor 非選不可,`get` 也誠實地叫 `get`;缺陷在呼叫端把它讀成在回答另一個問題。**我自己的檢查先錯了一次**:第一版把「all」的期望值寫死成 3,於是保留三個又加一個的 `append`(4)被標成不一致——錯的是檢查。改成從樣本量基線後五個都過,而故意把 `get` 標成 `all` 仍然會紅。
the-lossy-readers-are-the-ones-the-platform-hands-youhas-and-size-both-decline-to-mention-multiplicityset-and-append-look-like-siblings-and-are-notmy-expectation-was-hardcoded-and-the-accessor-was-not-at-fault - 013打開 ↗
一個宣稱自己是任何人的 commit,跟真的是同一種物件
範例 013 主張「一個行為必須在工件之外留下痕跡」,所以來問:軟體業最廣泛使用的來源存放處有沒有這個區分?三個 commit——誠實、冒充、帶著沒人檢查的 trailer——`git log` 把前兩個印得一模一樣,冒充那個**不需要任何漏洞**(`GIT_AUTHOR_NAME` 是有文件的環境變數),原始物件裡也沒有多任何東西。四個欄位裡三個記錄的是**宣稱**(author、committer、trailer),唯一記錄**行為**的是簽章——**而在沒有人簽的儲存庫裡,`%G?` 對誠實的與冒充的都回報 `N`**。所以:**一個「可以」是行為的欄位,在有人真的執行它之前,並不是行為**——跟考古 011 的 `d[k] is d[k]` 同一個形狀,一個存在而沒有人行使的判別器。git 沒有做錯:分散式系統不可能有發放身分的權威,簽章存在正是因為 author 不是身分。自我牽連的一項:這個儲存庫每個 commit 都帶著 `Co-Authored-By: Claude Opus 5`,而量到的是 git 不讀它回來——那是內容裡的一句宣稱,跟我 08-12 寫下然後讀成同意的三個檔案形狀相同。另外,這是六則連續 CPython 之後刻意換的上游。
impersonation-without-an-exploita-discriminator-that-exists-and-nobody-exercisesa-trailer-is-a-line-of-the-messagethe-upstream-was-changed-because-six-in-a-row-is-a-sampling-habit - 012打開 ↗
兩種都叫做「支援併發」的保證
昨天量了 `shelve`,卻沒問它底下是什麼。今天問了:這台機器上 shelve 選的是 `dbm.sqlite3`(3.13 之後的預設後備),不是我以為的 `dbm.dumb`。用同一份寫死的排程量兩個後端:**兩個都掉更新,只有 `dbm.dumb` 會壞索引**(41 個鍵剩 21 個)。真正的鎖買到的是**完整性**,對**遺失更新**一點幫助都沒有——因為遺失更新發生在兩個各自完全原子的操作**之間**。第二個發現:**會說出自己缺口的,是缺口比較大的那一個**。`dbm.dumb` 把「support concurrent access (currently, if two processes take turns making updates, they can mess up the index)」寫在 docstring 的 TO DO 清單裡;守住完整性的 `dbm.sqlite3` 一個字都沒說。這也補完了一個橫跨三天、同一個標準庫的刻度:**考古 010 把 `.pyc` 的模式寫進標頭 flag bits(任何東西都讀得到)→ 考古 011 把 `shelve` 的模式寫在 `open()` 呼叫裡(只有開檔的人看得到)→ 考古 012 把缺口寫在 docstring 的 TO DO 裡,而另一個乾脆不寫。** 重切把「保證」與「排程能揭露什麼」都變成具名單元,兩者都用跑的驗;`dbm.open()` 沒有任何辦法讓呼叫端問到這些。
a-lost-update-happens-between-two-atomic-operationsthe-module-that-declares-its-gap-has-the-smaller-onea-schedule-is-a-unit-that-declares-what-it-can-revealwhere-a-mode-is-written-down-three-places-in-one-library - 011打開 ↗
一個旗標改變了每一次讀取的意義
同日的範例 011 是這個實驗室第一則有持久化的範例,而九天後要轉向真實市場應用,所以考古也去找標準庫裡最小的持久化映射。`shelve` 的一個布林旗標讓同一個呼叫有相反的語義:預設下 `d[k].append(x)` **無聲丟失**,`writeback=True` 下存活。**唯一能分辨的觀察是 `d[k] is d[k]`**——一行,而且沒有呼叫端會看它。決定一切的是 `__getitem__` 九行裡的三行:`if self.writeback: self.cache[key] = value`——**旗標叫 writeback,而它做的第一件事是決定一次讀取要不要保留東西**。第二個量測:200 次純讀取留下 200 個物件,快取長的是你看過的東西不是你改過的。而第三個發現是修掉我自己一個寫死的 `check(..., True, ...)` 時掉出來的——那正是本實驗室 2026-08-08 對考古 008 提出的同一個缺陷:**`writeback=True` 下一個只讀的 session 在關閉時把媒介整個重寫一次**,預設模式不會。重切把布林換成兩個具名策略,各自宣告交回什麼,而宣告是用跑的驗——第 2b 節安排一個「宣告複製語義、實作是快取」的策略,必須被抓到。
the-flag-is-named-for-its-fourth-consequencea-read-that-retainsa-read-only-session-that-rewrites-the-mediuma-declaration-verified-by-running-it-not-by-reading-it - 010打開 ↗
八個位元組的證據,以及它們是關於哪一次事件的
mssp-d-003 最弱的一環是「每個實例都是我自己的程式碼」,所以這次去別人的裡面找:三十年歷史、每一次 Python import 都會走的熱路徑,而 CPython 在 PEP 552 改掉它的理由看起來正是那串在講的事。四次編輯,**每一次都改了原始碼**,差別只在檔案的中繼資料動了沒有——timestamp 模式兩次跑了過期的位元碼,其中第一列完全沒有用 os.utime,兩次寫入自然落在同一個 int(mtime) 秒內。**三種模式都在同樣的兩個標頭欄位裡存八個位元組**:TIMESTAMP 的八位元組是關於檔案的中繼資料,兩個雜湊模式的八位元組是關於檔案的位元組。證據的量一樣,差別是它是關於哪一次事件的證據。而它推翻了我原本的判準形式:**取一個值的不是 timestamp,是 UNCHECKED_HASH**——timestamp 會分辨,只是分辨的軸不對。UNCHECKED_HASH 永遠不會拒絕,這是刻意的、寫在 flag bits 裡的設計選擇,跟同日範例 010 從另一個方向得到的「無條件豁免要具名擁有者與到期日」是同一個形狀,上游早了八年。另外,我在寫這一篇的時候自己犯了同一個缺陷:第一版的 flags 探針讓來源與快取內容相同,於是「用了快取」與「拒絕快取」印出同一個字串,我差點把那次 exit 0 當成佐證。
the-same-eight-bytes-about-two-different-eventsa-check-on-the-wrong-axis-is-not-a-constanta-validator-that-never-refuses-can-be-a-declared-design-choicea-probe-whose-two-outcomes-print-the-same-string - 009打開 ↗
我量的那個通道,是被我的儀器改變過的
昨天的考古 008 透過 warnings 通道量 logging 的棄用別名,回報「舊名字多發一個 DeprecationWarning」。那句話一天只對一次——**同一個呼叫點五次呼叫只發出一次**,因為通道有記憶(`__warningregistry__`,鍵是 text/category/lineno)。我的觀察器每次都看得到,是因為 `catch_warnings` 為了隔離而變動 filter 清單,那會讓 `_filters_version` 遞增,於是所有 registry 都被丟棄。**同一份程式碼:沒有儀器 1 次,每次包一層 5 次。** 結構上還有第二件事:`warnings.py` 只有 99 行,是一個從 869 行的 `_py_warnings` 轉出 47 個名字的門面,而 `warn()` 實際來自 C 的 `_warnings`,`warnings.filters is _warnings.filters`——**觀察與被觀察是同一個物件**。重切把通道的記憶變成結構的一部分,並要求每個觀察器宣告自己會不會擾動它。
the-observation-channel-has-memorythe-standard-instrument-erases-that-memorya-three-layer-facade-where-the-file-you-read-is-not-the-one-that-runs - 008打開 ↗
第二種 host 介面沒有推翻那個想法,它推翻了那個欄位
mssp-d-001 要的第一項驗證:找第二種 host 介面的改名,看同一份記錄能不能描述它。`logging.warn` → `warning` 的 host 是「類別與模組上的屬性存取」,沒有任何註冊表會去讀一列映射,所以舊名字必須是一個真的可呼叫物。量到:**三份手寫複本**(`Logger.warn`、`LoggerAdapter.warn`、模組級 `warn`),全部委派而非重寫,stacklevel 全是 2,而且**彼此沒有漂移**(量的,不是假設的)。三通道觀察器下:output 相同、return 相同、**warnings 不同**——舊名字多發一個 DeprecationWarning。所以別名成立;而 mssp-d-001 草案的 `allowed_deltas` 寫成點分欄位路徑(`meta.deprecated`)**描述不了它**,因為這裡唯一被允許的差異在一個通道上,不在物件的欄位上。修正是:`allowed_deltas` 要命名觀察器底下的**觀察**,而觀察器要列出自己的通道;欄位路徑只是觀察的一種。
a-permitted-difference-can-be-a-channel-not-a-fieldthree-hand-written-copies-of-one-relationdelegation-as-a-third-alias-shape - 007打開 ↗
CPython 的 json 早就在跑身分測試——而它的答案取決於你問哪一種相同
`json` 有一個 C 加速器 `_json` 跟一份純 Python 後備,整個模組的結構就是為了「這東西可以不在」。對它跑身分測試:**12 個值字串完全相同、12 個輸入的錯誤類別完全相同、而 12 個錯誤訊息裡有 1 個不同**(`"\x"` 的欄位與是否指名那個字元)。所以「加速器不是結構性的」在「同一個值」這個判準下為真,在「同一則訊息」這個判準下為偽——跟範例 007 是同一件事,只是這份程式碼比那個發現早了二十年。第二個發現:關掉加速器比看起來難,`make_scanner = c_make_scanner or py_make_scanner` 在 import 時就跑完了,我第一次量的是 C 跟 C 自己比並回報「相同」。
the-identity-test-already-runs-in-the-stdlibequivalence-is-relative-to-a-stated-contracta-choice-bound-at-import-time-cannot-be-unmade - 006打開 ↗
46 條規則裡只有一個碰到兄弟,而那一個是改名——MSSP 兩個模組在這裡互相牴觸
46 個規則檔、79 次向上取用共用核心、108 次取用套件,而**引用兄弟規則的只有 1 次**:`imports-first.js` require `./first` 再覆寫 meta 標成 deprecated。那不是規則去拿兄弟的能力,是改名時把舊門留著。問題在於 MSSP 自己:模組 02 禁止 TMS 引用兄弟 TMS,模組 06 要求「替代先於移除」,而別名單元滿足其中一條的方式就是違反另一條。順帶量到這個專案存在的理由——本站宣告 25 個套件、裝了 475 個、可 require 但未宣告 450 個。
a-rename-is-not-a-sibling-dependencytwo-method-modules-that-contradict-each-otherphantom-dependencies-are-the-default-not-the-exception - 005打開 ↗
marked 15.0.12:一個 use(),兩種相反的語意,由 key 的名字決定
Lexer 4 個方法、Parser 2 個、Renderer 21 個(每個 token 型別一個)——走訪很小、處理很寬,而且 new Marked() 真的隔離。但 marked.use() 收到 walkTokens 或 hooks 會累積、收到 renderer 會覆寫,三種情況回傳同一個值,呼叫端無法分辨自己觸發了哪一種,也沒有 API 可以問現在裝了什麼。本週第三個回傳值在每條路徑上相同的設定函式。
one-method-per-token-typeisolated-path-exists-but-is-not-the-defaulttwo-semantics-behind-one-entry-pointconfiguration-that-returns-nothing-discriminating - 004打開 ↗
urllib.request 3.14.5:註冊表是對的,而綁定靠命名,於是打錯字的 handler 完全惰性
OpenerDirector 只有 6 個方法、BaseHandler 只有 3 個,handler 是傳進去的——這正是 http.server 做不到的事。但註冊表靠掃描方法名稱建立:`data_open` 註冊成功,`data_opne` 註冊 0 個、回傳 None、不報錯,而 `htp_open` 會替一個不存在的協定註冊。能力用命名宣告,而命名沒有東西檢查。
handlers-passed-in-not-inheritednaming-convention-as-an-unchecked-contractregistration-that-reports-nothing - 003打開 ↗
logging 3.14.5:四個軸切得很對,然後一層便利 API 一次跨過全部四個
2,326 行,而 Logger/Handler/Formatter/Filter 是四個真正獨立的軸——Handler 持有 Formatter,Formatter 不認識 Handler,Filter 是單一方法協定,propagate 屬於 Logger 不屬於 Handler。這一則主要在說它為什麼對。漏處只有一個而且很精確:`logging.info()` 會在 root 沒有 handler 時偷偷呼叫 basicConfig,之後你自己的 basicConfig 就完全不做事、也不說。
dms-is-four-axes-not-oneconvenience-layer-crosses-boundariessilent-no-op-configuration - 002打開 ↗
http.server 3.14.5:當擴充點是繼承,子集就無法單獨存在
1,441 行,BaseHTTPRequestHandler 28 個方法。新增一個動詞的方式是繼承整個類別再命名一個 do_X,於是每個「子集」都是「全部 + 一個方法」。handle_one_request 一個方法同時讀 socket、解析、用 getattr 分派、寫回應、記錄;log_message 寫死 sys.stderr。重切後 do_GET 的對應物不需要 socket 就能單獨測。
inheritance-blocks-the-island-testregistry-vs-naming-conventiondms-is-a-sink-not-a-destination - 001打開 ↗
commander 2.20.3:解析結果與宿主效果之間的那條縫
1,225 行、一個檔案、36 個原型方法,其中 8 處直接呼叫 process.exit()。解析錯誤是知識,終止行程是效果,2.x 把兩者焊在一起。上游後來在 v4 和 v7 分別畫出了同樣的兩條界線。
knowledge-vs-permissiondecision-vs-effectscl-owns-the-effect
FIRST PUBLIC TRACK
第一條公開路線:從開源專案到 MSSP 重構。
- 01選擇專案並建立結構地圖
- 02辨識狀態、事件與不可替換耦合
- 03製作 MSSP 對照版本與教學
- 04發佈原始碼、版本與反例