1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
//! Tolerant JSON extraction from LLM responses.
//!
//! The model returns prose-wrapped, fenced, or slightly malformed JSON. The
//! extraction strategy is tried in this order, with the first success winning:
//!
//! 1. Parse the whole response directly - a response that is already JSON is
//! the answer, even if it happens to quote a fence inside a string.
//! 2. Extract a fenced block (with or without a `json` info string) and use
//! its body.
//! 3. Parse the fenced body directly.
//! 4. Repair trailing commas before `}` or `]` and parse.
//! 5. Balance unclosed braces/brackets and parse.
//!
//! Strategies 1-4 that succeed return [`Extracted::Complete`]. Only strategy 5
//! returns [`Extracted::Truncated`].
//!
//! ## Why `Truncated` is a type, not a log line
//!
//! Recovering truncated JSON yields a *partial* findings list. While LLM
//! findings only inform, under `--fail-on` a truncated response could
//! silently omit the one blocking finding and the gate would pass. The analyzer
//! treats `Truncated` as *unanalyzed* rather than clean when gating.
//! Losing this distinction reintroduces the exact failure the whole project
//! exists to prevent.
//!
//! ## What is deliberately unsupported
//!
//! - **Single-quote repair.** It corrupts any JSON string containing an
//! apostrophe (`"don't"`, `` "`os` imported but unused" ``), which finding
//! messages routinely do.
//! - **`fuzzy_inference`.** It guessed field values out of prose with
//! per-field regexes; a wrong finding is worse than a missing one.
use LazyLock;
use Regex;
use Value;
/// What `extract_json` returns when it can pull a JSON value out of the model
/// output.
/// The opening and closing tags of an inline reasoning block.
const THINK_OPEN: &str = "<think>";
const THINK_CLOSE: &str = "</think>";
/// Drop a leading `<think>...</think>` block, returning what follows it.
///
/// Reasoning models are supposed to stream deliberation on a side channel -
/// `reasoning_content` on DeepSeek and z.ai, `thinking` blocks on Anthropic -
/// which the SDK routes away from content before drep ever sees it. Several
/// OpenAI-compatible servers do not: MiniMax's M-series and most local
/// llama.cpp and MLX builds of Qwen emit the whole trace inline at the head of
/// `message.content`, wrapped in these tags.
///
/// That breaks the ladder rather than merely adding noise. Deliberation about
/// a code review quotes code, so the reasoning carries fenced blocks of its
/// own; `FENCE_RE` takes the *first* fence in the text, so the ladder selected
/// the reasoning's sample, strategies 2 through 4 all failed on it, and the
/// file came back `Unparseable` - which by design neither fails over nor
/// retries, so the configured fallback was never reached and every file failed.
///
/// Three properties, each load-bearing:
///
/// - **Anchored at the start.** A `<think>` appearing anywhere else is content -
/// a finding about a file that contains the tag, most obviously - and
/// stripping it would rewrite the model's answer.
/// - **A closing tag is required.** Without one the block never ended, so the
/// answer never arrived; `Unparseable` is then the honest outcome and
/// swallowing the rest of the response would hide it.
/// - **Leading whitespace is tolerated** before the opening tag but the tag
/// itself must come first, because servers differ on whether they emit a
/// newline ahead of it.
/// Strip a fenced JSON block out of `content`, returning the body.
///
/// Returns `None` if there is no fence. Trims surrounding whitespace from the
/// body so callers can hand it to `serde_json` without further scrubbing.
static FENCE_RE: = new;
/// Match a trailing comma before `}` or `]`, capturing the closing delimiter
/// so `replace_all` can drop just the comma.
/// Drop commas that sit immediately before a closing `}` or `]`, ignoring any
/// that appear inside a JSON string.
///
/// This was a regex (`,(\s*[}\]])`) applied to the raw text, which had no idea
/// what a string was: `{"a":",}","b":1,}` was "repaired" into `{"a":"}","b":1}`,
/// parsing as `Complete` with the string value silently rewritten from `,}` to
/// `}`. That is the same defect class as the single-quote repair this module
/// deliberately does not implement - a repair that damages valid input.
/// Run the strategy ladder against one LLM response and return the first thing
/// that parses.
///
/// Returns `None` when every strategy fails. An empty input also returns
/// `None` - there is nothing to parse.
/// Walk `s` and return it with the missing closing delimiters appended, in the
/// order that actually closes the structure.
///
/// Uses a **stack**, not per-delimiter counters. Counting how many `{` and `[`
/// are unclosed tells you how many closers to add but not their order, and
/// order is load-bearing: `{"xs":[{"k":1` is missing `}]}`, while independent
/// counters would emit `}}]` and fail to parse. Anything nested more than one
/// level deep hits this.
///
/// Returns `None` when there is nothing to do - the input is already balanced -
/// because the caller invokes this only after strategies 2 and 3 fail, and a
/// balanced re-parse would be identical to the failed parse. Distinguishing
/// "already balanced" from "would not parse even balanced" keeps the
/// `Truncated` signal meaningful: it fires only when a closing delimiter was
/// genuinely missing.