diff --git a/openspec/changes/agent-codex-replace-custom-semver-jsonc-stdin-helper-2026-04-22-18-06/proposal.md b/openspec/changes/agent-codex-replace-custom-semver-jsonc-stdin-helper-2026-04-22-18-06/proposal.md new file mode 100644 index 00000000..7abdaec6 --- /dev/null +++ b/openspec/changes/agent-codex-replace-custom-semver-jsonc-stdin-helper-2026-04-22-18-06/proposal.md @@ -0,0 +1,24 @@ +## Why + +- `src/toolchain/index.js` and `src/cli/main.js` still carry duplicate custom version-comparison and stdin line-reader helpers. +- The custom semver logic ignores prerelease ordering, and the single-byte stdin reader splits multi-byte characters. +- `src/scaffold/index.js` still hand-rolls JSONC stripping even though the repo only needs robust JSONC parsing for settings-style files. + +## What Changes + +- Add shared core helpers for semver comparison and stdin line reading, then route both `src/toolchain/index.js` and `src/cli/main.js` through them. +- Replace the custom JSONC stripping/parsing path in `src/scaffold/index.js` with `jsonc-parser`. +- Add focused regression coverage for prerelease version ordering, multi-byte stdin reads, and JSONC parsing with comments/trailing commas. + +## Acceptance Criteria + +- Prerelease ordering comes from shared semver helpers under `src/core`, so `1.2.3` sorts after `1.2.3-alpha.4` and `1.2.3-alpha.10` sorts after `1.2.3-alpha.2`. +- Interactive yes/no prompts keep reading a single logical line without corrupting multi-byte UTF-8 input. +- JSONC parsing for scaffold-owned settings files uses `jsonc-parser` and preserves string literals that contain comment-like text. +- CLI commands keep their existing names, prompts, and output wording on `status`, `release`, and `setup` flows. + +## Impact + +- Primary files: `package.json`, `package-lock.json`, `src/core/**`, `src/toolchain/index.js`, `src/cli/main.js`, `src/scaffold/index.js`, and targeted tests. +- Main risk is behavior drift in `gx status`, self-update prompts, README-driven release selection, and VS Code settings repair, so verification stays focused on those paths. +- This is an internal cleanup/correctness pass only; command names, output wording, and managed-file behavior must stay stable. diff --git a/openspec/changes/agent-codex-replace-custom-semver-jsonc-stdin-helper-2026-04-22-18-06/specs/cli-modularization/spec.md b/openspec/changes/agent-codex-replace-custom-semver-jsonc-stdin-helper-2026-04-22-18-06/specs/cli-modularization/spec.md new file mode 100644 index 00000000..fd7a7280 --- /dev/null +++ b/openspec/changes/agent-codex-replace-custom-semver-jsonc-stdin-helper-2026-04-22-18-06/specs/cli-modularization/spec.md @@ -0,0 +1,14 @@ +## MODIFIED Requirements + +### Requirement: Module seams mirror operational responsibility +The CLI SHALL keep version comparison, interactive stdin reading, and JSONC parsing in single-sourced shared helpers instead of redefining custom parser logic in command modules. + +#### Scenario: Toolchain and release flows reuse the same version/stdin helpers +- **WHEN** maintainers inspect `src/toolchain/index.js` and `src/cli/main.js` +- **THEN** semver comparison and interactive stdin line reading come from shared helpers under `src/core` +- **AND** `src/toolchain/index.js` and `src/cli/main.js` do not reintroduce local copies of those helpers. + +#### Scenario: Scaffold JSONC parsing uses a standards-based parser +- **WHEN** Guardex reads repo-owned JSONC-style files such as shared VS Code settings +- **THEN** comments and trailing commas are parsed through `jsonc-parser` +- **AND** escaped string content is preserved without custom comment-stripping logic. diff --git a/openspec/changes/agent-codex-replace-custom-semver-jsonc-stdin-helper-2026-04-22-18-06/tasks.md b/openspec/changes/agent-codex-replace-custom-semver-jsonc-stdin-helper-2026-04-22-18-06/tasks.md new file mode 100644 index 00000000..d5c70185 --- /dev/null +++ b/openspec/changes/agent-codex-replace-custom-semver-jsonc-stdin-helper-2026-04-22-18-06/tasks.md @@ -0,0 +1,37 @@ +## Definition of Done + +This change is complete only when all of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks, add a `BLOCKED:` line under section 4 and stop. + +## Handoff + +- Handoff: change=`agent-codex-replace-custom-semver-jsonc-stdin-helper-2026-04-22-18-06`; branch=`agent/codex/replace-custom-semver-jsonc-stdin-helper-2026-04-22-18-06`; scope=`package.json`, `package-lock.json`, `src/core/**`, `src/toolchain/index.js`, `src/cli/main.js`, `src/scaffold/index.js`, and targeted tests; action=`replace the remaining custom semver/jsonc/stdin helpers with shared, standards-based implementations without changing CLI behavior`. + +## 1. Specification + +- [x] 1.1 Capture the bounded cleanup scope and acceptance criteria for the semver/jsonc/stdin helper replacement. +- [x] 1.2 Add a `cli-modularization` spec delta that keeps version/stdin helper ownership single-sourced and JSONC parsing standards-based. + +## 2. Implementation + +- [x] 2.1 Add focused regression coverage for prerelease version ordering, multi-byte stdin reads, and JSONC parsing before deleting the custom helper code. +- [x] 2.2 Add shared core helpers for semver comparison and stdin line reading, then route `src/toolchain/index.js` and `src/cli/main.js` through them. +- [x] 2.3 Replace the custom JSONC stripping/parsing code in `src/scaffold/index.js` with `jsonc-parser`. +- [x] 2.4 Remove the now-duplicated local helper implementations from `src/cli/main.js` and `src/toolchain/index.js`. + +## 3. Verification + +- [x] 3.1 Run `node --check src/core/versions.js src/core/stdin.js src/toolchain/index.js src/scaffold/index.js src/cli/main.js`. +- [x] 3.2 Run targeted tests for the touched surfaces (`node --test test/status.test.js test/release.test.js test/setup.test.js test/core-version.test.js test/core-stdin.test.js test/scaffold-jsonc.test.js`). +- [x] 3.3 Run `npm test`. +- [x] 3.4 Run `openspec validate agent-codex-replace-custom-semver-jsonc-stdin-helper-2026-04-22-18-06 --type change --strict`. +- [x] 3.5 Run `openspec validate --specs`. + +## 4. Cleanup + +- [ ] 4.1 Run `gx branch finish --branch agent/codex/replace-custom-semver-jsonc-stdin-helper-2026-04-22-18-06 --base main --via-pr --wait-for-merge --cleanup`. +- [ ] 4.2 Record PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is removed and no local/remote refs remain for the branch. diff --git a/package-lock.json b/package-lock.json index 8a9d5853..c9da059b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "@imdeadpool/guardex", "version": "7.0.22", "license": "MIT", + "dependencies": { + "jsonc-parser": "^3.3.1", + "semver": "^7.7.4" + }, "bin": { "gitguardex": "bin/multiagent-safety.js", "guardex": "bin/multiagent-safety.js", @@ -47,6 +51,12 @@ "node": ">=8.0.0" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -63,6 +73,18 @@ } ], "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } } } } diff --git a/package.json b/package.json index bcbabe7f..c9cdc501 100644 --- a/package.json +++ b/package.json @@ -69,5 +69,9 @@ }, "devDependencies": { "fast-check": "^3.23.2" + }, + "dependencies": { + "jsonc-parser": "^3.3.1", + "semver": "^7.7.4" } } diff --git a/src/cli/main.js b/src/cli/main.js index e9fbe728..56e99e90 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -113,6 +113,12 @@ const { runReviewBotCommand, invokePackageAsset, } = require('../core/runtime'); +const { + parseVersionString, + compareParsedVersions, + isNewerVersion, +} = require('../core/versions'); +const { readSingleLineFromStdin } = require('../core/stdin'); const { normalizeManagedForcePath, parseCommonArgs, @@ -169,9 +175,6 @@ const { removeLegacyManagedRepoFile, ensureAgentsSnippet, ensureManagedGitignore, - stripJsonComments, - stripJsonTrailingCommas, - parseJsonObjectLikeFile, buildRepoVscodeSettings, ensureRepoVscodeSettings, configureHooks, @@ -867,44 +870,6 @@ function isInteractiveTerminal() { return Boolean(process.stdin.isTTY && process.stdout.isTTY); } -const stdinWaitArray = new Int32Array(new SharedArrayBuffer(4)); - -function sleepSyncMs(milliseconds) { - Atomics.wait(stdinWaitArray, 0, 0, milliseconds); -} - -function readSingleLineFromStdin() { - let input = ''; - const buffer = Buffer.alloc(1); - - while (true) { - let bytesRead = 0; - try { - bytesRead = fs.readSync(process.stdin.fd, buffer, 0, 1); - } catch (error) { - if (error && ['EAGAIN', 'EWOULDBLOCK', 'EINTR'].includes(error.code)) { - sleepSyncMs(15); - continue; - } - return input; - } - - if (bytesRead === 0) { - if (process.stdin.isTTY) { - sleepSyncMs(15); - continue; - } - return input; - } - - const char = buffer.toString('utf8', 0, bytesRead); - if (char === '\n' || char === '\r') { - return input; - } - input += char; - } -} - function parseAutoApproval(name) { const raw = process.env[name]; if (raw == null) return null; @@ -983,38 +948,6 @@ function describeGuardexRepoToggle(toggle) { return `${toggle.source} (${GUARDEX_REPO_TOGGLE_ENV}=${toggle.raw})`; } -function parseVersionString(version) { - const match = String(version || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)/); - if (!match) return null; - return [ - Number.parseInt(match[1], 10), - Number.parseInt(match[2], 10), - Number.parseInt(match[3], 10), - ]; -} - -function compareParsedVersions(left, right) { - if (!left || !right) return 0; - for (let index = 0; index < Math.max(left.length, right.length); index += 1) { - const leftValue = left[index] || 0; - const rightValue = right[index] || 0; - if (leftValue > rightValue) return 1; - if (leftValue < rightValue) return -1; - } - return 0; -} - -function isNewerVersion(latest, current) { - const latestParts = parseVersionString(latest); - const currentParts = parseVersionString(current); - - if (!latestParts || !currentParts) { - return String(latest || '').trim() !== String(current || '').trim(); - } - - return compareParsedVersions(latestParts, currentParts) > 0; -} - function parseNpmVersionOutput(stdout) { const trimmed = String(stdout || '').trim(); if (!trimmed) return ''; diff --git a/src/core/stdin.js b/src/core/stdin.js new file mode 100644 index 00000000..57a57975 --- /dev/null +++ b/src/core/stdin.js @@ -0,0 +1,52 @@ +const fs = require('node:fs'); +const { StringDecoder } = require('node:string_decoder'); + +const stdinWaitArray = new Int32Array(new SharedArrayBuffer(4)); + +function sleepSyncMs(milliseconds) { + Atomics.wait(stdinWaitArray, 0, 0, milliseconds); +} + +function readSingleLineFromStdin(options = {}) { + const fsModule = options.fsModule || fs; + const input = options.input || process.stdin; + const sleepSync = options.sleepSync || sleepSyncMs; + const retryDelayMs = options.retryDelayMs == null ? 15 : options.retryDelayMs; + const buffer = Buffer.alloc(1); + const decoder = new StringDecoder('utf8'); + let text = ''; + + while (true) { + let bytesRead = 0; + try { + bytesRead = fsModule.readSync(input.fd, buffer, 0, 1); + } catch (error) { + if (error && ['EAGAIN', 'EWOULDBLOCK', 'EINTR'].includes(error.code)) { + sleepSync(retryDelayMs); + continue; + } + return text + decoder.end(); + } + + if (bytesRead === 0) { + if (input.isTTY) { + sleepSync(retryDelayMs); + continue; + } + return text + decoder.end(); + } + + const char = decoder.write(buffer.subarray(0, bytesRead)); + if (!char) { + continue; + } + if (char === '\n' || char === '\r') { + return text; + } + text += char; + } +} + +module.exports = { + readSingleLineFromStdin, +}; diff --git a/src/core/versions.js b/src/core/versions.js new file mode 100644 index 00000000..58423e53 --- /dev/null +++ b/src/core/versions.js @@ -0,0 +1,33 @@ +const semver = require('semver'); + +function parseVersionString(version) { + const trimmed = String(version || '').trim(); + if (!trimmed) { + return null; + } + return semver.valid(trimmed) || null; +} + +function compareParsedVersions(left, right) { + if (!left || !right) { + return 0; + } + return semver.compare(left, right); +} + +function isNewerVersion(latest, current) { + const latestParts = parseVersionString(latest); + const currentParts = parseVersionString(current); + + if (!latestParts || !currentParts) { + return String(latest || '').trim() !== String(current || '').trim(); + } + + return semver.gt(latestParts, currentParts); +} + +module.exports = { + parseVersionString, + compareParsedVersions, + isNewerVersion, +}; diff --git a/src/scaffold/index.js b/src/scaffold/index.js index 3ac4c55e..1c12980e 100644 --- a/src/scaffold/index.js +++ b/src/scaffold/index.js @@ -26,6 +26,7 @@ const { EXECUTABLE_RELATIVE_PATHS, CRITICAL_GUARDRAIL_PATHS, } = require('../context'); +const { parse: parseJsonc, printParseErrorCode } = require('jsonc-parser'); const { run } = require('../core/runtime'); function ensureParentDir(repoRoot, filePath, dryRun) { @@ -576,121 +577,13 @@ function ensureManagedGitignore(repoRoot, dryRun) { return { status: 'updated', file: '.gitignore', note: 'appended gitguardex-managed entries' }; } -function stripJsonComments(source) { - let result = ''; - let inString = false; - let escapeNext = false; - let inLineComment = false; - let inBlockComment = false; - - for (let index = 0; index < source.length; index += 1) { - const current = source[index]; - const next = source[index + 1]; - - if (inLineComment) { - if (current === '\n' || current === '\r') { - inLineComment = false; - result += current; - } - continue; - } - - if (inBlockComment) { - if (current === '*' && next === '/') { - inBlockComment = false; - index += 1; - continue; - } - if (current === '\n' || current === '\r') { - result += current; - } - continue; - } - - if (inString) { - result += current; - if (escapeNext) { - escapeNext = false; - } else if (current === '\\') { - escapeNext = true; - } else if (current === '"') { - inString = false; - } - continue; - } - - if (current === '"') { - inString = true; - result += current; - continue; - } - - if (current === '/' && next === '/') { - inLineComment = true; - index += 1; - continue; - } - - if (current === '/' && next === '*') { - inBlockComment = true; - index += 1; - continue; - } - - result += current; - } - - return result; -} - -function stripJsonTrailingCommas(source) { - let result = ''; - let inString = false; - let escapeNext = false; - - for (let index = 0; index < source.length; index += 1) { - const current = source[index]; - - if (inString) { - result += current; - if (escapeNext) { - escapeNext = false; - } else if (current === '\\') { - escapeNext = true; - } else if (current === '"') { - inString = false; - } - continue; - } - - if (current === '"') { - inString = true; - result += current; - continue; - } - - if (current === ',') { - let lookahead = index + 1; - while (lookahead < source.length && /\s/.test(source[lookahead])) { - lookahead += 1; - } - if (source[lookahead] === '}' || source[lookahead] === ']') { - continue; - } - } - - result += current; - } - - return result; -} - function parseJsonObjectLikeFile(source, relativePath) { - let parsed; - try { - parsed = JSON.parse(stripJsonTrailingCommas(stripJsonComments(source))); - } catch (error) { - throw new Error(`Unable to parse ${relativePath} as JSON or JSONC: ${error.message}`); + const errors = []; + const parsed = parseJsonc(source, errors, { allowTrailingComma: true }); + + if (errors.length > 0) { + const formattedErrors = errors.map((entry) => printParseErrorCode(entry.error)).join(', '); + throw new Error(`Unable to parse ${relativePath} as JSON or JSONC: ${formattedErrors}`); } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { @@ -871,8 +764,6 @@ module.exports = { removeLegacyManagedRepoFile, ensureAgentsSnippet, ensureManagedGitignore, - stripJsonComments, - stripJsonTrailingCommas, parseJsonObjectLikeFile, buildRepoVscodeSettings, ensureRepoVscodeSettings, diff --git a/src/toolchain/index.js b/src/toolchain/index.js index 0a6f871a..dbe8ac72 100644 --- a/src/toolchain/index.js +++ b/src/toolchain/index.js @@ -17,50 +17,18 @@ const { envFlagIsTruthy, } = require('../context'); const { run } = require('../core/runtime'); +const { + parseVersionString, + compareParsedVersions, + isNewerVersion, +} = require('../core/versions'); +const { readSingleLineFromStdin } = require('../core/stdin'); const { colorize } = require('../output'); function isInteractiveTerminal() { return Boolean(process.stdin.isTTY && process.stdout.isTTY); } -const stdinWaitArray = new Int32Array(new SharedArrayBuffer(4)); - -function sleepSyncMs(milliseconds) { - Atomics.wait(stdinWaitArray, 0, 0, milliseconds); -} - -function readSingleLineFromStdin() { - let input = ''; - const buffer = Buffer.alloc(1); - - while (true) { - let bytesRead = 0; - try { - bytesRead = fs.readSync(process.stdin.fd, buffer, 0, 1); - } catch (error) { - if (error && ['EAGAIN', 'EWOULDBLOCK', 'EINTR'].includes(error.code)) { - sleepSyncMs(15); - continue; - } - return input; - } - - if (bytesRead === 0) { - if (process.stdin.isTTY) { - sleepSyncMs(15); - continue; - } - return input; - } - - const char = buffer.toString('utf8', 0, bytesRead); - if (char === '\n' || char === '\r') { - return input; - } - input += char; - } -} - function parseAutoApproval(name) { const raw = process.env[name]; if (raw == null) return null; @@ -70,38 +38,6 @@ function parseAutoApproval(name) { return null; } -function parseVersionString(version) { - const match = String(version || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)/); - if (!match) return null; - return [ - Number.parseInt(match[1], 10), - Number.parseInt(match[2], 10), - Number.parseInt(match[3], 10), - ]; -} - -function compareParsedVersions(left, right) { - if (!left || !right) return 0; - for (let index = 0; index < Math.max(left.length, right.length); index += 1) { - const leftValue = left[index] || 0; - const rightValue = right[index] || 0; - if (leftValue > rightValue) return 1; - if (leftValue < rightValue) return -1; - } - return 0; -} - -function isNewerVersion(latest, current) { - const latestParts = parseVersionString(latest); - const currentParts = parseVersionString(current); - - if (!latestParts || !currentParts) { - return String(latest || '').trim() !== String(current || '').trim(); - } - - return compareParsedVersions(latestParts, currentParts) > 0; -} - function parseNpmVersionOutput(stdout) { const trimmed = String(stdout || '').trim(); if (!trimmed) return ''; diff --git a/test/cli-args-dispatch.test.js b/test/cli-args-dispatch.test.js index 366c2685..b71a2b02 100644 --- a/test/cli-args-dispatch.test.js +++ b/test/cli-args-dispatch.test.js @@ -107,6 +107,7 @@ test('parseAgentsArgs applies interval overrides and validates the subcommand', reviewIntervalSeconds: 15, cleanupIntervalSeconds: 45, idleMinutes: 12, + pid: null, }); }); diff --git a/test/core-stdin.test.js b/test/core-stdin.test.js new file mode 100644 index 00000000..ec52a1b1 --- /dev/null +++ b/test/core-stdin.test.js @@ -0,0 +1,54 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { readSingleLineFromStdin } = require('../src/core/stdin'); + +function createReadSyncFromBuffer(source) { + const bytes = Buffer.from(source, 'utf8'); + let index = 0; + + return { + fsModule: { + readSync(_fd, buffer, offset, length) { + if (index >= bytes.length) { + return 0; + } + const next = bytes.subarray(index, index + length); + next.copy(buffer, offset); + index += next.length; + return next.length; + }, + }, + getIndex() { + return index; + }, + getByteLength() { + return bytes.length; + }, + }; +} + +test('readSingleLineFromStdin preserves multi-byte characters', () => { + const { fsModule } = createReadSyncFromBuffer('žluťoučký\n'); + + const line = readSingleLineFromStdin({ + fsModule, + input: { fd: 0, isTTY: false }, + sleepSync() {}, + }); + + assert.equal(line, 'žluťoučký'); +}); + +test('readSingleLineFromStdin stops at the first newline without overreading later bytes', () => { + const source = createReadSyncFromBuffer('🌍\nrest of input'); + + const line = readSingleLineFromStdin({ + fsModule: source.fsModule, + input: { fd: 0, isTTY: false }, + sleepSync() {}, + }); + + assert.equal(line, '🌍'); + assert.equal(source.getIndex() < source.getByteLength(), true); +}); diff --git a/test/core-version.test.js b/test/core-version.test.js new file mode 100644 index 00000000..21c09652 --- /dev/null +++ b/test/core-version.test.js @@ -0,0 +1,29 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + parseVersionString, + compareParsedVersions, + isNewerVersion, +} = require('../src/core/versions'); + +test('parseVersionString normalizes valid semver strings including prereleases', () => { + assert.equal(parseVersionString(' v1.2.3-alpha.10 '), '1.2.3-alpha.10'); + assert.equal(parseVersionString('not-a-version'), null); +}); + +test('compareParsedVersions honors prerelease precedence', () => { + const alpha2 = parseVersionString('v1.2.3-alpha.2'); + const alpha10 = parseVersionString('v1.2.3-alpha.10'); + const stable = parseVersionString('v1.2.3'); + + assert.equal(compareParsedVersions(alpha2, alpha10) < 0, true); + assert.equal(compareParsedVersions(alpha10, stable) < 0, true); + assert.equal(compareParsedVersions(stable, alpha10) > 0, true); +}); + +test('isNewerVersion treats stable releases as newer than matching prereleases', () => { + assert.equal(isNewerVersion('v1.2.3', 'v1.2.3-alpha.4'), true); + assert.equal(isNewerVersion('v1.2.3-alpha.4', 'v1.2.3'), false); + assert.equal(isNewerVersion('v1.2.3-alpha.10', 'v1.2.3-alpha.2'), true); +}); diff --git a/test/scaffold-jsonc.test.js b/test/scaffold-jsonc.test.js new file mode 100644 index 00000000..1fd340c7 --- /dev/null +++ b/test/scaffold-jsonc.test.js @@ -0,0 +1,42 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { parseJsonObjectLikeFile } = require('../src/scaffold'); + +test('parseJsonObjectLikeFile accepts JSONC comments and trailing commas', () => { + const parsed = parseJsonObjectLikeFile( + `{ + // Keep VS Code style comments. + "folders": [ + "src", + ], + "enabled": true, + }`, + '.vscode/settings.json', + ); + + assert.deepEqual(parsed, { + folders: ['src'], + enabled: true, + }); +}); + +test('parseJsonObjectLikeFile preserves string content that looks like comment syntax', () => { + const parsed = parseJsonObjectLikeFile( + `{ + "url": "https://example.test//keep", + "pattern": "/* literal */" + }`, + '.vscode/settings.json', + ); + + assert.equal(parsed.url, 'https://example.test//keep'); + assert.equal(parsed.pattern, '/* literal */'); +}); + +test('parseJsonObjectLikeFile still rejects invalid JSONC input', () => { + assert.throws( + () => parseJsonObjectLikeFile('{ "enabled": true,, }', '.vscode/settings.json'), + /Unable to parse \.vscode\/settings\.json as JSON or JSONC:/, + ); +});