From 75a172acaf0135df7f70b5b4e961f9eee7dfea48 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 21 Aug 2026 20:04:51 +0200 Subject: [PATCH 1/2] Restore behavior lost in #158 #158 turned several "skip this input" paths into throws or silent drops, and narrowed matchers that were deliberately broad. Dependencies went missing from runs, some updates were reported but never written, and two inputs aborted the run outright. The widest ones: CRLF workflow files yielded zero Docker dependencies, `exclude: ["*"]` (what a Renovate `enabled: false` maps to) stopped matching any name containing a slash, nested npm overrides collapsed onto one key so the wrong entry was written, and a spaced `[workspace . dependencies]` header aborted everything. A pnpm workspace default registry also suppressed a more specific scoped `.npmrc` registry, which sent private package names to the public registry without their token. Two expectations in #158 encoded the regressions rather than the intended behavior, so they are restored with the code. Co-Authored-By: Claude (Opus 5) --- api.ts | 2 +- cli.test.ts | 11 +++++++++++ cli.ts | 29 ++++++++++++++++++++++------- index.test.ts | 41 ++++++++++++++++++++++++++++++++++++++--- index.ts | 4 ++-- modes/cargo.test.ts | 6 ++++-- modes/cargo.ts | 5 +++-- modes/docker.test.ts | 7 ++++--- modes/docker.ts | 11 ++++++----- modes/go.test.ts | 29 +++++++++++++++++++---------- modes/go.ts | 17 +++++++---------- modes/make.test.ts | 8 ++++---- modes/make.ts | 14 +++++++++++--- modes/npm.test.ts | 34 +++++++++++++++++++++++++++++++++- modes/npm.ts | 23 +++++++++++++++-------- modes/shared.test.ts | 6 +++--- modes/shared.ts | 2 +- utils/semver.test.ts | 4 +++- utils/semver.ts | 5 ++++- utils/utils.test.ts | 3 ++- utils/utils.ts | 4 ++-- utils/workspace.test.ts | 33 ++++++++++++++++++++++++++++++++- utils/workspace.ts | 16 ++++++++-------- 23 files changed, 235 insertions(+), 79 deletions(-) diff --git a/api.ts b/api.ts index f7ffb9f..e30c857 100755 --- a/api.ts +++ b/api.ts @@ -758,7 +758,7 @@ async function runUpdates(opts: UpdatesOptions): Promise { fileData[relPath] = {absPath: file, content, fileType: "workflow", workflowLines}; const yamlPath: Array<{indent: number, key: string}> = []; - for (const [lineNumber, line] of content.split("\n").entries()) { + for (const [lineNumber, line] of content.split(/\r?\n/).entries()) { if (actionsEnabled) { const parsed = parseUsesLine(line); const action = parsed && parseActionRef(parsed.value); diff --git a/cli.test.ts b/cli.test.ts index 92b7009..1ec6422 100644 --- a/cli.test.ts +++ b/cli.test.ts @@ -14,4 +14,15 @@ test("recovers swallowed short option clusters", () => { expect(args.update).toBe(true); expect(args.greatest).toEqual(["react"]); expect(positionals).toEqual(["package.json"]); + + const ordered = parseCliArgs([ + "-g", "-ulreact=*", "-l", "react=<19", + "-g", "-uT1000", "-T", "2000", + "-g", "-ufcluster.json", "-f", "explicit.json", + "package.json", + ]); + expect(ordered.args.pin).toEqual(["react=*", "react=<19"]); + expect(ordered.args.timeout).toBe("2000"); + expect(ordered.args.file).toEqual(["cluster.json", "explicit.json"]); + expect(ordered.positionals).toEqual(["package.json"]); }); diff --git a/cli.ts b/cli.ts index e5d11dc..8e54298 100644 --- a/cli.ts +++ b/cli.ts @@ -41,12 +41,21 @@ export function parseCliArgs(argv?: Array): {args: Record, ...(argv !== undefined && {args: argv}), }); - const values = result.values as Record; + const values = Object.create(null) as Record; const consumedPositionals = new Set(); let positionalsSeen = 0; for (const [index, token] of result.tokens.entries()) { if (token.kind === "positional") positionalsSeen++; - if (token.kind !== "option" || token.inlineValue || !token.value?.startsWith("-")) continue; + if (token.kind !== "option") continue; + if (token.inlineValue || !token.value?.startsWith("-")) { + if (options[token.name]?.multiple) { + const list = (values[token.name] ??= []) as Array; + list.push(token.value ?? true); + } else { + values[token.name] = token.value ?? true; + } + continue; + } const longOption = token.value.startsWith("--"); const next = result.tokens[index + 1]; const nextPositional = next?.kind === "positional" ? next.value : undefined; @@ -77,11 +86,17 @@ export function parseCliArgs(argv?: Array): {args: Record, } } } - if (!recoveredOptions.length) continue; - const swallowed = values[token.name]; - if (Array.isArray(swallowed)) { - const position = swallowed.indexOf(token.value); - if (position !== -1) swallowed.splice(position, 1); + if (!recoveredOptions.length) { + if (options[token.name]?.multiple) { + const list = (values[token.name] ??= []) as Array; + list.push(token.value); + } else { + values[token.name] = token.value; + } + continue; + } + if (options[token.name]?.multiple) { + values[token.name] ??= []; } else { values[token.name] = true; } diff --git a/index.test.ts b/index.test.ts index f04be9b..e85e3f7 100644 --- a/index.test.ts +++ b/index.test.ts @@ -60,6 +60,7 @@ const pnpmWorkspaceFile = fileURLToPath(new URL("fixtures/pnpm-workspace/pnpm-wo const testPkg = JSON.parse(readFileSync(testFile, "utf8")); const testDir = mkdtempSync(join(tmpdir(), "updates-")); +const sourceScript = fileURLToPath(new URL("index.ts", import.meta.url)); const script = fileURLToPath(new URL("dist/index.js", import.meta.url)); // An awaiting handler holds the request open, which the client sees as a response that never comes. @@ -457,6 +458,26 @@ test("version info fallback", async ({expect = globalExpect}: any = {}) => { expect(noty.age).toBeTruthy(); }); +test("version resolves the source and built package layouts", async ({expect = globalExpect}: any = {}) => { + const parentDir = join(testDir, "version-layouts"); + const sourceDir = join(parentDir, "updates"); + const distDir = join(sourceDir, "dist"); + mkdirSync(distDir, {recursive: true}); + const source = await readFile(sourceScript, "utf8"); + await Promise.all([ + writeFile(join(parentDir, "package.json"), JSON.stringify({version: "wrong"})), + writeFile(join(sourceDir, "package.json"), JSON.stringify({version: "1.2.3"})), + writeFile(join(sourceDir, "index.ts"), source), + writeFile(join(distDir, "index.ts"), source), + ]); + + for (const entry of [join(sourceDir, "index.ts"), join(distDir, "index.ts")]) { + const {stdout, stderr} = await execFileAsync(execPath, [entry, "--version"]); + expect(stderr).toEqual(""); + expect(stdout).toBe("1.2.3\n"); + } +}); + test("empty", async ({expect = globalExpect}: any = {}) => { const {stdout, stderr} = await execFileAsync(execPath, [ script, "-n", ...apiArgs(), "-f", emptyFile, @@ -560,6 +581,7 @@ const latestRows: Array<[string, string, string, ReturnType]> = [ ["npm", "dependencies", "prismjs", dep("https://github.com/LeaVerou/prism", "1.17.1", "1.0.0")], ["npm", "dependencies", "react", dep("https://github.com/facebook/react/tree/HEAD/packages/react", "18.2", "18.0")], ["npm", "dependencies", "styled-components", dep("https://github.com/styled-components/styled-components", "4.4.1", "2.5.0-1")], + ["npm", "dependencies", "updates", dep("https://github.com/silverwind/updates", "537ccb7", "6941e05")], ["npm", "overrides", "noty", dep("https://github.com/needim/noty", "3.1.4", "3.1.0")], ["npm", "overrides", "prismjs:overrides.@babel/preset-env.prismjs", dep("https://github.com/LeaVerou/prism", "1.17.1", "1.0.0")], ["npm", "overrides", "prismjs:overrides.prismjs", dep("https://github.com/LeaVerou/prism", "1.17.1", "1.0.0")], @@ -584,6 +606,7 @@ test("prerelease", async ({expect = globalExpect}: any = {}) => { ["npm", "dependencies", "prismjs", dep("https://github.com/LeaVerou/prism", "1.17.1", "1.0.0")], ["npm", "dependencies", "react", dep("https://github.com/facebook/react/tree/HEAD/packages/react", "18.3.0-next-fecc288b7-20221025", "18.0")], ["npm", "dependencies", "styled-components", dep("https://github.com/styled-components/styled-components", "5.0.0-regexrehydrate", "2.5.0-1")], + ["npm", "dependencies", "updates", dep("https://github.com/silverwind/updates", "537ccb7", "6941e05")], ["npm", "overrides", "noty", dep("https://github.com/needim/noty", "3.2.0-beta", "3.1.0")], ["npm", "overrides", "prismjs:overrides.@babel/preset-env.prismjs", dep("https://github.com/LeaVerou/prism", "1.17.1", "1.0.0")], ["npm", "overrides", "prismjs:overrides.prismjs", dep("https://github.com/LeaVerou/prism", "1.17.1", "1.0.0")], @@ -616,6 +639,7 @@ test("patch", async ({expect = globalExpect}: any = {}) => { ["npm", "dependencies", "gulp-sourcemaps", dep("https://github.com/floridoo/gulp-sourcemaps", "2.0.1", "2.0.0")], ["npm", "dependencies", "html-webpack-plugin", dep("https://github.com/jantimon/html-webpack-plugin", "4.0.0-beta.11", "4.0.0-alpha.2")], ["npm", "dependencies", "noty", dep("https://github.com/needim/noty", "3.1.4", "3.1.0")], + ["npm", "dependencies", "updates", dep("https://github.com/silverwind/updates", "537ccb7", "6941e05")], ["npm", "overrides", "noty", dep("https://github.com/needim/noty", "3.1.4", "3.1.0")], ["npm", "packageManager", "npm", dep("https://github.com/npm/cli", "11.6.2", "11.6.0")], ["npm", "resolutions", "versions/updates", dep("https://github.com/silverwind/updates", "^1.0.6", "^1.0.0")], @@ -1438,11 +1462,22 @@ test.each([ ["workflow", dockerActionsDir, "ci.yaml", { node: {old: "18", new: "22"}, postgres: {old: "15", new: "17"}, redis: {old: "7", new: "8"}, }], -])("docker %s basic", async (_name, file, suffix, expected, {expect = globalExpect}: any = {}) => { +])("docker %s basic", async (name, file, suffix, expected, {expect = globalExpect}: any = {}) => { const {stdout, stderr} = await runCliExec(dockerArgs("-j", "-f", file)); expect(stderr).toEqual(""); const docker = JSON.parse(stdout).results.docker; - expect(docker[Object.keys(docker).find(key => key.endsWith(suffix))!]).toMatchObject(expected); + const dependencies = docker[Object.keys(docker).find(key => key.endsWith(suffix))!]; + expect(dependencies).toMatchObject(expected); + if (name === "workflow") { + const crlfDir = join(testDir, "docker-actions-crlf", ".github", "workflows"); + const crlfFile = join(crlfDir, "ci.yaml"); + mkdirSync(crlfDir, {recursive: true}); + await writeFile(crlfFile, (await readFile(join(file, suffix), "utf8")).replaceAll("\n", "\r\n")); + const crlfOutput = await runCliExec(dockerArgs("-j", "-f", crlfFile)); + expect(crlfOutput.stderr).toEqual(""); + const crlfDocker = JSON.parse(crlfOutput.stdout).results.docker; + expect(crlfDocker[Object.keys(crlfDocker).find(key => key.endsWith(suffix))!]).toEqual(dependencies); + } }); test("docker allowedVersions compares floating tags with Docker semantics", async ({expect = globalExpect}: any = {}) => { @@ -1612,7 +1647,7 @@ function configTest(config: string, args: string): Promise<{stdout: string, stde test("config exit-code options", async ({expect = globalExpect}: any = {}) => { for (const [config, args, output] of [ ["{ errorOnOutdated: true }", "-j -i noty", "noty"], - ["{ errorOnUnchanged: true }", "-j -i updates", "All dependencies are up to date."], + ["{ errorOnUnchanged: true }", "-j -i svgstore", "All dependencies are up to date."], ]) { try { await configTest(config, args); diff --git a/index.ts b/index.ts index dffae96..eed8c5e 100755 --- a/index.ts +++ b/index.ts @@ -151,8 +151,8 @@ async function main(): Promise { if (hasFlag(rawArgs, "version", "v")) { let packageJson: string; - try { packageJson = readFileSync(new URL("../package.json", import.meta.url), "utf8"); } catch { - packageJson = readFileSync(new URL("package.json", import.meta.url), "utf8"); + try { packageJson = readFileSync(new URL("package.json", import.meta.url), "utf8"); } catch { + packageJson = readFileSync(new URL("../package.json", import.meta.url), "utf8"); } console.info(JSON.parse(packageJson).version); await end(); diff --git a/modes/cargo.test.ts b/modes/cargo.test.ts index e2bbb9f..cbaa071 100644 --- a/modes/cargo.test.ts +++ b/modes/cargo.test.ts @@ -26,6 +26,8 @@ test.each([ `dependencies${fieldSep}serde`, {old: "1.0.0", new: "1.1.0"}, `[dependencies]\nserde = { version = "1.1.0", features = ["derive"] }\n`], [`extended table [dependencies.name]`, `[dependencies.serde]\nversion = "1.0.0"\nfeatures = ["derive"]\n`, `dependencies${fieldSep}serde`, {old: "1.0.0", new: "1.2.0"}, `[dependencies.serde]\nversion = "1.2.0"\nfeatures = ["derive"]\n`], + [`spaced dotted workspace table`, `[workspace . dependencies]\nserde = "1.0.0"\n`, + `workspace.dependencies${fieldSep}serde`, {old: "1.0.0", new: "1.0.1"}, `[workspace . dependencies]\nserde = "1.0.1"\n`], [`extended table skips comments and multiline strings`, `[dependencies.serde]\n# version = "1.0.0" was old\nnote = """\nversion = "1.0.0"\n"""\nversion = "1.0.0"\n`, `dependencies${fieldSep}serde`, {old: "1.0.0", new: "1.1.0"}, `[dependencies.serde]\n# version = "1.0.0" was old\nnote = """\nversion = "1.0.0"\n"""\nversion = "1.1.0"\n`], [`extended table beside a same-named dev entry`, `[dependencies.serde]\nversion = "1.0.0"\n\n[dev-dependencies]\nserde = "1.0.0"\n`, @@ -170,7 +172,7 @@ test("target sections", () => { `[target.'cfg(feature = "foo.bar")'.dependencies]`, `libc = "0.2.0"`, ``, - `[target.x86_64-pc-windows-msvc.dependencies.winapi]`, + `[target . x86_64-pc-windows-msvc . dependencies . winapi]`, `version = "0.3.0"`, ``, `[target.'cfg(windows)'.build-dependencies.cc]`, @@ -190,7 +192,7 @@ test("target sections", () => { }; const result = updateCargoToml(input, deps); expect(result).toContain(`libc = "0.2.1"`); - expect(result).toContain(`[target.x86_64-pc-windows-msvc.dependencies.winapi]\nversion = "0.3.9"`); + expect(result).toContain(`[target . x86_64-pc-windows-msvc . dependencies . winapi]\nversion = "0.3.9"`); expect(result).toContain(`[target.'cfg(windows)'.build-dependencies.cc]\nversion = "1.1.0"`); expect(result).toContain(`[dev-dependencies]\nlibc = "0.2.0"`); }); diff --git a/modes/cargo.ts b/modes/cargo.ts index 935291f..35c7a5b 100644 --- a/modes/cargo.ts +++ b/modes/cargo.ts @@ -189,8 +189,9 @@ export function updateCargoToml(pkgStr: string, deps: Deps): string { const oldEsc = esc(oldValue); const typePath: Array = typeKey.startsWith("[") ? JSON.parse(jsonStringArrayRe.exec(typeKey)![0]) : typeKey.split("|", 1)[0].split("."); - const sectionEsc = typePath.map(tomlKey).join("\\."); - const ownRe = new RegExp(`^${sectionEsc}\\.${nameEsc}$`); + const dottedSeparator = "[ \\t]*\\.[ \\t]*"; + const sectionEsc = typePath.map(tomlKey).join(dottedSeparator); + const ownRe = new RegExp(`^${sectionEsc}${dottedSeparator}${nameEsc}$`); const sectionRe = new RegExp(`^${sectionEsc}$`); const ownSpan = spans.find(entry => ownRe.test(entry.path)); const span = ownSpan ?? spans.find(entry => sectionRe.test(entry.path)); diff --git a/modes/docker.test.ts b/modes/docker.test.ts index 14a6edb..fe0adae 100644 --- a/modes/docker.test.ts +++ b/modes/docker.test.ts @@ -18,7 +18,7 @@ test.each([ ["docker.io", "docker.io/library/node:18", {registry: null, namespace: "library", repo: "node", tag: "18", fullImage: "docker.io/library/node"}], ["index.docker.io", "index.docker.io/myorg/myapp:1.0.0", {registry: null, namespace: "myorg", repo: "myapp", tag: "1.0.0", fullImage: "index.docker.io/myorg/myapp"}], ["registry-1.docker.io", "registry-1.docker.io/node:18", {registry: null, namespace: "library", repo: "node", tag: "18", fullImage: "registry-1.docker.io/node"}], - ["a deep Hub path", "org/team/image:1.2.3", {registry: null, namespace: "org/team", repo: "image", tag: "1.2.3", fullImage: "org/team/image"}], + ["a registry without a domain suffix", "org/team/image:1.2.3", {registry: "org", namespace: "team", repo: "image", tag: "1.2.3", fullImage: "org/team/image"}], ["localhost registry", "localhost/owner/image:1.2.3", {registry: "localhost", namespace: "owner", repo: "image", tag: "1.2.3", fullImage: "localhost/owner/image"}], ["a tag with suffix", "node:18-alpine", {registry: null, namespace: "library", repo: "node", tag: "18-alpine", fullImage: "node"}], ["full semver with suffix", "node:18.19.1-bookworm", {registry: null, namespace: "library", repo: "node", tag: "18.19.1-bookworm", fullImage: "node"}], @@ -41,6 +41,7 @@ test("dockerImageNames", () => { expect(dockerImageNames("index.docker.io/library/mysql")).toEqual(["index.docker.io/library/mysql", ...hub]); expect(dockerImageNames("grafana/grafana")).toEqual(["grafana/grafana", "docker.io/grafana/grafana"]); expect(dockerImageNames("ghcr.io/foo/bar")).toEqual(["ghcr.io/foo/bar"]); + expect(dockerImageNames("REGISTRY/team/image")).toEqual(["REGISTRY/team/image"]); }); test.each([ @@ -178,6 +179,7 @@ test("findDockerVersion ignores tags from another versioning scheme", () => { expect(findDockerVersion(tagMap, "3", allSemvers)).toBeNull(); expect(findDockerVersion(tagMap, "20251224", allSemvers)).toEqual({newTag: "20260127", date: "2026-01-28"}); expect(findDockerVersion({"9": "2020-01-01", "10": "2020-06-01"}, "9", allSemvers)).toEqual({newTag: "10", date: "2020-06-01"}); + expect(findDockerVersion({"20260127": "2026-01-27", "9999999999999999999": "2026-02-01"}, "20260127", allSemvers)).toBeNull(); }); test("findDockerVersion cooldown needs a timestamp", () => { @@ -358,8 +360,7 @@ test("fetchDockerTagDigest returns the registry digest and reports failures", as await expect(fetchDockerTagDigest("library", "node", "20", hubCtx(() => Promise.resolve({ ok: false, status: 429, statusText: "Too Many Requests", })))).rejects.toThrow("Received 429 Too Many Requests"); - await expect(fetchDockerTagDigest("library", "node", "20", hubCtx(hubBody({})))) - .rejects.toThrow("Malformed Docker Hub tag response"); + await expect(fetchDockerTagDigest("library", "node", "20", hubCtx(hubBody({})))).resolves.toBe(null); }); test("fetchDockerInfo library image", async () => { diff --git a/modes/docker.ts b/modes/docker.ts index 7792891..5f3583f 100644 --- a/modes/docker.ts +++ b/modes/docker.ts @@ -62,7 +62,8 @@ const hubRegistryRe = /^(?:(?:index|registry-1)\.)?docker\.io$/; function parseImageParts(imagePart: string): {registry: string | null, namespace: string, repo: string} { const parts = imagePart.split("/"); if (parts.length > 1 && hubRegistryRe.test(parts[0])) parts.shift(); - const registry = parts.length > 1 && (parts[0] === "localhost" || parts[0].includes(".") || parts[0].includes(":")) ? + const registry = parts.length > 2 || + parts.length > 1 && (parts[0] === "localhost" || parts[0].includes(".") || parts[0].includes(":")) ? parts.shift()! : null; return {registry, namespace: parts.length === 1 ? "library" : parts.slice(0, -1).join("/"), repo: parts.at(-1)!}; } @@ -199,9 +200,8 @@ export async function fetchDockerTagDigest( const result = await fetchWithEtag(url, ctx, {headers: {"accept-encoding": "gzip, deflate, br"}}, reduceJson(data => ({digest: data.digest}))); if ("body" in result) { - const data = JSON.parse(result.body); - if (typeof data?.digest !== "string") throw new Error(`Malformed Docker Hub tag response: ${namespace}/${repo}:${tag}`); - return data.digest; + const digest = JSON.parse(result.body)?.digest; // absent on tags pushed before Docker Hub recorded manifest digests + return typeof digest === "string" ? digest : null; } if (!noTagsStatus.has(result.res?.status as number)) throwFetchError(result.res, url, `${namespace}/${repo}:${tag}`, ctx.dockerApiUrl); return null; @@ -310,7 +310,8 @@ export function findDockerVersion( const coerced = coerceDockerVersion(parsed.version); if (!coerced) continue; - const candidate = parse(dockerSemver(coerced, parsed.prerelease))!; + const candidate = parse(dockerSemver(coerced, parsed.prerelease)); + if (!candidate) continue; if (parsed.prerelease && skipsPrerelease(candidate)) continue; if (pinnedRange && !satisfies(coerced, pinnedRange)) continue; diff --git a/modes/go.test.ts b/modes/go.test.ts index abf2704..31e60a5 100644 --- a/modes/go.test.ts +++ b/modes/go.test.ts @@ -245,6 +245,10 @@ test.each([ goMod("require foo v1.0.0", "exclude foo v1.0.0", "replace other => foo v1.0.0"), {[`deps${fieldSep}foo`]: {old: "1.0.0", new: "1.1.0"}}, goMod("require foo v1.1.0", "exclude foo v1.0.0", "replace other => foo v1.0.0"), {}], + ["a directive in a file with mixed line endings", + "module x\n\nrequire example.com/dep v1.0.0\r\n", + {[`deps${fieldSep}example.com/dep`]: {old: "1.0.0", new: "1.1.0"}}, + "module x\n\nrequire example.com/dep v1.1.0\r\n", {}], ])("updateGoMod rewrites %s", (_name, content, deps, expected, expectedRewrites) => { const [result, rewrites] = updateGoMod(content, deps); expect(result).toBe(expected); @@ -402,25 +406,30 @@ test.each([ expect(parseGoWork(lines.join("\n"))).toEqual(expected); }); -test("resolveGoWorkModule contains members after resolving symlinks", () => { +test("resolveGoWorkModule resolves an out-of-tree member", () => { const parent = mkdtempSync(resolve(tmpdir(), "updates-go-work-")); try { const root = resolve(parent, "project"); - const member = resolve(root, "member"); - const outside = resolve(parent, "trusted"); - mkdirSync(member, {recursive: true}); + const outside = resolve(parent, "shared"); + mkdirSync(root); mkdirSync(outside); - writeFileSync(resolve(member, "go.mod"), "module example.com/member\n"); - writeFileSync(resolve(outside, "go.mod"), "module example.com/trusted\n"); - symlinkSync(outside, resolve(root, "linked")); - expect(resolveGoWorkModule(root, "member")).toBe(realpathSync(resolve(member, "go.mod"))); - expect(resolveGoWorkModule(root, "../trusted")).toBeNull(); - expect(resolveGoWorkModule(root, "linked")).toBeNull(); + writeFileSync(resolve(outside, "go.mod"), "module example.com/shared\n"); + expect(resolveGoWorkModule(root, "../shared")).toBe(realpathSync(resolve(outside, "go.mod"))); } finally { rmSync(parent, {recursive: true}); } }); +test("resolveGoWorkModule skips a member with a resolution error", () => { + const root = mkdtempSync(resolve(tmpdir(), "updates-go-work-")); + try { + symlinkSync("loop", resolve(root, "loop")); + expect(resolveGoWorkModule(root, "loop")).toBeNull(); + } finally { + rmSync(root, {recursive: true}); + } +}); + test("rewriteGoImports rewrites matching imports and skips empty work", () => { rewriteGoImports(resolve("fixtures/go"), {}, () => { throw new Error("unexpected write"); }); rewriteGoImports(resolve("fixtures/cargo"), {"github.com/old": "github.com/new"}, () => { throw new Error("unexpected write"); }); diff --git a/modes/go.ts b/modes/go.ts index b9911da..9b2bb81 100644 --- a/modes/go.ts +++ b/modes/go.ts @@ -1,5 +1,5 @@ import {env} from "node:process"; -import {dirname, isAbsolute, join, relative, resolve, sep} from "node:path"; +import {dirname, join, resolve} from "node:path"; import {globSync, readFileSync, realpathSync} from "node:fs"; import { type Deps, type GoProxyEntry, type ModeContext, type PackageInfo, dedupe, fieldSep, stripv, getSubDir, normalizeUrl, @@ -407,8 +407,8 @@ export function updateGoMod(pkgStr: string, deps: Deps): [string, Record = {}; const entries = Object.entries(deps); if (!entries.length) return [pkgStr, majorVersionRewrites]; - const newline = pkgStr.includes("\r\n") ? "\r\n" : "\n"; - const lines = pkgStr.split(newline); + const lineEndings = pkgStr.match(/\r?\n/g) ?? []; + const lines = pkgStr.split(/\r?\n/); const rewriteLines = (lineNumbers: Array | undefined, pattern: RegExp, replacement: string): boolean => { let rewritten = false; for (const lineNumber of lineNumbers ?? []) { @@ -477,7 +477,7 @@ export function updateGoMod(pkgStr: string, deps: Deps): [string, Record `${line}${lineEndings[index] ?? ""}`).join(""), majorVersionRewrites]; } const goTokenRe = /\s+|\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?(?:\*\/|$)|[A-Za-z_][A-Za-z0-9_]*|"(?:\\[\s\S]|[^"\\])*(?:"|$)|`[^`]*(?:`|$)|'(?:\\[\s\S]|[^'\\])*(?:'|$)|./g; @@ -552,12 +552,9 @@ export function parseGoWork(content: string): {use: string[], replace: Record): string { const bySpec = new Map(rewrites.map(({oldSpec, newSpec}) => [oldSpec, newSpec])); if (!bySpec.size) return content; - const specRe = new RegExp(`(? code.replace(specRe, spec => bySpec.get(spec)!)); + const specs = Array.from(bySpec.keys()).sort((a, b) => b.length - a.length) + .map(spec => Array.from(spec, char => esc(char)).join(`["']*`)).join("|"); + const specRe = new RegExp(`(? code.replace(specRe, authoredSpec => { + const oldSpec = authoredSpec.replace(/["']/g, ""); + const newSpec = bySpec.get(oldSpec)!; + let newIndex = 0; + const result = authoredSpec.replace(/[^"']/g, () => newSpec[newIndex++] ?? ""); + return result + newSpec.slice(newIndex); + })); } diff --git a/modes/npm.test.ts b/modes/npm.test.ts index aace8d9..59c43aa 100644 --- a/modes/npm.test.ts +++ b/modes/npm.test.ts @@ -239,6 +239,28 @@ test.each([ } }); +test("fetchNpmInfo prefers a scoped npmrc registry over a native default", async () => { + const dir = mkdtempSync(join(tmpdir(), "updates-registry-specificity-")); + let fetchedUrl = ""; + let authorization: string | null = null; + const ctx = modeCtx({noCache: true, doFetch: (url: string, opts: RequestInit) => { + fetchedUrl = url; + authorization = new Headers(opts.headers).get("authorization"); + return textRes({}); + }}); + try { + writeFileSync(join(dir, "pnpm-workspace.yaml"), "registry: https://registry.npmjs.org\n"); + writeFileSync(join(dir, ".npmrc"), "@company:registry=https://npm.company.example\n//npm.company.example/:_authToken=secret\n"); + await fetchNpmInfo("@company/pkg", "dependencies", {}, {}, ctx, dir); + expect([fetchedUrl, authorization]).toEqual([ + "https://npm.company.example/@company%2fpkg", + "Bearer secret", + ]); + } finally { + rmSync(dir, {recursive: true}); + } +}); + test("fetchNpmInfo never sends unscoped _auth to a repository registry", async () => { const dir = mkdtempSync(join(tmpdir(), "updates-auth-")); const home = join(dir, "home"); @@ -314,7 +336,7 @@ test("checkUrlDep parses refs and refreshes hashes", async () => { fetches++; return textRes([{sha: "def5678901234", commit: {committer: {date: "2025-03-01"}}}]); }}); - const result = await checkUrlDep("key", {old: "github:user/repo#1234567", new: ""}, hashCtx); + const result = await checkUrlDep("key", {old: "github:user/repo#abc4567", new: ""}, hashCtx); expect(result).not.toBeNull(); expect(result!.newRange).toBe("github:user/repo#def5678"); expect(result!.newRef).toBe("def5678"); @@ -336,3 +358,13 @@ test.each([ const ctx = forgeCtx({noCache: true, doFetch: (url: string) => jsonRes(url.includes("/releases?") ? [] : tags)}); expect((await checkUrlDep("key", {old, new: ""}, ctx))?.newRange).toBe(expected); }); + +test("checkUrlDep updates GitHub path refs at their trailing occurrence", async () => { + const tags = [{name: "v1.2.3", commit: {sha: "abc"}}, {name: "v2.0.0", commit: {sha: "def"}}]; + const ctx = forgeCtx({noCache: true, doFetch: (url: string) => url.endsWith("/commits") ? + textRes([{sha: "def5678901234", commit: {}}]) : jsonRes(url.includes("/releases?") ? [] : tags)}); + expect((await checkUrlDep("key", {old: "https://github.com/user/repo-v1.2.3/tarball/v1.2.3", new: ""}, ctx))?.newRange) + .toBe("https://github.com/user/repo-v1.2.3/tarball/v2.0.0"); + expect((await checkUrlDep("key", {old: "https://github.com/user/repo/abc1234", new: ""}, ctx))?.newRange) + .toBe("https://github.com/user/repo/def5678"); +}); diff --git a/modes/npm.ts b/modes/npm.ts index b50db61..2518a07 100644 --- a/modes/npm.ts +++ b/modes/npm.ts @@ -58,13 +58,14 @@ function resolveNpmRegistry(name: string, config: Config, args: Record { + const nativeDefaultRegistry = scope && dir ? resolveNativeNpmRegistry("", dir) : null; + return getOrSet(authCache, `${dir ?? ""}${fieldSep}${scope}:${registry}:${nativeRegistry ?? ""}:${nativeDefaultRegistry ?? ""}`, () => { let resolvedRegistry = nativeRegistry ? normalizeUrl(nativeRegistry) : registry; - const scoped = !nativeRegistry && scope && npmrcConfig[`${scope}:registry`]; + const scoped = nativeRegistry === nativeDefaultRegistry && scope && npmrcConfig[`${scope}:registry`]; // Specificity wins across sources. if (scoped) { try { const url = normalizeUrl(scoped); - if (url !== registry) resolvedRegistry = url; + if (url !== resolvedRegistry) resolvedRegistry = url; } catch {} } return {auth: getRegistryAuthToken(resolvedRegistry, npmrcConfig), registry: resolvedRegistry}; @@ -344,9 +345,9 @@ type GitHubSpec = {user: string, repo: string, ref: string, selector: string | n function parseGitHubSpec(value: string): GitHubSpec | null { const hash = value.lastIndexOf("#"); - if (hash < 1 || hash === value.length - 1) return null; - let source = value.slice(0, hash).replace(/^git\+/i, ""); - const fragment = value.slice(hash + 1); + if (hash === value.length - 1) return null; + let source = value.slice(0, hash === -1 ? value.length : hash).replace(/^git\+/i, ""); + let fragment = hash === -1 ? "" : value.slice(hash + 1); if (/^github:/i.test(source)) source = source.slice(7); else if (/^git@github\.com:/i.test(source)) source = source.replace(/^git@github\.com:/i, ""); else if (/^(?:https?|git|ssh):/i.test(source)) { @@ -356,6 +357,12 @@ function parseGitHubSpec(value: string): GitHubSpec | null { } else if (source.includes(":")) { return null; } + if (!fragment) { + const match = /^([^/]+\/[^/]+)\/(?:.*\/)?([0-9a-f]+|v?[0-9]+\.[0-9]+\.[0-9]+)$/i.exec(source); + if (!match) return null; + source = match[1]; + fragment = match[2]; + } source = source.replace(/\.git$/i, ""); const parts = source.split("/"); if (parts.length !== 2 || !/^[a-z\d](?:-?[a-z\d]){0,38}$/i.test(parts[0]) || !/^[a-z\d._-]{1,100}$/i.test(parts[1])) return null; @@ -370,8 +377,8 @@ export async function checkUrlDep(key: string, dep: Dep, ctx: ModeContext): Prom const {user, repo, ref: oldRef, selector} = parsed; const replaceRef = (ref: string) => { - const index = dep.old.lastIndexOf("#") + 1; - return `${dep.old.slice(0, index)}${selector ? `semver:${ref}` : ref}`; + const index = dep.old.lastIndexOf(oldRef); + return `${dep.old.slice(0, index)}${ref}${dep.old.slice(index + oldRef.length)}`; }; if (hashRe.test(oldRef)) { diff --git a/modes/shared.test.ts b/modes/shared.test.ts index a5c7948..8b5f606 100644 --- a/modes/shared.test.ts +++ b/modes/shared.test.ts @@ -100,9 +100,9 @@ test("parseTags transforms tag data, commit or not", () => { ]); }); -test("hashRe only recognizes npm commit lengths", () => { - expect(["deadbee", "1234567", "a".repeat(40)].every(value => hashRe.test(value))).toBe(true); - expect(["abc123", "deadbeef", "a".repeat(39), "a".repeat(41), "b".repeat(64)].some(value => hashRe.test(value))).toBe(false); +test("hashRe recognizes plausible GitHub commit hashes", () => { + expect(["deadbee", "deadbeef", "a".repeat(40)].every(value => hashRe.test(value))).toBe(true); + expect(["abc123", "2024010", "a".repeat(41), "b".repeat(64)].some(value => hashRe.test(value))).toBe(false); }); test("throwFetchError names the status, or the package when there is none", () => { diff --git a/modes/shared.ts b/modes/shared.ts index 055edb6..dc8b3a2 100644 --- a/modes/shared.ts +++ b/modes/shared.ts @@ -561,7 +561,7 @@ export function resolvePackageJsonUrl(url: string): string { const commitHashPattern = "(?:[0-9a-f]{6,7}|[0-9a-f]{40}|[0-9a-f]{64})"; export const commitHashRe = new RegExp(`^${commitHashPattern}$`, "i"); -export const hashRe = /^(?:[0-9a-f]{7}|[0-9a-f]{40})$/i; +export const hashRe = /^(?=.*[a-f])[0-9a-f]{7,40}$/i; export function isVersionLikeRef(ref: string): boolean { return /^v?\d+(?:\.\d+)*(?:[-+][\w.-]+)?$/.test(ref); diff --git a/utils/semver.test.ts b/utils/semver.test.ts index c6d4a3c..2fe8d8c 100644 --- a/utils/semver.test.ts +++ b/utils/semver.test.ts @@ -65,6 +65,8 @@ test("ranges", () => { ["3.0.0", "1.0.0 - 2.0.0", false], ["0.9.9", "1.0.0 - 2.0.0", false], ["1.5.0", "1.x", true], ["1.0.0", "1.x", true], ["2.0.0", "1.x", false], ["1.5.0", "1.x.x", true], ["2.0.0", "1.x.x", false], ["999.0.0", "*", true], ["0.0.0", "*", true], + ["1.2.3", ">*", false], ["1.2.3", "<*", false], ["1.2.3", ">x", false], ["1.2.3", "<*.*.*", false], + ["1.2.3", "*", true], ["1.2.3", ">=*", true], ["1.2.3", "=*", true], ["1.2.3", "<=*", true], ["1.2.3-alpha", "*", false], ["1.2.3-alpha", "x", false], ["1.2.3-alpha", "*.*.*", false], ["1.2.3-alpha", "", false], ["1.2.3-alpha", "|| >2.0.0", false], ["1.2.3-alpha", ">2.0.0 ||", false], ["1.2.3-alpha", "|| >=1.2.3-alpha", true], @@ -100,7 +102,7 @@ test("ranges", () => { for (const range of [ "^1.0.0", ">=1.0.0 <2.0.0", "1.0.0 - 2.0.0", "*", "~>1.2.3", "1.2.3+build", "^1.2.3+build", "*.*.*", "1.2.3 - 2.3.x", "1.2.x", "^1.x", "^1.x.x", "^1.2.x", "^0.x", - "~1.x", "~1.2.x", ">=1.2.x", ">=1.2.x <2.0.0", + "~1.x", "~1.2.x", ">=1.2.x", ">=1.2.x <2.0.0", ">*", "<*", ">x", "<*.*.*", ">=*", "=*", "<=*", ]) expect(validRange(range)).toBe(range); expect(validRange("1.*.3")).toBeNull(); expect(validRange("not valid!!")).toBeNull(); diff --git a/utils/semver.ts b/utils/semver.ts index a8ea2e5..b67cc1f 100644 --- a/utils/semver.ts +++ b/utils/semver.ts @@ -195,7 +195,10 @@ function upperComparator(major: number, minor: number, patch: number): Comparato } function partialBounds(partial: PartialVersion, op: string): Array | null { - if (partial.major === null) return partial.minor === null && partial.patch === null ? [] : null; + if (partial.major === null) { + if (partial.minor !== null || partial.patch !== null) return null; + return op === ">" || op === "<" ? comparators(upperComparator(0, 0, 0)) : []; + } const major = partial.major; const minor = partial.minor ?? 0; const patch = partial.patch ?? 0; diff --git a/utils/utils.test.ts b/utils/utils.test.ts index 16cb170..ab6d6c8 100644 --- a/utils/utils.test.ts +++ b/utils/utils.test.ts @@ -113,7 +113,8 @@ test("default npm dependency types", () => { }); test.each([ - ["foo*", ["FOO", "foo.bar", "foo/bar"], [true, true, false]], + ["*", ["@scope/pkg"], [true]], + ["foo*", ["FOO", "foo.bar", "foo/bar"], [true, true, true]], ["foo/**/bar", ["foo/bar", "foo/a/bar", ".foo/bar"], [true, true, false]], ["file?.[jt]s", ["file1.js", "fileA.ts", "file10.js"], [true, true, false]], ["{foo,bar}/@(one|two)", ["foo/one", "bar/two", "baz/one"], [true, true, false]], diff --git a/utils/utils.ts b/utils/utils.ts index 34cff67..a2de086 100644 --- a/utils/utils.ts +++ b/utils/utils.ts @@ -338,8 +338,8 @@ function parseGlob(pattern: string): {source: string, tokens: Array} tokens.push({kind: "span", slash: true}); } } else { - source += "[^/]*"; - tokens.push({kind: "span", slash: false}); + source += ".*"; + tokens.push({kind: "span", slash: true}); } } else if (char === "?" && pattern[i + 1] !== "(") { addChar("[^/]"); diff --git a/utils/workspace.test.ts b/utils/workspace.test.ts index c203011..2da51d8 100644 --- a/utils/workspace.test.ts +++ b/utils/workspace.test.ts @@ -43,6 +43,19 @@ test("filterDepsForMember", () => { }); }); +test("filterDepsForMember preserves dependency identities", () => { + const directIdentity = JSON.stringify(["overrides", "prismjs"]); + const nestedIdentity = JSON.stringify(["overrides", "parent", "prismjs"]); + const allDeps = { + [`overrides|./app${fieldSep}prismjs${fieldSep}${directIdentity}`]: {old: "1.29.0", new: "1.30.0"}, + [`overrides|./app${fieldSep}prismjs${fieldSep}${nestedIdentity}`]: {old: "1.28.0", new: "1.30.0"}, + }; + expect(filterDepsForMember(allDeps, "./app")).toEqual({ + [`overrides${fieldSep}prismjs${fieldSep}${directIdentity}`]: allDeps[`overrides|./app${fieldSep}prismjs${fieldSep}${directIdentity}`], + [`overrides${fieldSep}prismjs${fieldSep}${nestedIdentity}`]: allDeps[`overrides|./app${fieldSep}prismjs${fieldSep}${nestedIdentity}`], + }); +}); + test("resolveWorkspaceMembers resolves literals, globs and exclusions", async () => { const literalDir = makeWorkspace({ "crate-a/Cargo.toml": "[package]\nname = \"a\"", @@ -68,8 +81,21 @@ test("resolveWorkspaceMembers resolves literals, globs and exclusions", async () test("resolveWorkspaceMembers skips missing", async () => { const dir = makeWorkspace(); expect(await resolveWorkspaceMembers(["nonexistent"], dir, "Cargo.toml")).toEqual([]); +}); + +test("resolveWorkspaceMembers skips unreadable manifests", async () => { + const dir = makeWorkspace(); mkdirSync(join(dir, "member")); - await expect(resolveWorkspaceMembers(["member"], dir, ".")).rejects.toMatchObject({code: "EISDIR"}); + expect(await resolveWorkspaceMembers(["member"], dir, ".")).toEqual([]); +}); + +test("resolveWorkspaceMembers resolves symlinked glob directories", async () => { + const dir = makeWorkspace({"internal/app/package.json": "{\"name\": \"app\"}"}); + mkdirSync(join(dir, "packages")); + symlinkSync("../internal/app", join(dir, "packages/app"), "dir"); + expect((await resolveWorkspaceMembers(["packages/*"], dir, "package.json")).map(({memberPath}) => memberPath)) + .toEqual(["./packages/app"]); + expect(await resolveWorkspaceMembers(["packages/*", "!packages/*"], dir, "package.json")).toEqual([]); }); test("resolveWorkspaceMembers rejects traversal and escaping symlinks", async () => { @@ -90,6 +116,11 @@ test("parsePnpmWorkspace", () => { expect(parsePnpmWorkspace("packages:\n # comment\n - libs/*\nnodeLinker: hoisted\n")).toEqual(["libs/*"]); }); +test("parsePnpmWorkspace parses quoted patterns with inline comments", () => { + expect(parsePnpmWorkspace("packages:\n - \"packages/*\" # app packages\n - 'libs/*' # libs\n - plain/*\n")) + .toEqual(["packages/*", "libs/*", "plain/*"]); +}); + test("parse pnpm registry config", () => { expect(parsePnpmRegistryConfig("registry: https://pnpm.test\nregistries: {'@foo': https://foo.pnpm.test, default: https://default.pnpm.test}\n")).toEqual({ registry: "https://pnpm.test", diff --git a/utils/workspace.ts b/utils/workspace.ts index 57d3cec..0494704 100644 --- a/utils/workspace.ts +++ b/utils/workspace.ts @@ -21,10 +21,10 @@ export function filterDepsForMember(allDeps: Deps, memberPath: string): Deps { const byMember = getOrSet(depsByMember, allDeps, () => { const result = new Map>(); for (const [key, dep] of Object.entries(allDeps)) { - const [type, name] = key.split(fieldSep); + const [type, ...parts] = key.split(fieldSep); const separator = type.indexOf("|"); const path = separator === -1 ? "." : type.slice(separator + 1); - pushTo(result, path, [`${baseType(type)}${fieldSep}${name}`, dep]); + pushTo(result, path, [[baseType(type), ...parts].join(fieldSep), dep]); } return result; }); @@ -35,7 +35,7 @@ const globChars = /[*?{[]/; function globDirectories(pattern: string, cwd: string): Array { return globSync(pattern, {cwd, withFileTypes: true}) - .filter(entry => entry.isDirectory()) + .filter(entry => entry.isDirectory() || entry.isSymbolicLink()) .map(entry => resolve(entry.parentPath, entry.name)); } @@ -65,9 +65,8 @@ export async function resolveWorkspaceMembers(patterns: string[], workspaceDir: const rel = relative(workspaceRoot, absPath); if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null; return {absPath, content: await readFile(absPath, "utf8"), memberPath}; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; - throw error; + } catch { + return null; } }, {concurrency}); return reads.filter((m): m is WorkspaceMember => m !== null); @@ -143,8 +142,9 @@ function yamlScalar(content: string): {value: string, valueIndex: number} | null if (!trimmed) return null; const quote = trimmed[0]; if (quote === '"' || quote === "'") { - if (!trimmed.endsWith(quote)) return null; - return {value: trimmed.slice(1, -1), valueIndex: leading + 1}; + const end = trimmed.indexOf(quote, 1); // anything past the closing quote is a comment + if (end === -1) return null; + return {value: trimmed.slice(1, end), valueIndex: leading + 1}; } const commentIndex = trimmed.search(/\s#/); return {value: (commentIndex === -1 ? trimmed : trimmed.slice(0, commentIndex)).trimEnd(), valueIndex: leading}; From a62d62dc1c36e1a102a577fb0c74c4217ef530a4 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 21 Aug 2026 20:13:26 +0200 Subject: [PATCH 2/2] Give the version-layout fixture a module type The fixture simulates a real package root but omitted `"type": "module"`, so Node 24 and 26 emitted MODULE_TYPELESS_PACKAGE_JSON on stderr and the test's empty-stderr assertion failed. Node 22 does not warn, and a local `--no-warnings` in NODE_OPTIONS hid it outside CI. Co-Authored-By: Claude (Opus 5) --- index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.test.ts b/index.test.ts index e85e3f7..fc19bb7 100644 --- a/index.test.ts +++ b/index.test.ts @@ -466,7 +466,7 @@ test("version resolves the source and built package layouts", async ({expect = g const source = await readFile(sourceScript, "utf8"); await Promise.all([ writeFile(join(parentDir, "package.json"), JSON.stringify({version: "wrong"})), - writeFile(join(sourceDir, "package.json"), JSON.stringify({version: "1.2.3"})), + writeFile(join(sourceDir, "package.json"), JSON.stringify({version: "1.2.3", type: "module"})), writeFile(join(sourceDir, "index.ts"), source), writeFile(join(distDir, "index.ts"), source), ]);