From ec92d38106e713285b40367de0fbba8eae83022a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adel=20Rodr=C3=ADguez?= Date: Fri, 2 Oct 2026 12:20:28 -0400 Subject: [PATCH 1/2] Fix sync failing on workspace packages without a version Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/quiet-links-sync.md | 5 ++ .../manifests/__tests__/javascript.test.ts | 65 ++++++++++++++++++- src/lib/manifests/javascript.ts | 14 +++- 3 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 .changeset/quiet-links-sync.md diff --git a/.changeset/quiet-links-sync.md b/.changeset/quiet-links-sync.md new file mode 100644 index 0000000..3752d8d --- /dev/null +++ b/.changeset/quiet-links-sync.md @@ -0,0 +1,5 @@ +--- +"packref": patch +--- + +Fix `packref sync` in monorepos that have private workspace packages with no `version` field. Packref does not show a manifest parse error for these packages now. Packref also does not read `node_modules` for `link:`, `file:`, and `portal:` dependencies. diff --git a/src/lib/manifests/__tests__/javascript.test.ts b/src/lib/manifests/__tests__/javascript.test.ts index 0cb1668..39d14d4 100644 --- a/src/lib/manifests/__tests__/javascript.test.ts +++ b/src/lib/manifests/__tests__/javascript.test.ts @@ -1,6 +1,6 @@ import type * as FileSystem from "effect/FileSystem" import type * as Path from "effect/Path" -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import * as NodeServices from "@effect/platform-node/NodeServices" @@ -485,6 +485,69 @@ importers: expect(dependencies[0]?.exactVersion).toBe("4.0.0-beta.55") }) + it("reports no exact version for a symlinked workspace package without a version", async () => { + const workspaceRoot = await makeTempDirectory() + const projectPath = join(workspaceRoot, "apps", "web") + const workspacePackagePath = join(workspaceRoot, "tooling", "linting") + await mkdir(projectPath, { recursive: true }) + await mkdir(workspacePackagePath, { recursive: true }) + await writeFile( + join(projectPath, "package.json"), + JSON.stringify({ + dependencies: { + effect: "^4.0.0-beta.50", + }, + devDependencies: { + "@tooling/linting": "workspace:*", + }, + }) + ) + await writeFile( + join(workspacePackagePath, "package.json"), + JSON.stringify({ name: "@tooling/linting", private: true }) + ) + await mkdir(join(projectPath, "node_modules", "@tooling"), { recursive: true }) + await symlink(workspacePackagePath, join(projectPath, "node_modules", "@tooling", "linting")) + await mkdir(join(projectPath, "node_modules", "effect"), { recursive: true }) + await writeFile( + join(projectPath, "node_modules", "effect", "package.json"), + JSON.stringify({ version: "4.0.0-beta.55" }) + ) + + const dependencies = await run(readJavascriptManifest(projectPath)) + + expect(dependencies.map(({ exactVersion, name }) => [name, exactVersion])).toEqual([ + ["effect", "4.0.0-beta.55"], + ["@tooling/linting", undefined], + ]) + }) + + it.each(["link:../local", "file:../local", "portal:../local"])( + "does not read node_modules for the local specifier %s", + async (specifier) => { + const projectPath = await makeTempDirectory() + await writeFile( + join(projectPath, "package.json"), + JSON.stringify({ + dependencies: { + local: specifier, + }, + }) + ) + await mkdir(join(projectPath, "node_modules", "local"), { recursive: true }) + await writeFile( + join(projectPath, "node_modules", "local", "package.json"), + "{ not valid JSON" + ) + + const dependencies = await run(readJavascriptManifest(projectPath)) + + expect(dependencies).toEqual([ + { group: "dependencies", name: "local", registry: "npm", specifier }, + ]) + } + ) + it("reports no dependency for installed packages absent from the manifest", async () => { const projectPath = await makeTempDirectory() await writeFile( diff --git a/src/lib/manifests/javascript.ts b/src/lib/manifests/javascript.ts index 963e68d..77caddc 100644 --- a/src/lib/manifests/javascript.ts +++ b/src/lib/manifests/javascript.ts @@ -21,6 +21,12 @@ const DEPENDENCY_GROUPS = ["dependencies", "devDependencies", "peerDependencies" const NODE_MODULES_RESOLUTION_CONCURRENCY = 8 +// These specifiers point to source on disk, so no registry version exists for them. +const LOCAL_SPECIFIER_PREFIXES = ["link:", "file:", "portal:"] as const + +const isLocalSpecifier = (specifier: string) => + LOCAL_SPECIFIER_PREFIXES.some((prefix) => specifier.startsWith(prefix)) + const DependencyRecordSchema = Schema.Record(Schema.String, Schema.String) export const JavascriptPackageManifestSchema = Schema.StructWithRest( @@ -34,7 +40,7 @@ export const JavascriptPackageManifestSchema = Schema.StructWithRest( const InstalledPackageSchema = Schema.StructWithRest( Schema.Struct({ - version: Schema.String, + version: Schema.optional(Schema.String), }), [Schema.Record(Schema.String, Schema.Unknown)] ) @@ -485,7 +491,7 @@ const readNodeModulesVersion = Effect.fn("readNodeModulesVersion")(function* ( toManifestParseError(packageJsonPath) ) - return valid(manifest.version) === null + return manifest.version === undefined || valid(manifest.version) === null ? Result.fail(void 0) : Result.succeed(manifest.version) }) @@ -536,7 +542,9 @@ export const readJavascriptManifest = Effect.fn("readJavascriptManifest")(functi const lockedVersion = lockedVersions.get(dependency.name) const exactVersion = lockedVersion === undefined - ? readNodeModulesVersion(projectPath, dependency.name) + ? isLocalSpecifier(dependency.specifier) + ? Effect.succeedNone + : readNodeModulesVersion(projectPath, dependency.name) : Effect.succeedSome(lockedVersion) return Effect.map( From 639a28db44e50a7be9954048ecc9e405a6f975e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adel=20Rodr=C3=ADguez?= Date: Fri, 2 Oct 2026 12:34:30 -0400 Subject: [PATCH 2/2] Stop at the nearest installed package and keep local path versions Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/quiet-links-sync.md | 2 +- .../manifests/__tests__/javascript.test.ts | 48 +++++++++---------- src/lib/manifests/javascript.ts | 21 ++++---- 3 files changed, 34 insertions(+), 37 deletions(-) diff --git a/.changeset/quiet-links-sync.md b/.changeset/quiet-links-sync.md index 3752d8d..f388411 100644 --- a/.changeset/quiet-links-sync.md +++ b/.changeset/quiet-links-sync.md @@ -2,4 +2,4 @@ "packref": patch --- -Fix `packref sync` in monorepos that have private workspace packages with no `version` field. Packref does not show a manifest parse error for these packages now. Packref also does not read `node_modules` for `link:`, `file:`, and `portal:` dependencies. +Fix `packref sync` in monorepos that have private workspace packages with no `version` field. Packref does not show a manifest parse error for these packages now. Packref also stops at the nearest installed package, and does not use a version from a different package with the same name in a parent `node_modules`. diff --git a/src/lib/manifests/__tests__/javascript.test.ts b/src/lib/manifests/__tests__/javascript.test.ts index 39d14d4..117ce31 100644 --- a/src/lib/manifests/__tests__/javascript.test.ts +++ b/src/lib/manifests/__tests__/javascript.test.ts @@ -485,7 +485,7 @@ importers: expect(dependencies[0]?.exactVersion).toBe("4.0.0-beta.55") }) - it("reports no exact version for a symlinked workspace package without a version", async () => { + it("stops at the nearest installed package when it has no version", async () => { const workspaceRoot = await makeTempDirectory() const projectPath = join(workspaceRoot, "apps", "web") const workspacePackagePath = join(workspaceRoot, "tooling", "linting") @@ -508,6 +508,11 @@ importers: ) await mkdir(join(projectPath, "node_modules", "@tooling"), { recursive: true }) await symlink(workspacePackagePath, join(projectPath, "node_modules", "@tooling", "linting")) + await mkdir(join(workspaceRoot, "node_modules", "@tooling", "linting"), { recursive: true }) + await writeFile( + join(workspaceRoot, "node_modules", "@tooling", "linting", "package.json"), + JSON.stringify({ name: "@tooling/linting", version: "2.0.0" }) + ) await mkdir(join(projectPath, "node_modules", "effect"), { recursive: true }) await writeFile( join(projectPath, "node_modules", "effect", "package.json"), @@ -522,31 +527,26 @@ importers: ]) }) - it.each(["link:../local", "file:../local", "portal:../local"])( - "does not read node_modules for the local specifier %s", - async (specifier) => { - const projectPath = await makeTempDirectory() - await writeFile( - join(projectPath, "package.json"), - JSON.stringify({ - dependencies: { - local: specifier, - }, - }) - ) - await mkdir(join(projectPath, "node_modules", "local"), { recursive: true }) - await writeFile( - join(projectPath, "node_modules", "local", "package.json"), - "{ not valid JSON" - ) + it("keeps the installed version for a local path dependency", async () => { + const projectPath = await makeTempDirectory() + await writeFile( + join(projectPath, "package.json"), + JSON.stringify({ + dependencies: { + example: "file:../example", + }, + }) + ) + await mkdir(join(projectPath, "node_modules", "example"), { recursive: true }) + await writeFile( + join(projectPath, "node_modules", "example", "package.json"), + JSON.stringify({ name: "example", version: "1.0.0" }) + ) - const dependencies = await run(readJavascriptManifest(projectPath)) + const dependencies = await run(readJavascriptManifest(projectPath)) - expect(dependencies).toEqual([ - { group: "dependencies", name: "local", registry: "npm", specifier }, - ]) - } - ) + expect(dependencies[0]?.exactVersion).toBe("1.0.0") + }) it("reports no dependency for installed packages absent from the manifest", async () => { const projectPath = await makeTempDirectory() diff --git a/src/lib/manifests/javascript.ts b/src/lib/manifests/javascript.ts index 77caddc..bf2e4ee 100644 --- a/src/lib/manifests/javascript.ts +++ b/src/lib/manifests/javascript.ts @@ -21,12 +21,6 @@ const DEPENDENCY_GROUPS = ["dependencies", "devDependencies", "peerDependencies" const NODE_MODULES_RESOLUTION_CONCURRENCY = 8 -// These specifiers point to source on disk, so no registry version exists for them. -const LOCAL_SPECIFIER_PREFIXES = ["link:", "file:", "portal:"] as const - -const isLocalSpecifier = (specifier: string) => - LOCAL_SPECIFIER_PREFIXES.some((prefix) => specifier.startsWith(prefix)) - const DependencyRecordSchema = Schema.Record(Schema.String, Schema.String) export const JavascriptPackageManifestSchema = Schema.StructWithRest( @@ -491,10 +485,15 @@ const readNodeModulesVersion = Effect.fn("readNodeModulesVersion")(function* ( toManifestParseError(packageJsonPath) ) - return manifest.version === undefined || valid(manifest.version) === null - ? Result.fail(void 0) - : Result.succeed(manifest.version) + // Like Node resolution, stop at the nearest installed package even if it has no version. + return Result.succeed(manifest.version) }) + ).pipe( + Effect.map( + Option.filter( + (version): version is string => version !== undefined && valid(version) !== null + ) + ) ) }) @@ -542,9 +541,7 @@ export const readJavascriptManifest = Effect.fn("readJavascriptManifest")(functi const lockedVersion = lockedVersions.get(dependency.name) const exactVersion = lockedVersion === undefined - ? isLocalSpecifier(dependency.specifier) - ? Effect.succeedNone - : readNodeModulesVersion(projectPath, dependency.name) + ? readNodeModulesVersion(projectPath, dependency.name) : Effect.succeedSome(lockedVersion) return Effect.map(