From c41896f5019482844b96dec2b2d1c1f030cafb28 Mon Sep 17 00:00:00 2001 From: Kexin Chen <22360275@zju.edu.cn> Date: Thu, 15 Oct 2026 22:25:59 +0800 Subject: [PATCH 1/2] fix(config): preserve quoted JSONC strings --- src/services/jsonc.ts | 38 ++++++++++++++++++++++++++++++-- tests/config-resolution.test.ts | 24 ++++++++++++++++++++ tests/jsonc.test.ts | 39 +++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 tests/jsonc.test.ts diff --git a/src/services/jsonc.ts b/src/services/jsonc.ts index 7959907d..3af27bb0 100644 --- a/src/services/jsonc.ts +++ b/src/services/jsonc.ts @@ -80,6 +80,40 @@ export function stripJsoncComments(content: string): string { i++; } - // Remove trailing commas before } or ] - return result.replace(/,\s*([}\]])/g, "$1"); + return removeTrailingCommas(result); +} + +function removeTrailingCommas(content: string): string { + let result = ""; + let inString = false; + let escaped = false; + + for (let i = 0; i < content.length; i++) { + const char = content[i]; + if (inString) { + result += char; + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + } else if (char === ",") { + let next = i + 1; + while (next < content.length && /\s/.test(content.charAt(next))) next++; + if (content[next] === "}" || content[next] === "]") { + i = next - 1; + continue; + } + } + result += char; + } + + return result; } diff --git a/tests/config-resolution.test.ts b/tests/config-resolution.test.ts index f2b6f51e..e5b92ddf 100644 --- a/tests/config-resolution.test.ts +++ b/tests/config-resolution.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, spyOn } from "bun:test"; import * as fs from "node:fs"; +import { buildMemoryProviderConfig } from "../src/services/ai/provider-config.js"; +import { applySafeExtraParams } from "../src/services/ai/providers/base-provider.js"; import { initConfig, CONFIG, @@ -53,6 +55,28 @@ describe("project-scoped config resolution", () => { expect(CONFIG.opencodeModel).toBe("project-model"); }); + it("preserves JSONC string values through configuration loading and provider parameters", () => { + existsSpy = spyOn(fs, "existsSync").mockImplementation((p: unknown) => + normalizePath(p).includes(".config/opencode/opencode-mem") + ); + readSpy = spyOn(fs, "readFileSync").mockReturnValue(`{ + "storagePath": "/tmp/cache,]v2", + "memoryModel": "local-model", + "memoryApiUrl": "https://example.invalid/v1", + "memoryApiKey": "fixture-only-key", + "memoryExtraParams": {"stop": [",}", ",]",],}, + }`); + + initConfig("/some/project"); + + expect(CONFIG.storagePath).toBe("/tmp/cache,]v2"); + expect(CONFIG.memoryExtraParams).toEqual({ stop: [",}", ",]"] }); + const providerConfig = buildMemoryProviderConfig(CONFIG); + const requestBody: Record = {}; + applySafeExtraParams(requestBody, providerConfig.extraParams ?? {}); + expect(requestBody.stop).toEqual([",}", ",]"]); + }); + it("keeps automatic cleanup policy under global configuration", () => { existsSpy = spyOn(fs, "existsSync").mockReturnValue(true); readSpy = spyOn(fs, "readFileSync").mockImplementation((p) => { diff --git a/tests/jsonc.test.ts b/tests/jsonc.test.ts new file mode 100644 index 00000000..3d4b726e --- /dev/null +++ b/tests/jsonc.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "bun:test"; +import { stripJsoncComments } from "../src/services/jsonc.js"; + +describe("JSONC string preservation", () => { + for (const value of [",}", ",]", ", }", ", ]", 'say "a, }b"', "\\path,]end"]) { + it(`preserves ${JSON.stringify(value)} inside string values and keys`, () => { + const expected = { [value]: value }; + expect(JSON.parse(stripJsoncComments(JSON.stringify(expected)))).toEqual(expected); + }); + } + + it("handles escaped backslashes before a closing quote", () => { + const expected = { path: "C:\\cache\\", next: "value" }; + expect(JSON.parse(stripJsoncComments(JSON.stringify(expected)))).toEqual(expected); + }); + + it("keeps comment-like text inside strings", () => { + const expected = { url: "https://example.invalid/a//b", text: "/* keep, } */" }; + expect(JSON.parse(stripJsoncComments(JSON.stringify(expected)))).toEqual(expected); + }); + + it("removes structural trailing commas separated by comments and whitespace", () => { + const input = `{ + // A line comment + "stop": [",}", ",]", /* before array end */ ], + "nested": {"enabled": true, // before object end + }, + }`; + expect(JSON.parse(stripJsoncComments(input))).toEqual({ + stop: [",}", ",]"], + nested: { enabled: true }, + }); + }); + + it("keeps ordinary separators between array and object entries", () => { + const expected = { first: [1, 2, 3], second: { a: true, b: false } }; + expect(JSON.parse(stripJsoncComments(JSON.stringify(expected)))).toEqual(expected); + }); +}); From f1e4da7972f35e868ecc059f31f6505caf9cd9bc Mon Sep 17 00:00:00 2001 From: EyJunge1 <149941075+EyJunge1@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:44:18 +0200 Subject: [PATCH 2/2] fix(config): fold JSONC trailing-comma strip into one pass Avoid a second string scanner after comment stripping; detect trailing commas in the existing state machine while peeking past whitespace and comments. --- src/services/jsonc.ts | 61 ++++++++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/src/services/jsonc.ts b/src/services/jsonc.ts index 3af27bb0..15a7f527 100644 --- a/src/services/jsonc.ts +++ b/src/services/jsonc.ts @@ -76,44 +76,51 @@ export function stripJsoncComments(content: string): string { continue; } + // Drop structural trailing commas (comma before } or ], allowing + // whitespace and comments in between). + if (char === "," && isTrailingComma(content, i)) { + i++; + continue; + } + result += char; i++; } - return removeTrailingCommas(result); + return result; } -function removeTrailingCommas(content: string): string { - let result = ""; - let inString = false; - let escaped = false; +/** True when the comma at `commaIndex` is followed only by whitespace/comments then } or ]. */ +function isTrailingComma(content: string, commaIndex: number): boolean { + let next = commaIndex + 1; + while (next < content.length) { + const c = content.charAt(next); + const n = content.charAt(next + 1); - for (let i = 0; i < content.length; i++) { - const char = content[i]; - if (inString) { - result += char; - if (escaped) { - escaped = false; - } else if (char === "\\") { - escaped = true; - } else if (char === '"') { - inString = false; - } + if (/\s/.test(c)) { + next++; continue; } - if (char === '"') { - inString = true; - } else if (char === ",") { - let next = i + 1; - while (next < content.length && /\s/.test(content.charAt(next))) next++; - if (content[next] === "}" || content[next] === "]") { - i = next - 1; - continue; + if (c === "/" && n === "/") { + next += 2; + while (next < content.length && content.charAt(next) !== "\n") next++; + continue; + } + + if (c === "/" && n === "*") { + next += 2; + while ( + next + 1 < content.length && + !(content.charAt(next) === "*" && content.charAt(next + 1) === "/") + ) { + next++; } + next += 2; + continue; } - result += char; - } - return result; + return c === "}" || c === "]"; + } + return false; }