Skip to content

Range improvements#7

Merged
thetechnick merged 14 commits into
package-operator:mainfrom
thetechnick:range-improvements
May 20, 2026
Merged

Range improvements#7
thetechnick merged 14 commits into
package-operator:mainfrom
thetechnick:range-improvements

Conversation

@thetechnick
Copy link
Copy Markdown
Contributor

Fixes a range of range compare issues and improves error messages.

thetechnick and others added 4 commits May 19, 2026 14:03
- Fix Range.Contains(OR) to check ALL branches instead of ANY
- Implement OR.Contains(OR) with proper branch comparison
- Implement AND.Contains(AND) with constraint matching
- Add 30+ edge case tests to prevent regressions

Fixed bugs:
1. Range.Contains(OR): Was returning true if ANY branch was contained,
   now correctly checks that ALL branches are contained
2. OR.Contains(OR): Implemented proper branch-by-branch comparison
3. AND.Contains(AND): Implemented proper constraint matching for AND
   with multiple != constraints

All edge cases now correctly handled including:
- OR with multiple branches extending beyond range bounds
- OR contains OR with disjoint ranges
- AND with multiple != exclusion constraints
- Mixed AND/OR with exclusions
- Tilde (~) and Caret (^) ranges in complex expressions
- Unbounded ranges (>=, <=, >, <) with OR/AND

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Add validation to detect when version constraints are impossible to
satisfy (over-constrained) and reject them with clear error messages
that include the position where the issue was detected.

Detected cases:
- Individual ranges with min > max
- Non-overlapping ranges in AND (e.g., 1-2 && 3-4)
- Conflicting bounds (e.g., >=5.0.0 && <=3.0.0, >=2 && <1)
- NOT excluding exact version (e.g., =1.0.0 && !=1.0.0)

Error messages include precise column positions and clear descriptions
of why the constraint is impossible, helping users fix their constraints.

Examples:
- `>=2.0.0 && <1.0.0` now errors with "over-constrained, ranges do not overlap"
- `=1.0.0 && !=1.0.0` now errors with "excludes all versions"
- `1-2 && 3-4` now errors with "ranges do not overlap"

This prevents the parser from accepting constraints that would never
match any version, making errors visible at parse time rather than
at runtime when the constraint unexpectedly matches nothing.

Test coverage:
- 45 new test cases for over-constraint detection
- Updated existing tests that had incorrect expectations
- Fixed test case using AND instead of OR for disjoint ranges

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Added 38 test cases covering all four range sorters (AscendingMin, AscendingMax, DescendingMin, DescendingMax) with edge cases including empty slices, single elements, already sorted data, and version component differences.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Member

@erdii erdii left a comment

Choose a reason for hiding this comment

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

Found some issues. Here's a set of reproducers / regression tests to discuss.

./review_regression_test.go:

package semver

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// Bug: compactAndValidateLogicalAND merges adjacent ranges as a union
// (1-2 && 2-3 → 1-3) when AND semantics require intersection.
func TestANDCompaction_Check(t *testing.T) {
	t.Parallel()

	// Adjacent AND must not be compacted into a single Range.
	c, err := NewConstraint("1 - 2 && 2 - 3")
	require.NoError(t, err)
	oic := c.(*originalInputConstraint)
	_, isSingleRange := oic.Constraint.(*Range)
	assert.False(t, isSingleRange,
		"adjacent AND should not compact to a single Range (union); AND means intersection")

	tests := []struct {
		name       string
		constraint string
		version    string
		expected   bool
	}{
		// Adjacent ranges [1,2] ∩ [2,3] = {2.0.0}
		{"adjacent AND matches at boundary", "1 - 2 && 2 - 3", "2.0.0", true},
		{"adjacent AND rejects left-only version", "1 - 2 && 2 - 3", "1.5.0", false},
		{"adjacent AND rejects right-only version", "1 - 2 && 2 - 3", "2.5.0", false},
		{"adjacent AND rejects left boundary", "1 - 2 && 2 - 3", "1.0.0", false},
		{"adjacent AND rejects right boundary", "1 - 2 && 2 - 3", "3.0.0", false},
		{"adjacent AND rejects below both", "1 - 2 && 2 - 3", "0.5.0", false},
		{"adjacent AND rejects above both", "1 - 2 && 2 - 3", "3.5.0", false},

		// Overlapping ranges [1,3] ∩ [2,4] = [2,3]
		{"overlapping AND matches intersection start", "1.0.0 - 3.0.0 && 2.0.0 - 4.0.0", "2.0.0", true},
		{"overlapping AND matches intersection mid", "1.0.0 - 3.0.0 && 2.0.0 - 4.0.0", "2.5.0", true},
		{"overlapping AND matches intersection end", "1.0.0 - 3.0.0 && 2.0.0 - 4.0.0", "3.0.0", true},
		{"overlapping AND rejects left-only", "1.0.0 - 3.0.0 && 2.0.0 - 4.0.0", "1.5.0", false},
		{"overlapping AND rejects right-only", "1.0.0 - 3.0.0 && 2.0.0 - 4.0.0", "3.5.0", false},

		// Same range AND'd with itself
		{"identical AND matches inside", "1.0.0 - 2.0.0 && 1.0.0 - 2.0.0", "1.5.0", true},
		{"identical AND rejects outside", "1.0.0 - 2.0.0 && 1.0.0 - 2.0.0", "2.5.0", false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			c, err := NewConstraint(tt.constraint)
			require.NoError(t, err)
			assert.Equal(t, tt.expected, c.Check(MustNewVersion(tt.version)))
		})
	}
}

