From 2bef8cc47ea4c1c4213b5d3aeb09794f6579315a Mon Sep 17 00:00:00 2001 From: Loi Nguyen <1948922+lntutor@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:30:34 +0700 Subject: [PATCH 1/2] ci: validate pnpm override resolution Co-Authored-By: OpenAI Codex --- .github/workflows/check-frontend.yaml | 2 + package.json | 6 +- pnpm-lock.yaml | 30 ++++--- scripts/check-pnpm-overrides.mjs | 110 ++++++++++++++++++++++++++ scripts/check-pnpm-overrides.test.mjs | 73 +++++++++++++++++ 5 files changed, 208 insertions(+), 13 deletions(-) create mode 100644 scripts/check-pnpm-overrides.mjs create mode 100644 scripts/check-pnpm-overrides.test.mjs diff --git a/.github/workflows/check-frontend.yaml b/.github/workflows/check-frontend.yaml index 90fbefb035..31c75c5609 100644 --- a/.github/workflows/check-frontend.yaml +++ b/.github/workflows/check-frontend.yaml @@ -12,6 +12,8 @@ jobs: fail-fast: false matrix: include: + - name: 'Validate pnpm overrides' + command: pnpm check-pnpm-overrides - name: 'Linting: frontend' command: pnpm lint - name: 'Formatting: frontend' diff --git a/package.json b/package.json index 7cf5177505..73802fa742 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,15 @@ "husky": "^9.1.6", "lint-staged": "^13.3.0", "prettier": "^3.8.1", + "semver": "^7.7.3", "shell-exec": "^1.1.2", "ts-node": "^10.9.1", "typescript": "^5.2.2", - "typescript-eslint": "^8.58.0" + "typescript-eslint": "^8.58.0", + "yaml": "^2.8.1" }, "scripts": { + "check-pnpm-overrides": "node scripts/check-pnpm-overrides.mjs", "preinstall": "npx only-allow pnpm", "prepare": "husky", "build": "pnpm run --recursive build", @@ -34,6 +37,7 @@ "format": "pnpm format-check --write", "type-check": "pnpm run --parallel type-check", "test": "pnpm run --recursive test", + "test:check-pnpm-overrides": "node --test scripts/check-pnpm-overrides.test.mjs", "test:e2e": "cypress run", "test:e2e:interactive": "cypress open" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e114f9235..3472d510af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: prettier: specifier: ^3.8.1 version: 3.8.1 + semver: + specifier: ^7.7.3 + version: 7.7.4 shell-exec: specifier: ^1.1.2 version: 1.1.2 @@ -75,6 +78,9 @@ importers: typescript-eslint: specifier: ^8.58.0 version: 8.58.0(eslint@10.2.0)(typescript@5.2.2) + yaml: + specifier: ^2.8.1 + version: 2.9.0 packages: @@ -2113,11 +2119,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.2: - resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} - engines: {node: '>=10'} - hasBin: true - semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -2425,7 +2426,7 @@ packages: engines: {'0': node >=0.6.0} wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + resolution: {integrity: sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=} which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} @@ -2470,6 +2471,11 @@ packages: resolution: {integrity: sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==} engines: {node: '>= 14'} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} @@ -2878,8 +2884,8 @@ snapshots: '@jridgewell/remapping@2.3.5': dependencies: - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/resolve-uri@3.1.1': {} @@ -2897,7 +2903,7 @@ snapshots: '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.1 - '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping@0.3.9': dependencies: @@ -3431,7 +3437,7 @@ snapshots: process: 0.11.10 proxy-from-env: 1.0.0 request-progress: 3.0.0 - semver: 7.7.2 + semver: 7.7.4 supports-color: 8.1.1 tmp: 0.2.3 tree-kill: 1.2.2 @@ -4662,8 +4668,6 @@ snapshots: semver@6.3.1: {} - semver@7.7.2: {} - semver@7.7.4: {} set-function-length@1.2.2: @@ -5030,6 +5034,8 @@ snapshots: yaml@2.3.1: {} + yaml@2.9.0: {} + yauzl@2.10.0: dependencies: buffer-crc32: 0.2.13 diff --git a/scripts/check-pnpm-overrides.mjs b/scripts/check-pnpm-overrides.mjs new file mode 100644 index 0000000000..185e947e37 --- /dev/null +++ b/scripts/check-pnpm-overrides.mjs @@ -0,0 +1,110 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import semver from 'semver'; +import yaml from 'yaml'; + +export const DEFAULT_LOCKFILES = [ + 'pnpm-lock.yaml', + 'frontend/pnpm-lock.yaml', + 'libs/copilot/pnpm-lock.yaml', + 'libs/react-client/pnpm-lock.yaml' +]; + +export function parseOverrideSelector(selector) { + const atIndex = selector.lastIndexOf('@'); + + if (atIndex <= 0) { + throw new Error(`Invalid override selector "${selector}"`); + } + + return { + packageName: selector.slice(0, atIndex), + sourceRange: selector.slice(atIndex + 1) + }; +} + +export function parsePackageKey(packageKey) { + const match = packageKey.match(/^((?:@[^/]+\/)?[^@]+)@([^()]+?)(?=\(|$)/); + + if (!match) { + return null; + } + + return { + packageName: match[1], + version: match[2] + }; +} + +export function validateLockfile(lockfilePath, lockfileContents) { + const parsed = yaml.parse(lockfileContents); + const overrides = parsed.overrides || {}; + const packages = parsed.packages || {}; + const packageVersions = new Map(); + const errors = []; + + for (const packageKey of Object.keys(packages)) { + const parsedPackage = parsePackageKey(packageKey); + + if (!parsedPackage || !semver.valid(parsedPackage.version)) { + continue; + } + + if (!packageVersions.has(parsedPackage.packageName)) { + packageVersions.set(parsedPackage.packageName, new Set()); + } + + packageVersions.get(parsedPackage.packageName).add(parsedPackage.version); + } + + for (const [selector, targetRange] of Object.entries(overrides)) { + const { packageName } = parseOverrideSelector(selector); + const resolvedVersions = [...(packageVersions.get(packageName) || [])].sort( + semver.compare + ); + + if (!semver.validRange(targetRange)) { + errors.push( + `${lockfilePath}: override "${selector}" has invalid target range "${targetRange}"` + ); + continue; + } + + for (const version of resolvedVersions) { + if ( + !semver.satisfies(version, targetRange, { includePrerelease: true }) + ) { + errors.push( + `${lockfilePath}: resolved ${packageName}@${version} does not satisfy override "${selector}" -> "${targetRange}"` + ); + } + } + } + + return errors; +} + +export function main(lockfilePaths = DEFAULT_LOCKFILES) { + const allErrors = []; + + for (const relativePath of lockfilePaths) { + const absolutePath = path.resolve(relativePath); + const contents = fs.readFileSync(absolutePath, 'utf8'); + allErrors.push(...validateLockfile(relativePath, contents)); + } + + if (allErrors.length > 0) { + console.error(allErrors.join('\n')); + process.exitCode = 1; + return; + } + + console.log( + `Validated pnpm override targets across ${lockfilePaths.length} lockfiles.` + ); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const cliPaths = process.argv.slice(2); + main(cliPaths.length > 0 ? cliPaths : DEFAULT_LOCKFILES); +} diff --git a/scripts/check-pnpm-overrides.test.mjs b/scripts/check-pnpm-overrides.test.mjs new file mode 100644 index 0000000000..1f806433f0 --- /dev/null +++ b/scripts/check-pnpm-overrides.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + parseOverrideSelector, + parsePackageKey, + validateLockfile +} from './check-pnpm-overrides.mjs'; + +test('parseOverrideSelector extracts package name and selector range', () => { + assert.deepEqual(parseOverrideSelector('ws@>=8.17.1 <8.21.0'), { + packageName: 'ws', + sourceRange: '>=8.17.1 <8.21.0' + }); + + assert.deepEqual(parseOverrideSelector('@babel/core@<7.29.6'), { + packageName: '@babel/core', + sourceRange: '<7.29.6' + }); +}); + +test('parsePackageKey extracts package name and resolved version', () => { + assert.deepEqual(parsePackageKey('@babel/core@7.29.0'), { + packageName: '@babel/core', + version: '7.29.0' + }); + + assert.deepEqual( + parsePackageKey( + 'vite-plugin-svgr@4.2.0(rollup@4.31.0)(typescript@5.2.2)(vite@5.4.14(@types/node@20.5.7))' + ), + { + packageName: 'vite-plugin-svgr', + version: '4.2.0' + } + ); +}); + +test('validateLockfile reports non-applied overrides', () => { + const lockfile = ` +lockfileVersion: '9.0' + +overrides: + ws@>=8.17.1 <8.21.0: '>=8.21.0 <9.0.0' + +packages: + + ws@8.18.0: + resolution: {integrity: sha512-demo} +`; + + const errors = validateLockfile('frontend/pnpm-lock.yaml', lockfile); + + assert.deepEqual(errors, [ + 'frontend/pnpm-lock.yaml: resolved ws@8.18.0 does not satisfy override "ws@>=8.17.1 <8.21.0" -> ">=8.21.0 <9.0.0"' + ]); +}); + +test('validateLockfile passes when resolved versions satisfy override targets', () => { + const lockfile = ` +lockfileVersion: '9.0' + +overrides: + cross-spawn@>=7.0.0 <7.0.5: '>=7.0.5' + +packages: + + cross-spawn@7.0.6: + resolution: {integrity: sha512-demo} +`; + + assert.deepEqual(validateLockfile('pnpm-lock.yaml', lockfile), []); +}); From bca01879de29ef7807ff5ba76b3a1ba457266032 Mon Sep 17 00:00:00 2001 From: Loi Nguyen Date: Fri, 31 Jul 2026 21:15:50 +0700 Subject: [PATCH 2/2] fix(ci): handle pnpm override selector semantics Co-Authored-By: OpenAI Codex --- .github/workflows/check-frontend.yaml | 2 + scripts/check-pnpm-overrides.mjs | 135 ++++++++++++++++++++++---- scripts/check-pnpm-overrides.test.mjs | 94 ++++++++++++++++++ 3 files changed, 213 insertions(+), 18 deletions(-) diff --git a/.github/workflows/check-frontend.yaml b/.github/workflows/check-frontend.yaml index 31c75c5609..5c51abebfc 100644 --- a/.github/workflows/check-frontend.yaml +++ b/.github/workflows/check-frontend.yaml @@ -12,6 +12,8 @@ jobs: fail-fast: false matrix: include: + - name: 'Test pnpm override validator' + command: pnpm test:check-pnpm-overrides - name: 'Validate pnpm overrides' command: pnpm check-pnpm-overrides - name: 'Linting: frontend' diff --git a/scripts/check-pnpm-overrides.mjs b/scripts/check-pnpm-overrides.mjs index 185e947e37..26dc12e280 100644 --- a/scripts/check-pnpm-overrides.mjs +++ b/scripts/check-pnpm-overrides.mjs @@ -10,17 +10,69 @@ export const DEFAULT_LOCKFILES = [ 'libs/react-client/pnpm-lock.yaml' ]; -export function parseOverrideSelector(selector) { +function parsePackageSelector(selector) { const atIndex = selector.lastIndexOf('@'); + const packageNameEnd = selector.startsWith('@') ? selector.indexOf('/') : 0; - if (atIndex <= 0) { + if (!selector || (selector.startsWith('@') && packageNameEnd <= 1)) { throw new Error(`Invalid override selector "${selector}"`); } - return { - packageName: selector.slice(0, atIndex), - sourceRange: selector.slice(atIndex + 1) - }; + const parsedSelector = + atIndex <= packageNameEnd + ? { + packageName: selector, + sourceRange: '*' + } + : { + packageName: selector.slice(0, atIndex), + sourceRange: selector.slice(atIndex + 1) || '*' + }; + + if ( + !/^(?:@[a-z0-9._~-]+\/)?[a-z0-9._~-]+$/i.test(parsedSelector.packageName) + ) { + throw new Error(`Invalid override selector "${selector}"`); + } + + return parsedSelector; +} + +export function parseOverrideSelector(selector) { + try { + const packageSelector = parsePackageSelector(selector); + + if (semver.validRange(packageSelector.sourceRange)) { + return packageSelector; + } + } catch { + // Try parsing the selector as a parent-to-dependency edge below. + } + + for (let edgeIndex = selector.indexOf('>'); edgeIndex >= 0; ) { + try { + const parentSelector = parsePackageSelector(selector.slice(0, edgeIndex)); + const dependencySelector = parsePackageSelector( + selector.slice(edgeIndex + 1) + ); + + if ( + semver.validRange(parentSelector.sourceRange) && + semver.validRange(dependencySelector.sourceRange) + ) { + return { + ...dependencySelector, + parentSelector + }; + } + } catch { + // Keep looking because the range itself may contain a comparator. + } + + edgeIndex = selector.indexOf('>', edgeIndex + 1); + } + + throw new Error(`Invalid override selector "${selector}"`); } export function parsePackageKey(packageKey) { @@ -36,10 +88,56 @@ export function parsePackageKey(packageKey) { }; } +function parseDependencyVersion(reference) { + if (typeof reference !== 'string') { + return null; + } + + const parsedPackage = parsePackageKey(reference); + + if (parsedPackage && semver.valid(parsedPackage.version)) { + return parsedPackage.version; + } + + const version = reference.split('(', 1)[0]; + return semver.valid(version) ? version : null; +} + +function getEdgeVersions(snapshots, parentSelector, dependencyName) { + const versions = new Set(); + + for (const [snapshotKey, snapshot] of Object.entries(snapshots)) { + const parsedParent = parsePackageKey(snapshotKey); + + if ( + !parsedParent || + parsedParent.packageName !== parentSelector.packageName || + !semver.valid(parsedParent.version) || + !semver.satisfies(parsedParent.version, parentSelector.sourceRange, { + includePrerelease: true + }) + ) { + continue; + } + + const dependencyReference = + snapshot.dependencies?.[dependencyName] ?? + snapshot.optionalDependencies?.[dependencyName]; + const dependencyVersion = parseDependencyVersion(dependencyReference); + + if (dependencyVersion) { + versions.add(dependencyVersion); + } + } + + return [...versions].sort(semver.compare); +} + export function validateLockfile(lockfilePath, lockfileContents) { const parsed = yaml.parse(lockfileContents); const overrides = parsed.overrides || {}; const packages = parsed.packages || {}; + const snapshots = parsed.snapshots || {}; const packageVersions = new Map(); const errors = []; @@ -58,22 +156,23 @@ export function validateLockfile(lockfilePath, lockfileContents) { } for (const [selector, targetRange] of Object.entries(overrides)) { - const { packageName } = parseOverrideSelector(selector); - const resolvedVersions = [...(packageVersions.get(packageName) || [])].sort( - semver.compare - ); - - if (!semver.validRange(targetRange)) { - errors.push( - `${lockfilePath}: override "${selector}" has invalid target range "${targetRange}"` - ); + const { packageName, sourceRange, parentSelector } = + parseOverrideSelector(selector); + + if (typeof targetRange !== 'string' || !semver.validRange(targetRange)) { continue; } + const resolvedVersions = parentSelector + ? getEdgeVersions(snapshots, parentSelector, packageName) + : [...(packageVersions.get(packageName) || [])].sort(semver.compare); + for (const version of resolvedVersions) { - if ( - !semver.satisfies(version, targetRange, { includePrerelease: true }) - ) { + if (semver.satisfies(version, targetRange, { includePrerelease: true })) { + continue; + } + + if (semver.satisfies(version, sourceRange, { includePrerelease: true })) { errors.push( `${lockfilePath}: resolved ${packageName}@${version} does not satisfy override "${selector}" -> "${targetRange}"` ); diff --git a/scripts/check-pnpm-overrides.test.mjs b/scripts/check-pnpm-overrides.test.mjs index 1f806433f0..2188f2c144 100644 --- a/scripts/check-pnpm-overrides.test.mjs +++ b/scripts/check-pnpm-overrides.test.mjs @@ -17,6 +17,25 @@ test('parseOverrideSelector extracts package name and selector range', () => { packageName: '@babel/core', sourceRange: '<7.29.6' }); + + assert.deepEqual(parseOverrideSelector('@babel/core'), { + packageName: '@babel/core', + sourceRange: '*' + }); + + assert.deepEqual(parseOverrideSelector('qar@1>zoo'), { + packageName: 'zoo', + sourceRange: '*', + parentSelector: { + packageName: 'qar', + sourceRange: '1' + } + }); + + assert.deepEqual(parseOverrideSelector('foo@>1'), { + packageName: 'foo', + sourceRange: '>1' + }); }); test('parsePackageKey extracts package name and resolved version', () => { @@ -71,3 +90,78 @@ packages: assert.deepEqual(validateLockfile('pnpm-lock.yaml', lockfile), []); }); + +test('validateLockfile ignores package versions outside the selector range', () => { + const lockfile = ` +lockfileVersion: '9.0' + +overrides: + foo@<2: '>=2 <3' + +packages: + + foo@3.1.0: + resolution: {integrity: sha512-unrelated} +`; + + assert.deepEqual(validateLockfile('pnpm-lock.yaml', lockfile), []); +}); + +test('validateLockfile evaluates dependency edge selectors', () => { + const lockfile = ` +lockfileVersion: '9.0' + +overrides: + qar@1>zoo: '>=2' + +packages: + + qar@1.5.0: + resolution: {integrity: sha512-qar-one} + + qar@2.0.0: + resolution: {integrity: sha512-qar-two} + + zoo@1.0.0: + resolution: {integrity: sha512-zoo-one} + + zoo@3.0.0: + resolution: {integrity: sha512-zoo-three} + +snapshots: + + qar@1.5.0: + dependencies: + zoo: 1.0.0 + + qar@2.0.0: + dependencies: + zoo: 3.0.0 +`; + + assert.deepEqual(validateLockfile('pnpm-lock.yaml', lockfile), [ + 'pnpm-lock.yaml: resolved zoo@1.0.0 does not satisfy override "qar@1>zoo" -> ">=2"' + ]); +}); + +test('validateLockfile skips supported non-semver override targets', () => { + const lockfile = ` +lockfileVersion: '9.0' + +overrides: + quux: 'npm:@myorg/quux@^1.0.0' + foo: '$foo' + foo@1>bar: '-' + baz: 'catalog:' + +packages: + + foo@1.0.0: + resolution: {integrity: sha512-foo} + + quux@1.0.0: + resolution: {integrity: sha512-quux} +`; + + assert.deepEqual(validateLockfile('pnpm-lock.yaml', lockfile), []); +});