@changfenhuang/dsh-genui 0.11.3 → 0.11.4

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.
@@ -52,11 +52,10 @@ export function describeJsonFailure(raw: string): string | null {
52
52
  * fail near that quote with "Expected ',' or ']'...".
53
53
  * 2. Trailing commas before `}` / `]` or at end of input.
54
54
  *
55
- * The state-machine scan walks the raw body tracking string-open state:
56
- * - inside a string, a quote whose next non-space char is NOT one of `, ] } :`
57
- * (or end of input) cannot legally close the string → escape it as `\"`;
58
- * - a `,` whose next non-space char is `}` / `]` / end of input is a trailing
59
- * comma → drop it.
55
+ * A shared grammar-aware scan distinguishes object keys from values and
56
+ * checks the continuation after a potential string terminator. Ambiguous
57
+ * value quotes use bounded backtracking; trailing commas are dropped only
58
+ * outside strings.
60
59
  *
61
60
  * Returns `{ text, repairs }` on success, or null when nothing needed fixing
62
61
  * or the body still does not parse (callers fall through to tier-2 / banner).
@@ -68,61 +67,335 @@ export function repairFenceJson(raw: string): { text: string; repairs: number }
68
67
  } catch {
69
68
  // fall through to the repair scan
70
69
  }
70
+ return scanFenceJson(raw, false)
71
+ }
72
+
73
+ /** A fixed search budget prevents quote ambiguity from becoming exponential. */
74
+ const MAX_QUOTE_ATTEMPTS = 32
75
+ const MAX_QUOTE_LOOKAHEAD = 4096
76
+
77
+ type JsonScope = {
78
+ closer: '}' | ']'
79
+ expecting: 'key' | 'colon' | 'value' | 'comma'
80
+ }
81
+ type RepairScan = {
82
+ text: string
83
+ repairs: number
84
+ rootEnd: number
85
+ rawRootEnd: number
86
+ choices: number[]
87
+ unfinishedString: boolean
88
+ invalidValue: boolean
89
+ }
90
+
91
+ function isJsonSpace(ch: string | undefined): boolean {
92
+ return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'
93
+ }
94
+
95
+ /**
96
+ * Quote closure is contextual: only keys may be followed by `:`, and a comma
97
+ * must introduce the next member of the enclosing object/array. Walk closing
98
+ * delimiters too, so a bracket in a quoted code example cannot end a value
99
+ * when prose immediately follows it inside the enclosing JSON structure.
100
+ * Lookahead has a fixed bound; inconclusive long continuations keep the
101
+ * terminator interpretation and leave the final JSON.parse as the arbiter.
102
+ */
103
+ function quoteCanClose(raw: string, index: number, key: boolean, scopes: JsonScope[], complete: boolean): boolean {
104
+ const limit = Math.min(raw.length, index + MAX_QUOTE_LOOKAHEAD)
105
+ let cursor = index + 1
106
+ const skipSpace = (): void => { while (cursor < limit && isJsonSpace(raw[cursor])) cursor++ }
107
+ skipSpace()
108
+ if (cursor >= limit) return true
109
+ if (key) return raw[cursor] === ':'
110
+ let scopeIndex = scopes.length - 1
111
+ if (raw[cursor] === ':') return false
112
+ while (raw[cursor] === '}' || raw[cursor] === ']') {
113
+ const scope = scopes[scopeIndex]
114
+ if (scope === undefined) return false
115
+ if (raw[cursor] === scope.closer) scopeIndex--
116
+ else if (!complete) return false
117
+ cursor++
118
+ skipSpace()
119
+ if (cursor >= limit) return true
120
+ // Tier-2 can retain a complete root prefix followed by junk. It tries
121
+ // whole-body quote alternatives before actually adopting that prefix.
122
+ if (scopeIndex < 0) return complete
123
+ }
124
+ if (raw[cursor] !== ',') return false
125
+ const scope = scopes[scopeIndex]
126
+ if (scope === undefined) return false
127
+ cursor++
128
+ skipSpace()
129
+ if (cursor >= limit) return true
130
+ if (raw[cursor] === scope.closer) return true // a removable trailing comma
131
+ if (scope.closer === ']') return /["[{tfn\-0-9]/.test(raw[cursor]!)
132
+ if (raw[cursor] !== '"') return false
133
+ // An object comma must be followed by a quoted key and its colon. Escapes
134
+ // in that key are skipped without changing them.
135
+ cursor++
136
+ while (cursor < limit) {
137
+ if (raw[cursor] === '\\') { cursor += 2; continue }
138
+ if (raw[cursor] === '"') {
139
+ cursor++
140
+ skipSpace()
141
+ return cursor >= limit || raw[cursor] === ':'
142
+ }
143
+ if (raw[cursor]!.charCodeAt(0) < 0x20) return false
144
+ cursor++
145
+ }
146
+ return true
147
+ }
148
+
149
+ /** Both tiers share the same string-aware scan and quote decisions. */
150
+ function scanFenceCandidate(raw: string, complete: boolean, contentQuotes: ReadonlySet<number>): RepairScan {
71
151
  let out = ''
152
+ const scopes: JsonScope[] = []
72
153
  let inString = false
154
+ let key = false
155
+ let interiorQuote = false
73
156
  let escaped = false
74
157
  let repairs = 0
158
+ let rootEnd = 0
159
+ let rawRootEnd = 0
160
+ let invalidValue = false
161
+ const choices: number[] = []
162
+ const finishValue = (): void => {
163
+ const scope = scopes[scopes.length - 1]
164
+ if (scope !== undefined) scope.expecting = 'comma'
165
+ }
75
166
  for (let i = 0; i < raw.length; i++) {
76
- const ch = raw[i]
167
+ const ch = raw[i]!
77
168
  if (escaped) {
169
+ if (inString && ch === '"') interiorQuote = true
78
170
  out += ch
79
171
  escaped = false
80
172
  continue
81
173
  }
82
- if (inString && ch === '\\') {
83
- out += ch
84
- escaped = true
85
- continue
86
- }
87
- if (ch === '"') {
88
- if (!inString) {
89
- inString = true
90
- out += ch
91
- continue
92
- }
93
- // Inside a string: is this quote the terminator? Look past whitespace.
94
- let j = i + 1
95
- while (j < raw.length && (raw[j] === ' ' || raw[j] === '\t' || raw[j] === '\n' || raw[j] === '\r')) j++
96
- const next = j < raw.length ? raw[j] : ''
97
- if (next === ',' || next === ']' || next === '}' || next === ':' || next === '') {
174
+ if (inString) {
175
+ if (ch === '\\') { out += ch; escaped = true; continue }
176
+ if (ch !== '"') { out += ch; continue }
177
+ let next = i + 1
178
+ while (next < raw.length && isJsonSpace(raw[next])) next++
179
+ // Without any interior quote, a delimiter-looking quote is a normal
180
+ // terminator even if its sibling is malformed. Do not absorb a broken
181
+ // bare value merely to make structural completion succeed.
182
+ const ordinaryEnd = !key && ((!interiorQuote && ',}]:'.includes(raw[next] ?? '\0'))
183
+ || (scopes[scopes.length - 1]?.closer === ']' && raw[next] === ','))
184
+ if (!contentQuotes.has(i) && (ordinaryEnd || quoteCanClose(raw, i, key, scopes, complete))) {
98
185
  inString = false
99
186
  out += ch
187
+ const scope = scopes[scopes.length - 1]
188
+ if (key) {
189
+ if (scope !== undefined) scope.expecting = 'colon'
190
+ } else {
191
+ finishValue()
192
+ // Only value terminators create alternatives. Retaining at most
193
+ // 32 recent decisions bounds the pending search and its memory.
194
+ if (scope !== undefined && interiorQuote) {
195
+ if (choices.length === MAX_QUOTE_ATTEMPTS) choices.shift()
196
+ choices.push(i)
197
+ }
198
+ }
100
199
  } else {
101
- // Free-standing quote inside a value → escape it.
102
200
  out += '\\"'
201
+ interiorQuote = true
103
202
  repairs++
104
203
  }
105
204
  continue
106
205
  }
206
+ if (ch === '"') {
207
+ key = scopes[scopes.length - 1]?.expecting === 'key'
208
+ interiorQuote = false
209
+ inString = true
210
+ out += ch
211
+ continue
212
+ }
213
+ if (ch === '{' || ch === '[') {
214
+ finishValue()
215
+ scopes.push({ closer: ch === '{' ? '}' : ']', expecting: ch === '{' ? 'key' : 'value' })
216
+ out += ch
217
+ continue
218
+ }
219
+ if (ch === '}' || ch === ']') {
220
+ if (scopes[scopes.length - 1]?.closer === ch) {
221
+ scopes.pop()
222
+ out += ch
223
+ if (scopes.length === 0 && rootEnd === 0) {
224
+ rootEnd = out.length
225
+ rawRootEnd = i + 1
226
+ }
227
+ } else if (complete) repairs++
228
+ else out += ch
229
+ continue
230
+ }
107
231
  if (ch === ',') {
108
- // Trailing comma before `}` / `]` / end of input → drop it.
109
232
  let j = i + 1
110
- while (j < raw.length && (raw[j] === ' ' || raw[j] === '\t' || raw[j] === '\n' || raw[j] === '\r')) j++
111
- const next = j < raw.length ? raw[j] : ''
112
- if (next === '}' || next === ']' || next === '') {
233
+ while (j < raw.length && isJsonSpace(raw[j])) j++
234
+ if (j === raw.length || raw[j] === '}' || raw[j] === ']') {
113
235
  repairs++
114
236
  continue
115
237
  }
238
+ const scope = scopes[scopes.length - 1]
239
+ if (scope !== undefined) scope.expecting = scope.closer === '}' ? 'key' : 'value'
240
+ } else if (ch === ':') {
241
+ const scope = scopes[scopes.length - 1]
242
+ if (scope !== undefined) scope.expecting = 'value'
243
+ } else if (!isJsonSpace(ch) && scopes[scopes.length - 1]?.expecting === 'value') {
244
+ // Invalid bare values are not quote ambiguity: do not turn an earlier
245
+ // finished string into a container for `broken` / `undefined` tokens.
246
+ let j = i + 1
247
+ while (j < raw.length && !isJsonSpace(raw[j]) && !'{}[],:"'.includes(raw[j]!)) j++
248
+ const token = raw.slice(i, j)
249
+ if (!/^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)$/.test(token)) invalidValue = true
250
+ finishValue()
251
+ out += token
252
+ i = j - 1
253
+ continue
116
254
  }
117
255
  out += ch
118
256
  }
119
- if (repairs === 0) return null
120
- try {
121
- JSON.parse(out)
122
- return { text: out, repairs }
123
- } catch {
124
- return null
257
+ const unfinishedString = inString
258
+ if (complete) {
259
+ if (inString) { out += '"'; repairs++ }
260
+ while (scopes.length > 0) { out += scopes.pop()!.closer; repairs++ }
261
+ }
262
+ return { text: out, repairs, rootEnd, rawRootEnd, choices, unfinishedString, invalidValue }
263
+ }
264
+
265
+ /** Find an authoritative, already-valid object/array prefix without repair. */
266
+ function originalJsonPrefix(raw: string): string | null {
267
+ const scopes: string[] = []
268
+ let inString = false
269
+ let escaped = false
270
+ let started = false
271
+ for (let i = 0; i < raw.length; i++) {
272
+ const ch = raw[i]!
273
+ if (escaped) { escaped = false; continue }
274
+ if (inString) {
275
+ if (ch === '\\') escaped = true
276
+ else if (ch === '"') inString = false
277
+ continue
278
+ }
279
+ if (!started) {
280
+ if (isJsonSpace(ch)) continue
281
+ if (ch !== '{' && ch !== '[') return null
282
+ started = true
283
+ }
284
+ if (ch === '"') { inString = true; continue }
285
+ if (ch === '{' || ch === '[') scopes.push(ch === '{' ? '}' : ']')
286
+ else if (ch === '}' || ch === ']') {
287
+ if (scopes.pop() !== ch) return null
288
+ if (scopes.length === 0) {
289
+ const text = raw.slice(0, i + 1).trimEnd()
290
+ try { JSON.parse(text); return text } catch { return null }
291
+ }
292
+ }
293
+ }
294
+ return null
295
+ }
296
+
297
+ /**
298
+ * Prefer real terminators; if the resulting whole body fails, try interpreting
299
+ * a bounded number of ambiguous value quotes as content. Each attempt is an
300
+ * iterative scan, not a recursive parser; no engine-specific error offset is
301
+ * needed. Fixed attempts/lookahead give O(n) time and O(n) working storage.
302
+ */
303
+ function scanFenceJson(raw: string, complete: boolean): { text: string; repairs: number } | null {
304
+ // A genuine original root is authoritative. Never reinterpret its closing
305
+ // quote to absorb a second object or prose, even during speculative repair.
306
+ const original = originalJsonPrefix(raw)
307
+ if (original !== null) return complete ? { text: original, repairs: 1 } : null
308
+ const pending: number[][] = [[]]
309
+ let prefix: { text: string; repairs: number; rawEnd: number } | null = null
310
+ for (let attempt = 0; attempt < MAX_QUOTE_ATTEMPTS && pending.length > 0; attempt++) {
311
+ const forced = pending.pop()!
312
+ const scanned = scanFenceCandidate(raw, complete, new Set(forced))
313
+ // A speculative branch must find a real closing quote. Appending one to
314
+ // that branch could swallow malformed sibling fields into invented text.
315
+ if (scanned.repairs > 0 && !(forced.length > 0 && scanned.unfinishedString)) {
316
+ try {
317
+ JSON.parse(scanned.text)
318
+ return { text: scanned.text, repairs: scanned.repairs }
319
+ } catch { /* try a different bounded quote interpretation */ }
320
+ }
321
+ // Validate possible prefixes now, but do not adopt one until all whole-
322
+ // body alternatives have failed. Prefer the one that retains the most
323
+ // source text, rather than an earlier quote mistaken for a root end.
324
+ if (complete && scanned.rootEnd > 0 && (prefix === null || scanned.rawRootEnd > prefix.rawEnd)) {
325
+ const text = scanned.text.slice(0, scanned.rootEnd).trimEnd()
326
+ try {
327
+ JSON.parse(text)
328
+ prefix = { text, repairs: scanned.repairs + 1, rawEnd: scanned.rawRootEnd }
329
+ } catch { /* a balanced but invalid prefix cannot be adopted */ }
330
+ }
331
+ if (scanned.invalidValue) continue
332
+ const last = forced[forced.length - 1] ?? -1
333
+ for (const quote of scanned.choices) {
334
+ if (quote > last) pending.push([...forced, quote])
335
+ }
336
+ // Newer decisions are tried first. Discard older pending branches when
337
+ // the fixed budget is full instead of accumulating an exponential tree.
338
+ if (pending.length > MAX_QUOTE_ATTEMPTS) pending.splice(0, pending.length - MAX_QUOTE_ATTEMPTS)
339
+ }
340
+ // Balanced-prefix adoption remains strictly tier-2 / settled-only, and only
341
+ // after whole-body repair attempts have failed.
342
+ if (prefix !== null) return { text: prefix.text, repairs: prefix.repairs }
343
+ return null
344
+ }
345
+
346
+ /**
347
+ * 取**第一个平衡根值**的文本(丢弃其后的杂字符);没有平衡根时返回 null。
348
+ *
349
+ * 与 {@link completeFenceJson} 里的前缀回退同源,但**只做裁剪、不做结构补全**:
350
+ * 内容识别用它来容忍「合法 JSON + 尾部泄漏文本」(真实样本:模型把自己的工具调用
351
+ * 模板泄漏在 JSON 之后,且围栏没闭合)。根值正好结束在末尾时返回 null —— 那种情况
352
+ * `JSON.parse` 本来就会成功。
353
+ *
354
+ * @param text - 候选正文。
355
+ * @returns 平衡根前缀;无可裁剪内容时 null。
356
+ */
357
+ export function trimToBalancedRoot(text: string): string | null {
358
+ const stack: Array<'}' | ']'> = []
359
+ let inString = false
360
+ let escaped = false
361
+ let started = false
362
+ for (let i = 0; i < text.length; i += 1) {
363
+ const ch = text[i]
364
+ if (escaped) {
365
+ escaped = false
366
+ continue
367
+ }
368
+ if (inString) {
369
+ if (ch === '\\') escaped = true
370
+ else if (ch === '"') inString = false
371
+ continue
372
+ }
373
+ if (ch === '"') {
374
+ inString = true
375
+ started = true
376
+ continue
377
+ }
378
+ if (ch === '{') {
379
+ stack.push('}')
380
+ started = true
381
+ continue
382
+ }
383
+ if (ch === '[') {
384
+ stack.push(']')
385
+ started = true
386
+ continue
387
+ }
388
+ if (ch === '}' || ch === ']') {
389
+ // 不匹配的闭括号 ⇒ 这份正文结构上已经坏了,不做"裁剪"式的猜测。
390
+ if (stack[stack.length - 1] !== ch) return null
391
+ stack.pop()
392
+ if (stack.length === 0 && started) {
393
+ if (i + 1 >= text.length) return null
394
+ return text.slice(0, i + 1).trim()
395
+ }
396
+ }
125
397
  }
398
+ return null
126
399
  }
127
400
 
128
401
  /**
@@ -230,8 +503,8 @@ function rewriteTetrisTableColumns(raw: string): { text: string; repairs: number
230
503
  * client uses the host-provided fence source; the validate tool is by
231
504
  * definition pre-emission), so a streaming half can never flash premature UI.
232
505
  *
233
- * ONE unified scan: the tier-1 fixes (quote escaping + trailing-comma drops)
234
- * are folded into the same pass, so bodies that combine BOTH defect classes
506
+ * One shared scan implementation folds the tier-1 fixes (quote escaping +
507
+ * trailing-comma drops) into structural completion, so bodies with BOTH defects
235
508
  * (a trailing comma AND a missing closer) heal in one shot — the old
236
509
  * two-phase chain lost tier-1's partial work when its whole-body parse
237
510
  * failed, and re-scanning the raw text could not compose the repairs.
@@ -259,97 +532,5 @@ export function completeFenceJson(raw: string): { text: string; repairs: number
259
532
  if (scanned === null) return null
260
533
  return { text: scanned.text, repairs: scanned.repairs + tetris.repairs }
261
534
  }
262
- let out = ''
263
- const stack: Array<'}' | ']'> = []
264
- let inString = false
265
- let escaped = false
266
- let repairs = 0
267
- for (let i = 0; i < raw.length; i++) {
268
- const ch = raw[i]
269
- if (escaped) {
270
- out += ch
271
- escaped = false
272
- continue
273
- }
274
- if (inString) {
275
- if (ch === '\\') {
276
- out += ch
277
- escaped = true
278
- continue
279
- }
280
- if (ch !== '"') {
281
- out += ch
282
- continue
283
- }
284
- // Inside a string: is this quote the terminator? Look past whitespace.
285
- let j = i + 1
286
- while (j < raw.length && (raw[j] === ' ' || raw[j] === '\t' || raw[j] === '\n' || raw[j] === '\r')) j++
287
- const next = j < raw.length ? raw[j] : ''
288
- if (next === ',' || next === ']' || next === '}' || next === ':' || next === '') {
289
- inString = false
290
- out += ch
291
- } else {
292
- // Free-standing quote inside a value → escape it (tier-1 fix).
293
- out += '\\"'
294
- repairs++
295
- }
296
- continue
297
- }
298
- if (ch === '"') {
299
- inString = true
300
- out += ch
301
- continue
302
- }
303
- if (ch === '{') {
304
- stack.push('}')
305
- out += ch
306
- continue
307
- }
308
- if (ch === '[') {
309
- stack.push(']')
310
- out += ch
311
- continue
312
- }
313
- if (ch === '}' || ch === ']') {
314
- if (stack[stack.length - 1] === ch) {
315
- stack.pop()
316
- out += ch
317
- } else {
318
- // Mismatched closer (e.g. a `]` mistyped as `}`, or a duplicated
319
- // terminator): no legal JSON can contain it here, so skip it and let
320
- // the remaining closers pair up again. The whole-body parse below is
321
- // the final arbiter — if skipping made things worse, nothing is
322
- // adopted and the diagnostic banner stays.
323
- repairs++
324
- }
325
- continue
326
- }
327
- if (ch === ',') {
328
- // Trailing comma before `}` / `]` / end of input → drop it (tier-1 fix).
329
- let j = i + 1
330
- while (j < raw.length && (raw[j] === ' ' || raw[j] === '\t' || raw[j] === '\n' || raw[j] === '\r')) j++
331
- const next = j < raw.length ? raw[j] : ''
332
- if (next === '}' || next === ']' || next === '') {
333
- repairs++
334
- continue
335
- }
336
- }
337
- out += ch
338
- }
339
- if (inString) {
340
- // Unterminated string value → close it.
341
- out += '"'
342
- repairs++
343
- }
344
- while (stack.length > 0) {
345
- out += stack.pop()
346
- repairs++
347
- }
348
- if (repairs === 0) return null
349
- try {
350
- JSON.parse(out)
351
- return { text: out, repairs }
352
- } catch {
353
- return null
354
- }
535
+ return scanFenceJson(raw, true)
355
536
  }