Files
2026-07-13 12:38:36 +08:00

299 lines
8.4 KiB
JavaScript

#!/usr/bin/env node
/**
* Supply chain hardening: expands transitive dependencies of a package into
* explicit, pinned direct dependencies in package.json.
*
* This ensures that `pnpm install nx@latest` produces a fully deterministic
* install with zero resolver freedom — every package version is predetermined.
*
* Usage:
* npx ts-node scripts/expand-deps.ts --project nx
* npx ts-node scripts/expand-deps.ts --project nx --dry-run
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import * as yaml from 'yaml';
const workspaceRoot = join(__dirname, '..');
interface LockfileImporterDep {
specifier: string;
version: string;
}
interface LockfileSnapshot {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
}
interface Lockfile {
importers: Record<
string,
{
dependencies?: Record<string, LockfileImporterDep>;
optionalDependencies?: Record<string, LockfileImporterDep>;
}
>;
snapshots: Record<string, LockfileSnapshot>;
}
function parseArgs(): { project: string; dryRun: boolean; verbose: boolean } {
const args = process.argv.slice(2);
let project = '';
let dryRun = false;
let verbose = false;
for (const arg of args) {
if (arg === '--dry-run') {
dryRun = true;
} else if (arg === '--verbose') {
verbose = true;
} else if (!arg.startsWith('-')) {
project = arg;
}
}
if (!project) {
console.error('Usage: expand-deps <project> [--dry-run] [--verbose]');
process.exit(1);
}
return { project, dryRun, verbose };
}
/**
* Strip peer dep qualifiers from a version string.
* e.g. "1.15.11(debug@4.4.1)" -> "1.15.11"
*/
function stripPeerQualifier(version: string): string {
const parenIndex = version.indexOf('(');
return parenIndex === -1 ? version : version.substring(0, parenIndex);
}
/**
* Build the snapshot lookup key for a package.
* In the snapshots section, entries are keyed as "name@version" or
* "name@version(peer-qualifiers)". We need to find the right entry.
*/
function findSnapshotEntry(
snapshots: Record<string, LockfileSnapshot>,
name: string,
version: string
): LockfileSnapshot | null {
// Try exact match first (name@version)
const exactKey = `${name}@${version}`;
if (snapshots[exactKey]) {
return snapshots[exactKey];
}
// Look for entries with peer qualifiers (name@version(...))
for (const key of Object.keys(snapshots)) {
if (key === exactKey || key.startsWith(`${exactKey}(`)) {
return snapshots[key];
}
}
return null;
}
interface ConflictInfo {
name: string;
version1: string;
path1: string[];
version2: string;
path2: string[];
}
function walkDeps(
snapshots: Record<string, LockfileSnapshot>,
name: string,
version: string,
resolved: Map<string, { version: string; path: string[] }>,
conflicts: ConflictInfo[],
currentPath: string[]
): void {
const path = [...currentPath, `${name}@${version}`];
// Check for conflicts
const existing = resolved.get(name);
if (existing) {
if (existing.version === version) {
return; // Already visited with same version
}
conflicts.push({
name,
version1: existing.version,
path1: existing.path,
version2: version,
path2: path,
});
return;
}
resolved.set(name, { version, path });
const snapshot = findSnapshotEntry(snapshots, name, version);
if (!snapshot) {
// Leaf dependency with no sub-deps (e.g. "cli-spinners@2.6.1: {}")
return;
}
const deps = snapshot.dependencies || {};
for (const [depName, depVersionRaw] of Object.entries(deps)) {
const depVersion = stripPeerQualifier(depVersionRaw);
walkDeps(snapshots, depName, depVersion, resolved, conflicts, path);
}
}
function main() {
const { project, dryRun, verbose } = parseArgs();
const packageJsonPath = join(
workspaceRoot,
'packages',
project,
'package.json'
);
const lockfilePath = join(workspaceRoot, 'pnpm-lock.yaml');
// Read package.json
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
const originalDeps: Record<string, string> = packageJson.dependencies || {};
const directDepNames = new Set(Object.keys(originalDeps));
// Parse lockfile
console.log('Parsing pnpm-lock.yaml...');
const lockfileContent = readFileSync(lockfilePath, 'utf-8');
const lockfile: Lockfile = yaml.parse(lockfileContent);
// Find the importer entry for this package
const importerKey = `packages/${project}`;
const importer = lockfile.importers[importerKey];
if (!importer) {
console.error(
`ERROR: No importer entry found for "${importerKey}" in pnpm-lock.yaml`
);
process.exit(1);
}
const importerDeps = importer.dependencies || {};
// Resolve direct deps to their pinned versions from the lockfile
const directDepsResolved = new Map<
string,
{ specifier: string; resolvedVersion: string }
>();
const skippedDeps: string[] = [];
for (const depName of directDepNames) {
const importerDep = importerDeps[depName];
if (!importerDep) {
// This dep is in package.json but not in the lockfile importer section.
// This happens when package.json is dirty from a previous expand-deps run
// that wasn't cleaned up, or when someone forgot to run `pnpm install`.
// In CI we fail hard; locally we skip and warn.
if (process.env.CI) {
console.error(
`ERROR: Direct dependency "${depName}" not found in lockfile importers section. Run \`pnpm install\` first.`
);
process.exit(1);
}
skippedDeps.push(depName);
continue;
}
directDepsResolved.set(depName, {
specifier: originalDeps[depName],
resolvedVersion: stripPeerQualifier(importerDep.version),
});
}
if (skippedDeps.length > 0) {
console.warn(
`\nWARNING: ${skippedDeps.length} dependencies were not expanded since they were missing in the lockfile. Run with --verbose to see the list. Make sure you run \`pnpm install\` first.`
);
if (verbose) {
for (const dep of skippedDeps) {
console.warn(` - ${dep}`);
}
}
}
// Walk the full transitive dependency tree
const resolved = new Map<string, { version: string; path: string[] }>();
const conflicts: ConflictInfo[] = [];
for (const [depName, { resolvedVersion }] of directDepsResolved) {
walkDeps(
lockfile.snapshots,
depName,
resolvedVersion,
resolved,
conflicts,
[]
);
}
// Check for conflicts
if (conflicts.length > 0) {
console.error('\nERROR: Version conflicts detected:\n');
for (const conflict of conflicts) {
console.error(` "${conflict.name}":`);
console.error(` Version ${conflict.version1}`);
console.error(` Path: ${conflict.path1.join(' -> ')}`);
console.error(` Version ${conflict.version2}`);
console.error(` Path: ${conflict.path2.join(' -> ')}`);
console.error('');
}
console.error('Resolve manually before publishing.');
process.exit(1);
}
// Build the expanded dependencies object
const expandedDeps: Record<string, string> = {};
const sortedNames = [...resolved.keys()].sort();
for (const name of sortedNames) {
expandedDeps[name] = resolved.get(name)!.version;
}
// Report
console.log(`\nexpand-deps: ${project}\n`);
// Direct deps changes
console.log('Direct deps (resolved from lockfile):');
for (const [depName, { specifier, resolvedVersion }] of directDepsResolved) {
if (specifier === resolvedVersion) {
console.log(` ${depName}: ${resolvedVersion} (unchanged)`);
} else {
console.log(
` ${depName}: ${resolvedVersion} (was "${specifier}" — ${specifier.startsWith('catalog:') ? 'resolved' : 'pinned'})`
);
}
}
// New transitive deps
const transitiveDeps = sortedNames.filter(
(name) => !directDepNames.has(name)
);
console.log(`\nNew transitive deps to add (${transitiveDeps.length}):`);
for (const name of transitiveDeps) {
console.log(` ${name}: ${resolved.get(name)!.version}`);
}
console.log(
`\nTotal: ${directDepNames.size} direct -> ${resolved.size} total deps (${transitiveDeps.length} transitive added)`
);
if (dryRun) {
console.log('\n--dry-run: no changes written.');
return;
}
// Write back
packageJson.dependencies = expandedDeps;
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
console.log(`\nWrote expanded dependencies to ${packageJsonPath}`);
}
main();