diff --git a/src/services/jsonc.ts b/src/services/jsonc.ts index 7959907d..15a7f527 100644 --- a/src/services/jsonc.ts +++ b/src/services/jsonc.ts @@ -76,10 +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++; } - // Remove trailing commas before } or ] - return result.replace(/,\s*([}\]])/g, "$1"); + return result; +} + +/** 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); + + if (/\s/.test(c)) { + next++; + 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; + } + + return c === "}" || c === "]"; + } + return false; } 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); + }); +});