Skip to content

Commit 0797e2e

Browse files
committed
chore(agents): fix version normalization for lockfiles
1 parent 6ceb997 commit 0797e2e

File tree

3 files changed

+137
-105
lines changed

3 files changed

+137
-105
lines changed

pkg/agentfs/sdk_version_check.go

Lines changed: 60 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ func CheckSDKVersion(dir string, projectType ProjectType, settingsMap map[string
6666
// Find the best result (prefer lock files over source files)
6767
bestResult := findBestResult(results)
6868
if bestResult == nil {
69-
return fmt.Errorf("package %s not found in any project files", getTargetPackageName(projectType))
69+
return fmt.Errorf("package %s not found in any project files", projectType.TargetPackageName())
7070
}
7171

7272
if !bestResult.Satisfied {
@@ -173,8 +173,8 @@ func parsePythonPackageVersion(line string) (string, bool) {
173173
// clean up the version string if it contains multiple constraints
174174
// handle comma-separated version constraints like ">=1.2.5,<2"
175175
if strings.Contains(version, ",") {
176-
parts := strings.Split(version, ",")
177-
for _, part := range parts {
176+
parts := strings.SplitSeq(version, ",")
177+
for part := range parts {
178178
trimmed := strings.TrimSpace(part)
179179
if regexp.MustCompile(`\d`).MatchString(trimmed) {
180180
if strings.ContainsAny(trimmed, "=~><") {
@@ -596,23 +596,37 @@ func checkUvLock(filePath, minVersion string) VersionCheckResult {
596596
return VersionCheckResult{Error: err}
597597
}
598598

599-
// Look for livekit-agents in the lock file
600-
pattern := regexp.MustCompile(`(?m)^\s*livekit-agents\s*=\s*"([^"]+)"`)
601-
matches := pattern.FindStringSubmatch(string(content))
602-
if matches != nil {
603-
version := matches[1]
604-
satisfied, err := isVersionSatisfied(version, minVersion, SourceTypeLock)
605-
return VersionCheckResult{
606-
PackageInfo: PackageInfo{
607-
Name: "livekit-agents",
608-
Version: version,
609-
FoundInFile: filePath,
610-
ProjectType: ProjectTypePythonUV,
611-
Ecosystem: "pypi",
612-
},
613-
MinVersion: minVersion,
614-
Satisfied: satisfied,
615-
Error: err,
599+
type uvLockPackage struct {
600+
Name string `toml:"name"`
601+
Version string `toml:"version"`
602+
}
603+
604+
type uvLockFile struct {
605+
Packages []uvLockPackage `toml:"package"`
606+
}
607+
608+
var uvLock uvLockFile
609+
if err := toml.Unmarshal(content, &uvLock); err != nil {
610+
return VersionCheckResult{Error: err}
611+
}
612+
613+
// Check for livekit-agents in the packages
614+
for _, pkg := range uvLock.Packages {
615+
if pkg.Name == "livekit-agents" {
616+
version := pkg.Version
617+
satisfied, err := isVersionSatisfied(version, minVersion, SourceTypeLock)
618+
return VersionCheckResult{
619+
PackageInfo: PackageInfo{
620+
Name: "livekit-agents",
621+
Version: version,
622+
FoundInFile: filePath,
623+
ProjectType: ProjectTypePythonUV,
624+
Ecosystem: "pypi",
625+
},
626+
MinVersion: minVersion,
627+
Satisfied: satisfied,
628+
Error: err,
629+
}
616630
}
617631
}
618632

@@ -660,10 +674,10 @@ func isVersionSatisfied(version, minVersion string, sourceType SourceType) (bool
660674
case SourceTypeLock:
661675
// For lock files, we have the exact version that was installed
662676
// Check if this exact version is >= the minimum version
663-
normalizedVersion := normalizeVersion(version)
677+
normalizedVersion := normalizeVersion(version, sourceType)
664678
v, err := semver.NewVersion(normalizedVersion)
665679
if err != nil {
666-
return false, fmt.Errorf("invalid version format: %s", version)
680+
return false, fmt.Errorf("failed to extract base version for %s: %w", version, err)
667681
}
668682

669683
min, err := semver.NewVersion(minVersion)
@@ -675,81 +689,54 @@ func isVersionSatisfied(version, minVersion string, sourceType SourceType) (bool
675689
return !v.LessThan(min), nil
676690

677691
case SourceTypePackage:
678-
// For package files, we have a constraint that will be resolved at install time
679-
// Check if this constraint would allow installing a version that satisfies the minimum requirement
680-
packageConstraint, err := semver.NewConstraint(version)
692+
// For package files, we may have a constraint that will be resolved at install time.
693+
694+
// First, we check if the normalized version is greater than or equal to the minimum version
695+
// This is safe because in < and <= checks, the newest version will always be installed and in
696+
// ^, ~ and >= checks, if the lower bound is greater than the minimum SDK version, we're good.
697+
normalizedVersion := normalizeVersion(version, sourceType)
698+
baseVersion, err := semver.NewVersion(normalizedVersion)
681699
if err != nil {
682-
return false, fmt.Errorf("invalid package constraint format: %s", version)
700+
return false, fmt.Errorf("failed to extract base version for %s: %w", version, err)
683701
}
684702

685703
min, err := semver.NewVersion(minVersion)
686704
if err != nil {
687705
return false, fmt.Errorf("invalid minimum version format: %s", minVersion)
688706
}
689707

690-
// Check if the package constraint would allow installing a version >= minimum
691-
// We do this by checking if there exists a version >= minimum that satisfies the package constraint
692-
if packageConstraint.Check(min) {
693-
// The minimum version satisfies the package constraint, so it would be installable
708+
if baseVersion.GreaterThanEqual(min) {
694709
return true, nil
695710
}
696711

697-
// Check if the package constraint allows any version >= minimum
698-
// This handles cases like ">=1.5.0" where 1.0.0 doesn't satisfy it, but it would install 1.5.0+ which > 1.0.0
699-
// We'll test a few strategic versions to see if any satisfy the package constraint and are >= minimum
700-
testVersions := []string{
701-
minVersion, // The minimum version itself
702-
fmt.Sprintf("%d.%d.%d", min.Major()+1, 0, 0),
703-
fmt.Sprintf("%d.%d.%d", min.Major(), min.Minor()+1, 0),
704-
fmt.Sprintf("%d.%d.%d", min.Major(), min.Minor(), min.Patch()+1),
705-
}
706-
707-
// Add more versions to cover edge cases
708-
if min.Major() > 0 {
709-
testVersions = append(testVersions, fmt.Sprintf("%d.0.0", min.Major()))
710-
}
711-
if min.Minor() > 0 {
712-
testVersions = append(testVersions, fmt.Sprintf("%d.%d.0", min.Major(), min.Minor()))
712+
// Next, we check if min itself satisfies the package constraint. This resolves
713+
// cases in which the range includes min, like ~1.0 when min is 1.0.0.
714+
packageConstraint, err := semver.NewConstraint(version)
715+
if err != nil {
716+
return false, fmt.Errorf("invalid package constraint format: %s", version)
713717
}
714-
715-
// Add some specific versions that might be common in constraints
716-
testVersions = append(testVersions,
717-
fmt.Sprintf("%d.%d.0", min.Major(), min.Minor()+2),
718-
fmt.Sprintf("%d.%d.0", min.Major(), min.Minor()+5),
719-
fmt.Sprintf("%d.%d.0", min.Major(), min.Minor()+10),
720-
)
721-
722-
for _, testVersion := range testVersions {
723-
if v, err := semver.NewVersion(testVersion); err == nil {
724-
// Check if this version is >= minimum and satisfies the package constraint
725-
if !v.LessThan(min) && packageConstraint.Check(v) {
726-
return true, nil
727-
}
728-
}
718+
if packageConstraint.Check(min) {
719+
return true, nil
729720
}
730-
721+
722+
// Finally, we need to check if the package constraint allows any version >= minimum.
723+
731724
return false, nil
732725

733726
default:
734727
return false, fmt.Errorf("unknown source type: %d", sourceType)
735728
}
736729
}
737730

738-
// normalizeVersion normalizes version strings for semver parsing
739-
func normalizeVersion(version string) string {
740-
// Remove common prefixes and suffixes
731+
// Cleans up version strings for parsing
732+
func normalizeVersion(version string, sourceType SourceType) string {
733+
// Remove whitespace, quotes, and version range specifiers
741734
version = strings.TrimSpace(version)
742-
version = strings.Trim(version, " \"'")
743-
744-
// Handle npm version ranges (^ and ~ are npm-specific, not semver constraints)
745-
if strings.HasPrefix(version, "^") || strings.HasPrefix(version, "~") {
746-
version = version[1:]
747-
}
748-
735+
version = strings.Trim(version, `"'^~><=`)
749736
return version
750737
}
751738

752-
// findBestResult finds the best result from multiple package checks
739+
// Finds the best possible source for version checks
753740
func findBestResult(results []VersionCheckResult) *VersionCheckResult {
754741
if len(results) == 0 {
755742
return nil
@@ -792,15 +779,3 @@ func findBestResult(results []VersionCheckResult) *VersionCheckResult {
792779

793780
return bestResult
794781
}
795-
796-
// getTargetPackageName returns the target package name for the project type
797-
func getTargetPackageName(projectType ProjectType) string {
798-
switch projectType {
799-
case ProjectTypePythonPip, ProjectTypePythonUV:
800-
return "livekit-agents"
801-
case ProjectTypeNode:
802-
return "@livekit/agents"
803-
default:
804-
return ""
805-
}
806-
}

pkg/agentfs/sdk_version_check_test.go

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,19 @@ livekit-agents = ">=1.0.0"`,
111111
expectError: true,
112112
errorMsg: "too old",
113113
},
114+
{
115+
name: "Node package.json with good version",
116+
projectType: ProjectTypeNode,
117+
setupFiles: map[string]string{
118+
"package.json": `{
119+
"dependencies": {
120+
"@livekit/agents": "^1.1.1"
121+
}
122+
}`,
123+
},
124+
expectError: false,
125+
},
126+
114127
{
115128
name: "Node package-lock.json with valid version",
116129
projectType: ProjectTypeNode,
@@ -139,7 +152,15 @@ version = "1.5.0"`,
139152
name: "Python uv.lock with valid version",
140153
projectType: ProjectTypePythonUV,
141154
setupFiles: map[string]string{
142-
"uv.lock": `livekit-agents = "1.5.0"`,
155+
"uv.lock": `[[package]]
156+
name = "livekit-agents"
157+
version = "1.2.5"
158+
source = { registry = "https://pypi.org/simple" }
159+
dependencies = [
160+
{ name = "aiohttp" },
161+
{ name = "watchfiles" },
162+
]
163+
`,
143164
},
144165
expectError: false,
145166
},
@@ -207,19 +228,24 @@ func TestIsVersionSatisfied(t *testing.T) {
207228
{"0.9.0", "1.0.0", SourceTypeLock, false, false},
208229
{"1.5.0", "2.0.0", SourceTypeLock, false, false},
209230
{"2.0.0", "2.0.0", SourceTypeLock, true, false},
210-
231+
211232
// Package file tests (constraint satisfaction)
212233
{">=1.5.0", "1.0.0", SourceTypePackage, true, false},
213234
{"<2.0.0", "1.0.0", SourceTypePackage, true, false},
214235
{">=2.0.0", "1.0.0", SourceTypePackage, true, false},
215236
{"~1.2.0", "1.0.0", SourceTypePackage, true, false},
216237
{"^1.0.0", "1.0.0", SourceTypePackage, true, false},
217-
238+
// Test the specific case that was failing: ^0.7.9 should satisfy minimum 0.0.7
239+
{"^0.7.9", "0.0.7", SourceTypePackage, true, false},
240+
// Test other caret scenarios
241+
{"^0.5.0", "0.0.7", SourceTypePackage, true, false}, // ^0.5.0 allows 0.5.0+ which >= 0.0.7
242+
{"^1.0.0", "0.0.7", SourceTypePackage, true, false}, // 1.0.0+ >= 0.0.7
243+
218244
// Special cases
219245
{"latest", "1.0.0", SourceTypeLock, true, false},
220246
{"*", "1.0.0", SourceTypeLock, true, false},
221247
{"", "1.0.0", SourceTypeLock, true, false},
222-
248+
223249
// Error cases
224250
{"invalid", "1.0.0", SourceTypeLock, false, true},
225251
{"1.5.0", "invalid", SourceTypeLock, false, true},
@@ -251,25 +277,45 @@ func TestIsVersionSatisfied(t *testing.T) {
251277

252278
func TestNormalizeVersion(t *testing.T) {
253279
tests := []struct {
254-
input string
255-
expected string
280+
input string
281+
sourceType SourceType
282+
expected string
283+
description string
256284
}{
257-
{"1.5.0", "1.5.0"},
258-
{"^1.5.0", "1.5.0"},
259-
{"~1.5.0", "1.5.0"},
260-
{">=1.5.0", ">=1.5.0"},
261-
{"<2.0.0", "<2.0.0"},
262-
{"==1.5.0", "==1.5.0"},
263-
{" 1.5.0 ", "1.5.0"},
264-
{`"1.5.0"`, "1.5.0"},
265-
{`'1.5.0'`, "1.5.0"},
266-
{"*", "*"},
267-
{"latest", "latest"},
285+
// Lock file tests (should remove ^ and ~)
286+
{"1.5.0", SourceTypeLock, "1.5.0", "exact version"},
287+
{"^1.5.0", SourceTypeLock, "1.5.0", "npm caret removed for lock file"},
288+
{"~1.5.0", SourceTypeLock, "1.5.0", "npm tilde removed for lock file"},
289+
{">=1.5.0", SourceTypeLock, "1.5.0", "semver operators removed for lock file"},
290+
{"<2.0.0", SourceTypeLock, "2.0.0", "semver operators removed for lock file"},
291+
{"==1.5.0", SourceTypeLock, "1.5.0", "semver operators removed for lock file"},
292+
{" 1.5.0 ", SourceTypeLock, "1.5.0", "whitespace removed"},
293+
{`"1.5.0"`, SourceTypeLock, "1.5.0", "quotes removed"},
294+
{`'1.5.0'`, SourceTypeLock, "1.5.0", "quotes removed"},
295+
{"*", SourceTypeLock, "*", "wildcard preserved"},
296+
{"latest", SourceTypeLock, "latest", "latest preserved"},
297+
298+
// Package file tests (should preserve ^ and ~)
299+
{"1.5.0", SourceTypePackage, "1.5.0", "exact version"},
300+
{"^1.5.0", SourceTypePackage, "1.5.0", "npm caret removed for package file"},
301+
{"~1.5.0", SourceTypePackage, "1.5.0", "npm tilde removed for package file"},
302+
{">=1.5.0", SourceTypePackage, "1.5.0", "semver operators removed for package file"},
303+
{"<2.0.0", SourceTypePackage, "2.0.0", "semver operators removed for package file"},
304+
{"==1.5.0", SourceTypePackage, "1.5.0", "semver operators removed for package file"},
305+
{" 1.5.0 ", SourceTypePackage, "1.5.0", "whitespace removed"},
306+
{`"1.5.0"`, SourceTypePackage, "1.5.0", "quotes removed"},
307+
{`'1.5.0'`, SourceTypePackage, "1.5.0", "quotes removed"},
308+
{"*", SourceTypePackage, "*", "wildcard preserved"},
309+
{"latest", SourceTypePackage, "latest", "latest preserved"},
268310
}
269311

270312
for _, tt := range tests {
271-
t.Run(tt.input, func(t *testing.T) {
272-
result := normalizeVersion(tt.input)
313+
sourceTypeStr := "Package"
314+
if tt.sourceType == SourceTypeLock {
315+
sourceTypeStr = "Lock"
316+
}
317+
t.Run(fmt.Sprintf("%s_%s_%s", tt.input, sourceTypeStr, tt.description), func(t *testing.T) {
318+
result := normalizeVersion(tt.input, tt.sourceType)
273319
if result != tt.expected {
274320
t.Errorf("Expected %s but got %s", tt.expected, result)
275321
}
@@ -371,7 +417,7 @@ func TestGetTargetPackageName(t *testing.T) {
371417

372418
for _, tt := range tests {
373419
t.Run(string(tt.projectType), func(t *testing.T) {
374-
result := getTargetPackageName(tt.projectType)
420+
result := tt.projectType.TargetPackageName()
375421
if result != tt.expected {
376422
t.Errorf("Expected %s but got %s", tt.expected, result)
377423
}

pkg/agentfs/utils.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,17 @@ func (p ProjectType) FileExt() string {
6565
}
6666
}
6767

68+
func (p ProjectType) TargetPackageName() string {
69+
switch p {
70+
case ProjectTypePythonPip, ProjectTypePythonUV:
71+
return "livekit-agents"
72+
case ProjectTypeNode:
73+
return "@livekit/agents"
74+
default:
75+
return ""
76+
}
77+
}
78+
6879
func LocateLockfile(dir string, p ProjectType) (bool, string) {
6980
pythonFiles := []string{
7081
"requirements.txt",

0 commit comments

Comments
 (0)