Validate Yarn lockfile selectors (#14663)

This commit is contained in:
Sean McManus
2026-08-11 12:14:31 -07:00
committed by GitHub
parent 0ec5075a0f
commit 9d19aa858f
4 changed files with 144 additions and 0 deletions
@@ -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
+97
View File
@@ -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 };
@@ -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), []);
});
+2
View File
@@ -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",