Repository navigation
Reject quote repairs that invent a reply key (#264) - #333
Merged
Merged
Conversation
_loads_model_json repairs unescaped inner quotes. An excerpt that quotes a
dict literal such as cfg = {"a": "b", "c": "d"} repairs into a finding with
a new key "c" and an excerpt cut at "b. The parse succeeds and the evidence
is silently wrong.
Callers now pass the keys their reply may carry, per level (ReplyKeys).
When a parse needed repair and the result carries a key the caller did not
list, the repair is discarded and the original JSONDecodeError is raised.
An api backend then spends its existing JSON-correction retry; the cli
inner-result parse falls back to the raw string, which the caller's
validation rejects. A parse that needed no repair is never checked.
Wired: L1 review passes (factories._run_pass), subagent spawn and the
test-assertion review (cli), the runtime axis and the real falsifier.
Refs #264
Format only; the AST of every file is unchanged. One message string in cli.py is split into two literals to stay within 105 columns. Needed because the lint gate runs ruff format --check on each touched file. Refs #264
When a CLI or proxy prints the model's JSON with no type:result envelope, _invoke_cli kept the parse it made without reply_keys, so a quote repair that invented a key went through even when the caller passed keys. Parse that stdout again under the caller's keys and keep the raw text on refusal, as the inner-result path already does. Tests: bare stdout with and without keys; one test per site that threads reply_keys (continuation partial and combined parse, wider retry, _invoke_api into both, llm_invoke into excerpt repair, excerpt repair into _invoke_api); review item lists reject an extra key; a repaired falsify reply may carry receipt, a runtime reply surfaces, a finding excerpt.
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
- A refused repair now raises ReplyKeyRejected, a JSONDecodeError at the original error. The API parse, the continuation parses and the wider retry no longer fall back to _extract_json_from_text for it. That scan could return an object quoted inside an excerpt, such as an empty review, and drop the real findings with no retry. - A CLI envelope (or the last stream event) whose result is an object is checked when the envelope parse itself needed quote repair. An unlisted key keeps the raw stdout, as a string result does. - _unknown_reply_key checks each object in a top-level list, since runtime._parse_llm_response unwraps a one-object list. A bare CLI stdout list is parsed again under the caller's keys.
) A quote repair that fails, or that the #216 quoted-key guard refuses, raised a plain JSONDecodeError, so _invoke_api scanned the text for any embedded object. An excerpt quoting x = {"findings": [], ...} then came back as a clean review and the real finding was lost after one call, with or without reply keys. Every failure after a repair was tried now raises RepairRefused, a JSONDecodeError subclass that ReplyKeyRejected derives from. After it, _fallback_reply tries only the reply's own leading object, so a cut falsify reply is still salvaged; after a key refusal it tries nothing. A failure with no repair tried keeps the full scan. Adds the CLI tests the cross-check asked for: a repaired envelope with only known keys keeps its result, and a raw newline is not a repair.
The embedded-object scan now skips an object followed by a '}' that nothing after it opens. That covers a prose-led reply, an earlier syntax error and a leading object closed early by a quoted '"}]'. The falsify salvage refuses a repeated key and a verdict that is not the first key.
The wiring tests searched each caller's source for "reply_keys=...". A call that passed reply_keys=None with the old text left in a comment still matched. Each test now patches llm_invoke, drives the caller, and asserts every call received that caller's key table (by identity): the L1 provider, the subagent spawn, the test-assertion review, the runtime runner and the real falsifier.
…-parse (#264) When a CLI stream array's last result event has no "result" member, the event falls through to the bare-stdout branch. With reply_keys set, that branch parsed the whole stdout again and returned the stream array instead of the event, which main returns. Re-parse only when the parsed value is stdout itself, not an event taken from it. Test with and without keys.
Replaces the unmatched-'}' count, which code opening a brace after a
quoted object could balance, and which refused a valid reply followed
by prose quoting a '}' (a regression against main).
- A '{' whose decode fails after reaching a '"' ends the scan, after the
falsify salvage is tried; later '{' may sit inside its strings.
- A decoded object followed by '}' or '"' is skipped.
- An empty unknown key now counts as unknown (is not None).
- ReplyKeys.items is read-only and left out of the hash.
… and 3.12 trailing commas (#264) - _json_continues_after: a key-matching object is not the reply when the text after it closes a string or object it sits in, including a stray quote later followed by '}' or ']' (open braces in the quoted code). - _error_is_at_quote: ',}' and ',]' are structure on Python 3.12 too. - _loads_model_json: with reply_keys, refuse duplicate keys; drop the unreachable final raise.
Format only. The next commit touches this file, and the lint gate runs ruff format --check on whole touched files.
Clears ruff PERF401 on this file, which the next commit touches; the lint gate runs ruff check on whole touched files. Same list, same order.
…emon (#264) - _text_after_refuses replaces _json_continues_after. After the object that opens a non-list reply, ',', ']', ')', ';', '=', '\', '//' and curly quotes count as continued code, not prose. A later whole object with the reply's keys also refuses the first: either could be the reply. - A '{' that opens the reply and fails to decode ends the scan, so a Python dict or JS object reply does not yield the JSON review it quotes. - _continue_truncated counts a partial as complete only when it parses whole. The fallback scan read a cut excerpts-first reply as an empty review and spent no continuation. - The inline canary review passes REVIEW_REPLY_KEYS, and the daemon-state questions pass their own key tables. A cut tail that starts with a word or a digit still reads as prose after a stop; the docstring and tests pin that limit. Two contract tests change: two matching objects now return None, and a reply that opens with '{a' returns None (F2 keeps its intent behind a prose prefix).
Adds the right curly quote, ')' and '=' tails to the cut excerpts-first cases, and a repaired daemon conflict item with an invented key.
The limit is any first character outside the refused set, not only a
word or a digit. Pins '(', a list item and a single quote as well.
…and literals (#264) - The text after a scanned reply object must start like prose or markdown (str.isspace whitespace), instead of refusing a fixed set of code marks. Chosen on a corpus of 57 cut tails and 32 valid trailing texts in three places: P1 lost 30/171 (was 156/171), valid replies refused 12/104 (was 14/104). The rule also applies after a list's object and a prose-led object, skips whole objects that follow, and refuses text opening more brackets than it closes or holding a string closing into JSON. - The scan decoder refuses a duplicate key, as _loads_model_json does. - After prose, a failed Python dict or JS object literal ends the scan at the first object with the reply's keys inside its braces. - A reply holding a '{' gets the JSON-correction retry even after prose, so a refusal costs one call rather than a failed review. - The canary review gets its own top-level keys; Q2Q3 checks the top level only, as on main, and the cost is documented. - Tests pin each _fallback_reply site against a full scan. Refs #264 Signed-off-by: Minxi Hou <houminxi@gmail.com>
…utation (#264) Mutation found that after a prose-led object the check of the text after its punctuation started at an offset into the slice, not the text, so `Here: {...}; = 1` was kept. Measure from the object's end. The corpus now loses 29/171 cut tails (was 30) and still refuses 12/104 valid replies. The leading-quote check is dropped: no quote starts prose, so the other rules already refuse it. New tests pin a duplicate key followed by a reply, literals whose quoted review is followed by prose, a spaced sentence mark, list punctuation, and the continuation site against a full scan. Refs #264 Signed-off-by: Minxi Hou <houminxi@gmail.com>
Format only, no behaviour change. They were unformatted on main and lint_changed checks whole changed files. Refs #264 Signed-off-by: Minxi Hou <houminxi@gmail.com>
… scan (#264) - _invoke_cli raises LLMInvokeError on a refused reply (duplicate key, invented key), in envelope, stream, object, string and bare forms, and parses stdout once. - loads_reply_json refuses duplicate keys and deep nesting; the reviewer JSON validator, canary review, factories, receipt and the canary provider in cli.py re-parse reply text through it. - A duplicate key gets its own correction message and spends the API correction retry; a repeat fails closed. - With reply keys only a leading or list object is taken. - Non-ASCII prose is a whitelist; a dict or object literal skips its strings when counting braces. - 256 KB and repair-count caps; RecursionError fails closed. - Echoed or restated continuations keep the partial; '$' no longer ends a sentence; '. Then' and '.keys()' are refused. - An object that opens a list must be followed by ']' or another item. Signed-off-by: Minxi Hou <houminxi@gmail.com>
#264) Signed-off-by: Minxi Hou <houminxi@gmail.com>
The canary provider's re-parse, the brace skip inside strings in a failed literal, and a '.' ending a prose word only before whitespace or the end each survived the mutation run. Each now has a test that fails under it. Signed-off-by: Minxi Hou <houminxi@gmail.com>
canary_gen and reviewer_json now parse replies with loads_reply_json, so their json imports were unused (ruff F401), which the lint gate flags. Signed-off-by: Minxi Hou <houminxi@gmail.com>
receipt.py was not formatted on main. This branch now touches it, and the lint gate runs ruff format --check on whole touched files. Signed-off-by: Minxi Hou <houminxi@gmail.com>
#264) Since round 9 a caller with reply keys refuses prose before the reply and sends it to the correction retry (F2). The embedded style here led with prose and ran with one attempt, so all six cases failed. Prose now only follows the reply, which still keeps raw text that is not the parsed JSON; the prose-led refusal is covered in test_reply_refusals.py. Signed-off-by: Minxi Hou <houminxi@gmail.com>
…264) The file was not formatted on main. This branch now touches it, and the lint gate runs ruff format --check on whole touched files. Signed-off-by: Minxi Hou <houminxi@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #264
Problem
_loads_model_jsonrepairs unescaped inner quotes. When a reply quotes a dict literal without escaping it, the repair can read part of the quote as structure. A finding whose excerpt iscfg = {"a": "b", "c": "d"}parses as:The parse succeeds, the excerpt is cut, and the finding gains a key
c. Nothing downstream notices.Change
Option (A) from the issue: callers say which keys their reply may have.
llm_invoketakesreply_keys: ReplyKeys | None.ReplyKeys.toplists the top-level keys.ReplyKeys.items[name]lists the keys allowed in each object under that member, whether the member holds one object or a list of them. When the reply is a top-level list, each object in it is checked as a reply, because the runtime parser unwraps a one-object list._loads_model_json, a parse that only succeeded after repair is checked againstreply_keys. If it carries an unlisted key, the repair is thrown away andReplyKeyRejectedis raised, with the original error's message and position. A parse that needed no repair gets no key check, so a model adding its own keys to valid JSON behaves as before. A duplicate key is refused in every parse, with or without keys (see Duplicate keys).RepairRefused. That covers a repair that still does not parse and the llm: bound the unescaped-quote repair to content quotes #216 quoted-key refusal.RepairRefusedis aJSONDecodeError, andReplyKeyRejectedis a subclass of it. A failure where no repair was tried stays a plainJSONDecodeError._fallback_reply, which picks the scan by error type (see below).LLMInvokeError, in bare stdout, atype: resultenvelope, a stream array, or a string or objectresult(F1). stdout is parsed once. Aresultstring that fails with no repair tried stays a raw string, as before, and the caller's validation rejects it. An objectresultis key-checked only when the envelope parse needed quote repair; a duplicate key in it is always refused.reply_keysis threaded through every parse in the call:_invoke_cli,_invoke_api,_continue_truncated,_retry_with_more_headroomand_repair_missing_excerpts.Callers that now pass keys:
factories.build_l1_provider), subagent spawn and the test-assertion review (cli)REVIEW_REPLY_KEYS: topfindings,code_excerpts,context_quotes. Findings may carryfile,line,severity,description,excerptand a nestedcode_excerpts.runtime.RuntimeRunner)RUNTIME_REPLY_KEYSfalsify_real.RealFalsifier)_FALSIFY_REPLY_KEYS, includingreceipt.commandandreceipt.outputcli._dispatch_inline_canary)CANARY_REVIEW_REPLY_KEYS: topfindingsonly. A model may add its own finding keys (title,fix,confidence), which the canary validator ignores, so items are not checked.daemon_state.DaemonStateRunner)Q1_REPLY_KEYS: topexternal_state.Q2Q3_REPLY_KEYS: topconflictsonly. Conflict items are not checked, since a model may add its own item keys.Not covered
Remaining #264 work:
Required keys. Only unknown keys are checked. A repair that drops or renames a required key is left to each caller's own validation.
Callers that pass no keys. Four remain, and their behaviour is unchanged:
backend.py:1136), which asks for{"ok": true};contract_loader.py:265), which asks for{"summary": ...};cli.py:3284), which takes free text;cli.py:3546), which returns mutations.None of these replies becomes a finding. Each can opt in later with its own
ReplyKeys.Deeper levels. Levels below
items, and members not listed initems, are not checked. The llm: bound the unescaped-quote repair to content quotes #216 quoted-key guard still applies there. One known case: an extra key in an item of a finding's nestedcode_excerptsis accepted, and_hoist_nested_excerptsthen moves that item to the root.Same-level key collisions. A repair whose invented key is also an allowed key at that level (an excerpt that quotes
"file": ..., say) passes the check.The CLI wrapper envelope. The keys of a
type: resultenvelope are not checked. That JSON comes from the CLI, not the model. A stringresultis always checked, and an objectresultfor unknown keys only when the envelope needed quote repair. A duplicate key anywhere in the envelope fails the call. A bare stdout with no envelope is checked.Callers that pass no keys, on cut or duplicated replies. Correction: the previous description listed two cases here, a cut
code_excerpts-first reply that quotes a review and a falsify reply with a quoted second"verdict". Both are now refused without keys too:_loads_model_jsonrefuses a duplicate key in every parse, and the repair of the first case repeatsfile,start_line,end_lineandcontent.Canary and Q2Q3 items. The canary review and Q2Q3 check top-level keys only. A repair inside a finding's or a conflict's string can still invent an item key there, as on
main. Checking items would refuse a repaired reply whose model added its own item keys.The review-focus line. With a focus spec, the review prompt says "in your response, state whether each area was checked" (
factories.pyaround line 532,cli.pyaround line 1193) without naming a key. A model that answers with a new top-level key in valid JSON is accepted, since unrepaired JSON is not checked. If that reply also needed quote repair, the extra key is refused and the reply goes to the correction retry. Naming a key (or listing one inREVIEW_REPLY_KEYS) is left to a follow-up.The excerpt-repair prompt.
_excerpt_repair_promptechoesparsed["findings"]back to the model. Those findings come from a parse that either needed no repair or passed the key check, so a repaired, unchecked object is never echoed. Values are not checked, only keys.Nested
code_excerpts. See Deeper levels: items in a finding's nestedcode_excerptsare not key-checked.The embedded-object scan
_extract_json_from_textdoes no quote repair, so it cannot invent a key. It does scan every{in the text, though, and returns the first object that has the reply's keys. After a failed repair, that object can be one quoted inside an excerpt.In the cross-check reproduction, a P1 finding's excerpt and a
code_excerptsitem quotex = {"findings": [], "code_excerpts": [...]}unescaped. Correction: an earlier version of this description said the reproduction needed a second literal,; y = {...}, to trigger the bug. That was wrong. The excerpt alone already movescontent,start_lineandend_lineonto the finding when no keys are passed, and raisesReplyKeyRejectedunderREVIEW_REPLY_KEYS. The; yliteral was only needed for a variant with an emptycode_excerpts.Other tails after the same excerpt lose the P1 with or without keys, on
maintoo. Examples are; f("a", "b"),y = ["a","b"]andprint("], ", "x"), and the #216 quoted-key refusal on a description ofreturns, ",", ok, empty. The repair fails, the scan finds the quoted{"findings": [], ...}, andllm_invokereturns a clean review after one call._fallback_replynow does this:ReplyKeyRejected, it tries no object.RepairRefused, it tries only the object that opens the reply, after any[and whitespace (leading_only=True). Every later{may sit inside that object's quoted content. This keeps the falsify salvage: a cut falsify reply with an unescaped quote still yields itsverdict.I also tried two simpler rules. Skipping the scan on every
RepairRefusedlost the falsify salvage above. Skipping it whenever the reply starts with{would drop the recovery of an object followed by prose. Correction: an earlier version of this description said that rule broketest_f2_many_invalid_braces_before_real_envelope. That test calls the extractor directly, so it does not cover_fallback_reply. Inside_fallback_reply, onlytest_failed_repair_still_salvages_the_leading_objectfails under that rule. Object-then-prose recovery had no test;test_object_followed_by_prose_is_recoverednow covers it.Errors before any repair
A reply that fails before its first unescaped quote gets no repair, so its error stays plain and the full scan ran unchanged. A cross-check showed that this still returned a quoted object as a clean review after one call, with or without keys, through
_invoke_apiandllm_invoke:Here is the review: {...}), with or without af("a", "b")tail;True, or a trailing comma in an earlier finding;code_excerptscome first, with an excerpt ofx"}], "findings": []}. Here the leading object itself decodes as{"code_excerpts": [...], "findings": []}, and the error isExtra data.Three checks in
_extract_json_from_textnow handle these. They replace the unmatched-}count from commits 9 and 10 and the code-mark list from commits 19 and 20.A
{that fails to decode can end the scan. The scan ends when that{opens the reply (after any[and whitespace), or when the text from it to the error position holds a". In both cases every later{may sit inside it. The falsify salvage is tried on that{first (falsify keys only, with the duplicate and verdict-first checks); if it yields nothing, the scan returnsNone. A{after prose that fails before reaching any quote ({a,{b}) is skipped as before, so"Note: " + "{a" * 10before a real envelope still works. After prose, a failed Python dict or JS object literal ({'key':or{key:) is remembered: an object with the reply's keys inside its braces is not returned. A literal that closes before the reply has no effect. I first ended the scan at any failed literal, but that broke existing contract tests for prose with a stray literal before the real envelope.The scan's decoder refuses duplicate keys, with or without
reply_keys(_scan_decoder,object_pairs_hook=_refuse_duplicate_keys). A scanned object with a duplicate key ends the scan withNone.A matching object is not returned when the text after it refuses it.
_text_after_refuseschecks the text after an object with the reply's keys. It refuses, in this order, when:{and[than it closes;_prose_may_follow, below);"closes into JSON ("},"],":or",) outside any whole object.The object's place is
leading(it opens the text),list(it opens the text after a[) orprose(text comes before it)._prose_may_followtreats anystr.isspacecharacter as whitespace and accepts:listplace);---, a table|, a footnote[1], a list or quote marker, a heading, or a number and a word. An indented line refuses;e+1,f(a)andd.kare not words;,and;and a BOM do not count;.,!or?that end the text, with no gap;listplace, only]followed by any of the above, or,then[or{. Nothing else, the end of the text included, since the list is still open;proseplace, also]followed by any of the above, or,then[or{;proseplace, with no gap:,,;,.,:,!or?then whitespace, followed by any of the above.Everything else refuses: code, a digit, a stray mark, a curly quote.
How the rule was chosen
The corpus and the measurement script are now committed (cr1, cr2 P3-4):
tests/fixtures/reply_tail_corpus.json: 57 cut tails, 3 whitespace-only tails, 32 valid tails and 4 prose prefixes.tests/reply_tail_corpus.py: runs each text as_invoke_apidoes on astopreply (_loads_model_json, then_fallback_replyif that raises) and prints the table below. EachREVargument is measured fromgit showas well.tests/test_reply_tail_corpus.py: pins the corpus size and the working-tree counts.The cases:
{"code_excerpts": [...], "findings": []}, as left by an excerpt that quotes"}], "findings": []}and a stream that stops there. Each tail is tried in the three places (171 cases). A returned object loses the P1.\nand two spaces, in the three places (9 cases).Output of
PYTHONPATH=src:. python tests/reply_tail_corpus.py 880b069 071a9eaat this head:880b069(code marks), either071a9ea(previous push), eitherWith review keys, a prose-led reply gets no scan (F2), so all 40 prose-place valid cases go to the correction retry. Without keys, the scan still reads them as before.
Correction. The previous description had a table of candidate rules (B, C, D and their variants with the brace balance H). Those rows came from scratch code that I did not keep, and the corpus copy I had kept was damaged. I rebuilt the corpus from the tails the description lists. The
880b069and071a9earows reproduce the counts it gave for them (156 and 14; 29 and 12). The candidate rows cannot be re-run, so they are dropped.The 8 cut tails lost with keys are
done,(see a.py),\n- note,'a',for k in d:,or y,in dand a lone!, all in the leading place. Without keys, the same 8 are lost after prose too, plus:,;,. Then,, b)and; doneafter prose. In the list place nothing is lost: a list must close with]or go on with,and another object (cr3 P3-4). The 12 valid cases refused in every mode are four texts in each place:I would return {"findings": []}.andThe format was {"findings": [...], ...}.(a later keyed object, or a quote closing into JSON),"Unchecked" means no guard.after a blank line (a double quote does not start prose) anduse `d["k"]` instead("]).What a refusal costs
_json_correction_promptnow offers the correction retry when the content holds a{anywhere. It used to require content starting with{or[, so a refused prose-led reply failed at once. A BOM, a zero-width space and aHere:prefix before a broken object moved from the no-correction cases to the correction cases intest_json_recovery.py. If the retry is refused too,llm_invokeraisesInvalidJSONResponseError. The L1 review passes (factories.py:656) record it as an INFRA, CONFIRMEDl1-<pass>-invoke-failfinding and keep the raw response. Falsify (machine.py:2243) marks the finding UNCERTAIN as a protocol violation. Neither retries again. Both fail closed.Cases that used to return an object and now cost a correction call, then fail closed if the retry is refused too:
{that does not decode, such as{a ....test_f2_many_invalid_braces_before_real_envelopekeeps its intent behind a prose prefix, and the bare{a-led text now assertsNone;Cut replies whose
code_excerptscome firstA cross-check on
9888e6cfound a reply that opens withcode_excerpts, quotes"}], "findings": []}in an excerpt, and is cut right after the quote. The leading object decodes whole as an empty review, and the P1 after the cut is lost._continue_truncatednow always asks for the rest onlength, even when the partial parses whole. A partial can parse whole because a quoted"}], "findings": []}closed it early. A continuation that adds nothing returns the partial: an empty one, or an echo of the partial's end (whitespace, fence marks, closing brackets). A continuation that restates the whole reply is read as that reply when it parses strictly. So alengthcut whose partial was already whole costs one call more than before, two in all, and the P1 is kept; the tests assert both. Two existing tests that expected no continuation now expect one (test_a_complete_partial_still_gets_a_continuationandtest_complete_json_partial_still_asks_for_a_continuation).stop, the rule above decides.Results per corpus tail through the stop path, with
REVIEW_REPLY_KEYSand without keys. Throughllm_invokewith a mocked API that returns the same text again, a refused reply ends inLLMInvokeErrorafter the correction call:stop, review keysstop, no keyslength,},]},// done,; f(a),\u007d,e+1, an indented line, fullwidth,and;:,;,. Then,, b),; donedone,(see a.py),\n- note,'a',for k in d:,or y,in d,!\n, two spaces (the partial parses whole)Python dict and JS object replies that quote a review, bare or after prose, and prose that quotes an example envelope before the real reply, are refused by the extractor and never return the quoted empty review.
Limit. One call can still return a wrong clean review. That needs a reply whose
code_excerptscome first, an excerpt that quotes"}], "findings": []}unescaped, and a stream that stops right after the quote while reportingstop. With review keys, that happens only when the object opens the reply and the text left after the quote is one of the 8 prose-like cut tails above or whitespace only (3 cases). Without keys (the four callers listed under Callers that pass no keys), the same tails after prose, and five punctuation tails after prose, are lost too. A reply cut by the output cap reportslengthand is continued. A whole reply carries its own"}], ...after the quote, which refuses it. Refusing these tails onstopwould refuse valid replies that end in prose.test_stop_cut_prose_like_tail_is_a_known_limitpins them.Correction. The previous description stated this limit for a leading object only, by first character, and its table said prose-led replies and example envelopes fail closed. Both were wrong: the code marks lost the P1 for 56 of 57 tails in a list or after prose, and a prose-led reply got no correction retry and ended in an error after one call.
Trailing commas on Python 3.12
CI runs Python 3.12 (
linux-tests.yml). There,,}fails with "Expecting property name enclosed in double quotes" and,]with "Expecting value", both at the closer. 3.13 and later report "Illegal trailing comma" at the comma. On 3.12_error_is_at_quoteread the closer as content and escaped an earlier quote. That failed three trailing-comma tests on 3.12 and could turn[{P1 ..., "description": "z",}, {P2}]into a review with only the P2._error_is_at_quotenow treats either message at}or]as structure when the previous non-space character is,, so no repair is tried on any version. The tests build the 3.12 and 3.13 errors directly, so they do not depend on the Python that runs them.Duplicate keys
_loads_model_jsonparses withobject_pairs_hook=_refuse_duplicate_keys, with or withoutreply_keys. A duplicate key raisesDuplicateReplyKey(aReplyKeyRejected), with the first repair error's position if a repair was tried, and the correction prompt names the key (Duplicate key "verdict" in reply). json keeps the last of two equal keys, so a quoted"verdict": "DISMISSED"after the real one would win, and a cutcode_excerpts-first reply could be repaired into an item whosefile,start_line,end_lineandcontentcome from the quote. Every later parse of reply text usesloads_reply_json, which applies the same hook (F1).The embedded-object scan now uses the same hook, with or without keys (rule 2 above). A later whole object with a duplicate key also refuses an earlier match. A mutant that lets a duplicate through in a whole object right after the match survives. It is equivalent: that object's
{does not start like prose, so_prose_may_followrefuses the text anyway.The plain
JSONDecodeErrorat the end of_loads_model_jsonis gone. The loop runs at least once, and every pass that neither returns nor raises setsfirst_error, so the line could not run. Anassertsays so.Falsify salvage
_salvage_truncated_object(reached through theleading_onlypath) could take a verdict from quoted content in a cut falsify reply. In{"verdict": "CONFIRMED", "reasoning": "the stub returns a", "verdict": "DISMISSED", ...the later duplicate won, and a reasoning-first reply with a quoted verdict was read the same way. This existed before this PR. The salvage now:object_pairs_hook);verdictthat is not its first key.falsify_real.pyasks for the verdict first. A salvage withreasoningonly and no verdict is still accepted, astest_falsify_real.pyexpects.Round 9: review fixes
This push answers the security review (F1-F7, N1, N2; F6 was withdrawn) and the three code reviews. Each item below says what changed and which test or mutant covers it.
Security review
LLMInvokeError("LLM subprocess reply refused -- ..."). That holds for bare stdout, atype: resultenvelope, a stream array, and a string or objectresult. Before, the raw text went on to the caller. A caller that reads it again withjson.loadswould keep the last of two equal keys, so a quoted"findings": []after the real findings read as a clean review. I searchedsrcforjson.loadson reply text and found five sites that parse a reply again. All five now useloads_reply_json, which refuses a duplicate key and raisesValueError, as any other invalid JSON does:reviewer_json.validate_reviewer_json, the canary review parse incanary_gen, the raw-response audit infactories, the canonical form inreceipt, and the canary provider incli._loads_model_jsonalso refuses a duplicate key in every parse now, with or without keys, repaired or not.tests/test_reply_refusal_e2e.pyruns the reported attack (a CLI reply with an escaped newline and a matching excerpt) through the review path. At071a9eait ends in PASS. After the fix the call fails closed. API-path tests check that a duplicate key spends the correction retry and that a second duplicate fails the call. N1 (the test-assertion review incli) goes through the same CLI path and is covered by the same refusal.reply_keysset,_fallback_replynow tries only the object that opens the reply or opens its list (leading_only). A prose-led reply gets no scan. It goes to the correction retry. Without keys, the full scan with the rules below still runs. This changed one existing test: theembeddedcase oftest_writer_schema_raw_retention.pyled with prose and ran one attempt, so it now puts the prose after the reply (816fad8)._PROSE_SYMBOLread any non-ASCII character as prose, and the failed-literal tracker counted braces inside strings._PROSE_SYMBOLis now a whitelist: a dash and a space, a non-ASCII letter, or a CJK full stop, comma or mark that ends the text or comes before whitespace or a letter. Fullwidth brackets and code punctuation, smart and corner quotes, a middle dot, zero-width characters and a BOM do not count._OpenLiteralskips strings inside a failed literal, so{key: "}", ...stays open.Noneon text over 256 KiB. Tests time adversarial texts (open lists, open braces, many quotes) of that size._invoke_cliparses stdout once (test_cli_parses_stdout_once).RecursionError. The parse in_loads_model_jsonraisesRepairRefused, the scan returnsNone, andloads_reply_jsonraisesValueError. Each path fails closed.dispatch_canary_reviewand the canary provider useloads_reply_json. A duplicatefindingsormutationsis refused, and the provider falls back to its templates.cr1
REVIEW_REPLY_KEYSlists the excerpt aliases thatreviewer_jsonreads (line_start,line_end,code)._prose_may_followdocstring is rewritten.cr2
_SENTENCE_ENDis anchored with$, and a.ends a prose word only before whitespace or the end, so. Then,d.keys()anda.bare not prose.code_excerptscome first is corrected.cr3
resultafter an envelope repair was accepted. The envelope parse now refuses a duplicate key anywhere in the envelope, including itsresult, and the call fails.Duplicate key "<key>" in reply, instead of the generic one.]and then more text, are tests.listplace, nothing after the object, or anything other than]or,and another object or list, now refuses, since the list is still open._keeps_the_p1fails on a missing P1 instead of passing on an exception, and the O(1) wording is gone.test_factories.py::TestParallelL1::test_api_pass_starts_are_staggeredis timing-sensitive. The mutation run does not count a failure of it as a kill.Known limits after this push
findingswrapper) that needs a quote repair is refused, because its keys are not top-level reply keys. It goes to the correction retry.Commits
9472a50: the change andtests/test_llm_reply_keys.py.50cbafc: ruff format of the six touched source files, format only. The AST of each file is unchanged. One message string incli.pyis split into two literals to stay under 105 columns. This commit is needed because the lint gate runsruff format --checkon whole touched files, and these files were not formatted on main.b39341b: six existing tests pinned the exact call shape of_invoke_cli,_repair_missing_excerptsor the API retry. Their fakes and expected kwargs now includereply_keys=None. No assertion was loosened.9d5e030: ruff format of two of those test files, format only, for the same lint-gate reason.af6ed18: the bare-stdout check in_invoke_cli, plus tests for it, for each site that passesreply_keyson, and for the key tables.310042e:ReplyKeyRejectedand no embedded-object scan after it, the object-resultcheck in_invoke_cli, and top-level lists in_unknown_reply_key, with tests.63fcd76:RepairRefused,_fallback_replyand theleading_onlyscan, with tests. Also two CLI tests for the object-result check.6869ce2: removes aleading_onlycheck that the scan guard already covers. A mutant deleting it survived, and no input reaches it.f171539: the enclosed-object rule, the falsify salvage checks, and tests: the budget raise,ReplyKeyRejectedas aRepairRefused, object-then-prose recovery, and the plain-error and salvage reproductions.b8858ed: tests that pin the brace count in_unmatched_close_after.bbbdcab: the wiring tests now call each caller withllm_invokepatched and check that it received its key table. They used to search the source text, which a commented-outreply_keys=...still matched.b38c59f: a CLI stream array whose last result event has noresultmember fell into the bare-stdout re-parse, which returned the whole array instead of the event when keys were set.mainreturns the event. Fixed, with tests with and without keys.87fbf3d: rule 1, the first form of rule 2, the unmatched-}count removed, an empty-string unknown key counted (is not Noneat three sites), andReplyKeys.itemsmade read-only (MappingProxyType) and left out of the hash.de21bf9: rule 2 as described above, the 3.12 trailing-comma fix, the duplicate-key check and the unreachable raise removed.ac448e4: a long test string wrapped, format only.f698405and9888e6c: tests for trailing commas (,},,], after a string and with whitespace), each tail case of rule 2, and the CLI object-result check with an empty key.7078f25: ruff format oftests/test_llm_invoke.py, format only (two blank lines). The file was not formatted onmaineither.4468bcc:daemon_state.pybuilds the static-rules list withextend. Ruff's PERF401 flagged the loop onmain, and the lint gate checks whole touched files.4ec743f: rule 1 at the reply start, rule 2 as described above, the whole-parse rule in_continue_truncated, keys for the inline canary review and both daemon-state questions, and tests. Two contract tests change, as listed under Costs.452a118,f0ead2fand880b069: tests for each code mark, a repaired daemon conflict item, a repaired Q1 reply with an unknown key, and three more prose-like tails; the docstring now states the limit by first character.Signed-off-by. I first made them without the sign-off and re-created them withgit cherry-pick -son880b069before pushing; nothing on the remote was amended or force-pushed.bffb448: alengthcut is always continued. Two tests that expected no continuation now expect one.3a87d55: the measured rule, the scan's duplicate-key hook, literal tracking after prose, the correction retry for any reply holding a{,CANARY_REVIEW_REPLY_KEYS, top-level-onlyQ2Q3_REPLY_KEYS, and tests at each_fallback_replysite. Its message gives 30 of 171 P1 lost; the next commit makes it 29.38720cb: the prose-punctuation check read its position from a slice offset, so it tested the wrong text. A mutant found it. Fixed, with the tests that kill the mutants from that run.196fd0a:test_per_pass_usage.py::test_factory_recovery_producers_preserve_unknown_usagefailed afterbffb448. Its transport returned a complete partial onlength, which is now continued. The test now cuts the reply after 12 characters and hands the rest to the continuation, and still checks that the usage isUsage(11, 0, 0)with no known fields. I missed this in the narrower runs and found it in the 133-file run below.0c50687:# noqa: BLE001with a reason on the canary dispatch's broad catch, which is onmain. The lint gate checks whole touched files.071a9ea: ruff format oftest_continuation_failure_contract.py,test_json_recovery.pyandtest_per_pass_usage.py, format only. They were not formatted onmain.Signed-off-by, on top of071a9ea; nothing on the remote was amended or force-pushed:7038ff0: the round 9 source changes (see Round 9: review fixes): CLI refusals raise,loads_reply_jsonat the five re-parse sites, the duplicate hook in every parse and its correction message,leading_onlywith keys, the_PROSE_SYMBOLwhitelist, strings skipped in_OpenLiteral, the size caps,RecursionErrorhandling, echo and restating continuations,_SENTENCE_ENDand.in prose words, the list rule, and the excerpt aliases. It also updatestest_json_recovery.pyandtest_llm_reply_keys.py, where the list-place known-limit cases now expectLLMInvokeError.35ae7b4:tests/test_reply_refusals.pyand the CLI end-to-end testtests/test_reply_refusal_e2e.py.be2e6e9: the corpus, its measurement script andtests/test_reply_tail_corpus.py.ad2163a: tests for the three mutants that survived the first run.32c1d47: drops thejsonimports incanary_genandreviewer_jsonthatloads_reply_jsonleft unused (ruff F401).ed92fc1: ruff format ofreceipt.py, format only. It was not formatted onmain, and this branch now touches it.816fad8: theembeddedcase intest_writer_schema_raw_retention.pynow puts prose only after the reply. With keys, prose before the reply is refused and costs the correction retry (F2), and that test runs one attempt, so its sixembeddedcases failed. Prose after the reply still gives raw text that is not the parsed JSON, which is what the test checks. The prose-led refusal is tested intest_reply_refusals.py.1983bbe: ruff format oftest_writer_schema_raw_retention.py, format only. It was not formatted onmain.Tests
tests/test_llm_reply_keys.py(528 tests):REVIEW_REPLY_KEYS, it raises.code_excerptsitem and acontext_quotesitem: each raises with its caller's keys.excerptand nestedcode_excerpts, a falsify reply withreceipt, a runtime reply withsurfaces._unknown_reply_key: each level, with unlisted members and deeper levels left unchecked._invoke_apiandllm_invoke. Without keys, the repair is kept and no retry happens.reply_keyson. Each sends the reproduction through that site and checks that the repair is refused: the partial and the combined parse in_continue_truncated,_retry_with_more_headroom,_invoke_apiinto each of those two,llm_invokeinto_repair_missing_excerpts, and_repair_missing_excerptsinto the API backend._invoke_apiandllm_invoke), both continuation parses and the wider retry. Each one retries or refuses instead of returning the quoted empty review.ReplyKeyRejectedkeeps the original error's message and position.RepairRefusedand keep the right error position._invoke_apiretries on each, with and without keys, andllm_invokereturns the P1 after the retry. The wider retry and both continuation parses do not return the quoted fragment. A failed repair still salvages a cut falsify reply's leading object, and an error with no repair still gets the full scan.cfall back to the raw stdout, and keep the repair without keys. An unrepaired envelope with an extra key in its object result is not checked. That includes an envelope with a raw newline inside a string, whichjson.loads(strict=False)reads without repair. A repaired envelope whose object result has only known keys keeps the result._unknown_reply_keychecks each object in a list. A runtime reply wrapped in a one-object list is refused on the CLI, where_parse_llm_responsethen rejects the raw text, and on the API, where it spends the correction retry._loads_model_json('{"a"}')raisesRepairRefusedafter the budget runs out, and'"{"findings": []}'tries only the leading object._fallback_replyon"}{"findings": []}},]][returnsNone.ReplyKeyRejectedis a subclass ofRepairRefused._invoke_apiandllm_invokeeither fail closed or retry and return the P1, with and without keys. Thecode_excerpts-first leading object is checked to hold an empty review. Unit tests cover the brace rule: an object followed by},} {,"}and"} {"a": 1}is skipped, and one followed by balanced braces is kept.{after the stray quote; a valid reply followed by prose with a lone}, before and after prose, and fenced (kept, one call); five falsify replies that quote a verdict (neverDISMISSED); quoted reviews followed byfunc f() { for {, prose-led,True-led andcode_excerpts-first, plus prose-led andTrue-led replies cut right after the quoted object, with finish reasonstopandlength(never a review without the P1);'{"x": 1, "ok": True} {"findings": []}}'; and each row of the table above._error_is_at_quotereturnsNonefor both 3.12 messages and the 3.13 one; a}after a stray quote with no comma is still repaired;[P1 with ",}", P2]never comes back as only the P2.code_excerpts-first reply are refused with keys; the refusal keeps the first repair error's position.ReplyKeysis hashable and itsitemscannot be changed.code_excerpts-first replies: each refused tail onstopraises after the retry, with and without keys; each prose-like tail onstopreturns the empty review (the limit); every tail onlengthis continued and keeps the P1; a cut partial that only the scan reads is continued.CANARY_REVIEW_REPLY_KEYS, and the daemon runner passesQ1_REPLY_KEYSandQ2Q3_REPLY_KEYS. A duplicateconflictsand a repaired Q1 reply with an unknown key are refused. A repaired conflict item with an invented key is kept, since Q2Q3 now checks the top level only; a test pins this.test_failed_repair_raises_repair_refused(four cases) andtest_fragment_repro_hides_an_empty_reviewused to assert that the full scan returns the quoted empty review. They now decode the quoted object directly to show the hazard, and assert that the extractor returnsNone.stopin all three places and: xin the leading and list places, with and without keys; the prose-like tails and: xafter prose pinned as the limit; every tail and the three whole-parse tails continued onlength;_text_after_refusesand_prose_may_followcases for each acceptance branch, each place, the punctuation offset, and!at the end;Nonewith and without keys, and_invoke_apireturns the corrected reply after one correction call;{aten times) that do not end the scan;_fallback_replysites (the API parse, the combined continuation parse and the wider retry), each fed a refused repair followed by a keyed empty review (with and without keys) and a duplicate-key reply (with keys). None returns the quoted review, and a test checks that a full scan would have;length, for no tail,and two spaces;Q2Q3_REPLY_KEYSpinned as top-level only.Mutation run on
be2e6e9, thenad2163a(this push). 31 mutants over the round 9 changes, on the 17 files listed for the previous run plustest_reply_refusals.py,test_reply_refusal_e2e.pyandtest_reply_tail_corpus.py. Python 3.14, a separate tree fromgit archive,__pycache__cleared before each mutant, bytecode writing off, no-xand no excluded tests. A mutant counts as killed only if a test other than the timing-sensitivetest_api_pass_starts_are_staggeredfails. The unmutated tree passed. A sanity mutant (no duplicate-key refusal in the hook) was killed by 40 tests. 28 of 31 were killed in the first run. The three survivors got tests inad2163aand were killed in a second run on that commit, which repeated the sanity mutant first (killed by 41):_parse_model_jsonwithout the duplicate hook (CLI stdout and envelope, cr3 P2)JSONDecodeErrorloads_reply_jsonwithout the duplicate hookreviewer_jsonre-parse back tojson.loadscanary_genre-parse back tojson.loadsfactoriesre-parse back tojson.loadsreceiptre-parse back tojson.loadsclicanary provider back tojson.loadsad2163aleading_onlyignoresreply_keys_PROSE_SYMBOLtakes any non-ASCII character_PROSE_SYMBOLtakes a fullwidth)_OpenLiteralcounts braces inside stringsad2163aRecursionErrornot caughtRecursionErrornot caughtloads_reply_jsonRecursionErrornot caught]in a list read as prose_SENTENCE_ENDwithout$.ends a prose word anywheread2163acontext_quotesdropped from the top keys]and, [rules after a prose-led object offF5 (call counts) is a test-only change, and F4's single CLI parse is pinned by
test_cli_parses_stdout_oncerather than a mutant.Mutation run on
071a9ea(the previous push). 31 mutants over the new code, on 17 files:test_llm_reply_keys.py,test_llm_invoke.py,test_json_extraction_contract.py,test_json_salvage_contract.py,test_json_recovery.py,test_continuation_failure_contract.py,test_output_budget_retry.py,test_falsify_real.py,test_daemon_state.py,test_per_pass_usage.py,test_canary_gen.py,test_canary_cli.py,test_llm_invoke_usage_contract.py,test_excerpt_repair_contract.py,test_l1_evidence_recovery.py,test_runtime.pyandtest_factories.py. Python 3.14, a separate worktree,__pycache__cleared before each mutant, bytecode writing off, no-xand no excluded tests. A sanity mutant (the scan returns a keyed object without the text-after check) was killed by 269 of 1,764 tests. 30 of 31 were killed:lengthpartial returned when it parses whole (P2){or["]and,rules only in thelistplace]and,rules in every place38720cb)str.isspacelistplace read asproseproseplace read asleading_invoke_apisiteREVIEW_REPLY_KEYSQ2Q3_REPLY_KEYSwith an item tableAn earlier run, with
-x, on the code before38720cbfound the punctuation-offset bug.38720cbfixes it and adds tests for the mutants that survived that run.Correction. An earlier version of this description said the tests killed all 21 hand mutations. That was wrong. A cross-check found 11 survivors: the
reply_keyshand-off at seven sites, an extra key in thecode_excerptsandcontext_quotesitem tables,receiptdropped from the falsify keys, andsurfacesdropped from the runtime keys.The mutation run is now redone with 38 mutants over the guard, the level walk, each parse and hand-off site, the bare-stdout branch, each key table and each caller. Each mutant runs against the related test files (
test_llm_reply_keys.py,test_llm_invoke*,test_json_recovery.py,test_excerpt_repair_*,test_l1_evidence_recovery.py,test_output_budget_retry.py,test_reviewer_json*,test_falsify_real.py,test_runtime.py,test_runtime_response_contract.py,test_factories.py).__pycache__is cleared before each mutant and bytecode writing is off. A first mutant that adds a failing test was killed, which shows mutants are loaded.First pass: 37 of 38 killed. The survivor was
excerptdropped from the finding keys. The allowed-keys test now includes anexcerpt.test_factories.py::TestParallelL1::test_api_pass_starts_are_staggeredfailed in two first-pass runs. It passed three times when run alone, so it looks timing-sensitive. Both mutants it hit were also killed bytest_llm_reply_keys.pytests.Second pass, against
test_llm_reply_keys.pyalone, for the 16 mutants in question: all killed. Each of the 11 reported survivors is killed by a test written for it.Commit 6: 13 mutants over the new code, on the same files and with the same setup, after a sanity mutant that was killed. All 13 were killed. They cover a plain error instead of
ReplyKeyRejected, a lost error position, the scan restored at each of the four sites, each condition of the object-result check and its fallback, the bare-list re-parse, and the list walk in_unknown_reply_key. The 38 earlier mutants were not re-run at the shifted line numbers.Commit 7: 17 mutants over the new code, on the 18 files, after a sanity mutant that was killed. 15 were killed. They cover each
RepairRefusedraise made plain, theexc is first_errorcheck, the error position, theReplyKeyRejectedbranch in_fallback_reply,leading_onlyforced on or off, thereply_startguard,[left out of the strip, the full scan restored at each of the four sites,_unknown_reply_keydropped from the CLI object-result check, andstrict=Truein_parses_without_repair. Two survived:RepairRefusedraise after the repair budget runs out. Correction: an earlier version of this description called this raise unreachable. That was wrong:'{"a"}'reaches it, and a cross-check found 4,645 of 100,000 random strings that do. Commit 9 adds tests that kill it. Only the plainJSONDecodeErrorafter it is unreachable.if leading_only: return Noneafter the leading object. Thereply_startguard returnsNoneat the next{anyway. Commit 8 removes it.Commits 9 and 10: 15 mutants over the new code, on the 28 files below, after a sanity mutant that was killed. All 15 were killed. For the enclosed-object rule, they cover the skip removed, the check made at the object's start, brace matching removed or unguarded, the threshold raised to two, the end-of-text flag set, and
]counted as a close. For the salvage, they cover the verdict-first check removed, forced on every object or keyed on the wrong key, and the duplicate-key check removed or never firing. They also cover the budget raise made plain or withleading_onlynever set, andReplyKeyRejectednot aRepairRefused. The brace-match and threshold mutants survived the first run and are killed by the tests in commit 10.Commits 11-16: 38 mutants on the 28 files below, Python 3.14,
__pycache__cleared before each and bytecode writing off. The first, a sanity mutant that makes every repaired parse returnNone, was killed by 235 tests.6869ce2:test_budget_exhaustion_*.ReplyKeyRejectedbased onJSONDecodeError): killed bytest_repair_refused_is_the_base_of_reply_key_rejected.return Nonetocontinuefor a later{inleading_onlymode): survives, equivalent. Every later{also hasi != reply_start, so the loop still ends withNone.reply_keys is not Nonedropped from the bare re-parse): survives, equivalent. Withreply_keys=Nonethe re-parse repeats the same pure call that already succeeded on the same text.reply_keys=Noneor omitted infactories.py, thecli.pyspawn, thecli.pytest-assertion review,runtime.py,falsify_real.py): each killed by its caller's wiring test.text[i : exc.pos]) survives, equivalent: the error lands on a"with no earlier quote in the span only for "Unterminated string" at the first key's opening quote, and then no quote follows, so no later object can carry a key.}only,]only, no quote needed, whitespace not skipped): all killed.}only, no comma needed, norstrip): all killed.}only was killed after9888e6cadded the["a", "b",]case.is not Nonemade truthy at the three sites, noMappingProxyType,itemshashed: all killed. The CLI truthy mutant survived the first run and is killed by the empty-key CLI test in9888e6c.Commits 17-20: 24 mutants on 18 files (
test_llm_reply_keys.py,test_llm_invoke*(five files),test_json_extraction_contract.py,test_json_salvage_contract.py,test_json_recovery.py,test_continuation_failure_contract.py,test_output_budget_retry.py,test_falsify_real.py,test_daemon_state.py,test_canary*(four files) andtest_runtime.py), Python 3.14, with the same setup. A sanity mutant that makes the extractor returnNonewas killed by 60 tests. 23 of the 23 others were killed:leadingforced off, forced on, or without the[check, and the code-mark check removed;_continue_truncated;reply_keysdropped from the canary review, the Q1 call and the Q2Q3 call; the conflict item table removed or widened; Q1's top keys widened. The Q1 mutant survived the first run and is killed by the Q1 test inf0ead2f.Local runs at
1983bbe(this push) over 214 related test files, withPYTHONPATH=src:.and bytecode writing off. The files are everytests/**/test_*.pythat imports a module this branch changes, plus the test files it changes.tests/eval/corpus/is left out because it holds fixture repositories, not tests (itstest_llm_invoke.pyfails to import by design). The selection command:main(869873f) on 3.14. They are environment failures intest_assertion_outcome_reporting.py(30),test_cli_phase_progress.py(9),test_cli_integration.py(8),test_cli_banner.py(3),test_integration.py(2),test_cli_empty_diff.py(1),test_mutation_ledger_ci_controls.py(1) andtest_report_projection_compat.py(1). None is in a file this branch changes.test_gate_check.py, which had two such failures in the previous run, does not import a changed module, so it is not in this selection.embeddedfailures intest_writer_schema_raw_retention.pyfixed by816fad8. Twotest_doctor.pyfailures in that run came from asrc/*.egg-infothat a local editable install had left in the worktree (doctor read its pins). With it removed,test_doctor.pypasses (121 tests).scripts/lint_changed.py --base origin/mainpasses with ruff 0.17.3 (ruff check, andruff format --checkon 26 files). No added line is over 105 columns.git merge-treeagainst869873freports no conflicts. The branch is not rebased.ad2163achange no source behaviour (an unused import, formatting, one test), so the mutation results below stand for this head.Local runs at
071a9ea(previous push) over 133 related test files (the files this branch touches and the tests that exercise the changed modules), withPYTHONPATH=src:.and bytecode writing off:ec8cca9on 3.14, with the same IDs. They are environment failures intest_assertion_outcome_reporting.py(30),test_cli_phase_progress.py(9),test_cli_integration.py(8),test_cli_banner.py(3),test_gate_check.py(2),test_mutation_ledger_ci_controls.py(1) andtest_report_projection_compat.py(1). None is in a file this branch changes.ruff checkandruff format --checkpass on the 20 changed Python files. No added line is over 105 columns.Local runs at
880b069over 46 files (the 18 above; the other seventest_falsify_*files;test_excerpt_repair_*(three files);test_reviewer_json*(two files); andtest_cli_head_diff_machine_options.py,test_cli_internal_failure_propagation.py,test_cross_repo_identity.py,test_daemon_state_drift.py,test_factories.py,test_gate_check.py,test_index_mutation_admission.py,test_kernel_context_cli.py,test_l1_evidence_recovery.py,test_lens_wiring_ast.py,test_outlet_c_cli.py,test_per_pass_usage.py,test_phase4_ic_t14_routes.py,test_review_uncertainty_boundary.py,test_runtime_response_contract.pyandtest_schema_corpus.py), withPYTHONPATH=src:., on Python 3.14.9 and 3.12.15:main(4822ac1), withouttest_llm_reply_keys.py: 3 failed, 2140 passed, 2 skipped, on both versions.test_falsify_raise_from.py::test_falsify_except_raise_names_its_cause(needsruffonPATH) and twotest_gate_check.py::TestFailureWaiverEvidencetests that run the systempython3, which has nopytesthere.git merge-treeagainst4822ac1reports no conflicts. The branch is not rebased.Local runs at
9888e6cover the same 28 files, withPYTHONPATH=src, on Python 3.14.9 (the box venv) and Python 3.12.15 (CI pins 3.12; a fresh venv with the test dependencies andgoogle-auth):main(ec8cca9, before it moved to4822ac1), withouttest_llm_reply_keys.py: 1 failed, 1381 passed, on both versions.test_falsify_raise_from.py::test_falsify_except_raise_names_its_cause, which needsruffonPATH.tests/test_llm_reply_keys.pyfrom this branch run againstb8858ed: 50 failed on 3.14 and 55 on 3.12. Against87fbf3d: 8 failed on 3.14 and 13 on 3.12 (before the later test commits).Local runs at commit 10, on this branch and on
main(ec8cca9), over 28 files: the 18 below, the other seventest_falsify_*files,test_json_extraction_contract.py,test_json_salvage_contract.pyandtest_review_uncertainty_boundary.py:main, withouttest_llm_reply_keys.py: 22 failed, 1329 passed and 31 skipped. The failing IDs are the same: the 21 Vertex tests below, andtest_falsify_raise_from.py::test_falsify_except_raise_names_its_cause, which needsruffonPATH.Local runs at commit 8, on this branch and on
main(ec8cca9):test_llm_reply_keys.py,test_llm_invoke*(five files),test_json_recovery.py,test_excerpt_repair_*(three files),test_l1_evidence_recovery.py,test_output_budget_retry.py,test_reviewer_json*(two files),test_falsify_real.py,test_runtime.py,test_runtime_response_contract.pyandtest_factories.py. That is 18 files. Earlier versions of this description said 16, which was a miscount.test_llm_reply_keys.py, both branches give 21 failed, 1114 passed and 31 skipped, with the same failing IDs. (Earlier versions reported 20 failed and 1104 passed for the miscounted set.)test_llm_invoke.pyand one intest_llm_invoke_url_scheme.py. They fail onmaintoo, withModuleNotFoundError: No module named 'google'. The box venv has no Google auth package.test_json_salvage_contract.py,test_json_extraction_contract.pyandtest_review_uncertainty_boundary.py: 62 passed.Earlier local runs, for commits 1-4:
main(ec8cca9), one at a time with a fresh temp dir:main: 169 failed, 5372 passed, 123 skipped. The failing test IDs are the same on both.test_llm_invoke*,test_json_recovery*,test_cli_integration*,test_falsify_sees_diff*,test_l1_prompt_byte_identity*,test_report_projection_isolation*andtest_llm_reply_keys.py: 32 failed and 623 passed on this branch. Onmain, the same 32 fail and 596 pass. The 27 extra passes are the new tests.The failures on both branches are in the box environment and exist on
main. CI is the reference.