// Bug: Contains() returns wrong results after union-based AND compaction.
func TestANDCompaction_Contains(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name     string
		rA       string
		rB       string
		expected bool
	}{
		{
			// [1,2] ∩ [2,3] = {2.0.0}, which does not contain [1,3].
			name:     "adjacent AND does not contain full union range",
			rA:       "2 - 3 && 1 - 2",
			rB:       "1 - 3",
			expected: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			assert.Equal(t, tt.expected,
				MustNewConstraint(tt.rA).Contains(MustNewConstraint(tt.rB)))
		})
	}
}

// Bug: not.Contains uses !other.Contains(&not.Range) which produces wrong
// results for not-vs-not comparisons. Also, and.coversConstraint falls back
// to String() comparison which breaks when semantically equivalent constraints
// have different string representations (e.g. !=2 vs !=2.0.0).
func TestNotContains(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name     string
		rA       string
		rB       string
		expected bool
	}{
		{
			// !=2 excludes all 2.x.x, !=2.0.0 excludes only 2.0.0.
			// (1-5 && !=2) is more restrictive — it should not contain (1-5 && !=2.0.0).
			name:     "wider exclusion does not contain narrower exclusion",
			rA:       "1 - 5 && !=2",
			rB:       "1 - 5 && !=2.0.0",
			expected: false,
		},
		{
			// Every version allowed by (1-5 && !=2) is also allowed by (1-5 && !=2.0.0).
			name:     "narrower exclusion contains wider exclusion",
			rA:       "1 - 5 && !=2.0.0",
			rB:       "1 - 5 && !=2",
			expected: true,
		},
		{
			// !=2.0.0 allows all versions except 2.0.0; !=2 allows all except 2.x.x.
			// Every version in !=2 is also in !=2.0.0.
			name:     "narrow not contains wide not",
			rA:       "!=2.0.0",
			rB:       "!=2",
			expected: true,
		},
		{
			// !=2.0.0 allows 2.1.0, but !=2 does not.
			name:     "wide not does not contain narrow not",
			rA:       "!=2",
			rB:       "!=2.0.0",
			expected: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			assert.Equal(t, tt.expected,
				MustNewConstraint(tt.rA).Contains(MustNewConstraint(tt.rB)))
		})
	}
}

// Bug: or.Contains checks each branch independently and misses cases where
// overlapping or adjacent branches together cover the queried constraint.
func TestORContains_OverlappingBranches(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name     string
		rA       string
		rB       string
		expected bool
	}{
		{
			// [1,3] ∪ [2,5] = [1,5], which contains [1,5].
			name:     "overlapping OR branches cover full range",
			rA:       "1 - 3 || 2 - 5",
			rB:       "1 - 5",
			expected: true,
		},
		{
			// [1,2] ∪ [2,3] = [1,3], which contains [1,3].
			name:     "adjacent OR branches cover full range",
			rA:       "1 - 2 || 2 - 3",
			rB:       "1 - 3",
			expected: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			assert.Equal(t, tt.expected,
				MustNewConstraint(tt.rA).Contains(MustNewConstraint(tt.rB)))
		})
	}
}

// Bug: over-constrained error messages have doubled position prefix
// ("col 16: AND col 16: ...") due to wrapping in parse() + validateNotOverConstrained.
func TestOverConstrainedErrorFormat(t *testing.T) {
	t.Parallel()

	_, err := NewConstraint("2 - 3 && 5 - 6 && 1 - 2")
	require.Error(t, err)
	assert.NotRegexp(t, `col \d+: AND col \d+:`, err.Error(),
		"error should not have doubled position prefix")
}

Comment thread sort_range.go Outdated
Comment thread sort_range.go Outdated
Comment thread sort_range.go Outdated
Comment thread sort_range.go Outdated
Comment thread constraintparser_test.go Outdated
thetechnick and others added 10 commits May 19, 2026 15:40
Co-authored-by: Josh Gwosdz <me@erdii.net>
Co-authored-by: Josh Gwosdz <me@erdii.net>
Co-authored-by: Josh Gwosdz <me@erdii.net>
Co-authored-by: Josh Gwosdz <me@erdii.net>
Co-authored-by: Josh Gwosdz <me@erdii.net>
Previously, the parser incorrectly combined adjacent ranges in AND operations,
treating them as unions instead of intersections. For example, "1-2 && 2-3"
was incorrectly combined into "1-3", when it should represent only "2.0.0"
(the intersection).

Changes:
- Removed range combining logic from AND operations - AND represents intersection
- Added compactLogicalOR function to properly merge adjacent/overlapping ranges in OR operations
- Updated test expectations to reflect correct AND semantics
- OR operations now correctly combine: "1-2 || 2-3" → "1-3" (union)
- AND operations now correctly preserve: "1-2 && 2-3" → intersection at 2.0.0

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Added test cases to verify:
- OR operations combine adjacent ranges: "1-2 || 2-3" → "1-3"
- AND operations preserve both ranges: "1-2 && 2-3" → intersection

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
When multiple ranges in an AND operation only overlap at a single version,
simplify them to a single range representing that exact version.

Examples:
- "1-2 && 2-3" → "2-2" (equivalent to =2.0.0)
- "1-5 && 3-7 && 5-9" → "5-5" (equivalent to =5.0.0)
- ">=2.5.0 && <=2.5.0" → "2.5.0-2.5.0" (equivalent to =2.5.0)

Ranges with broader overlaps are preserved as AND constraints to maintain
the intersection semantics, with the actual intersection calculated at
runtime during version checking.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@thetechnick thetechnick merged commit fd548ea into package-operator:main May 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants