Skip to content
7 changes: 5 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
6 changes: 4 additions & 2 deletions ctx.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ type Context struct {
Logger *log.Logger

// Flags
DryRun bool
Verbose bool
DryRun bool
PatchOnly bool
Verbose bool
}

var Ctx = Context{
Expand All @@ -32,5 +33,6 @@ var Ctx = Context{
HTTPClient: &http.Client{Timeout: 5 * time.Second},
Logger: log.Default(),
DryRun: false,
PatchOnly: false,
Verbose: false,
}
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ 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)")

Expand Down
140 changes: 109 additions & 31 deletions npm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
client = http.DefaultClient
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 {
Versions map[string]json.RawMessage `json:"versions"`
}

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.Versions))
for version := range result.Versions {
versions = append(versions, version)
}

return versions, nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func updateDependencies(deps []DependencyJSON) ([]DependencyUpdate, error) {
Expand Down Expand Up @@ -188,8 +180,21 @@ func updateDependencies(deps []DependencyJSON) ([]DependencyUpdate, error) {
}

if !shouldUpdate {
log.Debugf("Dependency %s already up to date (%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)
Expand All @@ -200,16 +205,48 @@ 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)
if Ctx.PatchOnly && changeType != SemverChangePatch {
return changeType, false
}
return changeType, changeType != SemverChangeNone && changeType != SemverChangeDowngrade
}

if currentVersion.String() == latestVersion.String() {
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
}
Comment on lines +245 to +248

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Don’t classify non-semver patch-only skips as SemverChangeNone.

Line 218 currently reports “none” even when the real reason is “not semver-comparable under patch-only,” which can mislead logs and diagnostics. Return SemverChangeInvalid with false to preserve reason while still skipping the update.

Suggested fix
 	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, false
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
}
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 SemverChangeInvalid, false
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@npm.go` around lines 216 - 219, The current branch that handles Ctx.PatchOnly
in the semver comparison path wrongly returns SemverChangeNone which hides that
the versions are non-semver-comparable; update the handler in the function that
checks Ctx.PatchOnly to return SemverChangeInvalid, false instead of
SemverChangeNone, false and keep the existing log (or adjust it to mention
“invalid/non-semver”) so callers and diagnostics can distinguish a skipped
non-semver comparison from a true “no change” result; refer to Ctx.PatchOnly,
SemverChangeNone and SemverChangeInvalid to locate and modify the return.


return SemverChangeInvalid, true
}

Expand Down Expand Up @@ -241,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 {
Expand Down
48 changes: 48 additions & 0 deletions npm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 4 additions & 1 deletion test_js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"adb-wifi": "^0.1.1"
"adb-wifi": "^0.1.1",
"left-pad": "1.1.2",
"@docusaurus/core": "3.6.2-canary-6154",
"react": "18.3.0"
}
}
Loading