Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/job-compile-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ jobs:
with:
node-version: 24

- name: Validate Yarn lockfile
Comment thread
sean-mcmanus marked this conversation as resolved.
run: yarn test-yarn-lock && yarn verify-yarn-lock
working-directory: Extension

- name: Install Dependencies
run: yarn install ${{ inputs.yarn-args }}
working-directory: Extension
Expand Down
97 changes: 97 additions & 0 deletions Extension/.scripts/verifyYarnLock.mjs
Original file line number Diff line number Diff line change
@@ -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 };
41 changes: 41 additions & 0 deletions Extension/.scripts/verifyYarnLock.test.mjs
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions Extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading