Skip to content

fix(flags): reject leading-zero versions in sortableSemVer#59290

Merged
dmarticus merged 6 commits into
masterfrom
dmarticus/strict-semver-blast-radius
May 21, 2026
Merged

fix(flags): reject leading-zero versions in sortableSemVer#59290
dmarticus merged 6 commits into
masterfrom
dmarticus/strict-semver-blast-radius

Conversation

@dmarticus
Copy link
Copy Markdown
Contributor

Problem

The HogQL sortableSemVer function (used to compute the rollout
blast-radius preview for feature flags with semver comparisons)
extracted the first dot-separated digit run from the property value
without enforcing strict semver rules. As a result \"3.07\" was
parsed as [3, 7] — exactly the same as \"3.7\" — so the preview
counted users with version \"3.07\" as matching a version >= 3.7
filter.

Runtime flag evaluation (Rust, semver crate)
rejects \"3.07\" outright. So a flag that the preview said would hit
N users actually hit fewer at runtime, and customers were filing the
discrepancy as a bug.

Changes

  • HogQL sortablesemver (posthog/hogql/functions/posthog.py):
    swap the lenient (\\d+(\\.\\d+)+) extraction for a strict, anchored
    regex that only matches MAJOR.MINOR.PATCH with no leading zeros,
    optionally with a v prefix or pre-release / build-metadata suffix.
    Invalid input now becomes NULL (via nullIf(extract(...), '')),
    which propagates through splitByChar/arrayMap and makes every
    semver comparison NULL — equivalent to false in WHERE, so the
    row is excluded. This matches what the Rust path does when
    Version::parse returns None.
  • Rust regression test (rust/feature-flags/src/properties/property_matching.rs):
    extend test_semver_invalid_versions with explicit assertions for
    \"3.07\", \"3.7\", and \"3.0\" to pin the strict-rejection
    behavior of the source-of-truth path so it can't silently relax.
  • HogQL tests (posthog/hogql/test/test_query.py):
    tighten test_sortable_semver to only sort strict X.Y.Z inputs,
    rewrite test_sortable_semver_output to cover the new accept-list
    (plain, v prefix, pre-release suffix, all-zero), and add two new
    tests — test_sortable_semver_rejects_invalid (lists everything
    Rust rejects and asserts IS NULL) and
    test_sortable_semver_invalid_excluded_from_comparisons (the exact
    bug repro: sortableSemVer('3.07') >= sortableSemVer('3.7.0') is
    now NULL instead of true).
  • Printer test (posthog/hogql/printer/test/test_printer.py):
    update the expected ClickHouse SQL string for the new template.

Behavior change to be aware of

Any value that isn't strict X.Y.Z (with no leading zeros) is now
treated as unmatchable for semver operators in the blast-radius
preview. That includes two-component versions like \"3.7\",
four-plus-component versions like \"1.2.3.4\", and trailing garbage
like \"2.2.0.betabac\". This is the intentional alignment with Rust
— blast radius now reflects what flag evaluation will actually do —
but it does change the number reported in the UI for projects whose
app-version property doesn't use strict semver. Worth a heads-up in
release notes.

How did you test this code?

I'm an agent (Claude Code).

  • ✅ Ran the Rust test locally:
    cargo test --package feature-flags --lib test_semver_invalid_versions
    passes with the new assertions for \"3.07\", \"3.7\", \"3.0\".
  • ✅ Cross-checked the new HogQL regex against ~25 input strings using
    a Python harness (mirroring the extract + capture-group-1 +
    nullIf flow), covering: strict valid (1.2.3, 0.0.0,
    v1.2.3-alpha+build, whitespace padding), the user-reported bug
    (3.07), other leading-zero shapes (01.2.3, 1.02.3, 1.2.03,
    01.02.03), wrong arity (3.0, 3.7, 1.2.3.4, 0.9,
    1.9.233434.10, 0.0.0.0.1000), structural junk (1..2.3,
    .1.2.3, 1.2.3., 1.-2.3, 2.2.0.betabac), and totally
    unparseable input (abc, \"\"). All 25+ matched expectations.
  • Did not run the HogQL integration tests locally — couldn't
    bring up local ClickHouse, leaving verification to CI.

Open item for reviewers

The fix relies on ClickHouse propagating NULL through
splitByChar('.', NULL) and arrayMap(f, NULL). The docs say
non-NULL-aware functions return NULL on NULL input, but I couldn't
confirm specifically for the higher-order arrayMap path. If CI
reveals it does not propagate, the fallback is to special-case
sortablesemver in posthog/hogql/printer/clickhouse.py (next to
lookupOrganicSourceType) and emit an explicit
if(empty(extract(...)), NULL, arrayMap(...)) that references args[0]
twice. Happy to land that follow-up if the integration tests flag it.

Publish to changelog?

no

🤖 Agent context

Authored by Claude Code (Opus 4.7, 1M context) — Dylan kicked it off
with a description of the user-visible blast-radius bug and an
explicit ask to match the Rust path strictly (chosen via
`AskUserQuestion` over only-fix-leading-zeros and
make-Rust-more-lenient). I rejected an earlier draft that just tweaked
the regex inside `extract` (it would silently truncate `"3.07"` to
`"3.0"`) and went with anchored-regex + capture-group-1 + `nullIf`
instead because it falls back to `NULL` rather than to a bogus
parsed array. The Rust test was added first so the source-of-truth
behavior is pinned regardless of how the HogQL fallback shakes out.

The HogQL sortableSemVer function used a permissive regex that treated
"3.07" as [3, 7], so the flag rollout blast-radius view counted users
with version "3.07" as matching a "version >= 3.7" filter even though
runtime flag evaluation (Rust, strict semver crate) rejects them. Users
were confused when the rollout preview disagreed with the live cohort.

Make HogQL's sortableSemVer match the Rust crate: only strict X.Y.Z with
no leading zeros (optionally with a 'v' prefix or pre-release/build
suffix). Invalid input now becomes NULL, which is falsy in WHERE and
excludes the row from any semver comparison.

Also add a Rust test pinning the strict-rejection behavior for "3.07",
"3.7", and "3.0" so the source-of-truth path can't drift back.
@dmarticus dmarticus requested a review from a team as a code owner May 20, 2026 20:59
@assign-reviewers-posthog assign-reviewers-posthog Bot requested a review from a team May 20, 2026 20:59
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 20, 2026

🎭 Playwright didn't run on this PR — your changes touch code that could affect E2E behavior, but Playwright is opt-in via label now to keep CI cost down.

Add the run-playwright label if you want an E2E sweep before merging — CI will pick it up automatically.

Most PRs don't need this. Real regressions still get caught on master and fix-forward.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 20, 2026

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
posthog/hogql/test/test_query.py:1905-1957
**Prefer parameterised tests**

`test_sortable_semver_rejects_invalid` and `test_sortable_semver_invalid_excluded_from_comparisons` test multiple distinct inputs by concatenating them into one SQL string. Per the team's convention ("We always prefer parameterised tests"), each invalid-version case should ideally be its own parameterised invocation so that failures pinpoint the exact offending input rather than the whole query. The batch-query design here is efficient for ClickHouse round-trips, but a `@parameterized.expand` (or `@pytest.mark.parametrize`) approach on individual `SELECT sortableSemVer('{version}') IS NULL` calls would make failures immediately actionable without having to inspect the result-set difference.

Reviews (1): Last reviewed commit: "fix(flags): reject leading-zero versions..." | Re-trigger Greptile

Comment thread posthog/hogql/test/test_query.py Outdated
dmarticus added 5 commits May 20, 2026 14:15
ClickHouse rejects Nullable(Array(String)), which is the type the
previous `nullIf(extract(...), '')` approach produced once the HogQL
codegen wrapped the comparison with ifNull(). Three TestBlastRadius
integration tests failed with:

  Nested type Array(String) cannot be inside Nullable type

Switch to a per-element-nullable shape instead: keep extract() non-
nullable (empty string on no-match) and convert each split component
with toInt64OrNull. Invalid input now parses to [NULL] of type
Array(Nullable(Int64)), which ClickHouse accepts. Element-wise array
comparison propagates NULL through any operator (>, >=, <, <=, =, !=),
so invalid versions still get excluded from every semver filter —
matching Rust's behavior — without producing a forbidden Nullable
array wrapper.
splitByChar('.', '') returns [] in ClickHouse, not [''] — so my last
commit would have parsed invalid versions to [] instead of [NULL]. The
practical problem: [] < [1, 2, 3] is true in ClickHouse, which would
silently include invalid versions in 'version < X' filters (the same
class of bug we set out to fix, just on the other operator).

Wrap extract() in coalesce(nullIf(extract, ''), '_') so the empty-match
case feeds splitByChar a single-character sentinel instead. The
sentinel splits to ['_'], which toInt64OrNull maps to [NULL] —
restoring the intended Array(Nullable(Int64)) behavior where any
comparison with an invalid version returns NULL and the row is
excluded from every operator.
ClickHouse compares arrays containing NULL elements as the *greatest*
value (not as NULL/unknown), so an invalid version parsed to [NULL]
would still satisfy '>= 3.7.0' filters in WHERE — leaving the original
blast-radius / runtime-evaluation discrepancy unresolved.

Wrap every semver comparison (=, !=, >, >=, <, <=, ~, ^, .*) in
property.py with an explicit match() against the strict semver regex.
Invalid property values are now dropped before the array comparison
fires, regardless of the operator — finally matching what Rust's
semver crate does at flag evaluation time.

Also rewrite the test_sortable_semver_comparison tests, which had been
asserting raw sortableSemver() comparisons would return NULL for
invalid input. They don't — that's the whole reason we need the gate.
The new tests only cover the valid-vs-valid case; invalid-exclusion is
verified end-to-end through the existing TestBlastRadius tests.
@dmarticus dmarticus merged commit ece219b into master May 21, 2026
256 checks passed
@dmarticus dmarticus deleted the dmarticus/strict-semver-blast-radius branch May 21, 2026 15:26
@deployment-status-posthog
Copy link
Copy Markdown

deployment-status-posthog Bot commented May 21, 2026

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-05-21 15:53 UTC Run
prod-us ✅ Deployed 2026-05-21 16:10 UTC Run
prod-eu ✅ Deployed 2026-05-21 16:10 UTC Run

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.

3 participants