diff --git a/.github/workflows/job-compile-and-test.yml b/.github/workflows/job-compile-and-test.yml index 18299aa63..37868e7f3 100644 --- a/.github/workflows/job-compile-and-test.yml +++ b/.github/workflows/job-compile-and-test.yml @@ -34,6 +34,10 @@ jobs: with: node-version: 24 + - name: Validate Yarn lockfile + run: yarn test-yarn-lock && yarn verify-yarn-lock + working-directory: Extension + - name: Install Dependencies run: yarn install ${{ inputs.yarn-args }} working-directory: Extension diff --git a/Extension/.scripts/verifyYarnLock.mjs b/Extension/.scripts/verifyYarnLock.mjs new file mode 100644 index 000000000..53f8610ab --- /dev/null +++ b/Extension/.scripts/verifyYarnLock.mjs @@ -0,0 +1,97 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const dependencySections = ['dependencies', 'devDependencies', 'optionalDependencies']; + +function parseLockfileKey(key) { + const selectors = []; + let selectorStart = 0; + let quoted = false; + let escaped = false; + + for (let index = 0; index < key.length; index++) { + const character = key[index]; + if (escaped) { + escaped = false; + } else if (character === '\\' && quoted) { + escaped = true; + } else if (character === '"') { + quoted = !quoted; + } else if (character === ',' && !quoted) { + selectors.push(key.slice(selectorStart, index)); + selectorStart = index + 1; + } + } + selectors.push(key.slice(selectorStart)); + + return selectors.map(selector => { + const trimmedSelector = selector.trim(); + return trimmedSelector.startsWith('"') ? JSON.parse(trimmedSelector) : trimmedSelector; + }); +} + +function parseLockfileSelectors(lockfile) { + const selectors = new Set(); + for (const line of lockfile.split(/\r?\n/)) { + if (/^[^\s#].*:\s*$/.test(line)) { + for (const selector of parseLockfileKey(line.replace(/:\s*$/, ''))) { + selectors.add(selector); + } + } + } + return selectors; +} + +function getResolutionPackageName(pattern) { + const segments = pattern.split('/'); + const packageName = segments.at(-1); + const scope = segments.at(-2); + return scope?.startsWith('@') ? `${scope}/${packageName}` : packageName; +} + +function getExpectedSelectors(manifest) { + const selectors = []; + for (const section of dependencySections) { + for (const [packageName, range] of Object.entries(manifest[section] ?? {})) { + selectors.push(`${packageName}@${range}`); + } + } + for (const [pattern, range] of Object.entries(manifest.resolutions ?? {})) { + selectors.push(`${getResolutionPackageName(pattern)}@${range}`); + } + return selectors; +} + +function findMissingSelectors(manifest, lockfile) { + const lockfileSelectors = parseLockfileSelectors(lockfile); + return getExpectedSelectors(manifest) + .filter(selector => !lockfileSelectors.has(selector)) + .sort(); +} + +function validateYarnLock(packageJsonPath, yarnLockPath) { + const manifest = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const lockfile = fs.readFileSync(yarnLockPath, 'utf8'); + const missingSelectors = findMissingSelectors(manifest, lockfile); + if (missingSelectors.length > 0) { + throw new Error(`yarn.lock is missing selectors required by package.json:\n${missingSelectors.map(selector => ` ${selector}`).join('\n')}\nRun yarn install to update yarn.lock.`); + } +} + +const invokedUrl = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : undefined; +if (invokedUrl === import.meta.url) { + const extensionRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const packageJsonPath = process.argv[2] ?? path.join(extensionRoot, 'package.json'); + const yarnLockPath = process.argv[3] ?? path.join(extensionRoot, 'yarn.lock'); + + try { + validateYarnLock(packageJsonPath, yarnLockPath); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} + +export { findMissingSelectors, getExpectedSelectors, parseLockfileSelectors, validateYarnLock }; diff --git a/Extension/.scripts/verifyYarnLock.test.mjs b/Extension/.scripts/verifyYarnLock.test.mjs new file mode 100644 index 000000000..ac52202b6 --- /dev/null +++ b/Extension/.scripts/verifyYarnLock.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { findMissingSelectors } from './verifyYarnLock.mjs'; + +test('reports a stale resolution selector', () => { + const manifest = { resolutions: { 'fast-uri': '^3.1.5' } }; + const lockfile = `fast-uri@^3.0.1, fast-uri@^3.1.4: + version "3.1.5" +`; + + assert.deepEqual(findMissingSelectors(manifest, lockfile), ['fast-uri@^3.1.5']); +}); + +test('accepts direct, scoped, and nested resolution selectors', () => { + const manifest = { + dependencies: { '@scope/direct': '^1.0.0' }, + devDependencies: { 'gulp-typescript': '^5.0.1' }, + resolutions: { + '@scope/resolved': '^2.0.0', + 'gulp-typescript/**/glob-parent': '^5.1.2', + 'parent/**/@nested/package': '~3.0.0' + } + }; + const lockfile = `"@nested/package@~3.0.0": + version "3.0.1" + +"@scope/direct@^1.0.0": + version "1.0.0" + +"@scope/resolved@^2.0.0": + version "2.0.0" + +glob-parent@^3.1.0, glob-parent@^5.1.2: + version "5.1.2" + +gulp-typescript@^5.0.1: + version "5.0.1" +`; + + assert.deepEqual(findMissingSelectors(manifest, lockfile), []); +}); diff --git a/Extension/package.json b/Extension/package.json index b2715001e..20d10f505 100644 --- a/Extension/package.json +++ b/Extension/package.json @@ -7163,6 +7163,8 @@ "scripts": "ts-node -T .scripts/scripts.ts", "show": "ts-node -T .scripts/clean.ts show", "clean": "ts-node -T .scripts/clean.ts", + "test-yarn-lock": "node --test .scripts/verifyYarnLock.test.mjs", + "verify-yarn-lock": "node .scripts/verifyYarnLock.mjs", "test": "yarn install && (yarn verify prep --quiet || yarn prep) && (yarn verify compiled --quiet || yarn build) && ts-node -T .scripts/test.ts", "code": "yarn install && (yarn verify compiled --quiet || yarn build) && yarn verify binaries && ts-node -T .scripts/code.ts", "verify": "ts-node -T .scripts/verify.ts",