From ae477c4f893167c5798dc8a76209f6974c0dda9c Mon Sep 17 00:00:00 2001 From: Zied Yousfi Date: Sun, 5 Apr 2026 12:11:32 +0200 Subject: [PATCH 1/8] feat(npm.go, main.go, ctx.go): add patch-only update mode --- ctx.go | 6 ++++-- main.go | 1 + npm.go | 5 ++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/ctx.go b/ctx.go index 15cb96c..7c9f263 100644 --- a/ctx.go +++ b/ctx.go @@ -22,8 +22,9 @@ type Context struct { Logger *log.Logger // Flags - DryRun bool - Verbose bool + DryRun bool + PatchOnly bool + Verbose bool } var Ctx = Context{ @@ -32,5 +33,6 @@ var Ctx = Context{ HTTPClient: &http.Client{Timeout: 5 * time.Second}, Logger: log.Default(), DryRun: false, + PatchOnly: false, Verbose: false, } diff --git a/main.go b/main.go index 04fec4f..9759e70 100644 --- a/main.go +++ b/main.go @@ -59,6 +59,7 @@ func main() { flag.BoolVar(&Ctx.DryRun, "dry-run", false, "Perform a dry run without making any changes") flag.BoolVar(&Ctx.DryRun, "d", false, "Perform a dry run without making any changes (shorthand)") + flag.BoolVar(&Ctx.PatchOnly, "patch-only", false, "Only update patch versions") flag.BoolVar(&Ctx.Verbose, "verbose", false, "Enable verbose logging") flag.BoolVar(&Ctx.Verbose, "v", false, "Enable verbose logging (shorthand)") diff --git a/npm.go b/npm.go index 538153e..3ec4b81 100644 --- a/npm.go +++ b/npm.go @@ -188,7 +188,7 @@ func updateDependencies(deps []DependencyJSON) ([]DependencyUpdate, error) { } if !shouldUpdate { - log.Debugf("Dependency %s already up to date (%s)", dep.Name, changeType) + log.Debugf("Dependency %s won't update (%s)", dep.Name, changeType) continue } @@ -203,6 +203,9 @@ func updateDependencies(deps []DependencyJSON) ([]DependencyUpdate, error) { func classifyDependencyUpdate(currentVersion DependencyVersion, latestVersion DependencyVersion) (SemverChange, bool) { if currentVersion.HasSemver && latestVersion.HasSemver { changeType := currentVersion.Semver.ChangeType(latestVersion.Semver) + if Ctx.PatchOnly && (changeType != SemverChangePatch || changeType == SemverChangeRevision) { + return changeType, false + } return changeType, changeType != SemverChangeNone && changeType != SemverChangeDowngrade } From 6ca040683d399e1845458f33610b79d63aee616a Mon Sep 17 00:00:00 2001 From: Zied Yousfi Date: Sun, 5 Apr 2026 13:51:22 +0200 Subject: [PATCH 2/8] fix(npm.go): honor patch-only for revision changes on 4-patch-only --- npm.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npm.go b/npm.go index 3ec4b81..a09c0d8 100644 --- a/npm.go +++ b/npm.go @@ -203,7 +203,7 @@ func updateDependencies(deps []DependencyJSON) ([]DependencyUpdate, error) { func classifyDependencyUpdate(currentVersion DependencyVersion, latestVersion DependencyVersion) (SemverChange, bool) { if currentVersion.HasSemver && latestVersion.HasSemver { changeType := currentVersion.Semver.ChangeType(latestVersion.Semver) - if Ctx.PatchOnly && (changeType != SemverChangePatch || changeType == SemverChangeRevision) { + if Ctx.PatchOnly && changeType != SemverChangePatch { return changeType, false } return changeType, changeType != SemverChangeNone && changeType != SemverChangeDowngrade From add9f88eb79ce153edd99e645954c17c2caead2a Mon Sep 17 00:00:00 2001 From: Zied Yousfi Date: Sun, 5 Apr 2026 14:35:21 +0200 Subject: [PATCH 3/8] fix(npm.go): skip non-semver updates in patch-only mode on 4-patch-only --- npm.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/npm.go b/npm.go index a09c0d8..330c9a3 100644 --- a/npm.go +++ b/npm.go @@ -213,6 +213,11 @@ func classifyDependencyUpdate(currentVersion DependencyVersion, latestVersion De return SemverChangeNone, false } + if Ctx.PatchOnly { + log.Warnf("Cannot determine change type for non-semver dependency version '%s' -> '%s', skipping update due to patch-only mode", currentVersion.String(), latestVersion.String()) + return SemverChangeNone, false + } + return SemverChangeInvalid, true } From 6a8c1a726fd4eded805537e89662af6e7c809592 Mon Sep 17 00:00:00 2001 From: Zied Yousfi Date: Sat, 9 May 2026 20:44:52 +0200 Subject: [PATCH 4/8] test(makefile, test_js): add patch-only JS coverage on 4-patch-only Add a dedicated `testjspatch` target that exercises `--patch-only` in the JS fixture, and extend the fixture dependency set to cover patch-only update cases. --- Makefile | 7 +++++-- test_js/package.json | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 6995fe6..1f7e40e 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,12 @@ -.PHONY: test testjs all +.PHONY: test testjs testjspatch all -all: test testjs +all: test testjs testjspatch test: go test ./... testjs: cd test_js && go run .. --dry-run --verbose + +testjspatch: + cd test_js && go run .. --dry-run --verbose --patch-only \ No newline at end of file diff --git a/test_js/package.json b/test_js/package.json index 25449cb..b6df61e 100644 --- a/test_js/package.json +++ b/test_js/package.json @@ -10,6 +10,7 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { - "adb-wifi": "^0.1.1" + "adb-wifi": "^0.1.1", + "left-pad": "1.1.2" } } From 5ff3ef1caedb784df61623178e50aaf79052923c Mon Sep 17 00:00:00 2001 From: Zied Yousfi Date: Sat, 9 May 2026 20:45:11 +0200 Subject: [PATCH 5/8] fix(npm.go): honor patch-only for npm dependencies on 4-patch-only When `--patch-only` blocks a normal semver update, fetch registry versions and apply the latest compatible patch release instead. --- npm.go | 132 +++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 101 insertions(+), 31 deletions(-) diff --git a/npm.go b/npm.go index 330c9a3..21ce642 100644 --- a/npm.go +++ b/npm.go @@ -122,45 +122,37 @@ func getNPMPackageLatestVersion(packageName string) (string, error) { return result.Version, nil } -func outputLogger() *log.Logger { - if Ctx.Logger != nil { - return Ctx.Logger +func getOtherNPMPackageVersions(packageName string) ([]string, error) { + registryURL := "https://registry.npmjs.org/" + url.PathEscape(packageName) + client := Ctx.HTTPClient + if client == nil { + return []string{}, fmt.Errorf("no HTTP client available") } - return log.Default() -} - -func formatDependencyDiff(before DependencyVersion, after DependencyVersion) string { - if before.HasSemver && after.HasSemver { - if before.Prefix == "" && after.Prefix == "" { - return before.Semver.Diff(after.Semver) - } - - return fmt.Sprintf("%s -> %s (%s)", before.String(), after.String(), before.Semver.ChangeType(after.Semver)) + resp, err := client.Get(registryURL) + if err != nil { + return []string{}, fmt.Errorf("request error: %w", err) } + defer resp.Body.Close() - return fmt.Sprintf("%s -> %s", before.String(), after.String()) -} - -func (update DependencyUpdate) String() string { - return fmt.Sprintf("%s: %s", update.Name, formatDependencyDiff(update.Before, update.After)) -} + if resp.StatusCode != http.StatusOK { + return []string{}, fmt.Errorf("unexpected status: %d", resp.StatusCode) + } -func printDependencyUpdates(packagePath string, section string, updates []DependencyUpdate) { - if len(updates) == 0 { - return + var result struct { + Time map[string]string `json:"time"` } - action := "Updated" - if Ctx.DryRun { - action = "Would update" + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return []string{}, fmt.Errorf("JSON decode error: %w", err) } - logger := outputLogger() - logger.Print(action + " " + section + " in " + packagePath + ":") - for _, update := range updates { - logger.Print("- " + update.String()) + versions := make([]string, 0, len(result.Time)) + for version := range result.Time { + versions = append(versions, version) } + + return versions, nil } func updateDependencies(deps []DependencyJSON) ([]DependencyUpdate, error) { @@ -188,8 +180,21 @@ func updateDependencies(deps []DependencyJSON) ([]DependencyUpdate, error) { } if !shouldUpdate { - log.Debugf("Dependency %s won't update (%s)", dep.Name, changeType) - continue + if !Ctx.PatchOnly || !dep.Version.HasSemver { + log.Debugf("Dependency %s won't update (%s)", dep.Name, changeType) + continue + } + + patchVersion, ok, err := getLatestPatchNPMPackageVersion(dep.Name, dep.Version) + if err != nil { + return nil, fmt.Errorf("failed to fetch other versions for %s: %w", dep.Name, err) + } + if !ok { + log.Debugf("Dependency %s has no available patch update", dep.Name) + continue + } + + latestVersion = patchVersion } updatedVersion := mergeDependencyVersion(dep.Version, latestVersion) @@ -200,6 +205,30 @@ func updateDependencies(deps []DependencyJSON) ([]DependencyUpdate, error) { return updates, nil } +func getLatestPatchNPMPackageVersion(packageName string, currentVersion DependencyVersion) (DependencyVersion, bool, error) { + versions, err := getOtherNPMPackageVersions(packageName) + if err != nil { + return DependencyVersion{}, false, err + } + + var latestPatch DependencyVersion + foundPatch := false + for _, version := range versions { + versionToCheck := parseDependencyVersion(version) + changeTypeForVersionToCheck, shouldUpdateForVersionToCheck := classifyDependencyUpdate(currentVersion, versionToCheck) + if !shouldUpdateForVersionToCheck || changeTypeForVersionToCheck != SemverChangePatch { + continue + } + + if !foundPatch || latestPatch.Semver.LessThan(versionToCheck.Semver) { + latestPatch = versionToCheck + foundPatch = true + } + } + + return latestPatch, foundPatch, nil +} + func classifyDependencyUpdate(currentVersion DependencyVersion, latestVersion DependencyVersion) (SemverChange, bool) { if currentVersion.HasSemver && latestVersion.HasSemver { changeType := currentVersion.Semver.ChangeType(latestVersion.Semver) @@ -249,6 +278,47 @@ func depsToMap(deps []DependencyJSON) map[string]string { return depsMap } +func outputLogger() *log.Logger { + if Ctx.Logger != nil { + return Ctx.Logger + } + + return log.Default() +} + +func formatDependencyDiff(before DependencyVersion, after DependencyVersion) string { + if before.HasSemver && after.HasSemver { + if before.Prefix == "" && after.Prefix == "" { + return before.Semver.Diff(after.Semver) + } + + return fmt.Sprintf("%s -> %s (%s)", before.String(), after.String(), before.Semver.ChangeType(after.Semver)) + } + + return fmt.Sprintf("%s -> %s", before.String(), after.String()) +} + +func (update DependencyUpdate) String() string { + return fmt.Sprintf("%s: %s", update.Name, formatDependencyDiff(update.Before, update.After)) +} + +func printDependencyUpdates(packagePath string, section string, updates []DependencyUpdate) { + if len(updates) == 0 { + return + } + + action := "Updated" + if Ctx.DryRun { + action = "Would update" + } + + logger := outputLogger() + logger.Print(action + " " + section + " in " + packagePath + ":") + for _, update := range updates { + logger.Print("- " + update.String()) + } +} + func processNPMPackage(packagePath string) error { packageJSONFile, err := os.ReadFile(packagePath) if err != nil { From 8c3a9aaed0d487e6514e15953f9b1ba81d157058 Mon Sep 17 00:00:00 2001 From: Zied Yousfi Date: Sat, 9 May 2026 20:46:52 +0200 Subject: [PATCH 6/8] fix(main.go): add patch-only shorthand on 4-patch-only --- main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/main.go b/main.go index 9759e70..22a2a78 100644 --- a/main.go +++ b/main.go @@ -60,6 +60,7 @@ func main() { flag.BoolVar(&Ctx.DryRun, "dry-run", false, "Perform a dry run without making any changes") flag.BoolVar(&Ctx.DryRun, "d", false, "Perform a dry run without making any changes (shorthand)") flag.BoolVar(&Ctx.PatchOnly, "patch-only", false, "Only update patch versions") + flag.BoolVar(&Ctx.PatchOnly, "po", false, "Only update patch versions (shorthand)") flag.BoolVar(&Ctx.Verbose, "verbose", false, "Enable verbose logging") flag.BoolVar(&Ctx.Verbose, "v", false, "Enable verbose logging (shorthand)") From 63469e7dcf9264bec04ae5f71277beb389d582e6 Mon Sep 17 00:00:00 2001 From: Zied Yousfi Date: Sat, 9 May 2026 20:51:41 +0200 Subject: [PATCH 7/8] fix(npm.go): use versions metadata for patch-only lookups on 4-patch-only Read registry versions from the `versions` payload and fall back to the default HTTP client when no client is configured. --- npm.go | 8 ++++---- npm_test.go | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/npm.go b/npm.go index 21ce642..86ab0c4 100644 --- a/npm.go +++ b/npm.go @@ -126,7 +126,7 @@ func getOtherNPMPackageVersions(packageName string) ([]string, error) { registryURL := "https://registry.npmjs.org/" + url.PathEscape(packageName) client := Ctx.HTTPClient if client == nil { - return []string{}, fmt.Errorf("no HTTP client available") + client = http.DefaultClient } resp, err := client.Get(registryURL) @@ -140,15 +140,15 @@ func getOtherNPMPackageVersions(packageName string) ([]string, error) { } var result struct { - Time map[string]string `json:"time"` + Versions map[string]json.RawMessage `json:"versions"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return []string{}, fmt.Errorf("JSON decode error: %w", err) } - versions := make([]string, 0, len(result.Time)) - for version := range result.Time { + versions := make([]string, 0, len(result.Versions)) + for version := range result.Versions { versions = append(versions, version) } diff --git a/npm_test.go b/npm_test.go index 1a20bf5..ef104b2 100644 --- a/npm_test.go +++ b/npm_test.go @@ -334,6 +334,54 @@ func TestGetNPMPackageLatestVersion(t *testing.T) { }) } +func TestGetOtherNPMPackageVersions(t *testing.T) { + t.Run("uses versions metadata only", func(t *testing.T) { + client := newRegistryClient(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{ + "time":{"created":"2020-01-01T00:00:00.000Z","modified":"2020-01-02T00:00:00.000Z","1.0.0":"2020-01-01T00:00:00.000Z"}, + "versions":{"1.0.0":{},"1.0.1":{}} + }`) + }) + + withTestContext(t, Context{HTTPClient: client}) + + versions, err := getOtherNPMPackageVersions("react") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + got := map[string]bool{} + for _, version := range versions { + got[version] = true + } + want := map[string]bool{"1.0.0": true, "1.0.1": true} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected versions %v, got %v", want, got) + } + }) + + t.Run("falls back to default client", func(t *testing.T) { + client := newRegistryClient(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"versions":{"2.0.0":{}}}`) + }) + previousDefaultClient := http.DefaultClient + http.DefaultClient = client + t.Cleanup(func() { + http.DefaultClient = previousDefaultClient + }) + + withTestContext(t, Context{}) + + versions, err := getOtherNPMPackageVersions("react") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !reflect.DeepEqual(versions, []string{"2.0.0"}) { + t.Fatalf("expected default client version, got %v", versions) + } + }) +} + func TestProcessNPMPackage(t *testing.T) { t.Run("updates dependencies and preserves document fields", func(t *testing.T) { tempDir := t.TempDir() From 623bd65e6defb2919153cfa956902208f0e92d0e Mon Sep 17 00:00:00 2001 From: Zied Yousfi Date: Sat, 9 May 2026 21:00:27 +0200 Subject: [PATCH 8/8] test(test_js): extend patch-only JS coverage on 4-patch-only Add canary and React dependencies to the JS fixture so patch-only update cases cover more registry metadata. --- test_js/package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test_js/package.json b/test_js/package.json index b6df61e..09e8724 100644 --- a/test_js/package.json +++ b/test_js/package.json @@ -11,6 +11,8 @@ }, "dependencies": { "adb-wifi": "^0.1.1", - "left-pad": "1.1.2" + "left-pad": "1.1.2", + "@docusaurus/core": "3.6.2-canary-6154", + "react": "18.3.0" } }