From 4d36a62e851c3ee07bfe527e8a117e9271ef4ec3 Mon Sep 17 00:00:00 2001 From: cadamsdev Date: Mon, 2 Feb 2026 20:22:27 -0500 Subject: [PATCH 1/6] implement updateInternalDependencies --- src/version.test.ts | 556 ++++++++++++++++++++++++++++++++++++++++++++ src/version.ts | 282 +++++++++++++++++++++- 2 files changed, 826 insertions(+), 12 deletions(-) diff --git a/src/version.test.ts b/src/version.test.ts index 9fd6c60..cec44da 100644 --- a/src/version.test.ts +++ b/src/version.test.ts @@ -1429,3 +1429,559 @@ v1 release`, }); }); }); + +describe("updateInternalDependencies", () => { + describe("helper functions", () => { + test("shouldUpdateDependency with 'patch' policy", () => { + const { shouldUpdateDependency } = require("./version.js"); + + expect(shouldUpdateDependency("patch", "patch")).toBe(true); + expect(shouldUpdateDependency("patch", "minor")).toBe(true); + expect(shouldUpdateDependency("patch", "major")).toBe(true); + }); + + test("shouldUpdateDependency with 'minor' policy", () => { + const { shouldUpdateDependency } = require("./version.js"); + + expect(shouldUpdateDependency("minor", "patch")).toBe(false); + expect(shouldUpdateDependency("minor", "minor")).toBe(true); + expect(shouldUpdateDependency("minor", "major")).toBe(true); + }); + + test("shouldUpdateDependency with 'major' policy", () => { + const { shouldUpdateDependency } = require("./version.js"); + + expect(shouldUpdateDependency("major", "patch")).toBe(false); + expect(shouldUpdateDependency("major", "minor")).toBe(false); + expect(shouldUpdateDependency("major", "major")).toBe(true); + }); + + test("shouldUpdateDependency with 'none' policy", () => { + const { shouldUpdateDependency } = require("./version.js"); + + expect(shouldUpdateDependency("none", "patch")).toBe(false); + expect(shouldUpdateDependency("none", "minor")).toBe(false); + expect(shouldUpdateDependency("none", "major")).toBe(false); + }); + + test("updateDependencyRange should preserve caret (^) operator", () => { + const { updateDependencyRange } = require("./version.js"); + + expect(updateDependencyRange("^1.0.0", "1.0.1")).toBe("^1.0.1"); + expect(updateDependencyRange("^1.0.0", "1.1.0")).toBe("^1.1.0"); + expect(updateDependencyRange("^1.0.0", "2.0.0")).toBe("^2.0.0"); + }); + + test("updateDependencyRange should preserve tilde (~) operator", () => { + const { updateDependencyRange } = require("./version.js"); + + expect(updateDependencyRange("~1.0.0", "1.0.1")).toBe("~1.0.1"); + expect(updateDependencyRange("~1.0.0", "1.1.0")).toBe("~1.1.0"); + }); + + test("updateDependencyRange should handle exact version", () => { + const { updateDependencyRange } = require("./version.js"); + + expect(updateDependencyRange("1.0.0", "1.0.1")).toBe("1.0.1"); + }); + + test("updateDependencyRange should preserve * wildcard", () => { + const { updateDependencyRange } = require("./version.js"); + + expect(updateDependencyRange("*", "1.0.1")).toBe("*"); + }); + + test("updateDependencyRange should handle workspace:* protocol", () => { + const { updateDependencyRange } = require("./version.js"); + + expect(updateDependencyRange("workspace:*", "1.0.1")).toBe("workspace:*"); + }); + + test("updateDependencyRange should handle workspace:^ protocol", () => { + const { updateDependencyRange } = require("./version.js"); + + expect(updateDependencyRange("workspace:^1.0.0", "1.0.1")).toBe("workspace:^1.0.1"); + expect(updateDependencyRange("workspace:~1.0.0", "1.0.1")).toBe("workspace:~1.0.1"); + }); + + test("updateDependencyRange should handle >= operator", () => { + const { updateDependencyRange } = require("./version.js"); + + expect(updateDependencyRange(">=1.0.0", "1.0.1")).toBe(">=1.0.1"); + }); + }); + + describe("buildDependencyGraph", () => { + beforeEach(() => { + spyOn(fs, "existsSync").mockReturnValue(true); + }); + + test("should build graph with single package", () => { + const { buildDependencyGraph } = require("./version.js"); + + const readFileSyncSpy = spyOn(fs, "readFileSync"); + readFileSyncSpy.mockReturnValue( + JSON.stringify({ + name: "@test/pkg-a", + version: "1.0.0", + }), + ); + + const graph = buildDependencyGraph(["packages/pkg-a/package.json"]); + + expect(graph.packages.size).toBe(1); + expect(graph.packages.has("@test/pkg-a")).toBe(true); + expect(graph.dependents.size).toBe(0); + }); + + test("should build graph with dependencies", () => { + const { buildDependencyGraph } = require("./version.js"); + + const readFileSyncSpy = spyOn(fs, "readFileSync"); + readFileSyncSpy.mockImplementation((path: string) => { + if (path === "packages/pkg-a/package.json") { + return JSON.stringify({ + name: "@test/pkg-a", + version: "1.0.0", + }); + } + if (path === "packages/pkg-b/package.json") { + return JSON.stringify({ + name: "@test/pkg-b", + version: "1.0.0", + dependencies: { + "@test/pkg-a": "^1.0.0", + }, + }); + } + return "{}"; + }); + + const graph = buildDependencyGraph([ + "packages/pkg-a/package.json", + "packages/pkg-b/package.json", + ]); + + expect(graph.packages.size).toBe(2); + expect(graph.dependents.get("@test/pkg-a")).toEqual(new Set(["@test/pkg-b"])); + }); + + test("should track devDependencies", () => { + const { buildDependencyGraph } = require("./version.js"); + + const readFileSyncSpy = spyOn(fs, "readFileSync"); + readFileSyncSpy.mockImplementation((path: string) => { + if (path === "packages/pkg-a/package.json") { + return JSON.stringify({ + name: "@test/pkg-a", + version: "1.0.0", + }); + } + if (path === "packages/pkg-b/package.json") { + return JSON.stringify({ + name: "@test/pkg-b", + version: "1.0.0", + devDependencies: { + "@test/pkg-a": "^1.0.0", + }, + }); + } + return "{}"; + }); + + const graph = buildDependencyGraph([ + "packages/pkg-a/package.json", + "packages/pkg-b/package.json", + ]); + + expect(graph.dependents.get("@test/pkg-a")).toEqual(new Set(["@test/pkg-b"])); + }); + + test("should track peerDependencies", () => { + const { buildDependencyGraph } = require("./version.js"); + + const readFileSyncSpy = spyOn(fs, "readFileSync"); + readFileSyncSpy.mockImplementation((path: string) => { + if (path === "packages/pkg-a/package.json") { + return JSON.stringify({ + name: "@test/pkg-a", + version: "1.0.0", + }); + } + if (path === "packages/pkg-b/package.json") { + return JSON.stringify({ + name: "@test/pkg-b", + version: "1.0.0", + peerDependencies: { + "@test/pkg-a": "^1.0.0", + }, + }); + } + return "{}"; + }); + + const graph = buildDependencyGraph([ + "packages/pkg-a/package.json", + "packages/pkg-b/package.json", + ]); + + expect(graph.dependents.get("@test/pkg-a")).toEqual(new Set(["@test/pkg-b"])); + }); + + test("should handle multiple dependents", () => { + const { buildDependencyGraph } = require("./version.js"); + + const readFileSyncSpy = spyOn(fs, "readFileSync"); + readFileSyncSpy.mockImplementation((path: string) => { + if (path === "packages/pkg-a/package.json") { + return JSON.stringify({ + name: "@test/pkg-a", + version: "1.0.0", + }); + } + if (path === "packages/pkg-b/package.json") { + return JSON.stringify({ + name: "@test/pkg-b", + version: "1.0.0", + dependencies: { + "@test/pkg-a": "^1.0.0", + }, + }); + } + if (path === "packages/pkg-c/package.json") { + return JSON.stringify({ + name: "@test/pkg-c", + version: "1.0.0", + dependencies: { + "@test/pkg-a": "^1.0.0", + }, + }); + } + return "{}"; + }); + + const graph = buildDependencyGraph([ + "packages/pkg-a/package.json", + "packages/pkg-b/package.json", + "packages/pkg-c/package.json", + ]); + + expect(graph.dependents.get("@test/pkg-a")).toEqual(new Set(["@test/pkg-b", "@test/pkg-c"])); + }); + + test("should not track external dependencies", () => { + const { buildDependencyGraph } = require("./version.js"); + + const readFileSyncSpy = spyOn(fs, "readFileSync"); + readFileSyncSpy.mockImplementation((path: string) => { + if (path === "packages/pkg-a/package.json") { + return JSON.stringify({ + name: "@test/pkg-a", + version: "1.0.0", + dependencies: { + react: "^18.0.0", + lodash: "^4.17.21", + }, + }); + } + return "{}"; + }); + + const graph = buildDependencyGraph(["packages/pkg-a/package.json"]); + + expect(graph.dependents.has("react")).toBe(false); + expect(graph.dependents.has("lodash")).toBe(false); + }); + }); + + describe("integration tests with version command", () => { + let writeFileSyncSpy: any; + let readFileSyncSpy: any; + let existsSyncSpy: any; + let globSyncSpy: any; + let unlinkSyncSpy: any; + + beforeEach(() => { + writeFileSyncSpy = spyOn(fs, "writeFileSync").mockImplementation(() => {}); + existsSyncSpy = spyOn(fs, "existsSync").mockReturnValue(true); + unlinkSyncSpy = spyOn(fs, "unlinkSync").mockImplementation(() => {}); + globSyncSpy = spyOn(tinyglobby, "globSync"); + readFileSyncSpy = spyOn(fs, "readFileSync"); + }); + + afterEach(() => { + writeFileSyncSpy.mockRestore(); + existsSyncSpy.mockRestore(); + unlinkSyncSpy.mockRestore(); + globSyncSpy.mockRestore(); + readFileSyncSpy.mockRestore(); + }); + + test("patch policy: should update dependency range for patch bump", async () => { + globSyncSpy.mockImplementation((opts: any) => { + if (opts.patterns[0] === ".changeset/*.md") { + return [".changeset/test.md"]; + } + return ["packages/pkg-a/package.json", "packages/pkg-b/package.json"]; + }); + + readFileSyncSpy.mockImplementation((path: string) => { + if (path === ".changeset/test.md") { + return `--- +"@test/pkg-a": fix +--- + +Bug fix`; + } + if (path === "packages/pkg-a/package.json") { + return JSON.stringify({ + name: "@test/pkg-a", + version: "1.0.0", + }); + } + if (path === "packages/pkg-b/package.json") { + return JSON.stringify({ + name: "@test/pkg-b", + version: "1.0.0", + dependencies: { + "@test/pkg-a": "^1.0.0", + }, + }); + } + return "{}"; + }); + + await version(); + + // Check that pkg-a was updated to 1.0.1 + const pkgACall = writeFileSyncSpy.mock.calls.find((call: any) => + call[0].includes("pkg-a/package.json"), + ); + expect(pkgACall).toBeDefined(); + const pkgAContent = JSON.parse(pkgACall[1]); + expect(pkgAContent.version).toBe("1.0.1"); + + // Check that pkg-b was updated to 1.0.1 (patch bump due to dependency update) + const pkgBCall = writeFileSyncSpy.mock.calls.find((call: any) => + call[0].includes("pkg-b/package.json"), + ); + expect(pkgBCall).toBeDefined(); + const pkgBContent = JSON.parse(pkgBCall[1]); + expect(pkgBContent.version).toBe("1.0.1"); + expect(pkgBContent.dependencies["@test/pkg-a"]).toBe("^1.0.1"); + }); + + test("should handle dependency chains (A depends on B, B depends on C)", async () => { + globSyncSpy.mockImplementation((opts: any) => { + if (opts.patterns[0] === ".changeset/*.md") { + return [".changeset/test.md"]; + } + return [ + "packages/pkg-a/package.json", + "packages/pkg-b/package.json", + "packages/pkg-c/package.json", + ]; + }); + + readFileSyncSpy.mockImplementation((path: string) => { + if (path === ".changeset/test.md") { + return `--- +"@test/pkg-c": feat +--- + +New feature in C`; + } + if (path === "packages/pkg-a/package.json") { + return JSON.stringify({ + name: "@test/pkg-a", + version: "1.0.0", + dependencies: { + "@test/pkg-b": "^1.0.0", + }, + }); + } + if (path === "packages/pkg-b/package.json") { + return JSON.stringify({ + name: "@test/pkg-b", + version: "1.0.0", + dependencies: { + "@test/pkg-c": "^1.0.0", + }, + }); + } + if (path === "packages/pkg-c/package.json") { + return JSON.stringify({ + name: "@test/pkg-c", + version: "1.0.0", + }); + } + return "{}"; + }); + + await version(); + + // pkg-c should be 1.1.0 (minor bump from changeset) + const pkgCCall = writeFileSyncSpy.mock.calls.find((call: any) => + call[0].includes("pkg-c/package.json"), + ); + expect(pkgCCall).toBeDefined(); + const pkgCContent = JSON.parse(pkgCCall[1]); + expect(pkgCContent.version).toBe("1.1.0"); + + // pkg-b should be 1.0.1 (patch bump, depends on pkg-c) + const pkgBCall = writeFileSyncSpy.mock.calls.find((call: any) => + call[0].includes("pkg-b/package.json"), + ); + expect(pkgBCall).toBeDefined(); + const pkgBContent = JSON.parse(pkgBCall[1]); + expect(pkgBContent.version).toBe("1.0.1"); + expect(pkgBContent.dependencies["@test/pkg-c"]).toBe("^1.1.0"); + + // pkg-a should be 1.0.1 (patch bump, depends on pkg-b) + const pkgACall = writeFileSyncSpy.mock.calls.find((call: any) => + call[0].includes("pkg-a/package.json"), + ); + expect(pkgACall).toBeDefined(); + const pkgAContent = JSON.parse(pkgACall[1]); + expect(pkgAContent.version).toBe("1.0.1"); + expect(pkgAContent.dependencies["@test/pkg-b"]).toBe("^1.0.1"); + }); + + test("should update devDependencies", async () => { + globSyncSpy.mockImplementation((opts: any) => { + if (opts.patterns[0] === ".changeset/*.md") { + return [".changeset/test.md"]; + } + return ["packages/pkg-a/package.json", "packages/pkg-b/package.json"]; + }); + + readFileSyncSpy.mockImplementation((path: string) => { + if (path === ".changeset/test.md") { + return `--- +"@test/pkg-a": fix +--- + +Bug fix`; + } + if (path === "packages/pkg-a/package.json") { + return JSON.stringify({ + name: "@test/pkg-a", + version: "1.0.0", + }); + } + if (path === "packages/pkg-b/package.json") { + return JSON.stringify({ + name: "@test/pkg-b", + version: "1.0.0", + devDependencies: { + "@test/pkg-a": "^1.0.0", + }, + }); + } + return "{}"; + }); + + await version(); + + const pkgBCall = writeFileSyncSpy.mock.calls.find((call: any) => + call[0].includes("pkg-b/package.json"), + ); + expect(pkgBCall).toBeDefined(); + const pkgBContent = JSON.parse(pkgBCall[1]); + expect(pkgBContent.devDependencies["@test/pkg-a"]).toBe("^1.0.1"); + }); + + test("should update peerDependencies", async () => { + globSyncSpy.mockImplementation((opts: any) => { + if (opts.patterns[0] === ".changeset/*.md") { + return [".changeset/test.md"]; + } + return ["packages/pkg-a/package.json", "packages/pkg-b/package.json"]; + }); + + readFileSyncSpy.mockImplementation((path: string) => { + if (path === ".changeset/test.md") { + return `--- +"@test/pkg-a": fix +--- + +Bug fix`; + } + if (path === "packages/pkg-a/package.json") { + return JSON.stringify({ + name: "@test/pkg-a", + version: "1.0.0", + }); + } + if (path === "packages/pkg-b/package.json") { + return JSON.stringify({ + name: "@test/pkg-b", + version: "1.0.0", + peerDependencies: { + "@test/pkg-a": "^1.0.0", + }, + }); + } + return "{}"; + }); + + await version(); + + const pkgBCall = writeFileSyncSpy.mock.calls.find((call: any) => + call[0].includes("pkg-b/package.json"), + ); + expect(pkgBCall).toBeDefined(); + const pkgBContent = JSON.parse(pkgBCall[1]); + expect(pkgBContent.peerDependencies["@test/pkg-a"]).toBe("^1.0.1"); + }); + + test("should add dependency updates to changelog", async () => { + globSyncSpy.mockImplementation((opts: any) => { + if (opts.patterns[0] === ".changeset/*.md") { + return [".changeset/test.md"]; + } + return ["packages/pkg-a/package.json", "packages/pkg-b/package.json"]; + }); + + readFileSyncSpy.mockImplementation((path: string) => { + if (path === ".changeset/test.md") { + return `--- +"@test/pkg-a": fix +--- + +Bug fix`; + } + if (path === "packages/pkg-a/package.json") { + return JSON.stringify({ + name: "@test/pkg-a", + version: "1.0.0", + }); + } + if (path === "packages/pkg-b/package.json") { + return JSON.stringify({ + name: "@test/pkg-b", + version: "1.0.0", + dependencies: { + "@test/pkg-a": "^1.0.0", + }, + }); + } + if (path.includes("CHANGELOG.md")) { + return ""; + } + return "{}"; + }); + + await version(); + + // Find the changelog write for pkg-b + const changelogCall = writeFileSyncSpy.mock.calls.find( + (call: any) => call[0].includes("pkg-b") && call[0].includes("CHANGELOG.md"), + ); + expect(changelogCall).toBeDefined(); + const changelogContent = changelogCall[1]; + expect(changelogContent).toContain("### 📦 Dependencies"); + expect(changelogContent).toContain("Updated @test/pkg-a from ^1.0.0 to ^1.0.1"); + }); + }); +}); diff --git a/src/version.ts b/src/version.ts index 5b3e252..ba774f4 100644 --- a/src/version.ts +++ b/src/version.ts @@ -14,6 +14,33 @@ export interface ChangesetReleaseType { isBreaking: boolean; } +export interface PackageInfo { + name: string; + version: string; + path: string; + packageJson: any; +} + +export interface DependencyGraph { + packages: Map; + dependents: Map>; +} + +export interface DependencyUpdate { + name: string; + from: string; + to: string; +} + +export interface UpdateResult { + packageName: string; + oldVersion: string; + newVersion: string; + releaseType: "major" | "minor" | "patch"; + reason: "changeset" | "dependency"; + dependencyUpdates?: DependencyUpdate[]; +} + export function parseChangesetFile(filePath: string): ChangesetReleaseType[] { const config = readConfig(); const content = readFileSync(filePath, "utf-8"); @@ -89,15 +116,207 @@ export function bumpVersion( } } +export function buildDependencyGraph(packageJsonPaths: string[]): DependencyGraph { + const packages = new Map(); + const dependents = new Map>(); + + // Load all package.json files + for (const pkgPath of packageJsonPaths) { + const packageJson = JSON.parse(readFileSync(pkgPath, "utf-8")); + if (!packageJson.name) continue; + + packages.set(packageJson.name, { + name: packageJson.name, + version: packageJson.version, + path: pkgPath, + packageJson, + }); + } + + // Build reverse dependency map (who depends on whom) + for (const [pkgName, pkgInfo] of packages) { + const allDeps = { + ...pkgInfo.packageJson.dependencies, + ...pkgInfo.packageJson.devDependencies, + ...pkgInfo.packageJson.peerDependencies, + }; + + for (const depName of Object.keys(allDeps)) { + // Only track internal dependencies (packages in the monorepo) + if (packages.has(depName)) { + if (!dependents.has(depName)) { + dependents.set(depName, new Set()); + } + dependents.get(depName)!.add(pkgName); + } + } + } + + return { packages, dependents }; +} + +export function shouldUpdateDependency( + updatePolicy: "patch" | "minor" | "major" | "none", + releaseType: "patch" | "minor" | "major", +): boolean { + if (updatePolicy === "none") return false; + if (updatePolicy === "patch") return true; + if (updatePolicy === "minor") return releaseType === "minor" || releaseType === "major"; + if (updatePolicy === "major") return releaseType === "major"; + return false; +} + +export function updateDependencyRange(currentRange: string, newVersion: string): string { + // Preserve the range operator while updating the version + + // Handle workspace protocol + if (currentRange.startsWith("workspace:")) { + const operator = currentRange.replace("workspace:", ""); + if (operator === "*") return currentRange; // Don't change workspace:* + // Extract operator and update version + const match = operator.match(/^([~^>=<]*)(.*)$/); + if (match) { + return `workspace:${match[1]}${newVersion}`; + } + } + + // Handle standard ranges + if (currentRange === "*") return currentRange; + + // Extract operator (^, ~, >=, etc.) + const match = currentRange.match(/^([~^>=<]*)(.*)$/); + if (match) { + return `${match[1]}${newVersion}`; + } + + // Exact version + return newVersion; +} + +export function getDependencyRange(packageJson: any, depName: string): string | null { + return ( + packageJson.dependencies?.[depName] || + packageJson.devDependencies?.[depName] || + packageJson.peerDependencies?.[depName] || + null + ); +} + +export function updatePackageDependencies( + packageJson: any, + updates: Map, +): DependencyUpdate[] { + const dependencyUpdates: DependencyUpdate[] = []; + const depTypes = ["dependencies", "devDependencies", "peerDependencies"]; + + for (const depType of depTypes) { + if (!packageJson[depType]) continue; + + for (const [depName, updateInfo] of updates) { + if (packageJson[depType][depName]) { + const currentRange = packageJson[depType][depName]; + const newRange = updateDependencyRange(currentRange, updateInfo.newVersion); + if (currentRange !== newRange) { + packageJson[depType][depName] = newRange; + dependencyUpdates.push({ + name: depName, + from: currentRange, + to: newRange, + }); + } + } + } + } + + return dependencyUpdates; +} + +export function cascadeVersionUpdates( + initialUpdates: Map, + dependencyGraph: DependencyGraph, + updatePolicy: "patch" | "minor" | "major" | "none", +): Map { + const allUpdates = new Map(); + const processed = new Set(); + const queue: Array<{ packageName: string; releaseType: string }> = []; + + // Initialize with packages that have changesets + for (const [pkgName, update] of initialUpdates) { + const pkgInfo = dependencyGraph.packages.get(pkgName); + if (!pkgInfo) continue; + + allUpdates.set(pkgName, { + packageName: pkgName, + oldVersion: pkgInfo.version, + newVersion: update.version, + releaseType: update.releaseType as "major" | "minor" | "patch", + reason: "changeset", + }); + + queue.push({ packageName: pkgName, releaseType: update.releaseType }); + } + + // Process queue with cascading updates + while (queue.length > 0) { + const current = queue.shift()!; + if (processed.has(current.packageName)) continue; + processed.add(current.packageName); + + // Find dependents of this package + const dependentNames = dependencyGraph.dependents.get(current.packageName); + if (!dependentNames) continue; + + for (const depName of dependentNames) { + // Skip if already updated by a changeset + if (initialUpdates.has(depName)) continue; + + // Check if we should update based on policy + if (!shouldUpdateDependency(updatePolicy, current.releaseType as any)) continue; + + // Skip if already processed + if (allUpdates.has(depName)) continue; + + const depInfo = dependencyGraph.packages.get(depName); + if (!depInfo) continue; + + // Dependent gets a patch bump (since its package.json is changing) + const newVersion = bumpVersion(depInfo.version, "patch", false); + + allUpdates.set(depName, { + packageName: depName, + oldVersion: depInfo.version, + newVersion, + releaseType: "patch", + reason: "dependency", + }); + + // Add to queue for further cascading + queue.push({ packageName: depName, releaseType: "patch" }); + } + } + + return allUpdates; +} + export function generateChangelog( packageName: string, version: string, changesetContents: string[], + dependencyUpdates?: DependencyUpdate[], ): string { const config = readConfig(); const date = new Date().toISOString().split("T")[0]; let changelog = `## ${version} (${date})\n\n`; + // Add dependency updates section first + if (dependencyUpdates && dependencyUpdates.length > 0) { + changelog += `### 📦 Dependencies\n`; + for (const dep of dependencyUpdates) { + changelog += `- Updated ${dep.name} from ${dep.from} to ${dep.to}\n`; + } + changelog += "\n"; + } + const typeGroups: Map = new Map(); const breakingChanges: string[] = []; @@ -207,32 +426,61 @@ export async function version({ dryRun = false, ignore = [] as string[], install patterns: ["**/package.json", "!**/node_modules/**", "!**/dist/**"], }); - const updatedPackages: string[] = []; - - for (const packageJsonPath of packageJsonPaths) { - const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); - const packageName = packageJson.name; + // Build dependency graph + const config = readConfig(); + const dependencyGraph = buildDependencyGraph(packageJsonPaths); - if (!packageName) continue; + // Collect initial updates from changesets + const initialUpdates = new Map(); + for (const [packageName, pkgInfo] of dependencyGraph.packages) { const releases = packageReleases.get(packageName); if (!releases) continue; - const currentVersion = packageJson.version; + const currentVersion = pkgInfo.version; const highestReleaseType = getHighestReleaseType(releases); const hasBreakingChange = releases.some((r) => r.isBreaking); const newVersion = bumpVersion(currentVersion, highestReleaseType, hasBreakingChange); - packageJson.version = newVersion; + initialUpdates.set(packageName, { + version: newVersion, + releaseType: highestReleaseType, + }); + } + + // Cascade updates to dependents + const allUpdates = cascadeVersionUpdates( + initialUpdates, + dependencyGraph, + config.updateInternalDependencies, + ); + + const updatedPackages: string[] = []; + + // Apply all updates + for (const [packageName, updateInfo] of allUpdates) { + const pkgInfo = dependencyGraph.packages.get(packageName); + if (!pkgInfo) continue; + + const packageJson = pkgInfo.packageJson; + packageJson.version = updateInfo.newVersion; + + // Update dependencies if this package depends on updated packages + const depUpdates = updatePackageDependencies(packageJson, allUpdates); if (!dryRun) { - writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n", "utf-8"); + writeFileSync(pkgInfo.path, JSON.stringify(packageJson, null, 2) + "\n", "utf-8"); - const packageDir = path.dirname(packageJsonPath); + const packageDir = path.dirname(pkgInfo.path); const changelogPath = path.join(packageDir, "CHANGELOG.md"); const changesetContents = packageChangelogs.get(packageName) || []; - const newChangelog = generateChangelog(packageName, newVersion, changesetContents); + const newChangelog = generateChangelog( + packageName, + updateInfo.newVersion, + changesetContents, + depUpdates.length > 0 ? depUpdates : undefined, + ); let existingChangelog = ""; if (existsSync(changelogPath)) { @@ -242,7 +490,17 @@ export async function version({ dryRun = false, ignore = [] as string[], install writeFileSync(changelogPath, newChangelog + "\n" + existingChangelog, "utf-8"); } - console.log(pc.green("✔"), pc.cyan(packageName), pc.dim(`(${currentVersion} → ${newVersion})`)); + console.log( + pc.green("✔"), + pc.cyan(packageName), + pc.dim(`(${updateInfo.oldVersion} → ${updateInfo.newVersion})`), + ); + + if (depUpdates.length > 0) { + for (const depUpdate of depUpdates) { + console.log(pc.dim(` ↳ Updated dependency: ${depUpdate.name} ${depUpdate.from} → ${depUpdate.to}`)); + } + } updatedPackages.push(packageName); } From 92f3bbef0496c1438332d48adcda345aec603a89 Mon Sep 17 00:00:00 2001 From: cadamsdev Date: Mon, 2 Feb 2026 20:58:26 -0500 Subject: [PATCH 2/6] use esm imports --- src/version.test.ts | 37 +++---------------------------------- 1 file changed, 3 insertions(+), 34 deletions(-) diff --git a/src/version.test.ts b/src/version.test.ts index cec44da..ddb5d93 100644 --- a/src/version.test.ts +++ b/src/version.test.ts @@ -85,6 +85,9 @@ import { bumpVersion, generateChangelog, version, + shouldUpdateDependency, + updateDependencyRange, + buildDependencyGraph, type ChangesetReleaseType, } from "./version.js"; @@ -1433,80 +1436,58 @@ v1 release`, describe("updateInternalDependencies", () => { describe("helper functions", () => { test("shouldUpdateDependency with 'patch' policy", () => { - const { shouldUpdateDependency } = require("./version.js"); - expect(shouldUpdateDependency("patch", "patch")).toBe(true); expect(shouldUpdateDependency("patch", "minor")).toBe(true); expect(shouldUpdateDependency("patch", "major")).toBe(true); }); test("shouldUpdateDependency with 'minor' policy", () => { - const { shouldUpdateDependency } = require("./version.js"); - expect(shouldUpdateDependency("minor", "patch")).toBe(false); expect(shouldUpdateDependency("minor", "minor")).toBe(true); expect(shouldUpdateDependency("minor", "major")).toBe(true); }); test("shouldUpdateDependency with 'major' policy", () => { - const { shouldUpdateDependency } = require("./version.js"); - expect(shouldUpdateDependency("major", "patch")).toBe(false); expect(shouldUpdateDependency("major", "minor")).toBe(false); expect(shouldUpdateDependency("major", "major")).toBe(true); }); test("shouldUpdateDependency with 'none' policy", () => { - const { shouldUpdateDependency } = require("./version.js"); - expect(shouldUpdateDependency("none", "patch")).toBe(false); expect(shouldUpdateDependency("none", "minor")).toBe(false); expect(shouldUpdateDependency("none", "major")).toBe(false); }); test("updateDependencyRange should preserve caret (^) operator", () => { - const { updateDependencyRange } = require("./version.js"); - expect(updateDependencyRange("^1.0.0", "1.0.1")).toBe("^1.0.1"); expect(updateDependencyRange("^1.0.0", "1.1.0")).toBe("^1.1.0"); expect(updateDependencyRange("^1.0.0", "2.0.0")).toBe("^2.0.0"); }); test("updateDependencyRange should preserve tilde (~) operator", () => { - const { updateDependencyRange } = require("./version.js"); - expect(updateDependencyRange("~1.0.0", "1.0.1")).toBe("~1.0.1"); expect(updateDependencyRange("~1.0.0", "1.1.0")).toBe("~1.1.0"); }); test("updateDependencyRange should handle exact version", () => { - const { updateDependencyRange } = require("./version.js"); - expect(updateDependencyRange("1.0.0", "1.0.1")).toBe("1.0.1"); }); test("updateDependencyRange should preserve * wildcard", () => { - const { updateDependencyRange } = require("./version.js"); - expect(updateDependencyRange("*", "1.0.1")).toBe("*"); }); test("updateDependencyRange should handle workspace:* protocol", () => { - const { updateDependencyRange } = require("./version.js"); - expect(updateDependencyRange("workspace:*", "1.0.1")).toBe("workspace:*"); }); test("updateDependencyRange should handle workspace:^ protocol", () => { - const { updateDependencyRange } = require("./version.js"); - expect(updateDependencyRange("workspace:^1.0.0", "1.0.1")).toBe("workspace:^1.0.1"); expect(updateDependencyRange("workspace:~1.0.0", "1.0.1")).toBe("workspace:~1.0.1"); }); test("updateDependencyRange should handle >= operator", () => { - const { updateDependencyRange } = require("./version.js"); - expect(updateDependencyRange(">=1.0.0", "1.0.1")).toBe(">=1.0.1"); }); }); @@ -1517,8 +1498,6 @@ describe("updateInternalDependencies", () => { }); test("should build graph with single package", () => { - const { buildDependencyGraph } = require("./version.js"); - const readFileSyncSpy = spyOn(fs, "readFileSync"); readFileSyncSpy.mockReturnValue( JSON.stringify({ @@ -1535,8 +1514,6 @@ describe("updateInternalDependencies", () => { }); test("should build graph with dependencies", () => { - const { buildDependencyGraph } = require("./version.js"); - const readFileSyncSpy = spyOn(fs, "readFileSync"); readFileSyncSpy.mockImplementation((path: string) => { if (path === "packages/pkg-a/package.json") { @@ -1567,8 +1544,6 @@ describe("updateInternalDependencies", () => { }); test("should track devDependencies", () => { - const { buildDependencyGraph } = require("./version.js"); - const readFileSyncSpy = spyOn(fs, "readFileSync"); readFileSyncSpy.mockImplementation((path: string) => { if (path === "packages/pkg-a/package.json") { @@ -1598,8 +1573,6 @@ describe("updateInternalDependencies", () => { }); test("should track peerDependencies", () => { - const { buildDependencyGraph } = require("./version.js"); - const readFileSyncSpy = spyOn(fs, "readFileSync"); readFileSyncSpy.mockImplementation((path: string) => { if (path === "packages/pkg-a/package.json") { @@ -1629,8 +1602,6 @@ describe("updateInternalDependencies", () => { }); test("should handle multiple dependents", () => { - const { buildDependencyGraph } = require("./version.js"); - const readFileSyncSpy = spyOn(fs, "readFileSync"); readFileSyncSpy.mockImplementation((path: string) => { if (path === "packages/pkg-a/package.json") { @@ -1670,8 +1641,6 @@ describe("updateInternalDependencies", () => { }); test("should not track external dependencies", () => { - const { buildDependencyGraph } = require("./version.js"); - const readFileSyncSpy = spyOn(fs, "readFileSync"); readFileSyncSpy.mockImplementation((path: string) => { if (path === "packages/pkg-a/package.json") { From 57dc0df963d51bbb9f49d5be811f559b11e6289d Mon Sep 17 00:00:00 2001 From: cadamsdev Date: Mon, 2 Feb 2026 21:01:13 -0500 Subject: [PATCH 3/6] fix test --- src/version.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/version.test.ts b/src/version.test.ts index ddb5d93..86cf83c 100644 --- a/src/version.test.ts +++ b/src/version.test.ts @@ -1663,7 +1663,7 @@ describe("updateInternalDependencies", () => { }); }); - describe("integration tests with version command", () => { + describe("version command with dependency updates", () => { let writeFileSyncSpy: any; let readFileSyncSpy: any; let existsSyncSpy: any; From 0316d5eec32a49738e3d2c6d05ab33f3d2ccdd4c Mon Sep 17 00:00:00 2001 From: cadamsdev Date: Mon, 2 Feb 2026 21:16:59 -0500 Subject: [PATCH 4/6] added changeset --- .changeset/fruity-birds-itch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fruity-birds-itch.md diff --git a/.changeset/fruity-birds-itch.md b/.changeset/fruity-birds-itch.md new file mode 100644 index 0000000..f164996 --- /dev/null +++ b/.changeset/fruity-birds-itch.md @@ -0,0 +1,5 @@ +--- +"@lazy-release/changesets": feat +--- + +Implemented updateInternalDependencies config option From 2bc3108b3a8257b3f000e5a61454332bb7a9ce40 Mon Sep 17 00:00:00 2001 From: cadamsdev Date: Mon, 2 Feb 2026 21:17:09 -0500 Subject: [PATCH 5/6] format code --- src/version.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/version.ts b/src/version.ts index ba774f4..7be1330 100644 --- a/src/version.ts +++ b/src/version.ts @@ -498,7 +498,9 @@ export async function version({ dryRun = false, ignore = [] as string[], install if (depUpdates.length > 0) { for (const depUpdate of depUpdates) { - console.log(pc.dim(` ↳ Updated dependency: ${depUpdate.name} ${depUpdate.from} → ${depUpdate.to}`)); + console.log( + pc.dim(` ↳ Updated dependency: ${depUpdate.name} ${depUpdate.from} → ${depUpdate.to}`), + ); } } From 84220c2275419aae466e717e1b03e01cb80c5969 Mon Sep 17 00:00:00 2001 From: cadamsdev Date: Mon, 2 Feb 2026 22:05:26 -0500 Subject: [PATCH 6/6] add version key if its missing in the package.json --- src/version.test.ts | 59 +++++++++++++++++++++++++++++++++++++++++++++ src/version.ts | 5 ++++ 2 files changed, 64 insertions(+) diff --git a/src/version.test.ts b/src/version.test.ts index 86cf83c..7f9ca2f 100644 --- a/src/version.test.ts +++ b/src/version.test.ts @@ -1661,6 +1661,26 @@ describe("updateInternalDependencies", () => { expect(graph.dependents.has("react")).toBe(false); expect(graph.dependents.has("lodash")).toBe(false); }); + + test("should initialize missing version field to 0.0.0", () => { + const readFileSyncSpy = spyOn(fs, "readFileSync"); + readFileSyncSpy.mockImplementation((path: string) => { + if (path === "packages/pkg-a/package.json") { + return JSON.stringify({ + name: "@test/pkg-a", + // No version field + }); + } + return "{}"; + }); + + const graph = buildDependencyGraph(["packages/pkg-a/package.json"]); + + const pkgInfo = graph.packages.get("@test/pkg-a"); + expect(pkgInfo).toBeDefined(); + expect(pkgInfo?.version).toBe("0.0.0"); + expect(pkgInfo?.packageJson.version).toBe("0.0.0"); + }); }); describe("version command with dependency updates", () => { @@ -1952,5 +1972,44 @@ Bug fix`; expect(changelogContent).toContain("### 📦 Dependencies"); expect(changelogContent).toContain("Updated @test/pkg-a from ^1.0.0 to ^1.0.1"); }); + + test("should initialize version to 0.0.0 if missing", async () => { + globSyncSpy.mockImplementation((opts: any) => { + if (opts.patterns[0] === ".changeset/*.md") { + return [".changeset/test.md"]; + } + return ["packages/pkg-a/package.json"]; + }); + + readFileSyncSpy.mockImplementation((path: string) => { + if (path === ".changeset/test.md") { + return `--- +"@test/pkg-a": fix +--- + +Bug fix`; + } + if (path === "packages/pkg-a/package.json") { + return JSON.stringify({ + name: "@test/pkg-a", + // No version field + }); + } + if (path.includes("CHANGELOG.md")) { + return ""; + } + return "{}"; + }); + + await version(); + + // Check that pkg-a was initialized to 0.0.0 and bumped to 0.0.1 + const pkgACall = writeFileSyncSpy.mock.calls.find((call: any) => + call[0].includes("pkg-a/package.json"), + ); + expect(pkgACall).toBeDefined(); + const pkgAContent = JSON.parse(pkgACall[1]); + expect(pkgAContent.version).toBe("0.0.1"); + }); }); }); diff --git a/src/version.ts b/src/version.ts index 7be1330..b0a5704 100644 --- a/src/version.ts +++ b/src/version.ts @@ -125,6 +125,11 @@ export function buildDependencyGraph(packageJsonPaths: string[]): DependencyGrap const packageJson = JSON.parse(readFileSync(pkgPath, "utf-8")); if (!packageJson.name) continue; + // Initialize version to "0.0.0" if missing + if (!packageJson.version) { + packageJson.version = "0.0.0"; + } + packages.set(packageJson.name, { name: packageJson.name, version: packageJson.version,