From 31d4d08bfaaa37a8592ef577ca47dc28416f03a0 Mon Sep 17 00:00:00 2001 From: h1065153539-create Date: Sun, 21 Jun 2026 03:31:39 +0800 Subject: [PATCH 1/2] feat: add pact replay mismatch summary --- docs/OPERATIONS.md | 23 +++ tools/fixtures/pact_replay_fail.json | 54 +++++ tools/fixtures/pact_replay_pass.json | 26 +++ tools/openapi_pact.lua | 290 ++++++++++++++++++++++++++- 4 files changed, 390 insertions(+), 3 deletions(-) create mode 100644 tools/fixtures/pact_replay_fail.json create mode 100644 tools/fixtures/pact_replay_pass.json diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 58642e7b..09e0fb63 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -276,6 +276,29 @@ Audit logs are retained for 365 days and include: | Penetration test | Quarterly | External vendor | | Compliance audit | Annually | External auditor | +## OpenAPI Pact Replay Triage + +When a pact replay fails, summarize the replay result before opening an +incident ticket. The summary keeps one line per failing interaction and calls +out the request method, path, expected and actual response status, and response +body shape differences. + +Human-readable summary: + +```sh +lua tools/openapi_pact.lua --replay-summary replay-result.json +``` + +Machine-readable summary for CI artifacts or chat-ops: + +```sh +lua tools/openapi_pact.lua --replay-summary-json replay-result.json +``` + +Fixture inputs are available under `tools/fixtures/` for passing and failing +replay examples. Use the JSON mode when attaching a compact artifact to an +incident; use the text mode during live triage. + ## Troubleshooting ### Common Issues diff --git a/tools/fixtures/pact_replay_fail.json b/tools/fixtures/pact_replay_fail.json new file mode 100644 index 00000000..ac67f366 --- /dev/null +++ b/tools/fixtures/pact_replay_fail.json @@ -0,0 +1,54 @@ +{ + "interactions": [ + { + "request": { + "method": "POST", + "path": "/auth/login" + }, + "expected_response": { + "status": 200, + "body": { + "access_token": "token", + "expires_in": 3600, + "user": { + "id": "usr_123", + "email": "user@example.com" + } + } + }, + "actual_response": { + "status": 401, + "body": { + "error": "invalid credentials", + "user": "missing" + } + } + }, + { + "method": "GET", + "path": "/market/instruments", + "expected_status": 200, + "actual_status": 200, + "expected_body": { + "instruments": [ + { + "id": "BTC-USD", + "symbol": "BTC/USD" + } + ], + "pagination": { + "page": 1, + "total": 100 + } + }, + "actual_body": { + "instruments": { + "id": "BTC-USD" + }, + "pagination": { + "page": 1 + } + } + } + ] +} diff --git a/tools/fixtures/pact_replay_pass.json b/tools/fixtures/pact_replay_pass.json new file mode 100644 index 00000000..8da988c1 --- /dev/null +++ b/tools/fixtures/pact_replay_pass.json @@ -0,0 +1,26 @@ +{ + "interactions": [ + { + "request": { + "method": "GET", + "path": "/admin/health" + }, + "expected_response": { + "status": 200, + "body": { + "status": "running", + "version": "3.2.0", + "uptime": "42s" + } + }, + "actual_response": { + "status": 200, + "body": { + "status": "ok", + "version": "3.2.1", + "uptime": "43s" + } + } + } + ] +} diff --git a/tools/openapi_pact.lua b/tools/openapi_pact.lua index 287f9eca..36b1b89c 100644 --- a/tools/openapi_pact.lua +++ b/tools/openapi_pact.lua @@ -25,6 +25,8 @@ -- lua tools/openapi_pact.lua # Generate all pacts -- lua tools/openapi_pact.lua --consumer web-app # Filter by consumer -- lua tools/openapi_pact.lua --validate # Validate existing pacts +-- lua tools/openapi_pact.lua --replay-summary file # Summarize replay mismatches +-- lua tools/openapi_pact.lua --replay-summary-json file -- lua tools/openapi_pact.lua --help # Display this message -- -- The --consumer flag filters the generated pacts to only include @@ -41,6 +43,13 @@ local SPEC_PATH = os.getenv("OPENAPI_SPEC_PATH") or "docs/openapi/v3.yaml" local DEFAULT_CONSUMER = "unknown-consumer" local DEFAULT_PROVIDER = "tent-of-trials-api" +local GREEN = "\27[32m" +local YELLOW = "\27[33m" +local RED = "\27[31m" +local CYAN = "\27[36m" +local RESET = "\27[0m" +local AJSON = { null = {} } + -- ============================================================================= -- Pact Generation Functions -- ============================================================================= @@ -395,6 +404,246 @@ local function validate_pacts() end end +-- ============================================================================= +-- Pact Replay Mismatch Summaries +-- ============================================================================= +-- Replay failures usually arrive as a pile of raw expected/actual blobs. This +-- summary keeps the operator view compact while preserving method, path, status, +-- and response body shape differences for quick triage. + +local function json_array(items) + items = items or {} + items.__json_array = true + return items +end + +local function read_text_file(path) + local file, err = io.open(path, "r") + if not file then + return nil, err or "could not open file" + end + local content = file:read("*all") + file:close() + return content +end + +local function get_any(tbl, names) + if type(tbl) ~= "table" then return nil end + for _, name in ipairs(names) do + if tbl[name] ~= nil then + return tbl[name] + end + end + return nil +end + +local function sorted_keys(tbl) + local keys = {} + if type(tbl) ~= "table" then return keys end + for key in pairs(tbl) do + if key ~= "__json_array" then + table.insert(keys, key) + end + end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + return keys +end + +local function value_kind(value) + if value == nil then return "missing" end + if value == AJSON.null then return "null" end + if type(value) ~= "table" then return type(value) end + if value.__json_array or #value > 0 then return "array" end + return "object" +end + +local function shape_path(parent, key) + if parent == "" then + return tostring(key) + end + if type(key) == "number" then + return parent .. "[" .. tostring(key) .. "]" + end + return parent .. "." .. tostring(key) +end + +local function add_shape_difference(diffs, path, expected_kind, actual_kind) + table.insert(diffs, { + path = path == "" and "$" or path, + expected = expected_kind, + actual = actual_kind + }) +end + +local function collect_shape_differences(expected, actual, path, diffs) + local expected_kind = value_kind(expected) + local actual_kind = value_kind(actual) + if expected_kind ~= actual_kind then + add_shape_difference(diffs, path, expected_kind, actual_kind) + return + end + + if expected_kind == "object" then + local seen = {} + for _, key in ipairs(sorted_keys(expected)) do + seen[key] = true + collect_shape_differences(expected[key], actual and actual[key], shape_path(path, key), diffs) + end + for _, key in ipairs(sorted_keys(actual)) do + if not seen[key] then + collect_shape_differences(nil, actual[key], shape_path(path, key), diffs) + end + end + elseif expected_kind == "array" then + local expected_len = #expected + local actual_len = #(actual or {}) + if expected_len ~= actual_len then + add_shape_difference(diffs, shape_path(path, "length"), tostring(expected_len), tostring(actual_len)) + end + local limit = math.min(expected_len, actual_len, 3) + for i = 1, limit do + collect_shape_differences(expected[i], actual[i], shape_path(path, i), diffs) + end + end +end + +local function stringify_mismatch(entry) + if type(entry) == "string" then return entry end + if type(entry) ~= "table" then return tostring(entry) end + + local path = get_any(entry, { "path", "field", "json_path", "jsonPath" }) or "$" + local reason = get_any(entry, { "reason", "message", "type", "kind" }) or "mismatch" + local expected = get_any(entry, { "expected", "expected_type", "expectedType" }) + local actual = get_any(entry, { "actual", "actual_type", "actualType" }) + if expected ~= nil or actual ~= nil then + return string.format("%s: %s (expected %s, actual %s)", tostring(path), tostring(reason), tostring(expected), tostring(actual)) + end + return string.format("%s: %s", tostring(path), tostring(reason)) +end + +local function list_from(value) + if type(value) ~= "table" then return json_array({}) end + if value.__json_array or #value > 0 then return value end + return json_array({ value }) +end + +local function summarize_replay_interaction(interaction) + local request = get_any(interaction, { "request", "expected_request", "expectedRequest" }) or {} + local expected = get_any(interaction, { "expected_response", "expectedResponse", "expected" }) or {} + local actual = get_any(interaction, { "actual_response", "actualResponse", "actual" }) or {} + + local method = get_any(interaction, { "method" }) or get_any(request, { "method" }) or "UNKNOWN" + local path = get_any(interaction, { "path" }) or get_any(request, { "path", "url" }) or "UNKNOWN" + local expected_status = get_any(interaction, { "expected_status", "expectedStatus" }) or get_any(expected, { "status", "status_code", "statusCode" }) + local actual_status = get_any(interaction, { "actual_status", "actualStatus" }) or get_any(actual, { "status", "status_code", "statusCode" }) + local expected_body = get_any(interaction, { "expected_body", "expectedBody" }) or get_any(expected, { "body" }) + local actual_body = get_any(interaction, { "actual_body", "actualBody" }) or get_any(actual, { "body" }) + local raw_mismatches = get_any(interaction, { "mismatches", "body_mismatches", "bodyMismatches", "body_shape_differences", "bodyShapeDifferences" }) + local notes = json_array({}) + + for _, mismatch in ipairs(list_from(raw_mismatches)) do + table.insert(notes, stringify_mismatch(mismatch)) + end + + local body_diffs = json_array({}) + if expected_body ~= nil or actual_body ~= nil then + collect_shape_differences(expected_body, actual_body, "body", body_diffs) + end + + local status_matches = expected_status == actual_status + local explicit_failed = get_any(interaction, { "failed", "failure" }) == true + local status_text = get_any(interaction, { "status", "result" }) + if type(status_text) == "string" then + local normalized = status_text:lower() + explicit_failed = explicit_failed or normalized == "fail" or normalized == "failed" or normalized == "error" + end + + local has_mismatch = explicit_failed or (expected_status ~= nil and actual_status ~= nil and not status_matches) or #body_diffs > 0 or #notes > 0 + if not has_mismatch then + return nil + end + + return { + method = method, + path = path, + status = { + expected = expected_status or "unknown", + actual = actual_status or "unknown", + matches = status_matches + }, + response_body_differences = body_diffs, + notes = notes + } +end + +local function build_replay_summary(replay) + local interactions = get_any(replay, { "interactions", "results", "replays", "mismatches" }) or {} + local summary = { + total_interactions = 0, + failed_interactions = 0, + mismatches = json_array({}) + } + + for _, interaction in ipairs(list_from(interactions)) do + summary.total_interactions = summary.total_interactions + 1 + local mismatch = summarize_replay_interaction(interaction) + if mismatch then + summary.failed_interactions = summary.failed_interactions + 1 + table.insert(summary.mismatches, mismatch) + end + end + + return summary +end + +local function print_replay_summary(summary) + print(string.format("[Pact] Replay summary: %d interaction(s), %d failing.", summary.total_interactions, summary.failed_interactions)) + if summary.failed_interactions == 0 then + print(GREEN .. "[Pact] No replay mismatches found." .. RESET) + return + end + + for index, mismatch in ipairs(summary.mismatches) do + print("") + print(string.format("%d. %s %s", index, tostring(mismatch.method), tostring(mismatch.path))) + print(string.format(" status: expected %s, actual %s", tostring(mismatch.status.expected), tostring(mismatch.status.actual))) + if #mismatch.response_body_differences > 0 then + print(" response body shape:") + for _, diff in ipairs(mismatch.response_body_differences) do + print(string.format(" - %s expected %s, actual %s", diff.path, tostring(diff.expected), tostring(diff.actual))) + end + end + if #mismatch.notes > 0 then + print(" notes:") + for _, note in ipairs(mismatch.notes) do + print(" - " .. note) + end + end + end +end + +local function summarize_replay_file(path, as_json) + local content, err = read_text_file(path) + if not content then + print(RED .. "[Pact] Failed to read replay file: " .. tostring(err) .. RESET) + os.exit(1) + end + + local ok, replay = pcall(decode_json, content) + if not ok or not replay or replay.parse_error then + print(RED .. "[Pact] Failed to parse replay JSON: " .. path .. RESET) + os.exit(1) + end + + local summary = build_replay_summary(replay) + if as_json then + print(encode_json(summary)) + else + print_replay_summary(summary) + end + return summary +end + -- ============================================================================= -- JSON Parser (the inverse of the encoder in openapi_mock.lua) -- ============================================================================= @@ -523,7 +772,7 @@ function encode_json(obj, indent) -- This fails for tables that are intentionally empty arrays vs objects. -- Elena is aware of this. She has a note on her desk that says "fix this." -- The note has been there for 8 months. The note is yellowing. - local is_array = #obj > 0 + local is_array = obj.__json_array == true or #obj > 0 if is_array then local parts = {} for i, v in ipairs(obj) do @@ -533,7 +782,11 @@ function encode_json(obj, indent) else local parts = {} local keys = {} - for k in pairs(obj) do table.insert(keys, k) end + for k in pairs(obj) do + if k ~= "__json_array" then + table.insert(keys, k) + end + end table.sort(keys) for _, k in ipairs(keys) do local v = obj[k] @@ -559,12 +812,19 @@ end local args = {...} local mode = "generate" local consumer_name = DEFAULT_CONSUMER +local replay_summary_path = nil for i, arg in ipairs(args) do if arg == "--consumer" and i < #args then consumer_name = args[i + 1] elseif arg == "--validate" then mode = "validate" + elseif arg == "--replay-summary" and i < #args then + mode = "replay_summary" + replay_summary_path = args[i + 1] + elseif arg == "--replay-summary-json" and i < #args then + mode = "replay_summary_json" + replay_summary_path = args[i + 1] elseif arg == "--help" then print("Tent of Trials OpenAPI Pact Generator") print("") @@ -572,6 +832,8 @@ for i, arg in ipairs(args) do print(" lua tools/openapi_pact.lua Generate all pacts") print(" lua tools/openapi_pact.lua --consumer web-app Filter by consumer") print(" lua tools/openapi_pact.lua --validate Validate pacts") + print(" lua tools/openapi_pact.lua --replay-summary file Print replay mismatch summary") + print(" lua tools/openapi_pact.lua --replay-summary-json file") print("") print("Elena wrote this tool during a particularly productive weekend.") print("She was house-sitting for a friend who had a cat named 'Monad.'") @@ -583,13 +845,23 @@ for i, arg in ipairs(args) do end end -print("") +if mode ~= "replay_summary_json" then + print("") +--[[ print(CYAN .. "╔════════════════════════════════════════════════════╗" .. RESET) print(CYAN .. "║ Tent of Trials Pact Contract Generator ║" .. RESET) print(CYAN .. "║ \"promises > code\" - Elena ║" .. RESET) print(CYAN .. "╚════════════════════════════════════════════════════╝" .. RESET) print("") +]] + print(CYAN .. "====================================================" .. RESET) + print(CYAN .. " Tent of Trials Pact Contract Generator" .. RESET) + print(CYAN .. " promises > code - Elena" .. RESET) + print(CYAN .. "====================================================" .. RESET) + print("") +end + if mode == "generate" then print(GREEN .. "[Pact] Generating pacts for consumer: " .. consumer_name .. RESET) print(GREEN .. "[Pact] Provider: " .. DEFAULT_PROVIDER .. RESET) @@ -607,6 +879,18 @@ if mode == "generate" then print(GREEN .. "[Pact] The cat's contributions are appreciated." .. RESET) elseif mode == "validate" then validate_pacts() +elseif mode == "replay_summary" then + if not replay_summary_path then + print(RED .. "[Pact] --replay-summary requires a JSON replay result path." .. RESET) + os.exit(1) + end + summarize_replay_file(replay_summary_path, false) +elseif mode == "replay_summary_json" then + if not replay_summary_path then + print(RED .. "[Pact] --replay-summary-json requires a JSON replay result path." .. RESET) + os.exit(1) + end + summarize_replay_file(replay_summary_path, true) end -- Elena's closing thoughts: From c5233ead82f27df673fe6023a3a37e1ddf2584da Mon Sep 17 00:00:00 2001 From: h1065153539-create Date: Sun, 21 Jun 2026 03:41:34 +0800 Subject: [PATCH 2/2] Add build diagnostics for 31d4d08b --- diagnostic/build-31d4d08b.json | 87 +++++++++++++++++++++++++++++++++ diagnostic/build-31d4d08b.logd | Bin 0 -> 8729 bytes 2 files changed, 87 insertions(+) create mode 100644 diagnostic/build-31d4d08b.json create mode 100644 diagnostic/build-31d4d08b.logd diff --git a/diagnostic/build-31d4d08b.json b/diagnostic/build-31d4d08b.json new file mode 100644 index 00000000..f9ca4929 --- /dev/null +++ b/diagnostic/build-31d4d08b.json @@ -0,0 +1,87 @@ +{ + "generated_at": "2026-06-20T19:41:33.026301+00:00", + "commit": "31d4d08b", + "diagnostic_logd": "diagnostic/build-31d4d08b.logd", + "diagnostic_logd_error": null, + "message_blocker": null, + "chunked": false, + "chunk_size_bytes": null, + "password": "38b5989266442895af11", + "decrypt_command": "encryptly unpack diagnostic/build-31d4d08b.logd --password 38b5989266442895af11", + "total_modules": 10, + "passed": 0, + "failed": 10, + "modules": [ + { + "name": "backend", + "status": "FAIL", + "elapsed_seconds": 0.02, + "artifact": null, + "output": "cargo unavailable in this WSL validation environment" + }, + { + "name": "frontend", + "status": "FAIL", + "elapsed_seconds": 0.02, + "artifact": null, + "output": "npm install failed:\nnpm unavailable in this WSL validation environment\n" + }, + { + "name": "market", + "status": "FAIL", + "elapsed_seconds": 0.018, + "artifact": null, + "output": "go unavailable in this WSL validation environment" + }, + { + "name": "frailbox", + "status": "FAIL", + "elapsed_seconds": 0.019, + "artifact": null, + "output": "make unavailable in this WSL validation environment" + }, + { + "name": "engine", + "status": "FAIL", + "elapsed_seconds": 0.019, + "artifact": null, + "output": "CMake configure failed:\ncmake unavailable in this WSL validation environment" + }, + { + "name": "compliance", + "status": "FAIL", + "elapsed_seconds": 0.018, + "artifact": null, + "output": "javac unavailable in this WSL validation environment" + }, + { + "name": "v2-market-stream", + "status": "FAIL", + "elapsed_seconds": 0.019, + "artifact": null, + "output": "ruby unavailable in this WSL validation environment" + }, + { + "name": "nfc-scanner", + "status": "FAIL", + "elapsed_seconds": 0.019, + "artifact": null, + "output": "luac unavailable in this WSL validation environment" + }, + { + "name": "openapi-haskell", + "status": "FAIL", + "elapsed_seconds": 0.019, + "artifact": null, + "output": "ghc unavailable in this WSL validation environment" + }, + { + "name": "openapi-tools", + "status": "FAIL", + "elapsed_seconds": 0.019, + "artifact": null, + "output": "luac unavailable in this WSL validation environment" + } + ], + "pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic/build-31d4d08b.logd. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging." +} diff --git a/diagnostic/build-31d4d08b.logd b/diagnostic/build-31d4d08b.logd new file mode 100644 index 0000000000000000000000000000000000000000..57b180bf0293dde3840d7141786307131041c049 GIT binary patch literal 8729 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 DCvoXb literal 0 HcmV?d00001