@changfenhuang/dsh-genui 0.11.2 → 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.
- package/CHANGELOG.md +25 -0
- package/README.md +13 -4
- package/README.zh-CN.md +13 -4
- package/SKILL.md +16 -4
- package/lib/assets/standalone-runtime.js +7 -6
- package/lib/client.js +15 -17
- package/lib/index.js +956 -354
- package/lib/types/client/blocks/forms.d.ts +5 -6
- package/lib/types/client/blocks/state.d.ts +2 -11
- package/lib/types/client/spec.d.ts +8 -11
- package/lib/types/client/submission-registry.d.ts +51 -0
- package/lib/types/client/table-details.d.ts +20 -0
- package/lib/types/client/walk-spec.d.ts +3 -0
- package/lib/types/plugin/fence-feedback.d.ts +50 -11
- package/lib/types/plugin/index.d.ts +3 -3
- package/lib/types/shared/fence-repair.d.ts +18 -7
- package/package.json +20 -19
- package/src/client/GenuiBlock.module.css +65 -1
- package/src/client/GenuiBlock.tsx +7 -14
- package/src/client/blocks/charts.tsx +41 -8
- package/src/client/blocks/forms.tsx +26 -48
- package/src/client/blocks/render-node.tsx +1 -1
- package/src/client/blocks/state.ts +2 -12
- package/src/client/dom-fence.tsx +94 -21
- package/src/client/genui-runtime/diagnostics.ts +20 -0
- package/src/client/genui-runtime/normalize.ts +28 -9
- package/src/client/guard.ts +93 -151
- package/src/client/inline.ts +11 -4
- package/src/client/spec.ts +8 -11
- package/src/client/submission-registry.ts +150 -0
- package/src/client/table-details.ts +151 -0
- package/src/client/walk-spec.ts +43 -0
- package/src/plugin/fence-feedback.ts +372 -53
- package/src/plugin/index.ts +7 -6
- package/src/shared/fence-repair.ts +309 -128
|
@@ -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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
83
|
-
out += ch
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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]
|
|
111
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
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
|
-
*
|
|
234
|
-
*
|
|
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
|
-
|
|
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
|
}
|