diff --git a/scripts/changed-packages.js b/scripts/changed-packages.js new file mode 100755 index 000000000..864de866b --- /dev/null +++ b/scripts/changed-packages.js @@ -0,0 +1,235 @@ +#!/usr/bin/env node +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +/** + * changed-packages.js + * + * Usage: + * node scripts/changed-packages.js + * + * Examples: + * node scripts/changed-packages.js main + * node scripts/changed-packages.js v1.2.3 + * + * Description: + * Prints the top-level package directories under `packages/` that + * have changed since , reads their package names, and prints + * both the dependency tree (internal packages it depends on) and + * the dependent tree (internal packages that depend on it). + * + * Options: + * -h, --help Show this help message + */ + +const USAGE = [ + 'Usage:', + ' node scripts/changed-packages.js ', + '', + 'Examples:', + ' node scripts/changed-packages.js main', + ' node scripts/changed-packages.js v1.2.3', + '', + 'Description:', + ' Print package directories under packages/ that have changed since ,', + ' and show their internal dependency and dependent trees.', + '', + 'Options:', + ' -h, --help Show this help message' +].join('\n'); + +const ref = process.argv.slice(2)[0]; + +if (!ref || ['-h', '--help'].includes(ref)) { + console.log(USAGE); + process.exit(0); +} + +// Validate ref exists +try { + const getRefs = execSync(`git show-ref`); + const refs = getRefs.toString().split('\n').filter((d) => d.includes('refs/heads') || d.includes('refs/tags')).map((d) => { + const [_, refPath] = d.split(/\s+/); + return refPath.replace('refs/heads/', '').replace('refs/tags/', ''); + }); + + if (!refs.includes(ref)) { + console.error('The passed in Ref cannot be found'); + process.exit(1); + } +} catch (err) { + console.error('Error checking git refs:', err.message); + process.exit(1); +} + +// Get changed files since ref and map to top-level package directories +let changedFiles = []; +try { + changedFiles = execSync(`git diff --name-only ${ref}`).toString().split('\n').filter(Boolean); +} catch (err) { + console.error('Error running git diff:', err.message); + process.exit(1); +} + +const changedPackageDirs = Array.from(new Set(changedFiles.map((f) => { + const parts = f.split('/'); + if (parts[0] === 'packages' && parts.length >= 2) { + return `packages/${parts[1]}`; + } + return null; +}).filter(Boolean))).sort(); + +if (changedPackageDirs.length === 0) { + console.log('No changed packages found since', ref); + process.exit(0); +} + +// Build map of all packages in packages/ -> name and their internal dependencies +const packagesRoot = path.join(process.cwd(), 'packages'); +const allPackageDirs = fs.readdirSync(packagesRoot).filter((d) => { + try { + return fs.statSync(path.join(packagesRoot, d)).isDirectory(); + } catch (e) { + return false; + } +}); + +const packageNameByDir = {}; // 'packages/foo' -> '@scope/foo' +const packageDirByName = {}; // '@scope/foo' -> 'packages/foo' +const rawPackageJsonByName = {}; // name -> package.json contents + +allPackageDirs.forEach((d) => { + const pkgJsonPath = path.join(packagesRoot, d, 'package.json'); + try { + const raw = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + if (raw && raw.name) { + const dir = `packages/${d}`; + packageNameByDir[dir] = raw.name; + packageDirByName[raw.name] = dir; + rawPackageJsonByName[raw.name] = raw; + } + } catch (e) { + // ignore directories without valid package.json + } +}); + +const packageNames = Object.keys(packageDirByName); + +// Build dependency maps (only internal package deps) +const depsMap = {}; // pkgName -> [internal dep names] +const dependentsMap = {}; // pkgName -> Set(internal dependent names) + +packageNames.forEach((name) => { + const pkg = rawPackageJsonByName[name] || {}; + const allDeps = Object.assign({}, pkg.dependencies || {}, pkg.devDependencies || {}, pkg.peerDependencies || {}); + const internalDeps = Object.keys(allDeps).filter((depName) => packageNames.includes(depName)); + depsMap[name] = internalDeps; + internalDeps.forEach((dep) => { + dependentsMap[dep] = dependentsMap[dep] || new Set(); + dependentsMap[dep].add(name); + }); +}); + +function printTree(rootName, map, seen = new Set(), indent = '') { + if (!map[rootName] || map[rootName].length === 0) return ''; // no children + let out = ''; + const children = map[rootName]; + children.forEach((child) => { + if (seen.has(child)) { + out += `${indent}- ${child} (cycle)\n`; + return; + } + out += `${indent}- ${child}\n`; + seen.add(child); + // For dependentsMap value is Set, convert to Array + const nextChildren = Array.isArray(map[child]) ? map[child] : (map[child] ? Array.from(map[child]) : []); + if (nextChildren.length > 0) { + out += printTree(child, map, seen, indent + ' '); + } + }); + return out; +} + +// For dependentsMap, convert sets to arrays for printing +const dependentsMapArr = {}; +Object.keys(dependentsMap).forEach((k) => { + dependentsMapArr[k] = Array.from(dependentsMap[k]); +}); + +// Build bottom-up tree for changed packages +const changedPackageNames = changedPackageDirs.map((d) => packageNameByDir[d]).filter(Boolean); + +function getTransitiveDependents(pkgName, visited = new Set()) { + if (visited.has(pkgName)) return []; + visited.add(pkgName); + + const directDependents = Array.from(dependentsMap[pkgName] || []); + let result = []; + + directDependents.forEach((dependent) => { + if (changedPackageNames.includes(dependent)) { + result.push(dependent); + } + result.push(...getTransitiveDependents(dependent, visited)); + }); + + return result; +} + +function buildUpdateOrder(changedPackages) { + const levels = []; + let remaining = new Set(changedPackages); + + while (remaining.size > 0) { + const currentLevel = []; + const nextLevel = []; + + remaining.forEach((pkg) => { + const deps = depsMap[pkg] || []; + const depsInRemaining = deps.filter((d) => remaining.has(d)); + + if (depsInRemaining.length === 0) { + currentLevel.push(pkg); + } else { + nextLevel.push(pkg); + } + }); + + if (currentLevel.length === 0) { + break; + } + + currentLevel.forEach((pkg) => remaining.delete(pkg)); + levels.push(currentLevel.sort()); + } + + return levels; +} + +console.log('='.repeat(80)); +console.log('Changed packages since', ref); +console.log('='.repeat(80)); +console.log(); + +const updateLevels = buildUpdateOrder(changedPackageNames); + +if (updateLevels.length === 0) { + console.log('No changed packages found.'); + process.exit(0); +} + +updateLevels.forEach((level, idx) => { + console.log(`Level ${idx + 1}:`); + level.forEach((pkgName) => { + const dir = packageDirByName[pkgName]; + console.log(` ${dir || pkgName} -> ${pkgName}`); + const transitiveDependents = getTransitiveDependents(pkgName); + if (transitiveDependents.length > 0) { + console.log(` ├─ Dependent packages: ${transitiveDependents.join(', ')}`); + } + }); + console.log(); +}); + +console.log('='.repeat(80));