From 2fd4079204192e418931afbb7b047868f260180d Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 22 Apr 2026 21:48:21 +0200 Subject: [PATCH] Keep helper behavior single-sourced before duplicate parsers drift again The CLI still carried duplicate custom semver, stdin, and JSONC helpers in multiple modules, which kept correctness fixes from landing once and sticking everywhere. This extracts shared core helpers, routes the toolchain and CLI through them, replaces scaffold JSONC parsing with jsonc-parser, and updates the stale agents-args expectation that was already failing on main so the full suite can go green again. Constraint: Preserve existing CLI names, prompts, and output wording while changing only helper internals Rejected: Leave the duplicate helpers in place and patch each bug locally | keeps version/stdin/jsonc behavior drifting across modules Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep semver comparison, interactive stdin reads, and JSONC parsing in shared helpers instead of reintroducing local copies in command modules Tested: node --check src/core/versions.js src/core/stdin.js src/toolchain/index.js src/scaffold/index.js src/cli/main.js; 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; node --test test/cli-args-dispatch.test.js; npm test; openspec validate agent-codex-replace-custom-semver-jsonc-stdin-helper-2026-04-22-18-06 --type change --strict; openspec validate --specs Not-tested: Root cause of the lingering full-suite node test runner process after the green npm test body printed --- .../proposal.md | 24 ++++ .../specs/cli-modularization/spec.md | 14 ++ .../tasks.md | 37 ++++++ package-lock.json | 22 ++++ package.json | 4 + src/cli/main.js | 79 +---------- src/core/stdin.js | 52 ++++++++ src/core/versions.js | 33 +++++ src/scaffold/index.js | 123 +----------------- src/toolchain/index.js | 76 +---------- test/cli-args-dispatch.test.js | 1 + test/core-stdin.test.js | 54 ++++++++ test/core-version.test.js | 29 +++++ test/scaffold-jsonc.test.js | 42 ++++++ 14 files changed, 331 insertions(+), 259 deletions(-) create mode 100644 openspec/changes/agent-codex-replace-custom-semver-jsonc-stdin-helper-2026-04-22-18-06/proposal.md create mode 100644 openspec/changes/agent-codex-replace-custom-semver-jsonc-stdin-helper-2026-04-22-18-06/specs/cli-modularization/spec.md create mode 100644 openspec/changes/agent-codex-replace-custom-semver-jsonc-stdin-helper-2026-04-22-18-06/tasks.md create mode 100644 src/core/stdin.js create mode 100644 src/core/versions.js create mode 100644 test/core-stdin.test.js create mode 100644 test/core-version.test.js create mode 100644 test/scaffold-jsonc.test.js 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:/, + ); +});