circle-ir-ai 2.43.0 → 2.45.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -104,6 +104,86 @@ export const crossFileTaintSignature = AxSignature.create(`
104
104
  * Maximum consecutive LLM failures before skipping further calls
105
105
  */
106
106
  const MAX_CONSECUTIVE_FAILURES = 5;
107
+ /**
108
+ * cognium-ai#217 — project-level dead-endpoint breaker threshold.
109
+ *
110
+ * The per-file breaker (`MAX_CONSECUTIVE_FAILURES`) is reset between files
111
+ * (`resetCircuitBreaker`) so one bad file doesn't disable LLM for the whole
112
+ * project. But when the *endpoint itself* is dead (empty-body 60s hangs,
113
+ * timeouts, connection failures on every call — the #217 Gemma-vLLM stall),
114
+ * that per-file reset means each new file re-pays ~5×60s of dead time before
115
+ * re-tripping, consuming the entire per-repo wall-clock budget (16/72 Tier-1
116
+ * repos hit the 60-min cap with zero output).
117
+ *
118
+ * This counts *endpoint-dead* failures (empty/timeout/connection) that are
119
+ * NOT interrupted by a success, and — unlike the per-file breaker — is NOT
120
+ * cleared by `resetCircuitBreaker`. Once it trips, LLM stays off for the rest
121
+ * of the scan and the run finishes static-only. Set to 4× the per-file
122
+ * threshold so a single unlucky file can't project-kill, while a genuinely
123
+ * dead endpoint trips after ~4 files (~2 min wall-clock at concurrency 10,
124
+ * vs the 60-min cap). A single success resets the counter, so a healthy
125
+ * endpoint with scattered blips never trips.
126
+ */
127
+ const PROJECT_DEAD_THRESHOLD = 20;
128
+ // ---------------------------------------------------------------------------
129
+ // cognium-ai#228 — MODULE-LEVEL circuit-breaker state.
130
+ //
131
+ // `getAxLLMClient()` returns a FRESH `AxLLMClient` per request (intentional —
132
+ // the per-call `lastErrorCategory`/`lastUsage`/`lastAttemptCount` fields would
133
+ // race under concurrency if a client were shared). But that means an
134
+ // *instance*-level breaker never accumulates across the throwaway clients —
135
+ // discovery/enrichment/verification each spin up their own client, so the
136
+ // per-file breaker (#16) and the project-dead breaker (#217) were both no-ops
137
+ // in the real scan path (confirmed on apache/camel: 613 empty-response calls,
138
+ // breaker never tripped). The cap (#160) worked only because it lives in
139
+ // call-limiter.ts at module scope. So the breaker state lives here, shared
140
+ // across every client; the instance methods below are thin accessors.
141
+ let breakerConsecutiveFailures = 0;
142
+ let breakerLLMDisabled = false;
143
+ let breakerConnectionWarned = false;
144
+ let breakerProjectDeadCalls = 0;
145
+ let breakerLLMProjectDead = false;
146
+ let breakerProjectDeadWarned = false;
147
+ /**
148
+ * cognium-ai#228 — reset ALL breaker state for a fresh scan. Called at the
149
+ * true top-level scan entry (scanner's file loop, single-file workflow) so a
150
+ * long-lived process (circle-pack) doesn't carry a tripped breaker across
151
+ * repos. The per-file `resetCircuitBreaker()` deliberately does NOT clear the
152
+ * project-dead state (#217); this does.
153
+ */
154
+ export function resetLLMBreakerForScan() {
155
+ breakerConsecutiveFailures = 0;
156
+ breakerLLMDisabled = false;
157
+ breakerConnectionWarned = false;
158
+ breakerProjectDeadCalls = 0;
159
+ breakerLLMProjectDead = false;
160
+ breakerProjectDeadWarned = false;
161
+ }
162
+ /**
163
+ * cognium-ai#217 — reset only the PER-FILE breaker (consecutive-failure count
164
+ * + the soft disable) between files within a scan, so one bad file doesn't
165
+ * disable LLM for the whole run. Deliberately does NOT clear the project-dead
166
+ * state — that's the cross-file backstop that must survive per-file resets.
167
+ */
168
+ export function resetLLMPerFileBreaker() {
169
+ breakerConsecutiveFailures = 0;
170
+ breakerLLMDisabled = false;
171
+ breakerConnectionWarned = false;
172
+ }
173
+ /** cognium-ai#217 — true once the project-dead breaker has tripped. Module
174
+ * accessor so callers (CLI degraded-status) don't need a client handle. */
175
+ export function isLLMEndpointDead() {
176
+ return breakerLLMProjectDead;
177
+ }
178
+ /** cognium-ai#228 — test/inspection accessor for the shared breaker state. */
179
+ export function getLLMBreakerState() {
180
+ return {
181
+ consecutiveFailures: breakerConsecutiveFailures,
182
+ llmDisabled: breakerLLMDisabled,
183
+ projectDeadCalls: breakerProjectDeadCalls,
184
+ llmProjectDead: breakerLLMProjectDead,
185
+ };
186
+ }
107
187
  /**
108
188
  * Maximum code context length to send to LLM (in characters).
109
189
  * Prevents 400 errors from exceeding model context limits AND keeps
@@ -214,6 +294,53 @@ const TRANSIENT_5XX_STATUSES = new Set([500, 502, 503, 504]);
214
294
  * proxy doesn't burn the rate-limit budget.
215
295
  */
216
296
  const MAX_TRANSIENT_5XX_RETRIES = 3;
297
+ /**
298
+ * cognium-ai#222 — Maximum retries for an *opaque* HTTP 400. Some
299
+ * OpenAI-compatible providers (observed: Novita.ai `qwen/qwen3-coder-next`)
300
+ * intermittently return `HTTP 400 invalid_request_error` for requests that
301
+ * succeed on a later attempt — a transient server-side rejection dressed as
302
+ * a client error. Small budget, independent of the 5xx/429 budgets.
303
+ */
304
+ const MAX_TRANSIENT_400_RETRIES = 2;
305
+ /**
306
+ * cognium-ai#222 — decide whether an HTTP 400 body looks *transient*
307
+ * (retryable) rather than a deterministic request defect.
308
+ *
309
+ * Deterministic 400s carry an actionable, request-shape message we must NOT
310
+ * retry — OpenAI: `"Unsupported parameter: 'temperature'"`,
311
+ * `"Use 'max_completion_tokens' instead of 'max_tokens'"`, unknown model,
312
+ * context-length-exceeded, content-policy. Retrying those just loops.
313
+ *
314
+ * Opaque 400s carry only a generic `invalid_request_error` + a `trace_id`
315
+ * and no actionable parameter name — empirically these are transient
316
+ * provider hiccups and recover on retry. Default to NOT retrying when the
317
+ * body is missing/ambiguous (conservative — preserves prior behavior).
318
+ */
319
+ export function isTransient400(body) {
320
+ if (!body)
321
+ return false; // no body → can't prove transient → terminal
322
+ const b = body.toLowerCase();
323
+ // Deterministic client-fix signals → never retry.
324
+ if (b.includes('unsupported parameter') ||
325
+ b.includes('max_completion_tokens') ||
326
+ b.includes('unsupported value') ||
327
+ b.includes('context length') ||
328
+ b.includes('context_length') ||
329
+ b.includes('maximum context') ||
330
+ b.includes('too many tokens') ||
331
+ b.includes('model_not_found') ||
332
+ b.includes('does not exist') ||
333
+ b.includes('content management policy') ||
334
+ b.includes('content_policy') ||
335
+ b.includes('content filter')) {
336
+ return false;
337
+ }
338
+ // Opaque provider rejection → treat as transient. Novita emits
339
+ // `{"message":"invalid request error trace_id: …","type":"invalid_request_error"}`.
340
+ return (b.includes('invalid_request_error') ||
341
+ b.includes('invalid request error') ||
342
+ b.includes('trace_id'));
343
+ }
217
344
  /**
218
345
  * Strip `<think>...</think>` reasoning blocks from LLM output.
219
346
  *
@@ -298,9 +425,9 @@ export class AxLLMClient {
298
425
  config;
299
426
  ai;
300
427
  generators = new Map();
301
- consecutiveFailures = 0;
302
- llmDisabled = false;
303
- connectionFailureWarned = false;
428
+ // cognium-ai#228 — circuit-breaker state is module-level (see top of file),
429
+ // NOT per-instance, because `getAxLLMClient()` hands out a fresh client per
430
+ // request. The breaker methods below read/write the shared `breaker*` vars.
304
431
  /**
305
432
  * Set by `_chatJSONImpl` on failure so the outer `chatJSON` wrapper
306
433
  * can write a categorical reason to the JSONL log without
@@ -386,7 +513,7 @@ export class AxLLMClient {
386
513
  * Check if LLM is currently available (not disabled due to failures)
387
514
  */
388
515
  isAvailable() {
389
- return !this.llmDisabled;
516
+ return !breakerLLMDisabled;
390
517
  }
391
518
  /**
392
519
  * Reset the circuit breaker so LLM calls are re-enabled.
@@ -394,23 +521,55 @@ export class AxLLMClient {
394
521
  * LLM for the entire project analysis.
395
522
  */
396
523
  resetCircuitBreaker() {
397
- this.consecutiveFailures = 0;
398
- this.llmDisabled = false;
524
+ resetLLMPerFileBreaker();
525
+ }
526
+ /**
527
+ * cognium-ai#217 — true once the project-level dead-endpoint breaker has
528
+ * tripped (the LLM endpoint returned empty/timeout/connection failures on
529
+ * ~4 files' worth of calls with no intervening success). The scan then
530
+ * runs static-only; callers can surface a `degraded: llm-unavailable`
531
+ * status instead of reporting a plain size timeout.
532
+ */
533
+ isLLMProjectDead() {
534
+ return breakerLLMProjectDead;
399
535
  }
400
536
  /**
401
- * Reset failure counter (call when LLM succeeds)
537
+ * Reset the per-file failure counter. Called on any HTTP 200 — note an
538
+ * HTTP-200-but-empty body is NOT a genuine success (it's the #217
539
+ * dead-endpoint signature), so the project-dead counter is reset elsewhere
540
+ * (on genuine content success in `chatJSON`), not here.
402
541
  */
403
542
  resetFailures() {
404
- this.consecutiveFailures = 0;
543
+ breakerConsecutiveFailures = 0;
405
544
  }
406
545
  /**
407
546
  * Track a failure and potentially disable LLM
408
547
  */
409
548
  trackFailure() {
410
- this.consecutiveFailures++;
411
- if (this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
549
+ breakerConsecutiveFailures++;
550
+ if (breakerConsecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
412
551
  console.warn(`LLM disabled after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`);
413
- this.llmDisabled = true;
552
+ breakerLLMDisabled = true;
553
+ }
554
+ }
555
+ /**
556
+ * cognium-ai#217 — track an *endpoint-dead* failure (empty response,
557
+ * timeout, or connection error). Unlike {@link trackFailure}, this counter
558
+ * survives the per-file `resetCircuitBreaker()`, so a wholesale endpoint
559
+ * stall trips a project-level breaker and the scan finishes static-only
560
+ * rather than re-paying dead time on every file to the wall-clock cap.
561
+ */
562
+ trackDeadCall() {
563
+ breakerProjectDeadCalls++;
564
+ if (!breakerLLMProjectDead &&
565
+ breakerProjectDeadCalls >= PROJECT_DEAD_THRESHOLD) {
566
+ breakerLLMProjectDead = true;
567
+ if (!breakerProjectDeadWarned) {
568
+ breakerProjectDeadWarned = true;
569
+ console.warn(`\nLLM endpoint appears dead: ${breakerProjectDeadCalls} empty/timeout/connection ` +
570
+ `failures with no success (${this.config.baseUrl}). Disabling LLM for the rest ` +
571
+ `of this scan and continuing static-only. Use --no-llm to silence.\n`);
572
+ }
414
573
  }
415
574
  }
416
575
  /**
@@ -450,14 +609,15 @@ export class AxLLMClient {
450
609
  * default LLM endpoint isn't reachable).
451
610
  */
452
611
  tripBreakerOnConnectionFailure(err) {
453
- if (!this.connectionFailureWarned) {
454
- this.connectionFailureWarned = true;
612
+ if (!breakerConnectionWarned) {
613
+ breakerConnectionWarned = true;
455
614
  const msg = err instanceof Error ? err.message : String(err);
456
615
  console.warn(`\nLLM endpoint unreachable (${this.config.baseUrl}): ${msg}`);
457
616
  console.warn('Falling back to static analysis. Use --no-llm to silence, or set LLM_BASE_URL/LLM_API_KEY.\n');
458
617
  }
459
- this.consecutiveFailures = MAX_CONSECUTIVE_FAILURES;
460
- this.llmDisabled = true;
618
+ breakerConsecutiveFailures = MAX_CONSECUTIVE_FAILURES;
619
+ breakerLLMDisabled = true;
620
+ this.trackDeadCall(); // #217: also counts toward the project-dead breaker
461
621
  }
462
622
  /**
463
623
  * Get or create a generator for a signature.
@@ -783,6 +943,14 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
783
943
  catch (e) {
784
944
  errorMsg = e instanceof Error ? e.message : String(e);
785
945
  }
946
+ // cognium-ai#217 — a genuine content success proves the endpoint is
947
+ // alive: reset the project-dead counter so scattered failures on a
948
+ // healthy endpoint never accumulate to the trip. (An HTTP-200-but-empty
949
+ // body returns null here, so it does NOT reset — that's the dead
950
+ // signature the breaker is counting.)
951
+ if (result !== null && errorMsg === undefined) {
952
+ breakerProjectDeadCalls = 0;
953
+ }
786
954
  // Derive the category: thrown exceptions take precedence; otherwise
787
955
  // use whatever `_chatJSONImpl` recorded. A null result with no
788
956
  // category is the historical "parse_error" path (no closer cause).
@@ -851,8 +1019,9 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
851
1019
  * consecutive-failure circuit breaker (rate limits are transient).
852
1020
  */
853
1021
  async _chatJSONImpl(systemPrompt, userPrompt, phase = 'enrichment', retryCount = 0, rateLimitRetryCount = 0) {
854
- // Skip if LLM has been disabled due to repeated failures
855
- if (this.llmDisabled) {
1022
+ // Skip if LLM has been disabled due to repeated failures (per-file
1023
+ // breaker) or the endpoint is dead across the whole scan (#217).
1024
+ if (breakerLLMDisabled || breakerLLMProjectDead) {
856
1025
  return null;
857
1026
  }
858
1027
  // Use the correct phase config
@@ -864,13 +1033,16 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
864
1033
  const maxRetries = 1; // parse / timeout / network retries
865
1034
  const maxRateLimitRetries = MAX_RATE_LIMIT_RETRIES;
866
1035
  const maxTransient5xxRetries = MAX_TRANSIENT_5XX_RETRIES;
1036
+ const maxTransient400Retries = MAX_TRANSIENT_400_RETRIES;
867
1037
  let attempt = retryCount; // 0 = initial; >0 = reduced-token retry
868
1038
  let rateLimitAttempt = rateLimitRetryCount;
869
1039
  let transient5xxAttempt = 0; // independent budget for 5xx
1040
+ let transient400Attempt = 0; // cognium-ai#222: budget for opaque 400
870
1041
  // Per-attempt logging (cognium-ai#87). One JSONL `kind:'attempt'` entry per
871
1042
  // loop iteration when LLM_LOG_JSONL or LLM_DEEP is set. Upper-bound on
872
1043
  // total possible iterations covers all three independent retry budgets.
873
- const maxAttempts = maxRetries + 1 + maxRateLimitRetries + maxTransient5xxRetries;
1044
+ const maxAttempts = maxRetries + 1 + maxRateLimitRetries + maxTransient5xxRetries +
1045
+ maxTransient400Retries;
874
1046
  let attemptIdx = 0;
875
1047
  const promptChars = systemPrompt.length + userPrompt.length;
876
1048
  const emitAttempt = (outcome, attemptStartMs, extras = {}) => {
@@ -905,13 +1077,13 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
905
1077
  // error, so retrying is just burning the failure budget. One clear
906
1078
  // diagnostic on stderr, then static-only for the rest of the run.
907
1079
  const stripped = phaseConfig.model.replace(/^[a-z]+\//, '');
908
- if (/-pro$/i.test(stripped) && !this.llmDisabled) {
1080
+ if (/-pro$/i.test(stripped) && !breakerLLMDisabled) {
909
1081
  console.error(`LLM model '${phaseConfig.model}' is not a chat-completions model ` +
910
1082
  `(OpenAI rejects */chat/completions for *-pro variants). ` +
911
1083
  `Use a chat-capable model — gpt-5-nano, gpt-4o-mini, gpt-4o, or claude-*-latest. ` +
912
1084
  `Falling back to static analysis. Use --no-llm to silence.`);
913
1085
  this.lastErrorCategory = 'http_error';
914
- this.llmDisabled = true;
1086
+ breakerLLMDisabled = true;
915
1087
  return null;
916
1088
  }
917
1089
  // All LLM requests go through the shared throttled queue.
@@ -927,10 +1099,13 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
927
1099
  while (true) {
928
1100
  attemptIdx++;
929
1101
  const attemptStart = Date.now();
930
- // On retry, request fewer tokens to reduce truncation risk
931
- const maxTokens = attempt > 0
932
- ? Math.min(phaseConfig.maxTokens, 4000)
933
- : phaseConfig.maxTokens;
1102
+ // cognium-ai#223: `max_tokens` is the OUTPUT cap — lowering it makes
1103
+ // the model truncate *sooner*. The old heuristic shrank it to 4000
1104
+ // on retry "to reduce truncation risk", which is backwards when the
1105
+ // failure IS output truncation (the dominant discovery parse-error:
1106
+ // a verdict array cut off mid-`reasoning`). Keep the full budget on
1107
+ // retry so a re-attempt has room to finish the JSON.
1108
+ const maxTokens = phaseConfig.maxTokens;
934
1109
  const controller = new AbortController();
935
1110
  const timeoutMs = phaseConfig.timeout || 60000;
936
1111
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
@@ -997,6 +1172,7 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
997
1172
  this.lastErrorCategory = 'connection_error';
998
1173
  }
999
1174
  this.trackFailure();
1175
+ this.trackDeadCall(); // #217: timeout / network error = endpoint-dead
1000
1176
  return null;
1001
1177
  }
1002
1178
  if (!response.ok) {
@@ -1034,10 +1210,14 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
1034
1210
  transient5xxAttempt++;
1035
1211
  continue;
1036
1212
  }
1037
- // #43: log the response body too, not just the status code.
1213
+ // #43: read the response body once, not just the status code.
1038
1214
  // OpenAI's 400s contain useful messages like "Unsupported
1039
1215
  // parameter: 'temperature'" or "Use 'max_completion_tokens'
1040
- // instead of 'max_tokens'" that would otherwise be invisible.
1216
+ // instead of 'max_tokens'" that would otherwise be invisible —
1217
+ // and cognium-ai#222 uses the body to classify 400s as
1218
+ // transient-vs-terminal. `response.text()` can only be consumed
1219
+ // once, so this single read feeds both the retry decision and the
1220
+ // terminal log below.
1041
1221
  let bodySnippet = '';
1042
1222
  try {
1043
1223
  bodySnippet = (await response.text()).slice(0, 300);
@@ -1045,6 +1225,25 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
1045
1225
  catch {
1046
1226
  // ignore — body may have been consumed or stream errored
1047
1227
  }
1228
+ // cognium-ai#222: opaque 400 (e.g. Novita `invalid_request_error`)
1229
+ // is a transient provider rejection — retry with backoff, like a
1230
+ // 5xx. Deterministic parameter-error 400s (`isTransient400` →
1231
+ // false) fall through to the terminal path unchanged.
1232
+ if (response.status === 400 &&
1233
+ transient400Attempt < maxTransient400Retries &&
1234
+ isTransient400(bodySnippet)) {
1235
+ const sleepMs = parseRetryAfterMs(response.headers.get('Retry-After'), transient400Attempt);
1236
+ console.error(`LLM opaque 400 (invalid_request_error); sleeping ${sleepMs}ms ` +
1237
+ `before retry ${transient400Attempt + 1}/${maxTransient400Retries}`);
1238
+ emitAttempt('transient_client_error', attemptStart, {
1239
+ httpStatus: 400,
1240
+ backoffMs: sleepMs,
1241
+ responseSnippet: bodySnippet || undefined,
1242
+ });
1243
+ await sleep(sleepMs);
1244
+ transient400Attempt++;
1245
+ continue;
1246
+ }
1048
1247
  console.error(`LLM call failed: HTTP ${response.status}${bodySnippet ? ' — ' + bodySnippet : ''}`);
1049
1248
  let terminalCategory;
1050
1249
  if (response.status === 429) {
@@ -1100,6 +1299,7 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
1100
1299
  this.lastErrorCategory = 'empty_response';
1101
1300
  emitAttempt('empty_response', attemptStart, { httpStatus: response.status });
1102
1301
  this.trackFailure();
1302
+ this.trackDeadCall(); // #217: empty body = endpoint-dead (the 60s stall)
1103
1303
  return null;
1104
1304
  }
1105
1305
  // Parse JSON from response (handle markdown code blocks)
@@ -1198,9 +1398,7 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
1198
1398
  * Handles cases where LLM output was cut off mid-response
1199
1399
  */
1200
1400
  tryRecoverTruncatedJSON(jsonStr) {
1201
- // Count open brackets
1202
- let braces = 0;
1203
- let brackets = 0;
1401
+ // First pass: detect a mid-string truncation (unbalanced quotes).
1204
1402
  let inString = false;
1205
1403
  let escaped = false;
1206
1404
  for (const char of jsonStr) {
@@ -1216,18 +1414,8 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
1216
1414
  inString = !inString;
1217
1415
  continue;
1218
1416
  }
1219
- if (inString)
1220
- continue;
1221
- if (char === '{')
1222
- braces++;
1223
- if (char === '}')
1224
- braces--;
1225
- if (char === '[')
1226
- brackets++;
1227
- if (char === ']')
1228
- brackets--;
1229
1417
  }
1230
- // If we're truncated mid-string, close it
1418
+ // If we're truncated mid-string, close it.
1231
1419
  if (inString) {
1232
1420
  jsonStr = jsonStr + '"';
1233
1421
  }
@@ -1237,14 +1425,44 @@ INFORMATION EXPOSURE (CWE-200/209) SPECIFIC:
1237
1425
  if (lastValidEnd > 0 && lastValidEnd < jsonStr.length - 1) {
1238
1426
  jsonStr = jsonStr.substring(0, lastValidEnd);
1239
1427
  }
1240
- // Add missing brackets
1241
- while (brackets > 0) {
1242
- jsonStr += ']';
1243
- brackets--;
1428
+ // cognium-ai#223: when the truncation point is a comma,
1429
+ // `findLastCompleteValue` keeps it, so appending closers yields `…,}]`
1430
+ // (invalid trailing comma). Strip a dangling `"key":` (no value) and any
1431
+ // trailing comma. A *complete* trailing `"key":"value"` ends in `"`, so
1432
+ // neither pattern touches it.
1433
+ jsonStr = jsonStr
1434
+ .replace(/,\s*"[^"]*"\s*:\s*$/, '') // drop `, "key":` with no value
1435
+ .replace(/,\s*$/, ''); // drop trailing comma
1436
+ // cognium-ai#223: close the OPEN delimiters in reverse nesting order.
1437
+ // The previous code closed all `]` then all `}`, which produced invalid
1438
+ // output like `…"path_traversal"]}` for a truncated `[{…` (array closed
1439
+ // before the object it contains). Rebuild the open-delimiter stack on
1440
+ // the final string and append the matching closers innermost-first.
1441
+ const stack = [];
1442
+ inString = false;
1443
+ escaped = false;
1444
+ for (const char of jsonStr) {
1445
+ if (escaped) {
1446
+ escaped = false;
1447
+ continue;
1448
+ }
1449
+ if (char === '\\') {
1450
+ escaped = true;
1451
+ continue;
1452
+ }
1453
+ if (char === '"') {
1454
+ inString = !inString;
1455
+ continue;
1456
+ }
1457
+ if (inString)
1458
+ continue;
1459
+ if (char === '{' || char === '[')
1460
+ stack.push(char);
1461
+ else if (char === '}' || char === ']')
1462
+ stack.pop();
1244
1463
  }
1245
- while (braces > 0) {
1246
- jsonStr += '}';
1247
- braces--;
1464
+ for (let i = stack.length - 1; i >= 0; i--) {
1465
+ jsonStr += stack[i] === '{' ? '}' : ']';
1248
1466
  }
1249
1467
  // Quick validation - should start with { or [
1250
1468
  const trimmed = jsonStr.trim();