e8c18f1e3b
Der Test prueft M.SUPPORTED bisher nur gegen sich selbst: erster Eintrag
wird angenommen, Unbekanntes abgelehnt, data.supported entspricht
M.SUPPORTED. Ein Modul mit {"2025-06-18","2025-11-25"} besteht ihn mit
14/14 -- obwohl lmcp 2025-11-25 nicht spricht. Genau das ist am
2026-08-08 passiert: die Gesellschaft hat sauber gearbeitet, der Test gab
Rueckgabe 0, und abgenommen wurde der falsche Vertrag.
Drei Instanzen liessen es durch, und keine war nachlaessig: @coder riet
eine echte MCP-Fassung, @foreman konnte es aus dem Vertragstext nicht
widerlegen und bestaetigte es zweimal ausdruecklich, und der Test hatte
die Luecke geerbt. Die Wurzel war der Vertrag: er verlangt "die
Fassungen, die DIESER Server spricht" und nennt sie nicht. Damit war die
Liste eine freie Variable statt einer Tatsache.
DER NAHELIEGENDE FIX WAERE FALSCH GEWESEN. Die Liste im Vertrag zu
NENNEN ersetzt eine ungebundene Variable durch eine zweite Kopie der
Tatsache -- die veraltet, sobald lmcp eine Fassung dazulernt, und dann
schreibt der Vertrag wieder etwas fest, das der Server nicht spricht,
nur andersherum. (Dank an Fable fuer den Widerspruch.)
Stattdessen misst der Test jetzt: er schickt dem laufenden lmcp ein
`initialize` und haelt M.SUPPORTED gegen die gemeldete protocolVersion.
Die Bindung sitzt damit an der Quelle, und keiner der Beteiligten kann
mehr raten.
Ohne erreichbaren Server FAELLT der Test, er ueberspringt nicht. Eine
Abnahme, die ihre zentrale Eigenschaft nicht pruefen kann, ist keine
Abnahme -- und ein stiller Uebersprung ist genau die Art Gruen, gegen die
diese Phase antritt. Gemessen: ohne LMCP_PROBE_URL Rueckgabe 1.
Damit hat die Regel drei Richtungen statt zwei:
rot gegen eine kaputte Fassung,
gruen gegen eine korrekte,
und gruen gegen die WIRKLICHKEIT.
Nachgemessen gegen hertz-tools:8080:
SUPPORTED = {"2025-06-18"} -> 0 Fehlschlaege, Rueckgabe 0
SUPPORTED = {"2025-06-18","2025-11-25"} -> 1 Fehlschlag, Rueckgabe 1
Das zweite ist das Modul, das heute Vormittag als GRUEN abgenommen
wurde. Die Abnahme ist damit rueckwirkend rot -- und das ist das
Ergebnis, nicht der Fehler.
Aufruf: LMCP_PROBE_URL=http://<host>:8080/mcp LMCP_PROBE_TOKEN=<token> \
lua5.4 tests/phase_b_acceptance.lua <impl.lua>
196 lines
8.2 KiB
Lua
196 lines
8.2 KiB
Lua
-- Abnahmetest fuer Phase B der Angleichung an MCP 2026-07-28.
|
|
--
|
|
-- Phase A war beobachtend: feststellen, welche Fassungen ueberhaupt verlangt
|
|
-- werden. Phase B setzt durch. Gemessen am 2026-08-08 an hertz-tools:8080
|
|
-- antwortet lmcp mit HTTP 200 auf JEDE Fassungsangabe - auch auf 1999-01-01,
|
|
-- eine Fassung, die es nie gab. Es liest den Kopf schlicht nicht.
|
|
--
|
|
-- VERTRAG. Eine in sich geschlossene Lua-5.4-Datei, Tabelle M:
|
|
--
|
|
-- M.SUPPORTED Liste der Fassungen, die dieser Server spricht.
|
|
-- M.check(version) -> true, nil wenn zulaessig
|
|
-- -> false, <fehlertabelle> sonst
|
|
--
|
|
-- 1. version == nil oder "" -> zulaessig. Ein fehlender Kopf ist erlaubt;
|
|
-- der Server nimmt dann seine Grundfassung an. Das ist kein Sonderfall
|
|
-- aus Bequemlichkeit: die sitzungslose Abkuerzung schickt ihn oft nicht,
|
|
-- und sie traegt den meisten Verkehr.
|
|
-- 2. Genaue Uebereinstimmung mit einem Eintrag in M.SUPPORTED -> zulaessig.
|
|
-- 3. Alles andere -> false plus Fehlertabelle mit code == -32022 und einer
|
|
-- data.supported-Liste, die GENAU M.SUPPORTED entspricht. Ohne die Liste
|
|
-- kann die Gegenstelle nicht nachverhandeln, sie kann nur aufgeben.
|
|
-- 4. Wirft nie. Zahl, Tabelle, Wahrheitswert, Funktion - alles beantwortet
|
|
-- sie mit false, nicht mit einem Laufzeitfehler. Ein Server, der an
|
|
-- einem fremden Kopf stirbt, ist schlechter als einer, der ihn ignoriert.
|
|
-- 5. Aendert M.SUPPORTED nicht. Ein Aufruf darf die Liste des naechsten
|
|
-- nicht verschieben.
|
|
-- 6. Keine neue globale Variable.
|
|
--
|
|
-- Aufruf: lua5.4 phase_b_acceptance.lua <impl.lua>
|
|
|
|
local impl_path = arg and arg[1]
|
|
if not impl_path then
|
|
io.stderr:write("usage: lua5.4 phase_b_acceptance.lua <impl.lua>\n")
|
|
os.exit(2)
|
|
end
|
|
|
|
-- Globale Variablen VOR dem Laden festhalten, damit Regel 6 pruefbar ist.
|
|
local vorher = {}
|
|
for k in pairs(_G) do vorher[k] = true end
|
|
|
|
local chunk, lerr = loadfile(impl_path)
|
|
if not chunk then
|
|
io.stderr:write("kann " .. impl_path .. " nicht laden: " .. tostring(lerr) .. "\n")
|
|
os.exit(2)
|
|
end
|
|
local ok_load, M = pcall(chunk)
|
|
if not ok_load then
|
|
io.stderr:write("Laden warf: " .. tostring(M) .. "\n")
|
|
os.exit(2)
|
|
end
|
|
|
|
local fehler = 0
|
|
local function pruefe(name, bedingung, detail)
|
|
if bedingung then
|
|
print(string.format("[ok ] %s", name))
|
|
else
|
|
fehler = fehler + 1
|
|
print(string.format("[FEHLER] %s%s", name, detail and (" -> " .. tostring(detail)) or ""))
|
|
end
|
|
end
|
|
|
|
-- 0. Form
|
|
pruefe("M ist eine Tabelle", type(M) == "table", type(M))
|
|
if type(M) ~= "table" then print(fehler .. " Pruefungen fehlgeschlagen"); os.exit(1) end
|
|
pruefe("M.check ist aufrufbar", type(M.check) == "function", type(M.check))
|
|
pruefe("M.SUPPORTED ist eine nicht-leere Liste",
|
|
type(M.SUPPORTED) == "table" and #M.SUPPORTED >= 1, type(M.SUPPORTED))
|
|
if type(M.check) ~= "function" or type(M.SUPPORTED) ~= "table" then
|
|
print(fehler .. " Pruefungen fehlgeschlagen"); os.exit(1)
|
|
end
|
|
|
|
local function ruf(v)
|
|
local ok, a, b = pcall(M.check, v)
|
|
return ok, a, b
|
|
end
|
|
|
|
-- 1. fehlender Kopf
|
|
local ok, zulaessig = ruf(nil)
|
|
pruefe("nil ist zulaessig", ok and zulaessig == true, ok and tostring(zulaessig) or "warf")
|
|
ok, zulaessig = ruf("")
|
|
pruefe("leerer String ist zulaessig", ok and zulaessig == true, ok and tostring(zulaessig) or "warf")
|
|
|
|
-- 2. bekannte Fassung
|
|
local bekannt = M.SUPPORTED[1]
|
|
ok, zulaessig = ruf(bekannt)
|
|
pruefe("bekannte Fassung " .. tostring(bekannt) .. " ist zulaessig",
|
|
ok and zulaessig == true, ok and tostring(zulaessig) or "warf")
|
|
|
|
-- 3. unbekannte Fassung -> -32022 samt Liste
|
|
local ok3, zul3, err3 = ruf("1999-01-01")
|
|
pruefe("unbekannte Fassung wird abgelehnt", ok3 and zul3 == false,
|
|
ok3 and tostring(zul3) or "warf")
|
|
pruefe("Ablehnung traegt code -32022",
|
|
ok3 and type(err3) == "table" and err3.code == -32022,
|
|
ok3 and type(err3) == "table" and tostring(err3.code) or type(err3))
|
|
local liste_ok = false
|
|
if ok3 and type(err3) == "table" and type(err3.data) == "table"
|
|
and type(err3.data.supported) == "table" then
|
|
liste_ok = (#err3.data.supported == #M.SUPPORTED)
|
|
for i = 1, #M.SUPPORTED do
|
|
if err3.data.supported[i] ~= M.SUPPORTED[i] then liste_ok = false end
|
|
end
|
|
end
|
|
pruefe("Ablehnung nennt genau M.SUPPORTED", liste_ok)
|
|
|
|
-- 3b. die Fassung, auf die wir zuwandern, ist noch NICHT zulaessig.
|
|
-- Wer 2026-07-28 durchwinkt, bevor er sie spricht, hat den Fehler nur verschoben.
|
|
local ok3b, zul3b, err3b = ruf("2026-07-28")
|
|
local spricht_neu = false
|
|
for i = 1, #M.SUPPORTED do if M.SUPPORTED[i] == "2026-07-28" then spricht_neu = true end end
|
|
if spricht_neu then
|
|
pruefe("2026-07-28 steht in SUPPORTED und wird angenommen", ok3b and zul3b == true)
|
|
else
|
|
pruefe("2026-07-28 wird abgelehnt, solange sie nicht in SUPPORTED steht",
|
|
ok3b and zul3b == false and type(err3b) == "table" and err3b.code == -32022,
|
|
ok3b and tostring(zul3b) or "warf")
|
|
end
|
|
|
|
-- 3c. VERANKERUNG: M.SUPPORTED gegen den LAUFENDEN Server.
|
|
--
|
|
-- Ohne diese Pruefung misst der Test die Liste nur an sich selbst -- und ein
|
|
-- Modul, das eine Fassung beansprucht, die der Server nicht spricht, besteht
|
|
-- ihn glatt. Genau das ist am 2026-08-08 passiert: {"2025-06-18","2025-11-25"}
|
|
-- ergab 14/14, obwohl lmcp nur 2025-06-18 meldet.
|
|
--
|
|
-- Gefragt wird per `initialize`; die Antwort nennt genau EINE protocolVersion,
|
|
-- naemlich die, die dieser Server spricht. M.SUPPORTED muss exakt daraus
|
|
-- bestehen.
|
|
--
|
|
-- Kein Uebersprung, wenn der Server fehlt: eine Abnahme, die ihre zentrale
|
|
-- Eigenschaft nicht pruefen kann, ist keine Abnahme.
|
|
local probe_url = os.getenv("LMCP_PROBE_URL")
|
|
local probe_token = os.getenv("LMCP_PROBE_TOKEN")
|
|
pruefe("LMCP_PROBE_URL ist gesetzt (ohne Server keine Verankerung)",
|
|
probe_url ~= nil and probe_url ~= "", tostring(probe_url))
|
|
|
|
if probe_url and probe_url ~= "" then
|
|
local rumpf = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":' ..
|
|
'{"protocolVersion":"2025-06-18","capabilities":{},' ..
|
|
'"clientInfo":{"name":"phase-b-acceptance","version":"1"}}}'
|
|
local befehl = "curl -s -m 15 -X POST"
|
|
.. " -H 'Content-Type: application/json'"
|
|
.. " -H 'Accept: application/json, text/event-stream'"
|
|
if probe_token and probe_token ~= "" then
|
|
befehl = befehl .. " -H 'Authorization: Bearer " .. probe_token .. "'"
|
|
end
|
|
befehl = befehl .. " -d '" .. rumpf .. "' '" .. probe_url .. "' 2>/dev/null"
|
|
|
|
local p = io.popen(befehl)
|
|
local antwort = p and p:read("*a") or ""
|
|
if p then p:close() end
|
|
|
|
local gemessen = antwort:match('"protocolVersion"%s*:%s*"([^"]+)"')
|
|
pruefe("Server nennt eine protocolVersion", gemessen ~= nil,
|
|
(#antwort > 0) and antwort:sub(1, 70) or "keine Antwort")
|
|
|
|
if gemessen then
|
|
local passt = (#M.SUPPORTED == 1) and (M.SUPPORTED[1] == gemessen)
|
|
pruefe("M.SUPPORTED entspricht GENAU dem, was der Server spricht ("
|
|
.. gemessen .. ")", passt, table.concat(M.SUPPORTED, ","))
|
|
end
|
|
end
|
|
|
|
-- 4. wirft nie
|
|
local fremde = { 42, true, false, {}, print, 0/0 }
|
|
local alle_still, welcher = true, nil
|
|
for _, v in ipairs({42, true, {}, print}) do
|
|
local okx, zulx = pcall(M.check, v)
|
|
if not okx or zulx ~= false then alle_still = false; welcher = tostring(v) end
|
|
end
|
|
pruefe("fremde Typen ergeben false statt Laufzeitfehler", alle_still, welcher)
|
|
|
|
-- 5. SUPPORTED bleibt unangetastet
|
|
local kopie = {}
|
|
for i, v in ipairs(M.SUPPORTED) do kopie[i] = v end
|
|
ruf("1999-01-01"); ruf(bekannt); ruf(nil)
|
|
local unveraendert = (#kopie == #M.SUPPORTED)
|
|
for i = 1, #kopie do if kopie[i] ~= M.SUPPORTED[i] then unveraendert = false end end
|
|
pruefe("M.SUPPORTED wird durch Aufrufe nicht veraendert", unveraendert)
|
|
|
|
-- 5b. wiederholte Ablehnung bleibt gleich (kein verbrauchbarer Zustand)
|
|
local _, _, e1 = ruf("1999-01-01")
|
|
local _, _, e2 = ruf("1999-01-01")
|
|
pruefe("zweite Ablehnung ist so vollstaendig wie die erste",
|
|
type(e1) == "table" and type(e2) == "table"
|
|
and e1.code == e2.code
|
|
and type(e2.data) == "table" and type(e2.data.supported) == "table")
|
|
|
|
-- 6. keine neuen Globalen
|
|
local neu = {}
|
|
for k in pairs(_G) do if not vorher[k] then neu[#neu + 1] = tostring(k) end end
|
|
pruefe("keine neuen globalen Variablen", #neu == 0, table.concat(neu, ","))
|
|
|
|
print(fehler .. " Pruefungen fehlgeschlagen")
|
|
os.exit(fehler == 0 and 0 or 1)
|