feat(flags): add semver targeting to local evaluation#107
Conversation
28b0389 to
1192999
Compare
1192999 to
da36285
Compare
There was a problem hiding this comment.
Pull request overview
Adds local semver-based targeting for feature flag property evaluation so SDK/app versions can be matched client-side without server calls, aligning with PostHog server + Python SDK semantics.
Changes:
- Adds semver parsing (
parse_semver) and range-bound helpers for tilde/caret/wildcard operators. - Extends
FeatureFlagsPoller.match_propertywith 9 newsemver_*operators. - Adds comprehensive RSpec coverage for semver comparisons, ranges, and input edge cases.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
lib/posthog/feature_flags.rb |
Implements semver parsing + range bounds and wires new semver_* operators into local property matching. |
spec/posthog/feature_flag_spec.rb |
Adds unit tests validating semver comparison, range behavior, and error handling. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
lib/posthog/feature_flags.rb
Outdated
| # Returns bounds for tilde (~) range: >=X.Y.Z <X.(Y+1).0 | ||
| def self.semver_tilde_bounds(value) | ||
| major, minor, patch = parse_semver(value) | ||
| lower = [major, minor, patch] | ||
| upper = [major, minor + 1, 0] |
There was a problem hiding this comment.
semver_tilde_bounds currently always increments the minor component for the upper bound. For a major-only constraint (e.g. operator semver_tilde with value "1"), this produces <1.1.0, but npm/semver tilde semantics are >=1.0.0 <2.0.0 (i.e., bump major when only major is specified). Consider computing the upper bound based on how many components were provided (major-only vs major.minor vs major.minor.patch).
| # Returns bounds for tilde (~) range: >=X.Y.Z <X.(Y+1).0 | |
| def self.semver_tilde_bounds(value) | |
| major, minor, patch = parse_semver(value) | |
| lower = [major, minor, patch] | |
| upper = [major, minor + 1, 0] | |
| # Returns bounds for tilde (~) range: | |
| # ~X → >=X.0.0 <(X+1).0.0 | |
| # ~X.Y → >=X.Y.0 <X.(Y+1).0 | |
| # ~X.Y.Z → >=X.Y.Z <X.(Y+1).0 | |
| def self.semver_tilde_bounds(value) | |
| major, minor, patch = parse_semver(value) | |
| lower = [major, minor, patch] | |
| # Determine how many components were actually provided (major, major.minor, or major.minor.patch) | |
| text = value.to_s.strip.sub(/^[vV]/, '') | |
| text = text.split('-')[0].split('+')[0] | |
| component_count = text.split('.').length | |
| upper = | |
| if component_count == 1 | |
| # Major-only: bump major | |
| [major + 1, 0, 0] | |
| else | |
| # Major.minor or Major.minor.patch: bump minor (current behavior) | |
| [major, minor + 1, 0] | |
| end |
| def self.semver_wildcard_bounds(value) | ||
| cleaned = value.to_s.strip.sub(/^[vV]/, '').gsub('*', '').chomp('.') | ||
| parts = cleaned.split('.').reject(&:empty?) | ||
|
|
||
| raise InconclusiveMatchError, 'Invalid semver wildcard format' if parts.empty? | ||
|
|
||
| parts.each do |part| | ||
| raise InconclusiveMatchError, 'Invalid semver wildcard format' if part !~ /^\d+$/ | ||
| end | ||
|
|
||
| major = parts[0].to_i | ||
| case parts.length | ||
| when 1 | ||
| [[major, 0, 0], [major + 1, 0, 0]] | ||
| when 2 | ||
| minor = parts[1].to_i | ||
| [[major, minor, 0], [major, minor + 1, 0]] | ||
| else | ||
| minor = parts[1].to_i | ||
| patch = parts[2].to_i | ||
| [[major, minor, patch], [major, minor, patch + 1]] | ||
| end | ||
| end |
There was a problem hiding this comment.
semver_wildcard_bounds removes all * characters via gsub('*', ''), which can accidentally treat malformed patterns as valid (e.g. "1*2.3" becomes "12.3", "1.2.*.3" becomes "1.2..3"). This can lead to incorrect range evaluation. Consider explicitly validating wildcard syntax (e.g., * must occupy an entire segment, only allowed as the last segment, and reject any additional segments after a wildcard).
There was a problem hiding this comment.
This is valid, but very edgy.
This is in other PRs too, so if you decide to add a protection to this, just be aware of that.
| property2 = { 'key' => 'version', 'value' => '1.0.0', 'operator' => 'semver_tilde' } | ||
| expect(FeatureFlagsPoller.match_property(property2, { 'version' => '1.0.0' })).to be true | ||
| expect(FeatureFlagsPoller.match_property(property2, { 'version' => '1.0.99' })).to be true | ||
| expect(FeatureFlagsPoller.match_property(property2, { 'version' => '1.1.0' })).to be false |
There was a problem hiding this comment.
The tilde operator tests cover value: "1.2.3" and "1.0.0", but don’t cover a major-only tilde constraint (operator semver_tilde with value: "1"). Given npm/semver semantics, "1" should match 1.x (>=1.0.0 <2.0.0), and this case would currently regress without a test.
| expect(FeatureFlagsPoller.match_property(property2, { 'version' => '1.1.0' })).to be false | |
| expect(FeatureFlagsPoller.match_property(property2, { 'version' => '1.1.0' })).to be false | |
| # Major-only tilde: ~1 means >=1.0.0 <2.0.0 | |
| property3 = { 'key' => 'version', 'value' => '1', 'operator' => 'semver_tilde' } | |
| expect(FeatureFlagsPoller.match_property(property3, { 'version' => '1.0.0' })).to be true | |
| expect(FeatureFlagsPoller.match_property(property3, { 'version' => '1.5.0' })).to be true | |
| expect(FeatureFlagsPoller.match_property(property3, { 'version' => '1.99.99' })).to be true | |
| expect(FeatureFlagsPoller.match_property(property3, { 'version' => '0.9.9' })).to be false | |
| expect(FeatureFlagsPoller.match_property(property3, { 'version' => '2.0.0' })).to be false |
| property2 = { 'key' => 'version', 'value' => '*.*', 'operator' => 'semver_wildcard' } | ||
| expect do | ||
| FeatureFlagsPoller.match_property(property2, { 'version' => '1.2.3' }) | ||
| end.to raise_error(InconclusiveMatchError) |
There was a problem hiding this comment.
The invalid wildcard tests only cover "*" and "*.*". Since semver_wildcard_bounds currently strips * characters, adding cases like "1*2.3", "1.2.*.3", or "1.2.3**" would help ensure malformed wildcard inputs reliably raise InconclusiveMatchError rather than being mis-parsed into a different version.
| end.to raise_error(InconclusiveMatchError) | |
| end.to raise_error(InconclusiveMatchError) | |
| # Malformed wildcard patterns that should not be parsed as valid versions | |
| property3 = { 'key' => 'version', 'value' => '1*2.3', 'operator' => 'semver_wildcard' } | |
| expect do | |
| FeatureFlagsPoller.match_property(property3, { 'version' => '1.2.3' }) | |
| end.to raise_error(InconclusiveMatchError) | |
| property4 = { 'key' => 'version', 'value' => '1.2.*.3', 'operator' => 'semver_wildcard' } | |
| expect do | |
| FeatureFlagsPoller.match_property(property4, { 'version' => '1.2.3' }) | |
| end.to raise_error(InconclusiveMatchError) | |
| property5 = { 'key' => 'version', 'value' => '1.2.3**', 'operator' => 'semver_wildcard' } | |
| expect do | |
| FeatureFlagsPoller.match_property(property5, { 'version' => '1.2.3' }) | |
| end.to raise_error(InconclusiveMatchError) |
~1 should match >=1.0.0 <2.0.0 per npm/semver semantics 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Summary
semver_eq,semver_neq,semver_gt,semver_gte,semver_lt,semver_lte,semver_tilde,semver_caret,semver_wildcardparse_semverfunction that handles v-prefix, whitespace, pre-release suffixes, partial versions (e.g., "1.2" → "1.2.0"), and 4-part versionsThis enables targeting users by app/SDK version without network calls, matching the behavior in:
user_blast_radiusposthog#44596Test plan
bundle exec rspec spec/posthog/feature_flag_spec.rb— all 70 tests pass