duckling 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e13fe61846d4bc85faed37d388e0845de0bf0a6742d5b73796524987b0c85681
4
- data.tar.gz: b8fec101cf3f92a760ef275261cbbebe3411185cb4ea84bd19756bd25b09bef1
3
+ metadata.gz: b5a2e1267025c49fdc9d0217f04a93543acc76d1a064eb8257cae7ca7c39c1fa
4
+ data.tar.gz: e2cfc90eb5fb08211176cc6d5b95c370ddd92d39abf6a209f871799dabc5023e
5
5
  SHA512:
6
- metadata.gz: 93d4e3ce65576eb6ab27ec20b617b7bc4cdcab1a1be8bf61ccacbf33ede40daf5293e9ec10053a893986d424761012d3d5083ace5bf7681750a6cfb1fa40574e
7
- data.tar.gz: 722c57d3d75412edc428b2476a6312ff5b6162a28537e50b523da94d41b0289f7c5b86a46072aa1a37ef72f580753ee9057c2452b0310580aba0843f76ff5a98
6
+ metadata.gz: 6d1fa9bb53e5c4869569678130cb7d492766bcbc40254629736efc383fc9dc04728d8fec5537a2d14326d85555c32b29f64b7f55ad64b3e215556949ac738301
7
+ data.tar.gz: 8e4ff2d3db274a3316261ab7ddb892f09c095dbed99de3b0d5edf36580df6cf1bac729a822ea8131da88f30b6a350bc2290fb5a9dcc25a1c8432708e2fdadd24
data/AGENTS.md CHANGED
@@ -31,17 +31,23 @@ If you notice this file describing a not-yet-built piece as current, or vice ver
31
31
  | `lib/duckling.rb` | Ruby module entrypoint. Loads the compiled native extension from the running Ruby's ABI directory (`duckling/<major.minor>/duckling`, as a precompiled fat gem ships it), falling back to the plain `duckling/duckling` path a source-gem build or a local `rake compile` writes — see "Build model" below for why one binary can't serve every Ruby. The extension defines `Duckling::Native.parse` (the raw entrypoint — no thread spawn). `Duckling.parse` is then defined here as a thin Ruby-level wrapper that only dispatches through `Thread.new { Native.parse(...) }.value` when `Fiber.scheduler` is installed on the calling thread — see the "Rust/Magnus wiring" section's GVL-release bullet below for why a bare GVL release alone isn't sufficient for a Fiber to yield, and why plain thread-pool callers skip the thread spawn entirely. |
32
32
  | `lib/duckling/version.rb` | `Duckling::VERSION` constant — single source of truth for the gem version, read by `duckling.gemspec` and the release pipeline. |
33
33
  | `test/` | Minitest suite. `test_helper.rb` sets up the load path and requires `minitest/autorun`; test files follow `<name>_test.rb` / `class <Name>Test < Minitest::Test` naming. `test/gem/` is the exception on both counts — see its own row below. |
34
+ | `test/support/` | Helpers required by `test_helper.rb`, not themselves test files (so outside the `*_test.rb` glob). `tz_capabilities.rb` holds the probes only the suite needs (see `Duckling::TZInfoCapabilities` under "Public and internal APIs" for why the split is by consumer); `tz_fixtures.rb` compiles `test/fixtures/tz/*.zi` into `tmp/tz-fixtures` with `zic`, and its `TZFixtures::Datasource` mixin swaps `TZInfo::DataSource` to them for the duration of each test in the including class (the datasource is process-global, so the `teardown` restore is mandatory, not hygiene). See "The tz-database axis" below. |
35
+ | `test/fixtures/tz/` | `zic` source for the three synthetic zones (`Fixture/NegativeDst`, `Fixture/HalfHourGap`, `Fixture/LateGap`) plus the minimal `iso3166.tab`/`zone1970.tab` a zoneinfo directory needs to be recognized. Compiled at test time, never committed as binaries. |
36
+ | `test/fixtures/zoneinfo-overrides/` | `zic` source for zones that `bin/build-stale-zoneinfo` compiles *over* a copy of the host's zoneinfo directory, rolling named zones back to earlier rules. Separate from `test/fixtures/tz/` on purpose: anything in that directory becomes a fixture-zone identifier, and the fixture datasource must expose only its own three. |
37
+ | `test/capabilities/` | tz-capability-gated test files, one per probe in `TZCapabilities::CAPABILITIES`, named `<capability>_test.rb`. `test_helper.rb` loads a file only where its probe passes on the database in use, so these tests run wherever the capability exists and are absent where it doesn't. Excluded from the Rakefile's glob precisely so the gate can't be bypassed. See "The tz-database axis" below. |
38
+ | `test/environments/` | Per-environment contracts, each a small `*_test.rb` invoked directly by the CI step that creates the environment (plain `ruby -Ilib -Itest`, the way `test/gem/` runs) — never loaded by the suite. Each asserts the environment's defining state positively, so a broken setup goes red instead of presenting as a smaller green suite. See "The tz-database axis" below. |
34
39
  | `test/gem/` | Two minitest suites that exercise a *built* or *installed* gem rather than this checkout's compiled extension. Neither requires `test_helper` and neither is in the default `test` glob (the `Rakefile` excludes the directory), because each needs a gem handed to it — see "Test guide" below for what each asserts and how to run it. |
35
40
  | `bin/` | Two kinds of scripts living side by side — see "bin/ scripts" below. Don't confuse the dev-workflow scripts (`worktree`, `check-worktree`, `claude-code-web-setup`, `lint`) with the gem's own build/test entrypoints (`setup`, `console`, `test`, `benchmark`, `benchmark-replay`). |
36
41
  | `benchmark/` | `benchmark-ips`-based suite exercising `Duckling.parse` (`parse_benchmark.rb`: ips + GC/allocation pressure + threaded-concurrency scenarios) and the environment-aware recording/reporting logic (`report.rb`: writes `docs/benchmarks/<environment>/<version>.json`, regenerates `docs/benchmarks/README.md`). `parse_benchmark.rb` tolerates `Duckling::Native` not existing (pre-issue-#64 implementations only defined `Duckling.parse` directly) by skipping the native-only scenarios — see `NATIVE_AVAILABLE` — since `bin/benchmark-replay` runs this harness against historical checkouts that may predate that split. Not part of what ships in the gem (excluded from packaging in `duckling.gemspec`) or of `task default:` (too slow/non-deterministic for every `bundle exec rake`). Run via `bin/benchmark` — see "Build and test commands" below. |
37
42
  | `docs/benchmarks/` | Per-environment, per-version benchmark history: `<environment>/<version>.json` raw results plus an auto-generated `README.md` (owned entirely by `DucklingBenchmark::Report.write_docs_readme!` — never hand-edited) with Mermaid charts comparing environments. `local` recordings are further bucketed by Ruby *minor* version (`local-3.3`, `local-3.4`, `local-4.0`, ...) since a dev machine's Ruby version drifts over a project's lifetime in a way CI runners' don't — see `Report.detect_environment`. Also holds `comparison-artifact-prompt.md`, a hand-maintained saved prompt (not auto-generated) for regenerating a richer interactive HTML cross-version comparison artifact than the README's latest-per-environment view supports — re-run it as new versions get recorded or `-rc` data changes. Committed to git but excluded from the packaged gem. Linked from the root `README.md`'s "Performance" section. |
43
+ | `Gemfile` | Bundler deps beyond the gemspec's. `tzinfo-data` is declared here rather than in `duckling.gemspec`, and conditionally: `DUCKLING_TZINFO_DATA` unset installs the current version, `none` omits it (so tzinfo falls back to the host's zoneinfo), and a `X.Y…`-shaped value pins that exact version. Anything else raises, rather than reaching the resolver as an unsatisfiable constraint. Pair non-default values with `BUNDLE_LOCKFILE` — see "The tz-database axis" below. |
38
44
  | `Brewfile` | Homebrew deps for local macOS dev: `rust` (bundles `cargo`/`rustc`/`rustfmt`/`clippy`), `hk` (see `hk.pkl` below — local-dev-only, not installed in CI or remote/web sessions), and `gh` (GitHub CLI, used by `benchmark:record_pr` to open PRs — needs `gh auth login` before that task can push/open a PR). `bin/setup` runs `brew bundle` against it, then `hk install`, when Homebrew is present. |
39
- | `duckling.gemspec` | Gem spec. Declares `spec.extensions = ["ext/duckling/extconf.rb"]` (the native-extension build entrypoint), depends on `rb_sys` + `tzinfo`/`tzinfo-data` (the latter two back `reference_zone:` — see "Public and internal APIs") and dev-depends on `rake-compiler` + `benchmark-ips` — see the gemspec's `add_dependency`/`add_development_dependency` lines for the current version constraints. Packaged files come from `git ls-files`, excluding `bin/`, `Gemfile`, `.gitignore`, `.env.local.example`, `test/`, `.github/`, `.standard.yml`, `hk.pkl`, `benchmark/`, `docs/benchmarks/`, `cross_targets.rb`. |
40
- | `Rakefile` | `task default: %i[standard compile test]` — runs StandardRB lint, compiles the Rust extension, then Minitest. `test` also declares an explicit `task test: :compile` prerequisite (`Minitest::TestTask` has no built-in way to express this itself), so `bundle exec rake test` run in isolation still compiles first — not just `bundle exec rake` via the `default` array's ordering. Loads `.env.local` via `Dotenv.load` at the top (no-ops if absent, e.g. in CI); also defines an opt-in `:dev` task (not part of `default`) that sets `RB_SYS_CARGO_PROFILE=dev` directly — use `bundle exec rake dev compile test` for a one-off dev-profile build without `.env.local` in place. Also defines `benchmark` / `benchmark:record` / `benchmark:record_pr` (all deliberately excluded from `task default:`) — see "Build and test commands" below for how they relate. |
45
+ | `duckling.gemspec` | Gem spec. Declares `spec.extensions = ["ext/duckling/extconf.rb"]` (the native-extension build entrypoint), depends on `rb_sys` + `tzinfo` (which backs `reference_zone:` — see "Public and internal APIs"; `tzinfo-data` is deliberately *not* a dependency, see "The tz-database axis") and dev-depends on `rake-compiler` + `benchmark-ips` — see the gemspec's `add_dependency`/`add_development_dependency` lines for the current version constraints. Packaged files come from `git ls-files`, excluding `bin/`, `Gemfile`, `.gitignore`, `.env.local.example`, `test/`, `.github/`, `.standard.yml`, `hk.pkl`, `benchmark/`, `docs/benchmarks/`, `cross_targets.rb`. |
46
+ | `Rakefile` | `task default: %i[standard compile test]` — runs StandardRB lint, compiles the Rust extension, then Minitest. `test` also declares an explicit `compile` prerequisite (`Minitest::TestTask` has no built-in way to express one itself, and it must be a prerequisite rather than an extra `task :test do` block — rake *appends* actions, so a second block would run after the test subprocess had already exited), so `bundle exec rake test` run in isolation still compiles first — not just `bundle exec rake` via the `default` array's ordering. Loads `.env.local` via `Dotenv.load` at the top (no-ops if absent, e.g. in CI); also defines an opt-in `:dev` task (not part of `default`) that sets `RB_SYS_CARGO_PROFILE=dev` directly — use `bundle exec rake dev compile test` for a one-off dev-profile build without `.env.local` in place. Also defines `benchmark` / `benchmark:record` / `benchmark:record_pr` (all deliberately excluded from `task default:`) — see "Build and test commands" below for how they relate. |
41
47
  | `.env.local.example` | Tracked template for `.env.local` (gitignored) — sets `RB_SYS_CARGO_PROFILE=dev` so `bin/setup` (see below) makes the dev Cargo profile the local default. |
42
48
  | `.standard.yml` | StandardRB config — see its `ruby_version:` field for the Ruby version StandardRB targets. StandardRB wraps RuboCop internally; there is no separate `.rubocop.yml`. |
43
49
  | `hk.pkl` | `hk` config (StandardRB + rustfmt + clippy via `hk`'s builtin steps) — scoped to local dev only. `bin/setup` installs `hk` (via `Brewfile`) and runs `hk install` to wire up a `git commit` pre-commit hook from this config. Neither `bin/lint` (the cpb-harness PostToolUse hook) nor CI shell out to `hk` — both run the same underlying tools directly instead, since `hk`'s Pkl config needs to fetch its schema package from a GitHub release on every invocation, which isn't reliable in sandboxed/network-restricted environments (CI, remote/web sessions). **Git stash merge gotcha**: when resolving merge conflicts, `git stash` (used by `hk`'s pre-commit hook) unconditionally clears `.git/MERGE_HEAD`, which can silently downgrade a merge commit to a single-parent commit if any unstaged changes remain. **Workaround**: bypass the hook for merge-resolution commits with `HK=0 git commit ...` after manually running `standardrb --fix`/`rustfmt`/`cargo clippy --fix`. |
44
- | `.github/workflows/main.yml` | CI: runs StandardRB lint, cross-platform Rust checks (`cargo fmt --check`, `cargo clippy -- -D warnings` against `ext/duckling/`), then `bundle exec rake`. Split into two jobs: `baseline` (`name: "Ruby 3.3.6"`, hardcoded Ruby 3.3.6 + a Rust toolchain pinned to track the Claude Code Web sandbox — the only entry branch protection on `main` requires, matched by that exact `name` string) and `informational` (`needs: baseline`, a 3-entry matrix of forward-compat signals allowed to fail — Ruby 3.4, Ruby latest, Rust latest). Because `informational` declares `needs: baseline`, GitHub Actions automatically skips it when `baseline` fails, instead of always burning CI minutes running all 4 entries in parallel regardless of the baseline's outcome (see #98). Rename the `baseline` job's `name` without updating the required check in the same PR and merges silently have no required check at all. Runs for every push to `main` and every PR. |
50
+ | `.github/workflows/main.yml` | CI: runs StandardRB lint, cross-platform Rust checks (`cargo fmt --check`, `cargo clippy -- -D warnings` against `ext/duckling/`), then `bundle exec rake`. Split into four jobs: `timezones` (`needs: baseline`, a 2-entry matrix covering the *stale* tz databases — see "The tz-database axis" below), `tz-containers` (`needs: baseline`, a 2-entry matrix of containerized tz environments no runner image provides — Debian + `tzdata-legacy` for system zoneinfo *with* the backward-compat links, and Alpine for vanguard modelling, which doubles as the suite's only musl source-build coverage; each leg is both a tz environment and a source build, which is what its name says, and the environment contract is what tells a changed tz answer from a toolchain break), `baseline` (`name: "Ruby 3.3.6"`, hardcoded Ruby 3.3.6 + a Rust toolchain pinned to track the Claude Code Web sandbox — the only entry branch protection on `main` requires, matched by that exact `name` string; it carries the three tz environments that must gate a merge — current `tzinfo-data`, then the host's zoneinfo, then the host's zoneinfo with the backward-compat links stripped — as extra steps rather than as matrix entries, specifically because `baseline` is the only required check) and `informational` (`needs: baseline`, a 3-entry matrix of forward-compat signals allowed to fail — Ruby 3.4, Ruby latest, Rust latest). Because every one of those seven matrix entries declares `needs: baseline`, GitHub Actions automatically skips them when `baseline` fails, instead of always burning CI minutes running them all in parallel regardless of the baseline's outcome (see #98). **Only `baseline` gates a merge, but `timezones` and `tz-containers` gate a release**: `release.yml` runs this workflow as its `ci` reusable-workflow job and `cross_gems`/`benchmark`/`publish` all `needs: ci`, and a reusable-workflow job succeeds only if every job in the called workflow does — so a stale-vintage disagreement or a musl/trixie build break holds a publish without holding a merge. Those container legs are also the only release-gating jobs that reach external package repositories at release time. Rename the `baseline` job's `name` without updating the required check in the same PR and merges silently have no required check at all. Runs for every push to `main` and every PR. |
45
51
  | `.github/workflows/release.yml` | Tag-triggered release workflow. Gates on CI, then runs cross-compilation and benchmarking in parallel. Builds the `ruby` source gem and pushes it with `x86_64-linux`/`x86_64-darwin`/`arm64-darwin`/`aarch64-linux` binary gems to RubyGems, then cuts a GitHub release. See "Gem release conventions" below. |
46
52
  | `cross_targets.rb` | Single source of truth for what the precompiled gems are built for: `RUBY_ABIS`, `PLATFORMS` (each platform's expected `file(1)` architecture and a runner label that can execute it), and the `required_ruby_version` ceiling those ABIs imply. Read by the `Rakefile`, both `test/gem/` suites, and `test/duckling_ci_test.rb`. `cross-gem.yml` is the one consumer that can't read it — YAML has no way to require Ruby — so it restates the matrix and `test/duckling_ci_test.rb` fails when the copy drifts. Excluded from the packaged gem. |
47
53
  | `.github/workflows/cross-gem.yml` | Reusable workflow that cross-compiles the native extension for `x86_64-linux`, `x86_64-darwin`, `arm64-darwin`, and `aarch64-linux` via Docker containers bundling their own Rust + cross-toolchain. Called from `release.yml` for releases; trigger manually with `gh workflow run cross-gem.yml --ref <branch>` to exercise cross-compilation. Two jobs: `cross_gems` builds each platform's fat gem and ends in a "Verify gem metadata and architecture" step running `ruby test/gem/packaged_gem_test.rb`; `smoke` (`needs: cross_gems`) then installs each gem on a real runner for that platform (`ubuntu-latest`, `ubuntu-24.04-arm`, `macos-15-intel`, `macos-latest`) once per Ruby ABI and runs `ruby test/gem/installed_gem_test.rb`. `smoke` is the only place a built gem is loaded and called; an ABI mismatch passes every metadata check and fails on the first call, so nothing earlier in the pipeline can catch it. Because `release.yml` needs the whole workflow, `smoke` gates publishing. |
@@ -53,7 +59,7 @@ If you notice this file describing a not-yet-built piece as current, or vice ver
53
59
 
54
60
  - **`bin/setup`** — runs `brew bundle` (installing the Rust toolchain and `hk` per `Brewfile`) and `hk install` (wiring up the local `git commit` pre-commit hook from `hk.pkl`) when Homebrew is present, then `bundle install`, then (if `.env.local` doesn't already exist, and `CI` isn't `true`) copies `.env.local.example` to `.env.local`, which sets `RB_SYS_CARGO_PROFILE=dev`. No-ops the Homebrew step gracefully on machines without `brew` (e.g. CI runners and remote/web sessions, which never install or need `hk` — see `hk.pkl` above), and skips the `.env.local` seed entirely in CI regardless (which always wants the release profile). Run this first in a fresh checkout/worktree.
55
61
  - **`bin/console`** — loads the gem and drops you into IRB for interactive experimentation.
56
- - **`bin/test [file:line]`** — routes through `bundle exec rake test` (not a raw `ruby -I test` invocation), so the Rakefile's `task test: :compile` prerequisite guarantees the extension is compiled first. `Minitest::TestTask` takes no CLI args directly, so arguments are "massaged" into the env vars it reads: a single `path/to/file.rb:LINE` ref (the `bin/worktree heal-reproduce` contract) is resolved to the nearest preceding `def test_*` method at/above that line and passed as `N=<name>` (`-i`/`--include`, exact method-name match); anything else passes through verbatim as `A="..."` (raw extra args, e.g. `-i test_foo`, `-v`, `--seed 123`). With no arguments, runs the full suite via `bundle exec rake` instead. In a remote Claude Code Web session (`CLAUDE_CODE_REMOTE=true`), it first JIT-installs gems via `bin/claude-web-deps.sh` (compiling the extension is no longer needed here — `bundle exec rake test` compiles it via the `:compile` prerequisite), since a bare Bash call doesn't trigger the Edit/Write-gated `bin/claude-code-web-setup` PreToolUse hook.
62
+ - **`bin/test [file:line]`** — routes through `bundle exec rake test` (not a raw `ruby -I test` invocation), so the Rakefile's `compile` prerequisite on `test` guarantees the extension is compiled first. `Minitest::TestTask` takes no CLI args directly, so arguments are "massaged" into the env vars it reads: a single `path/to/file.rb:LINE` ref (the `bin/worktree heal-reproduce` contract) is resolved to the nearest preceding `def test_*` method at/above that line and passed as `N=<name>` (`-i`/`--include`, exact method-name match); anything else passes through verbatim as `A="..."` (raw extra args, e.g. `-i test_foo`, `-v`, `--seed 123`). With no arguments, runs the full suite via `bundle exec rake` instead. In a remote Claude Code Web session (`CLAUDE_CODE_REMOTE=true`), it first JIT-installs gems via `bin/claude-web-deps.sh` (compiling the extension is no longer needed here — `bundle exec rake test` compiles it via that prerequisite), since a bare Bash call doesn't trigger the Edit/Write-gated `bin/claude-code-web-setup` PreToolUse hook.
57
63
  - **`bin/lint`** — the cpb-harness PostToolUse hook, invoked after every Edit/Write with `$CLAUDE_FILE_PATHS`, including in remote/web sessions. Splits the changed paths by extension and auto-fixes them directly with the same tools CI runs (`bundle exec standardrb --fix` for `.rb` files, `rustfmt` + `cargo clippy --fix` against `ext/duckling/Cargo.toml` for `.rs` files) — it does not shell out to `hk` (see `hk.pkl` above), so no `hk` provisioning is needed for this hook to work anywhere, including remote/web sessions. Requires `standardrb` (via `bundle install`) and `rustfmt`/`cargo clippy` (rustup components) on `PATH`.
58
64
  - **`rake` / `bundle exec rake`** — default task: `standard` (StandardRB lint) + `compile` (builds the Rust extension via `RbSys::ExtensionTask`) + `test` (Minitest).
59
65
  - **Compiling the native extension**: `rake compile` (via `RbSys::ExtensionTask`, wired in the `Rakefile`) builds `ext/duckling/` and places the compiled artifact under `lib/duckling/`. After `bin/setup` has run, this builds Cargo's `dev` profile locally (faster compile, slower runtime) because `.env.local` sets `RB_SYS_CARGO_PROFILE=dev` and the Rakefile loads it via `Dotenv.load(".env.local")`. `.env.local` is gitignored and never present in CI, so `bundle exec rake` in CI (`main.yml`) and `rake release` always build the optimized `release` profile regardless of this.
@@ -70,20 +76,32 @@ If you notice this file describing a not-yet-built piece as current, or vice ver
70
76
  ## Public and internal APIs
71
77
 
72
78
  - **`Duckling.parse(text, locale: "en", dims: ["time"], reference_time: nil, with_latent: false, reference_zone: nil)`** — the public Ruby API. This is a thin wrapper around `Duckling::Native.parse` that conditionally dispatches through `Thread.new { ... }.value` when a `Fiber.scheduler` is installed on the calling thread (important for async frameworks like Falcon). Plain thread-pool callers skip the thread spawn. It also has two real behavioral differences from `Native.parse`, not just dispatch details: it coerces `reference_time:` via `#to_time` before it reaches `Native.parse` (see below), and it implements `reference_zone:` entirely on the Ruby side.
73
- - **`reference_zone:`** — an IANA zone name String (e.g. `"America/New_York"`), resolved via `tzinfo`/`tzinfo-data` (both runtime gemspec dependencies). It never crosses into `Native.parse`: the wrapped Rust crate has no zone concept, only the single `FixedOffset` it derives from `reference_time:`, so per-date-correct (DST-aware) offsets have to come from a real tz database on the Ruby side. `Duckling.parse` applies it as a two-part step around the native call — before, it validates the zone (unknown identifier → `ArgumentError`) and, when `reference_time:` is also given, requires that `reference_time:`'s fixed `utc_offset` agree with the zone's real offset at that instant (disagreement → `ArgumentError`, since silently preferring either would resolve results against an offset the caller never asked for). After, `Duckling.apply_reference_zone` re-anchors each `Naive` leaf's wall-clock fields in the zone, so a leaf's offset reflects *its own date* rather than one offset applied uniformly. A **primary** wall-clock (a `Single`'s `value`, or an `Interval`'s `from`/`to`) and a generated **recurrence** entry (a `values` element — and note *every* `Single` carries a populated `values` array, not just explicit recurrences) that a spring-forward gap skipped or a fall-back overlap made ambiguous are resolved identically — there's no benefit to raising over one and not the other, whether the value is caller-named or parser-generated. Both resolve deterministically — a gap shifts the wall clock forward by the transition's delta (2:30am on a US spring-forward day becomes 3:30am EDT), an overlap takes the first (pre-transition) occurrence. This matches `ActiveSupport::TimeZone#parse`/`#local` for typical zones, with two deliberate departures where Rails gets edge zones wrong: the gap shift uses the transition's real width rather than a hardcoded hour (Australia/Lord_Howe's 30-minute gap), and the overlap's "first occurrence" is selected by position rather than tzinfo's dst flag, which inverts for negative-DST zones (Europe/Dublin models winter GMT as its `dst?`-true period) — see `local_time_in_zone`/`gap_delta` in `lib/duckling.rb`. Shifting the gap forward, rather than keeping its wall clock and stamping the post-transition offset on it, is what keeps the resolved `Time`'s instant and its rendered local time in agreement: `02:30 -04:00` is `06:30Z`, and New York still observes EST at `06:30Z`. Given *without* `reference_time:`, `reference_zone:` only reinterprets result offsets after the fact — it does not anchor the parse, so relative expressions ("tomorrow") still anchor on the machine-local clock, not "now" in that zone (pass a `reference_time:` in the zone to anchor as well). `Instant` leaves are deliberately left untouched — their relative arithmetic already collapsed against one `FixedOffset` inside the wrapped crate, and that imprecision is out of scope (issue #83).
79
+ - **`reference_zone:`** — an IANA zone name String (e.g. `"America/New_York"`), resolved via `tzinfo` (a runtime gemspec dependency; `tzinfo-data` is not, so *which* tz database answers is a property of the consumer's environment — see "The tz-database axis" below). It never crosses into `Native.parse`: the wrapped Rust crate has no zone concept, only the single `FixedOffset` it derives from `reference_time:`, so per-date-correct (DST-aware) offsets have to come from a real tz database on the Ruby side. `Duckling.parse` applies it as a two-part step around the native call — before, it validates the zone (unknown identifier → `ArgumentError`) and, when `reference_time:` is also given, requires that `reference_time:`'s fixed `utc_offset` agree with the zone's real offset at that instant (disagreement → `ArgumentError`, since silently preferring either would resolve results against an offset the caller never asked for). After, `Duckling.apply_reference_zone` re-anchors each `Naive` leaf's wall-clock fields in the zone, so a leaf's offset reflects *its own date* rather than one offset applied uniformly. A **primary** wall-clock (a `Single`'s `value`, or an `Interval`'s `from`/`to`) and a generated **recurrence** entry (a `values` element — and note *every* `Single` carries a populated `values` array, not just explicit recurrences) that a spring-forward gap skipped or a fall-back overlap made ambiguous are resolved identically — there's no benefit to raising over one and not the other, whether the value is caller-named or parser-generated. Both resolve deterministically — a gap shifts the wall clock forward by the transition's delta (2:30am on a US spring-forward day becomes 3:30am EDT), an overlap takes the first (pre-transition) occurrence. This matches `ActiveSupport::TimeZone#parse`/`#local` for typical zones, with two deliberate departures where Rails gets edge zones wrong: the gap shift uses the transition's real width rather than a hardcoded hour (Australia/Lord_Howe's 30-minute gap), and the overlap's "first occurrence" is selected by position rather than tzinfo's dst flag, which inverts for negative-DST zones (Europe/Dublin models winter GMT as its `dst?`-true period) — see `local_time_in_zone`/`gap_delta` in `lib/duckling.rb`. Shifting the gap forward, rather than keeping its wall clock and stamping the post-transition offset on it, is what keeps the resolved `Time`'s instant and its rendered local time in agreement: `02:30 -04:00` is `06:30Z`, and New York still observes EST at `06:30Z`. Given *without* `reference_time:`, `reference_zone:` only reinterprets result offsets after the fact — it does not anchor the parse, so relative expressions ("tomorrow") still anchor on the machine-local clock, not "now" in that zone (pass a `reference_time:` in the zone to anchor as well). `Instant` leaves are deliberately left untouched — their relative arithmetic already collapsed against one `FixedOffset` inside the wrapped crate, and that imprecision is out of scope (issue #83).
74
80
  - **`Duckling.apply_reference_zone(entities, reference_zone)`** — walks the `Single`/`Interval` tagged shape described under "Entity `:value` shapes" below, reinterpreting a `Single`'s `value` and every `values` recurrence entry, or an `Interval`'s `from`/`to` and every `values` endpoint pair's own `from`/`to` (an `Interval` leg is `Option<TimePoint>` in Rust, and serde emits `None` as a present key holding `nil`). It raises a `Duckling::ShapeError` (a named `RuntimeError` subclass — named, not bare, so it's greppable and can't be satisfied by the unrelated native-panic `RuntimeError`; mirrors `internal_error()` in `lib.rs`) on any tag it doesn't recognize at either the `TimeValue` or `TimePoint` layer: this shape must stay in lockstep with `patch_time_value` in `ext/duckling/src/lib.rs`, so a future drift there fails loudly instead of quietly returning results resolved against the wrong offset.
75
81
  - **`Duckling::Native.parse(...)`** — the raw Magnus/native entrypoint. Called directly by `Duckling.parse` and by benchmarks. Releases the GVL around the underlying Rust parse call. Its Magnus binding only accepts a strict `kind_of?(Time)` for `reference_time:` — passing an `ActiveSupport::TimeWithZone`, stdlib `DateTime`, or anything else that merely responds to `#to_time` raises a `TypeError`; `Duckling.parse` is what makes those work by coercing first (`lib/duckling.rb`).
76
82
  - **Entity `:value` shapes**: every entity hash carries `:body`/`:start`/`:end`/`:dim`/`:latent` plus a `:value` whose shape depends on the dimension. Every dimension, including `:time` (issue #91), gets its `:value` via the same generic-serialize-then-patch pattern: `serde_magnus` serializes the crate's `DimensionValue` and all Hash keys are symbolized in place (`ext/duckling/src/ruby_value.rs`), preserving serde's externally-tagged representation verbatim — the PascalCase tag key is kept uniformly at every enum layer (a deliberate choice: one consistent tagged shape rather than a mix of unwrapped and tagged layers). Resulting shapes: tagged scalars for number (`{Numeral: Float}`), ordinal (`{Ordinal: Integer}`), email/phone-number (`{Email:/PhoneNumber: String}` — normalized, separators stripped); tagged symbol-keyed Hashes for url (`{Url: {value:, domain:}}`), credit-card-number (`{CreditCardNumber: {value:, issuer:}}` — number normalized), duration (`{Duration: {value:, grain:, normalized_seconds:}}`), quantity (`{Quantity: {measurement:, product:}}` — `product` is an explicit `nil` when absent, serde emits `Option::None` as a present key); grain symbols (`:second`, `:no_grain`, …) for time-grain's payload (`{TimeGrain: :second}`) and duration's `:grain`, patched from serde's raw PascalCase variant names to match Time's `Grain::as_str()` convention. The five measurement dimensions (temperature, distance, volume, quantity's `:measurement`, amount-of-money) nest serde's `MeasurementValue` tag the same way: `{Temperature: {Value: {value:, unit:}}}` or `{AmountOfMoney: {Interval: {from: {value:, unit:}, to: {value:, unit:}}}}`. `:time` needs the deepest patching of the bunch, since `serde_magnus` has no escape hatch to emit a real Magnus `Time` mid-serialization (confirmed against `serde_magnus`'s `Serializer` impl — every method bottoms out in a primitive `serialize_*` call): `patch_time_value`/`patch_time_point` (`ext/duckling/src/lib.rs`) walk the generically-serialized `{Time: {Single: {value: {Naive:|Instant: {value:, grain:}}, values: [...], holidayBeta:}}}` / `{Time: {Interval: {from:, to:, values: [...]}}}` tree, using the already-typed `TimeValue`/`TimePoint` the serialization came from to overwrite every datetime leaf with a genuine Ruby `Time` (running `resolve_naive`'s reference-offset resolution for `Naive`, direct `IntoValue` for `Instant`) and every `grain` leaf with `Grain::as_str()`'s lowercase-snake_case symbol — `holidayBeta` and the rest of the structural shape pass through untouched.
83
+ - **`Duckling::TZDataUnavailable`** — raised when `reference_zone:` is given on a host with neither zoneinfo files nor `tzinfo-data` (a scratch or distroless container). Only reachable since the `tzinfo-data` dependency was dropped; before that a datasource always existed. Deliberately *not* an `ArgumentError` — it is an environment fault, and a caller validating user input by rescuing `ArgumentError` around `reference_zone:` shouldn't swallow "this deployment can't resolve any zone". Named for the same reason as `ShapeError`.
84
+ - **`Duckling::TZInfoCapabilities`** (`lib/duckling/tzinfo_capabilities.rb`) — describes which tz database `reference_zone:` resolved against, for `timezone_for`'s unknown-identifier error: `backward_compat_links?`, `identifier_count`, `datasource_description`, and the `unknown_identifier_diagnosis` that composes them. Behavioral because neither datasource exposes a version — `RubyDataSource` has no `version_info`, `ZoneinfoDataSource` offers only `zoneinfo_dir`. **Split by consumer, not by topic**: the suite discriminates the databases along more axes (`models_negative_dst?`, `greenland_2023_rules?`, `supports?`, `CAPABILITIES`), and those live in `test/support/tz_capabilities.rb` because `lib/` ships to every consumer and `test/` does not — a probe with no production caller has no business in the gem. `backward_compat_links?` is needed on both sides, so production owns it and the test module delegates. Nothing is memoized — `TZInfo::DataSource.set` can swap the database mid-process, and the fixture-zone tests do. Internal, not documented as public API.
85
+ - **`unknown_identifier_diagnosis`'s remedy clause is phrased as a condition, not a claim.** It fires on a property of the *database* (no backward-compat links), which says nothing about whether the identifier the caller passed is one of the ~100 in IANA's `backward` file. Asserting "backward-compat names such as this one" would tell every typo on a links-less host that `tzdata-legacy` supplies it; shipping the list to decide properly would be worse, since a name the list missed would get no remedy at all. `test_reference_zone_error_offers_the_backward_compat_remedy_only_where_relevant` pins it by requiring the hedge (`"if that is what this is"`) to be present and the caller's identifier to be absent from the remedy clause — positively, because forbidding one claim-shaped phrasing only rules out the wording nobody would reach by accident.
77
86
  - **`Duckling::PanickingNativeFake`** — test-only mock for testing panic propagation. Not part of the public API; never use in production code.
78
87
 
88
+ ## The tz-database axis
89
+
90
+ This gem depends on `tzinfo`. It deliberately does not depend on `tzinfo-data`. Which tz database answers `reference_zone:` is therefore a property of the consumer's environment, and the databases disagree: modelling (negative DST), backward-compat links, and vintage. A suite run cannot observe which database it ran against, so the coverage rests on four mechanisms: environments (`DUCKLING_TZINFO_DATA`, `DUCKLING_ZONEINFO_DIR`, `BUNDLE_LOCKFILE`), behavioral probes, capability-gated tests (`test/capabilities/`), and environment contracts (`test/environments/`).
91
+
92
+ **See [docs/tz-database-axis.md](docs/tz-database-axis.md) for the full reference**: the drift axes, the seven CI environments and their gating, the probe rules, the error-message rules, the fixture zones, the build scripts, `expect_failure`, and how to run an environment locally. When you change any of those, update that doc in the same PR.
93
+
79
94
  ## Test guide
80
95
 
81
96
  The test suite covers several distinct concerns:
82
97
 
83
- - **API shape, `reference_time:`, and `reference_zone:` behavior**: `test/duckling_test.rb` (parse result/interval shape, `reference_time:` coercion and type-checking, and `reference_zone:`'s DST-transition/gap/interval-leg/zone-mismatch/unrecognized-shape cases — see "Public and internal APIs" below).
98
+ - **API shape, `reference_time:`, and `reference_zone:` behavior**: `test/duckling_test.rb` (parse result/interval shape, `reference_time:` coercion and type-checking, and `reference_zone:`'s DST-transition/gap/interval-leg/zone-mismatch/unrecognized-shape cases — see "Public and internal APIs" below). `test_reference_zone_error_names_the_tz_datasource` runs on every environment — the diagnosis has to be right about whichever database is present, not just the impoverished one. The three database-dependent cases live in `test/capabilities/`: the `America/Nuuk` late-in-the-day gap, the `Europe/Dublin` negative-DST overlap, and the `US/Eastern` backward-compat lookup.
99
+ - **tz-capability-gated tests**: `test/capabilities/` — one file per probe, loaded only where the database in use can answer (see "The tz-database axis" above). A file run directly by hand runs regardless of the probe, which is how you exercise one against a database that lacks the capability.
100
+ - **Environment contracts**: `test/environments/` — one small file per synthesized/pinned environment, invoked by its CI step rather than by the suite. Each asserts the environment's defining state positively, so a broken setup goes red instead of presenting as a smaller green suite.
101
+ - **DST edges against synthetic zones**: `test/duckling_tz_fixture_test.rb` (`Fixture/NegativeDst`'s overlap-by-position, `Fixture/HalfHourGap`'s sub-hour gap width, `Fixture/LateGap`'s transition past the next UTC midnight). Swaps `TZInfo::DataSource` in `setup`/`teardown` — the datasource is process-global, so the restore is mandatory. This is the coverage that holds on every environment; see "The tz-database axis" above.
84
102
  - **Time-expression corpus**: `test/duckling_parse_time_basic_test.rb`, `test/duckling_parse_time_dates_test.rb`, `test/duckling_parse_time_interval_test.rb`, `test/duckling_parse_time_relative_test.rb`, `test/duckling_parse_time_weekdays_test.rb` — one file per expression category, ported from wafer-inc-duckling's own Rust corpus and the pyduckling suite it descends from.
85
103
  - **Locale and latent-entity keyword args**: `test/duckling_parse_locale_test.rb` (`locale:` validation/defaulting), `test/duckling_parse_latent_test.rb` (`with_latent:` behavior).
86
- - **Known upstream limitations**: `test/duckling_comma_list_test.rb` (characterizes a bare comma-separated `<time>, <time>` run collapsing into a single entity and silently dropping the rest — a wafer-inc-duckling grammar limitation, not a bug in this wrapper).
104
+ - **Known upstream limitations**: `test/duckling_comma_list_test.rb` (characterizes a bare comma-separated `<time>, <time>` run collapsing into a single entity and silently dropping the rest — a wafer-inc-duckling grammar limitation, not a bug in this wrapper) and the Monday pair in `test/duckling_parse_time_weekdays_test.rb`. These use `expect_failure` (`test_helper.rb`): the correct-behavior assertions run for real, report as skips while the limitation stands, and flunk the moment it stops reproducing.
87
105
  - **Non-Time dimension `:value` shapes**: `test/duckling_parse_dimensions_test.rb` (one representative case per non-Time dimension, pinning the exact shapes described under "Public and internal APIs" — these are also the tests that would catch a serde-representation drift in a future wrapped-crate upgrade).
88
106
  - **GC safety under stress**: `test/duckling_gc_stress_test.rb` (`GC.stress = true` across every dimension's conversion path; 2 passes by default to stay `bundle exec rake`-friendly, `DUCKLING_GC_STRESS_ITERATIONS=20` reproduces the full ~300-call verification).
89
107
  - **Native-extension infrastructure sanity**: `test/duckling_ci_test.rb` (build config files exist and are shaped correctly, and `cross-gem.yml`'s restatement of the build matrix — its ABI list, both platform matrices, and its runner labels — still agrees with `cross_targets.rb`).
@@ -123,7 +141,7 @@ The test suite covers several distinct concerns:
123
141
  - **Known gotchas**:
124
142
  - `rb_sys` is a *runtime* gemspec dependency because the source gem builds its extension at install time and `ext/duckling/extconf.rb` requires `rb_sys/mkmf` — RubyGems has no build-only dependency kind, so a development dependency would come too late. It is not loaded when the gem is used: `lib/duckling.rb` requires only `tzinfo` and the compiled extension. rb-sys's own `cross_compiling` hook (`rb_sys/extensiontask.rb`) therefore strips `rb_sys` from every precompiled gem's dependency list — verify with `gem spec <native gem> dependencies` if that ever looks suspect.
125
143
  - CI installs Rust via `dtolnay/rust-toolchain` and pins the version to track the Claude Code Web sandbox. Update `.github/workflows/main.yml`'s "Set up Rust" step when the sandbox image's Rust version changes.
126
- - Third-party GitHub actions are pinned to full commit SHAs with version comments (e.g. `actions/checkout@<sha> # vX.Y.Z`), not floating tags. `.github/dependabot.yml` opens PRs to bump these pins.
144
+ - Third-party GitHub actions are pinned to full commit SHAs with version comments (e.g. `actions/checkout@<sha> # vX.Y.Z`), not floating tags. `.github/dependabot.yml` opens PRs to bump these pins. Container images in `tz-containers` follow the same rule for the same reason — a floating `ruby:3.4-alpine` rebases across Alpine releases, which can drop the versioned `clang22-*` packages that leg installs *and* move the host tz data a leg asserts against, both with no change in this repo.
127
145
  - Cross-compiling locally (`rake 'native_gem[<platform>]'`) requires Docker.
128
146
 
129
147
  ## Gem release conventions
@@ -137,21 +155,33 @@ The test suite covers several distinct concerns:
137
155
  4. Build the `ruby` source gem.
138
156
  5. Push every built gem — the source gem plus one per cross-compiled platform — to RubyGems via `gem push` (the workflow globs `pkg/*.gem`, so this needs no update when the platform matrix changes).
139
157
  6. Create a GitHub release with all of those gems attached.
140
- 7. Open and auto-merge a PR to update `CHANGELOG.md` (post-release documentation).
158
+ 7. Open and auto-merge a PR to update `CHANGELOG.md` (post-release documentation). **It inserts a generated `## [X.Y.Z]` section immediately below `## [Unreleased]`, built from the GitHub release notes — it does not promote whatever is already under `## [Unreleased]`.** So hand-written entries staged there stay under "Unreleased" after the tag, with the generated section sitting above them. Whoever tags a release has to promote the hand-written section first (or merge the two afterwards); nothing in the pipeline does it.
141
159
  8. In parallel with the release publish path after CI, record benchmark data for this release under `docs/benchmarks/github-actions/` and open/auto-merge that PR.
142
160
  - **Tag protection**: `v*.*.*` tags can only be created/updated by repo admins. Configured via `.github/scripts/apply-tag-ruleset.sh`.
143
161
  - **Before a release**: test cross-compilation locally or via `gh workflow run cross-gem.yml --ref <branch>` — that dispatch runs the `smoke` job too, so it is the cheapest full rehearsal of what `release.yml` will publish. To rehearse one gem by hand (a Heroku-shaped, Rust-free install), download the artifact and run `test/gem/installed_gem_test.rb` against it in a plain Ruby container — see that file's own header for the one-liner. Capture additional benchmark data points from other environments via `gh workflow run benchmark.yml --ref <branch>` (adds `docs/benchmarks/<environment>/` data) or locally via `bin/benchmark` (see "Build and test commands" above). **`benchmark.yml` has real side effects even when dispatched ad hoc**: it always branches off `origin/main`, commits/pushes, and opens+auto-merges a PR (`bundle exec rake benchmark:record_pr`) — it is not read-only data capture.
144
162
 
145
- ## `bin/` scripts (dev-workflow tooling, not part of the gem)
163
+ ## `bin/` scripts (dev-workflow tooling; the gem ships none of them)
146
164
 
147
165
  These come from the cpb Claude Code plugin's harness (commit `d69ba38`) and manage git worktrees / tmux / GitHub PR workflow for *this development environment* — they are not part of what ships in the gem and shouldn't be touched when working on the gem's actual functionality:
148
166
 
167
+ Two exceptions *are* part of the gem's own test tooling, both building a zoneinfo directory no runner is in the state of — see "The tz-database axis" above:
168
+
169
+ - `bin/build-stale-zoneinfo <output-dir>` copies the host's zoneinfo directory and compiles `test/fixtures/zoneinfo-overrides/*.zi` over it, rolling named zones back to earlier rules.
170
+ - `bin/build-linkless-zoneinfo <output-dir>` copies it and removes the top-level backward-compat entries, so `US/Eastern` stops resolving. Its keep-list is transcribed from a real links-less host rather than guessed, and it hard-fails if `US/Eastern` survives the strip — a leg that silently stopped covering its own subject is the failure mode it exists to prevent. It does not reproduce the ~60 in-region aliases `tzdata-legacy` also owns (`Europe/Kiev` and friends), which are indistinguishable inside a region directory from aliases a stock host keeps, so the tree exposes a few more identifiers than a stock host's.
171
+
149
172
  - `bin/worktree` — large CLI (`add`, `cd`, `harness`, `cleanup`, `heal-poll`, etc.) for creating per-issue git worktrees and driving Claude/Gemini sessions in tmux.
150
173
  - `bin/check-worktree` — PreToolUse hook that blocks `Edit`/`Write` when on the `main` branch, steering you toward `bin/worktree add <branch>` instead.
151
- - `bin/claude-code-web-setup` — PreToolUse hook for remote/web Claude Code sessions. Before each `Edit`/`Write`, just-in-time installs gems (`bundle install`) and compiles the native extension (`bundle exec rake compile`) — each step cached via receipt files in `tmp/claude-web-receipts/` so it's a no-op after the first call per session. Does not provision `hk`: `bin/lint` (see above) calls the underlying lint tools directly, so remote sessions never need `hk` installed — it's local-dev-only (see `hk.pkl`/`Brewfile` above). The gems/extension installers live in `bin/claude-web-deps.sh` (sourced, not directly executable); `bin/test` shares its `install_gems` installer (called unconditionally, any-args or no-args) since Bash tool calls don't trigger this Edit/Write-gated hook — `bin/test` no longer needs `compile_extension` itself, since `bundle exec rake test`'s `:compile` prerequisite handles that.
174
+ - `bin/claude-code-web-setup` — PreToolUse hook for remote/web Claude Code sessions. Before each `Edit`/`Write`, just-in-time installs gems (`bundle install`) and compiles the native extension (`bundle exec rake compile`) — each step cached via receipt files in `tmp/claude-web-receipts/` so it's a no-op after the first call per session. Does not provision `hk`: `bin/lint` (see above) calls the underlying lint tools directly, so remote sessions never need `hk` installed — it's local-dev-only (see `hk.pkl`/`Brewfile` above). The gems/extension installers live in `bin/claude-web-deps.sh` (sourced, not directly executable); `bin/test` shares its `install_gems` installer (called unconditionally, any-args or no-args) since Bash tool calls don't trigger this Edit/Write-gated hook — `bin/test` no longer needs `compile_extension` itself, since `bundle exec rake test`'s compile prerequisite handles that.
152
175
 
153
176
  ## Code comment conventions
154
177
 
178
+ Inline comments are kept to the bare minimum. Anything longer than a line or
179
+ two belongs in a central doc under `docs/` (for example
180
+ [docs/tz-database-axis.md](docs/tz-database-axis.md)), with at most a
181
+ one-line pointer left inline. All code comments and documentation are written
182
+ in ASD-STE100 Simplified Technical English: short sentences, active voice,
183
+ one idea per sentence, approved vocabulary, no idioms.
184
+
155
185
  Comments (in Ruby, Rust, and this file) are long-lived documentation, not a
156
186
  transcript of the PR or session that wrote them. Prefer explaining the
157
187
  durable *why* — the invariant, constraint, or measured behavior a future
data/CHANGELOG.md CHANGED
@@ -8,6 +8,61 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
8
8
 
9
9
  ### Changed
10
10
 
11
+ - **On a stock Debian/Ubuntu host, roughly a hundred IANA zone identifiers
12
+ stop resolving.** `reference_zone: "US/Eastern"` — and every other
13
+ backward-compatibility name, such as `"US/Pacific"`, `"Europe/Kiev"`, or
14
+ `"Japan"` — now raises `ArgumentError` there. Those names live in the
15
+ `tzdata-legacy` system package, which is not installed by default. Two ways
16
+ to get them back, either of which restores the previous behavior exactly:
17
+
18
+ ```ruby
19
+ gem "tzinfo-data" # in your Gemfile
20
+ ```
21
+ ```bash
22
+ apt install tzdata-legacy # on the host
23
+ ```
24
+
25
+ Canonical identifiers (`"America/New_York"`, `"Europe/Kyiv"`) are
26
+ unaffected. A host with no zoneinfo files at all — a scratch or distroless
27
+ container — needs the gem for `reference_zone:` to work at all.
28
+
29
+ The error message names the tz database that answered, how many identifiers
30
+ it has, and both remedies, so this is distinguishable from a typo. The
31
+ datasource and the count describe whichever database answered on your host,
32
+ so both differ from the example below:
33
+
34
+ ```
35
+ invalid reference_zone: "US/Eastern" (resolved against system zoneinfo at
36
+ /usr/share/zoneinfo, which provides 497 identifiers; this database has no
37
+ backward-compat names (US/Eastern and ~100 others), so if that is what this
38
+ is, it needs either the tzinfo-data gem or the tzdata-legacy system package)
39
+ ```
40
+
41
+ The remedy is worded as a condition rather than a claim about the name you
42
+ passed: whether a given identifier is one of the ~100 in IANA's `backward`
43
+ file isn't knowable without shipping that list, and asserting it would tell
44
+ every typo on such a host that `tzdata-legacy` will supply it.
45
+
46
+ A second, quieter difference comes with the same change: some distributions
47
+ compile tzdata in *rearguard* format, which strips negative DST, and on such
48
+ a host `Europe/Dublin` is modelled as an ordinary positive-DST zone rather
49
+ than a negative-DST one. Which distributions is not guessable — Ubuntu 24.04
50
+ is rearguard, Debian trixie is vanguard — so if you depend on tzinfo's
51
+ `dst?` flag, read it from the host rather than assuming. Resolved offsets
52
+ are the same either way, so no `Duckling.parse` result changes because of
53
+ it.
54
+
55
+ - `tzinfo-data` is no longer a runtime dependency. `tzinfo` already prefers
56
+ that gem when it is installed and falls back to the host's zoneinfo files
57
+ otherwise, so depending on it forced bundled tz data on every consumer to
58
+ serve the ones who want it. This is the change that produces the identifier
59
+ behavior above. Consumers who add `gem "tzinfo-data"` themselves get exactly
60
+ the previous behavior with no code change, and can still pick up a
61
+ tz-database revision by bumping that one gem. Dropping it means the bundled
62
+ tz data is no longer loaded at boot, so the first zone lookup does less
63
+ work; steady-state parsing is unaffected either way, since `reference_zone:`
64
+ resolution goes through the same tzinfo call once a database is loaded.
65
+
11
66
  - **Breaking:** `reference_time:` now requires a Ruby `Time` object (or
12
67
  `nil`), not a Unix-seconds Integer. This lets the caller's `utc_offset` be
13
68
  preserved into offset-aware `Instant` results (e.g. `"in one hour"`),
@@ -40,6 +95,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
40
95
  entirely — check `interval[:to].nil?` rather than `interval.key?(:to)`
41
96
  to detect an unbounded endpoint.
42
97
 
98
+ ### Added
99
+
100
+ - `Duckling::TZDataUnavailable`, raised when `reference_zone:` is given on a
101
+ host with no tz database at all — no zoneinfo files and no `tzinfo-data`
102
+ gem, as in a scratch or distroless container. Newly reachable because of the
103
+ dependency change above; previously a database always existed. It names both
104
+ fixes, where the underlying tzinfo error mentioned neither this gem nor
105
+ `reference_zone:`. Deliberately not an `ArgumentError`: it reports the
106
+ deployment's state, not a bad argument, so code rescuing `ArgumentError`
107
+ around caller-supplied zone names does not swallow it. Every other keyword
108
+ works without a tz database.
109
+
43
110
  ## [0.2.0] - 2026-07-01
44
111
 
45
112
  ## What's Changed
data/README.md CHANGED
@@ -33,7 +33,7 @@ Duckling.parse("tomorrow", locale: "en")
33
33
  `Array` of entity `Hash`es (empty if nothing matched):
34
34
 
35
35
  ```ruby
36
- Duckling.parse(text, locale: "en", dims: ["time"], reference_time: nil, with_latent: false)
36
+ Duckling.parse(text, locale: "en", dims: ["time"], reference_time: nil, with_latent: false, reference_zone: nil)
37
37
  ```
38
38
 
39
39
  ### Keyword arguments
@@ -55,9 +55,49 @@ Duckling.parse(text, locale: "en", dims: ["time"], reference_time: nil, with_lat
55
55
  raises `TypeError`; wrap it in `Time.at(seconds)` first.
56
56
  - `with_latent:` (Boolean, default `false`) — include ambiguous/latent
57
57
  matches (e.g. a bare "morning") in the results.
58
+ - `reference_zone:` (String, default `nil`) — an IANA zone name, e.g.
59
+ `"America/New_York"`. Resolves each wall-clock result's UTC offset against
60
+ that zone on the result's *own* date, so a result before a DST transition
61
+ and one after it get different offsets instead of sharing
62
+ `reference_time:`'s single fixed one. An unknown identifier raises
63
+ `ArgumentError`; so does a `reference_time:` whose `utc_offset` disagrees
64
+ with the zone at that instant. It does not anchor the parse — see "Time
65
+ zone data" below.
58
66
 
59
67
  There is no `Duckling::Error` class — invalid `locale:`/`dims:` values raise
60
- plain `ArgumentError`.
68
+ plain `ArgumentError`, as do an unknown `reference_zone:` and a
69
+ `reference_time:` whose offset disagrees with it. The one named error a caller
70
+ is likely to meet is `Duckling::TZDataUnavailable`, for a host with no tz
71
+ database at all — see "Time zone data" below.
72
+
73
+ ### Time zone data
74
+
75
+ `reference_zone:` resolves against [tzinfo][], which uses the `tzinfo-data`
76
+ gem when it is installed and the host's own zoneinfo files otherwise. This gem
77
+ does not depend on `tzinfo-data`, so by default you get the host's database.
78
+
79
+ That is usually what you want, and it is the faster of the two to start up.
80
+ Two cases where it is not:
81
+
82
+ - **Backward-compatibility names.** Debian and Ubuntu ship names like
83
+ `"US/Eastern"` in a separate `tzdata-legacy` package that is not installed
84
+ by default, so roughly a hundred valid IANA identifiers raise
85
+ `ArgumentError` on a stock host. Either `gem "tzinfo-data"` or
86
+ `apt install tzdata-legacy` restores them. The error message names whichever
87
+ database answered and how many identifiers it has, so you can tell this
88
+ apart from a typo.
89
+ - **No zoneinfo files at all**, as in a scratch or distroless container.
90
+ `reference_zone:` raises `Duckling::TZDataUnavailable` there, naming both
91
+ fixes. Add `gem "tzinfo-data"` to bundle the data with your app, where you
92
+ can also patch its vintage by bumping one gem, or install the system
93
+ `tzdata` package. Every other keyword works without a tz database.
94
+
95
+ `reference_zone:` reinterprets result offsets; it does not anchor the parse.
96
+ Given without `reference_time:`, a relative expression like `"tomorrow"` still
97
+ anchors on the machine-local clock rather than on "now" in that zone — pass a
98
+ `reference_time:` in the zone to anchor as well.
99
+
100
+ [tzinfo]: https://github.com/tzinfo/tzinfo
61
101
 
62
102
  ### Return value
63
103
 
data/Rakefile CHANGED
@@ -60,7 +60,7 @@ end
60
60
  # extconf.rb runs in its own subprocess. ENV changes made there do not
61
61
  # reach the parent process, and the parent process runs `make`.
62
62
  #
63
- # So this fix changes ENV here, in the Rakefile, not in extconf.rb.
63
+ # So this fix changes ENV here, in the Rakefile.
64
64
  # The fix adds a prerequisite task to the local build's Makefile task.
65
65
  # This prerequisite task sets the correct host target. It runs before
66
66
  # the Makefile task, and before `make` runs later.
@@ -71,7 +71,7 @@ if (ruby_target = ENV["RUBY_TARGET"]) && ruby_target != RUBY_PLATFORM
71
71
  # away. Clearing RUST_TARGET alone lets rb_sys fall back to the target
72
72
  # baked into the container's $CARGO_HOME/config.toml, which is the
73
73
  # cross-compile target again. So a host triple that cannot be read is a
74
- # hard error, not something to skip past.
74
+ # hard error.
75
75
  #
76
76
  # Both variables belong to the whole rake process, and the cross build
77
77
  # reads them too. This is safe only because rake generates the cross
@@ -121,8 +121,8 @@ end
121
121
  #
122
122
  # So build from a throwaway plain clone instead, whose .git is a real
123
123
  # directory under the mount. Two consequences: the gem carries *committed*
124
- # state, not the working tree, and the clone gets its own Cargo target
125
- # directory rather than reusing this checkout's.
124
+ # state (uncommitted changes are excluded), and the clone gets its own Cargo
125
+ # target directory separate from this checkout's.
126
126
  CLONE_DIR = "tmp/native_gem_clone"
127
127
 
128
128
  def build_native_gem(platform)
@@ -163,10 +163,10 @@ end
163
163
 
164
164
  task :benchmark_env do
165
165
  # Force a realistic release-profile build regardless of .env.local's
166
- # RB_SYS_CARGO_PROFILE=dev (local dev checkouts only, never present in
167
- # CI). Must reenable :compile in case it already ran earlier in this same
168
- # rake process, so it's guaranteed to recompile under the forced profile
169
- # rather than reusing a stale dev-profile build.
166
+ # RB_SYS_CARGO_PROFILE=dev (local dev checkouts only; CI never has it).
167
+ # Must reenable :compile in case it already ran earlier in this same
168
+ # rake process, so it recompiles under the forced profile. A stale
169
+ # dev-profile build would be reused otherwise.
170
170
  ENV.delete("RB_SYS_CARGO_PROFILE")
171
171
  Rake::Task[:compile].reenable
172
172
  end
@@ -184,8 +184,8 @@ namespace :benchmark do
184
184
 
185
185
  desc "Run :record on a fresh branch off origin/main, then commit/push and open+auto-merge a PR via gh"
186
186
  task record_pr: ["release:guard_clean"] do
187
- # Explicit bash, not Rake's default `sh -c` (dash on Debian/Ubuntu
188
- # runners): dash's `set` doesn't support the `-o pipefail` flag below.
187
+ # Explicit bash: Rake's default `sh -c` is dash on Debian/Ubuntu
188
+ # runners, and dash's `set` doesn't support the `-o pipefail` flag below.
189
189
  sh("bash", "-c", <<~SH)
190
190
  set -euo pipefail
191
191
  original_ref="$(git symbolic-ref -q --short HEAD || git rev-parse HEAD)"
@@ -214,12 +214,14 @@ namespace :benchmark do
214
214
  end
215
215
 
216
216
  # The default suite exercises the extension compiled in this checkout.
217
- # test/gem/ exercises a *built* or *installed* gem instead — it needs one
218
- # handed to it, and the installed suite must not see this checkout's lib/ at
219
- # all so both run on their own, as plain `ruby test/gem/<file>`. See each
220
- # file's header.
217
+ # Three test subtrees run outside it:
218
+ # - test/gem/ exercises a *built* or *installed* gem instead it needs one
219
+ # handed to it, and the installed suite must not see this checkout's lib/.
220
+ # - test/capabilities/ is loaded by test_helper itself, gated on the tz probes.
221
+ # - test/environments/ holds contracts invoked directly by their CI step.
222
+ # See docs/tz-database-axis.md.
221
223
  Minitest::TestTask.create do |t|
222
- t.test_globs = FileList["test/**/*_test.rb"].exclude("test/gem/**/*")
224
+ t.test_globs = FileList["test/**/*_test.rb"].exclude("test/gem/**/*", "test/capabilities/**/*", "test/environments/**/*")
223
225
  end
224
226
 
225
227
  # Minitest::TestTask has no built-in way to declare a task dependency, and
@@ -227,7 +229,7 @@ end
227
229
  # `bundle exec rake` itself — `bundle exec rake test` run directly has no
228
230
  # guarantee `compile` ran first, which would surface as a confusing
229
231
  # LoadError/stale-behavior failure unrelated to the code under test.
230
- task test: :compile
232
+ task test: %i[compile]
231
233
 
232
234
  require "standard/rake"
233
235
 
@@ -0,0 +1,454 @@
1
+ # The tz-database axis
2
+
3
+ This document is the central reference for time zone (tz) data in this gem.
4
+ It replaces long inline comments. Keep inline comments short. Point here
5
+ instead.
6
+
7
+ ## Why the tz database is an axis
8
+
9
+ `reference_zone:` resolves zones with the `tzinfo` gem. tzinfo uses the
10
+ `tzinfo-data` gem when that gem is installed. Without that gem, tzinfo uses
11
+ the zoneinfo files of the host. This gem does not depend on `tzinfo-data`.
12
+ Both configurations are valid production configurations.
13
+
14
+ The two databases do not give the same answers. They differ on three
15
+ independent axes:
16
+
17
+ - **Modelling.** Some distributions compile tzdata in rearguard format.
18
+ Rearguard data strips negative DST. Example: `Europe/Dublin` is a
19
+ negative-DST zone in vanguard data. It is an ordinary positive-DST zone in
20
+ rearguard data. Ubuntu 24.04 and macOS ship rearguard data. Debian trixie,
21
+ Alpine, and FreeBSD ship vanguard data.
22
+ - **Backward-compatibility links.** Debian and Ubuntu move the links to a
23
+ separate `tzdata-legacy` package. That package is not installed by
24
+ default. `US/Eastern` and approximately 100 other names then stop
25
+ resolving. The identifier count drops from approximately 600 to
26
+ approximately 500.
27
+ - **Vintage.** A pinned `tzinfo-data` gem or an unpatched host can predate a
28
+ rule change. Example: `America/Nuuk` got new rules in tzdata 2023a. A
29
+ 2021–2022 vintage resolves the zone and answers with the old rules. Before
30
+ 2020a the name `America/Nuuk` does not exist at all.
31
+
32
+ A host can also have no tz database at all. Scratch and distroless
33
+ containers are examples. There `reference_zone:` raises
34
+ `Duckling::TZDataUnavailable`.
35
+
36
+ A suite run cannot observe which database it ran against. It passes
37
+ identically on both. Four mechanisms keep the coverage honest: environments,
38
+ probes, capability-gated tests, and environment contracts.
39
+
40
+ ## Mechanism 1: Environments
41
+
42
+ Two environment variables select the database under test:
43
+
44
+ - `DUCKLING_TZINFO_DATA` (read in the Gemfile) controls the `tzinfo-data`
45
+ gem in the bundle:
46
+ - unset: the current release of the gem. This is the default environment.
47
+ - `none`: no gem. tzinfo falls back to the zoneinfo files of the host.
48
+ - an exact version, for example `1.2022.7`: a stale database.
49
+ - `DUCKLING_ZONEINFO_DIR` (read in `test/test_helper.rb`) points tzinfo at a
50
+ specific compiled zoneinfo directory. It is set before `duckling` is
51
+ required. Nothing then resolves a zone against the default source first.
52
+
53
+ Always set `BUNDLE_LOCKFILE` for any environment except the default.
54
+ Without it, `bundle install` overwrites the committed `Gemfile.lock`. The
55
+ per-environment lockfiles are gitignored (`/Gemfile.*.lock`). A dirty tree
56
+ also blocks `rake release` and `rake benchmark:record_pr`. Both are guarded
57
+ by `release:guard_clean`. The error looks unrelated.
58
+
59
+ An unrecognized `DUCKLING_TZINFO_DATA` value raises in the Gemfile. A typo
60
+ must fail there. It must not fail deep in the resolver as an unsatisfiable
61
+ constraint.
62
+
63
+ Seven environments run in CI:
64
+
65
+ | Environment | CI job | Database under test |
66
+ |---|---|---|
67
+ | default | `baseline` | current `tzinfo-data` gem |
68
+ | system-zoneinfo | `baseline` step | zoneinfo files of the runner |
69
+ | linkless-zoneinfo | `baseline` step | built by `bin/build-linkless-zoneinfo` |
70
+ | tzinfo-data 1.2022.7 | `timezones` matrix | pinned gem |
71
+ | stale system zoneinfo | `timezones` matrix | built by `bin/build-stale-zoneinfo` |
72
+ | Debian + tzdata-legacy | `tz-containers` matrix | system zoneinfo with the links |
73
+ | Alpine | `tz-containers` matrix | vanguard zoneinfo, musl source build |
74
+
75
+ Gating:
76
+
77
+ - Only `baseline` blocks a merge. It is the only required check. So the
78
+ three environments a consumer is actually on run there as steps.
79
+ - `timezones` and `tz-containers` do not block a merge. They do block a
80
+ release. `release.yml` waits on the full workflow (`needs: ci`). A red
81
+ result there means "this vintage answers differently". It does not mean
82
+ "the gem is broken for anyone today".
83
+
84
+ The linkless environment must be built. No runner is in that state.
85
+ `ubuntu-latest` resolves `US/Eastern` from its own tzdata. macOS does too.
86
+ Note: the runner is not a stock Ubuntu for tz data. A plain `ubuntu:24.04`
87
+ container of an earlier tzdata point release does not resolve the links.
88
+ The runner does. So no runner fact settles the links. The suite probes them.
89
+
90
+ One configuration deliberately has no environment: a host with no tz
91
+ database at all. A runner without tz data would break more than this gem.
92
+ Every probe answers `false` there. So the suite loads and runs. The
93
+ `DataSourceNotFound` arms assert this. `test/gem/installed_gem_test.rb`
94
+ skips its `reference_zone:` case there for the same reason.
95
+
96
+ ## Mechanism 2: Behavioral probes
97
+
98
+ Neither datasource exposes a version. `RubyDataSource` keeps `version_info`
99
+ private. `ZoneinfoDataSource` exposes only `zoneinfo_dir`. So a probe asks
100
+ the database a question. It does not read a release string.
101
+
102
+ Probes are split by consumer:
103
+
104
+ - `Duckling::TZInfoCapabilities` (`lib/duckling/tzinfo_capabilities.rb`)
105
+ ships in the gem. It holds only what the unknown-identifier error message
106
+ needs.
107
+ - `TZCapabilities` (`test/support/tz_capabilities.rb`) holds the probes that
108
+ only the suite calls. `lib/` reaches every consumer. `test/` reaches
109
+ none. A probe with no production caller does not belong in `lib/`.
110
+
111
+ `backward_compat_links?` is needed on both sides. Production owns it. The
112
+ test module delegates to it.
113
+
114
+ Probe rules:
115
+
116
+ - A probe is a total boolean. It answers `false` on a host with no database.
117
+ It must not raise. `TZInfo::DataSourceNotFound` is a sibling of
118
+ `InvalidTimezoneIdentifier`. It is not a subclass. Rescue it explicitly.
119
+ - Nothing is memoized. `TZInfo::DataSource.set` can swap the database
120
+ mid-process. The fixture-zone tests do this. A cached answer would
121
+ describe a database that is no longer in use.
122
+ - `TZCapabilities.supports?` raises `ArgumentError` on an unknown capability
123
+ name. A typo must be a hard error. It must not be a silent `false`.
124
+
125
+ The three probes:
126
+
127
+ - `models_negative_dst?` asks about `Europe/Dublin`. IANA models it as
128
+ +01:00 standard all year, with a negative one-hour saving in winter. So
129
+ tzinfo reports January as the `dst?` period. Rearguard data re-expresses
130
+ the same offsets as ordinary positive DST.
131
+ - `backward_compat_links?` asks for `US/Eastern`. It is a link to
132
+ `America/New_York` in IANA's `backward` file.
133
+ - `greenland_2023_rules?` asks whether `America/Nuuk` skips
134
+ 2026-03-28 23:30. Older vintages fail in two ways. Before 2020a the name
135
+ does not exist. The 2021–2022 vintages know the name but answer with the
136
+ old rules. The zone resolves and gives a different answer. That is the
137
+ harder failure to attribute.
138
+
139
+ ## Mechanism 3: Capability-gated tests
140
+
141
+ A test whose premise is a capability lives in
142
+ `test/capabilities/<capability>_test.rb`. The loader at the bottom of
143
+ `test/test_helper.rb` loads the file only where the probe passes. On a
144
+ database that cannot answer, the test is not in the run. It does not fail
145
+ for want of the capability. It does not pass vacuously.
146
+
147
+ The filename is the declaration. The loader calls `supports?` with the
148
+ filename. An unknown name raises at load time.
149
+
150
+ Rules:
151
+
152
+ - A test whose weak mode is a vacuous pass must assert its own premise.
153
+ Example: `negative_dst_test.rb` asserts `models_negative_dst?` in the
154
+ test body. The probe gates the load. The assertion catches the day the
155
+ probe or the IANA data drifts. The test uses the same predicate as the
156
+ loader. Two definitions could drift apart.
157
+ - A file run directly (`ruby -Itest test/capabilities/negative_dst_test.rb`)
158
+ runs regardless of the probe. This is how you exercise one test against a
159
+ database that lacks the capability.
160
+ - Do not use `expect_failure` for environment-dependent tests. It cannot
161
+ tell an absent capability from a genuine regression. It would convert
162
+ either into the same skip.
163
+
164
+ ## Mechanism 4: Environment contracts
165
+
166
+ Each synthesized or pinned environment has a contract in
167
+ `test/environments/<name>_test.rb`. The CI step that creates the
168
+ environment invokes the contract directly:
169
+
170
+ ```bash
171
+ bundle exec ruby -Ilib -Itest test/environments/<name>_test.rb
172
+ ```
173
+
174
+ The suite never loads the contracts. A contract asserts its state
175
+ positively:
176
+
177
+ - `tzinfo_data_test.rb`: the datasource is the gem, and all three probes
178
+ answer true. The default environment is the one place all three
179
+ capabilities are guaranteed. So this contract is the tripwire for the
180
+ loader. A probe that rotted to false would unload its capability file
181
+ silently everywhere else. Here it turns red.
182
+ - `linkless_zoneinfo_test.rb`: `US/Eastern` raises, and the message names
183
+ both remedies. It also asserts the identifier count stays in the range of
184
+ a real links-less host. The build script's own check catches only
185
+ under-stripping. Over-stripping is the likelier drift. A future tzdata or
186
+ a different base image can add a top-level entry a real host keeps.
187
+ - `stale_vintage_test.rb`: `America/Nuuk` answers with the pre-2023a rules.
188
+ The wrong answer is asserted on purpose. A stale database's dangerous
189
+ failure is a wrong answer. A missing zone is easier to attribute. If the
190
+ pin or the rollback
191
+ stops taking effect, the capability-gated test simply starts loading and
192
+ passing. The suite goes green against a different database than the
193
+ environment exists for. This contract turns red instead.
194
+ - `system_zoneinfo_links_test.rb` and `alpine_vanguard_test.rb`: the
195
+ defining probe answers true. An environment that lost its defining
196
+ capability would silently run less. The contract is the loud half.
197
+
198
+ Without a contract, a broken setup presents as a smaller green suite. The
199
+ capability-gated files simply load less.
200
+
201
+ ## The error messages
202
+
203
+ ### Unknown identifier
204
+
205
+ `timezone_for` raises `ArgumentError` for an unknown identifier. The
206
+ message includes `unknown_identifier_diagnosis`. It names the database that
207
+ answered and how many identifiers it has. The count separates the two
208
+ databases legibly: approximately 600 against approximately 500.
209
+
210
+ Rules for the remedy clause:
211
+
212
+ - It is a condition the reader evaluates ("if that is what this is"). It is
213
+ not a claim about the identifier. Only the database is checked here.
214
+ Whether the name is one of the approximately 100 in IANA's `backward`
215
+ file is not knowable without shipping that list. A claim would tell every
216
+ typo on a links-less host that `tzdata-legacy` supplies it. A shipped
217
+ list would be worse: a name it missed would get no remedy at all.
218
+ - The identifier the caller passed must not appear in the remedy clause.
219
+ Naming it there turns the condition back into a claim.
220
+ - The clause appears only where the database has no links.
221
+ - The remedies assume the datasource is the host's default. A caller who
222
+ pointed `TZInfo::DataSource` at their own directory must fix that
223
+ directory instead. The message names the directory. That makes the case
224
+ recognizable.
225
+
226
+ Rules for `datasource_description`:
227
+
228
+ - The zoneinfo case is detected by capability (`respond_to?(:zoneinfo_dir)`).
229
+ A caller can install a custom subclass. The directory is the useful part
230
+ of the answer.
231
+ - The gem case is detected by class. Whether `tzinfo-data` is loaded says
232
+ nothing about whether it answered. A custom datasource in a bundle that
233
+ also carries the gem must not be described as tzinfo-data.
234
+ - An unrecognized datasource is named by its own class. Do not describe it
235
+ as another database.
236
+ - A host with no database gets its own string. This method builds failure
237
+ messages. It must not raise. Raising would replace the explanation with a
238
+ raw tzinfo error at the moment the explanation was wanted.
239
+
240
+ ### No database at all
241
+
242
+ `timezone_for` raises `Duckling::TZDataUnavailable` when tzinfo raises
243
+ `DataSourceNotFound`. tzinfo raises it before any identifier lookup. The
244
+ zone name is beside the point. The message names the `reference_zone:`
245
+ keyword and both fixes: the `tzinfo-data` gem, or the system `tzdata`
246
+ package.
247
+
248
+ `TZDataUnavailable` is deliberately not an `ArgumentError`. It reports the
249
+ state of the deployment. A caller that validates user
250
+ input by rescuing `ArgumentError` must not swallow it. It is a named class
251
+ for the same reason as `ShapeError`: greppable, and not satisfiable by an
252
+ unrelated `RuntimeError`.
253
+
254
+ ## Fixture zones
255
+
256
+ `test/fixtures/tz/*.zi` files are compiled by `zic` into a private zoneinfo
257
+ directory at test time. `TZFixtures::Datasource` swaps `TZInfo::DataSource`
258
+ in `setup` and restores it in `teardown`.
259
+
260
+ Why fixture zones:
261
+
262
+ - A fixture zone is identical on every host and every vintage. Real zones
263
+ are not. Which real zones exist depends on the datasource. What they do
264
+ depends on the vintage.
265
+ - The fixture directory exposes only its own three identifiers. Nothing
266
+ about the host's database can leak into a test.
267
+ - The swap is process-global because `timezone_for` reaches the datasource
268
+ through `TZInfo::Timezone.get` inside `Duckling.parse`. No injection
269
+ point exists. This is also why Ruby doubles cannot replace the fixture
270
+ zones. A double can only reach `local_time_in_zone` directly. That stops
271
+ short of the outside-in path through `Duckling.parse`.
272
+ - The restore in `teardown` is mandatory. A leaked fixture
273
+ datasource leaves every later test with three zones and nothing else.
274
+ - `TZInfo::DataSource.get` creates the default source when none is set. It
275
+ raises when it cannot. So setup tolerates the absence. Teardown restores
276
+ conditionally: `set(nil)` raises `ArgumentError`. On a failed setup that
277
+ would replace the real error with a worse one.
278
+
279
+ The tests reach the fixtures through `Duckling.parse`. The coverage stays
280
+ outside-in. One exception: the half-hour gap test calls
281
+ `local_time_in_zone` directly. No English expression lands reliably inside
282
+ a 30-minute window.
283
+
284
+ The three fixture zones:
285
+
286
+ - `Fixture/NegativeDst`: shaped like `Europe/Dublin`. Negative DST. It
287
+ distinguishes first-occurrence-by-position from a `dst?`-flag lookup.
288
+ Picking by flag gives the second occurrence, an hour off as an instant.
289
+ `ActiveSupport::TimeZone#local` picks by flag (`period_for_local`'s
290
+ `dst=true` default).
291
+ - `Fixture/HalfHourGap`: shaped like `Australia/Lord_Howe`. A 30-minute
292
+ gap. It distinguishes the transition's real width from a hardcoded
293
+ one-hour shift. ActiveSupport's `@time += 1.hour` retry overshoots.
294
+ Lord Howe is the only zone in current use with a sub-hour gap. The
295
+ coverage rested on one zone's continued existence.
296
+ - `Fixture/LateGap`: shaped like `America/Nuuk`. A gap late in the local
297
+ day in a negative-offset zone. The transition instant falls past the next
298
+ UTC midnight. `gap_delta`'s scan window must center on the skipped wall
299
+ clock read as UTC. A midnight-anchored window misses the transition.
300
+ `gap_delta` then crashes with `NoMethodError` on a nil `find`.
301
+
302
+ `zic` needs no provisioning except on Alpine:
303
+
304
+ - Debian/Ubuntu: `zic` is in `libc-bin` (Priority: required, a dependency
305
+ of libc6). It is not in `tzdata`. A slim image without
306
+ `/usr/share/zoneinfo` still has it.
307
+ - macOS: `/usr/sbin/zic` is a stock utility.
308
+ - Alpine: `zic` is in `tzdata-utils`. The `tz-containers` job installs it.
309
+ - `zic` lives in `sbin`. That is off a non-root `PATH`. `TZFixtures` and
310
+ `bin/build-stale-zoneinfo` search there explicitly.
311
+ - A missing `zic` is a hard error. These fixtures exist because this
312
+ coverage kept degrading silently on hosts nobody watched. A skip would
313
+ reintroduce exactly that.
314
+
315
+ ## The build scripts
316
+
317
+ ### `bin/build-linkless-zoneinfo <output-dir>`
318
+
319
+ Copies the host's zoneinfo directory and removes the top-level
320
+ backward-compatibility entries. `ZONEINFO_DIR` overrides the source
321
+ directory (default `/usr/share/zoneinfo`).
322
+
323
+ - The keep-list is transcribed from a real links-less host: a Debian-family
324
+ container with `tzdata` and no `tzdata-legacy`, 497 identifiers. No name
325
+ is added defensively. `Factory` and `posixrules` stay because Ubuntu
326
+ 24.04's tzdata 2025b still has them.
327
+ - The script hard-fails if `US/Eastern` survives the strip. That check
328
+ catches under-stripping only. The environment contract's identifier-count
329
+ range catches over-stripping.
330
+ - `cp -RL` dereferences the alias symlinks. Removing an entry cannot leave
331
+ a dangling link. It cannot follow one back into the host's directory.
332
+ - The approximately 60 in-region aliases that `tzdata-legacy` also owns are
333
+ not removed (`America/Godthab`, `Europe/Kiev`, `Asia/Calcutta`). Inside a
334
+ region directory they are indistinguishable from aliases a stock host
335
+ keeps (`Asia/Istanbul`, `Pacific/Samoa`). So the tree exposes more
336
+ identifiers than a stock host. That costs nothing. The guarantee the
337
+ environment needs is that `US/Eastern` is genuinely gone. It is the probe
338
+ target and the name the CHANGELOG and the error message use.
339
+ - The copy keeps the host's modelling. Only the links absence gets an
340
+ environment contract. Modelling follows the host. The capability-gated
341
+ Dublin test loads or does not load.
342
+
343
+ ### `bin/build-stale-zoneinfo <output-dir>`
344
+
345
+ Copies the host's zoneinfo directory and compiles
346
+ `test/fixtures/zoneinfo-overrides/*.zi` over it. The overrides roll named
347
+ zones back to earlier rules. `America/Nuuk` goes back to the pre-2023a
348
+ rules.
349
+
350
+ - The copy keeps the host's modelling and links state. The capability-gated
351
+ tests absorb both.
352
+ - Rolling back only the zones under assertion says plainly which staleness
353
+ is tested. Compiling a full old tzdata release would need a download.
354
+ - The override replaces the zone's entire history, including the pre-2023a
355
+ rules. Harmless for the contract: it only looks at 2026. Every other zone
356
+ in the copied directory stays as the host has it.
357
+ - This script needs `tzdata` installed. It copies `/usr/share/zoneinfo`. It
358
+ fails with a clear message if the directory is missing.
359
+ - The override shadows a real identifier on purpose. The
360
+ `test/fixtures/tz/*.zi` zones are `Fixture/`-prefixed so they cannot be
361
+ mistaken for real ones. Here shadowing is the point. The environment must
362
+ be a plausible stale host. A synthetic zone would give nothing to assert
363
+ against.
364
+
365
+ ## `expect_failure`
366
+
367
+ `expect_failure(reason)` in `test/test_helper.rb` is for known limitations
368
+ that fail on every host. The upstream grammar and ranking gaps are the
369
+ current cases. It runs the block for real:
370
+
371
+ - A failure reports as a skip that names the reason.
372
+ - A pass flunks. The limitation stopped reproducing. Drop the wrapper and
373
+ keep the assertions.
374
+ - Only `Minitest::Assertion` is rescued. It inherits from `Exception`, not
375
+ from `StandardError`. A wider rescue would launder any crash before the
376
+ assertions into "known limitation". A genuine regression must surface as
377
+ a crash.
378
+ - A `skip` inside the block is re-raised first. `Minitest::Skip` is a
379
+ subclass of `Minitest::Assertion`. Otherwise the real explanation would
380
+ be replaced by the reason.
381
+
382
+ Do not use it for anything environment-dependent. Use
383
+ `test/capabilities/` instead.
384
+
385
+ ## Running an environment locally
386
+
387
+ Always pass `BUNDLE_LOCKFILE` to both the `bundle install` and the run.
388
+ The suite adapts itself to whatever database it gets. There is no
389
+ environment name to set. Run the matching contract afterward to prove you
390
+ got the state you meant to.
391
+
392
+ ```bash
393
+ # no tzinfo-data: the host's zoneinfo files
394
+ export DUCKLING_TZINFO_DATA=none BUNDLE_LOCKFILE=Gemfile.system-zoneinfo.lock
395
+ bundle install && bundle exec rake test
396
+
397
+ # the same, with the backward-compat links stripped (US/Eastern stops resolving)
398
+ bin/build-linkless-zoneinfo /tmp/linkless-zoneinfo
399
+ DUCKLING_ZONEINFO_DIR=/tmp/linkless-zoneinfo bundle exec rake test
400
+ DUCKLING_ZONEINFO_DIR=/tmp/linkless-zoneinfo \
401
+ bundle exec ruby -Ilib -Itest test/environments/linkless_zoneinfo_test.rb
402
+
403
+ # a pinned stale vintage
404
+ export DUCKLING_TZINFO_DATA=1.2022.7 BUNDLE_LOCKFILE=Gemfile.tzinfo-data-1.2022.7.lock
405
+ bundle install && bundle exec rake test
406
+ bundle exec ruby -Ilib -Itest test/environments/stale_vintage_test.rb
407
+
408
+ # both axes at once
409
+ bin/build-stale-zoneinfo /tmp/stale-zoneinfo
410
+ export DUCKLING_TZINFO_DATA=none BUNDLE_LOCKFILE=Gemfile.stale-system-zoneinfo.lock
411
+ bundle install && DUCKLING_ZONEINFO_DIR=/tmp/stale-zoneinfo bundle exec rake test
412
+ DUCKLING_ZONEINFO_DIR=/tmp/stale-zoneinfo \
413
+ bundle exec ruby -Ilib -Itest test/environments/stale_vintage_test.rb
414
+ ```
415
+
416
+ `DUCKLING_ZONEINFO_DIR` alone (pointing at `/usr/share/zoneinfo`, with
417
+ `tzinfo-data` still bundled) reaches the same datasource as the system leg
418
+ without re-resolving anything. It is the quick way to reproduce a
419
+ system-zoneinfo failure. It is not the same configuration. The gem is still
420
+ installed. So it does not exercise tzinfo's own fallback.
421
+
422
+ ## CI notes
423
+
424
+ - `bundler-cache` is off in `timezones` and `tz-containers`. These
425
+ environments resolve a different bundle than the committed lockfile. The
426
+ cache is keyed on that lockfile.
427
+ - The system-zoneinfo step in `baseline` runs
428
+ `bundle config unset --local deployment` (and `frozen`).
429
+ `bundler-cache: true` writes `deployment: true` into `.bundle/config`.
430
+ Deployment requires a committed lockfile. The environment lockfiles are
431
+ gitignored by design. It must be unset in the local config.
432
+ `BUNDLE_DEPLOYMENT` cannot override it. Bundler resolves local config first and environment
433
+ variables second (`Bundler::Settings#configs`). An env var cannot
434
+ override anything `bundle config --local` has written.
435
+ - The Alpine image is pinned by digest. The floating `ruby:3.4-alpine` tag
436
+ silently rebases across Alpine releases. Alpine drops older versioned
437
+ clang packages as it rolls. A rebase also moves the host's tz data under
438
+ a leg that asserts against it. Bump by resolving the tag's current digest
439
+ (`docker buildx imagetools inspect ruby:3.4-alpine`). Then re-verify the
440
+ clang package names and the contract against the new release.
441
+ - Alpine splits clang's resource headers (`stdckdint.h`, which ruby-3.4's
442
+ headers include) away from the library's default search path.
443
+ `BINDGEN_EXTRA_CLANG_ARGS=-I<resource-dir>` points bindgen at them. Any
444
+ musl consumer building the source gem needs the same. No precompiled gem
445
+ targets musl (`cross_targets.rb`). So the source build is the path musl
446
+ consumers actually take.
447
+ - The banner line at suite start (`tz datasource: ...; negative_dst=true
448
+ ...`) records which database answered and which probes passed. Two images
449
+ with the same name can disagree about the links. The banner is what
450
+ reconciles a missing capability test with a CI log.
451
+ - The container jobs run the image's own Ruby. `ruby/setup-ruby` does not
452
+ apply (it has no musl support). The toolchain comes from the image's
453
+ package manager, git included. That is why the install step precedes
454
+ checkout.
@@ -2,6 +2,13 @@
2
2
  name = "duckling"
3
3
  version = "0.1.0"
4
4
  edition = "2024"
5
+ # Edition 2024 already floors the compiler at 1.85; naming it lets cargo
6
+ # fail with a clean MSRV error instead of a parse error. It is also the de
7
+ # facto floor of the Debian-trixie CI leg, whose rustc is frozen at 1.85.0
8
+ # for the life of Debian 13 — the first crate change requiring a newer
9
+ # compiler turns that leg red, and this field is what makes the error
10
+ # legible there.
11
+ rust-version = "1.85"
5
12
 
6
13
  [lib]
7
14
  crate-type = ["cdylib"]
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tzinfo"
4
+
5
+ module Duckling
6
+ # Which tz database `reference_zone:` resolves against, for the
7
+ # unknown-identifier error message. Behavioral because neither datasource
8
+ # exposes a version. Internal. See docs/tz-database-axis.md.
9
+ module TZInfoCapabilities
10
+ module_function
11
+
12
+ # DataSourceNotFound does not inherit from InvalidTimezoneIdentifier;
13
+ # name it explicitly. A host with no database answers false.
14
+ def backward_compat_links?
15
+ TZInfo::Timezone.get("US/Eastern")
16
+ true
17
+ rescue TZInfo::InvalidTimezoneIdentifier, TZInfo::DataSourceNotFound
18
+ false
19
+ end
20
+
21
+ def identifier_count
22
+ TZInfo::Timezone.all_identifiers.size
23
+ rescue TZInfo::DataSourceNotFound
24
+ 0
25
+ end
26
+
27
+ # Zoneinfo is detected by capability (a caller may install a subclass),
28
+ # the gem by class (loaded is not answered). Must not raise: it builds
29
+ # failure messages.
30
+ def datasource_description
31
+ source = begin
32
+ TZInfo::DataSource.get
33
+ rescue TZInfo::DataSourceNotFound
34
+ return "no tz datasource (no zoneinfo files, no tzinfo-data gem)"
35
+ end
36
+
37
+ return "system zoneinfo at #{source.zoneinfo_dir}" if source.respond_to?(:zoneinfo_dir)
38
+
39
+ if defined?(TZInfo::DataSources::RubyDataSource) && source.is_a?(TZInfo::DataSources::RubyDataSource)
40
+ version = " (tzdata #{TZInfo::Data::Version::TZDATA})" if defined?(TZInfo::Data::Version::TZDATA)
41
+ return "the tzinfo-data gem#{version}"
42
+ end
43
+
44
+ "the #{source.class} tz datasource"
45
+ end
46
+
47
+ # The remedy is phrased as a condition: only the database is checked.
48
+ # See docs/tz-database-axis.md.
49
+ def unknown_identifier_diagnosis
50
+ diagnosis = "resolved against #{datasource_description}, " \
51
+ "which provides #{identifier_count} identifiers"
52
+ return diagnosis if backward_compat_links?
53
+
54
+ "#{diagnosis}; this database has no backward-compat names (US/Eastern and ~100 others), " \
55
+ "so if that is what this is, it needs either the tzinfo-data gem or the " \
56
+ "tzdata-legacy system package"
57
+ end
58
+ end
59
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Duckling
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/duckling.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "tzinfo"
4
4
 
5
5
  require_relative "duckling/version"
6
+ require_relative "duckling/tzinfo_capabilities"
6
7
 
7
8
  # A precompiled gem carries one binary per Ruby ABI, each in its own
8
9
  # lib/duckling/<major.minor>/ directory. Load the one for the running Ruby.
@@ -21,7 +22,7 @@ rescue LoadError => abi_load_error
21
22
  require_relative "duckling/duckling"
22
23
  rescue LoadError
23
24
  # The ABI directory is missing, and so is the plain path. Raise the first
24
- # error, not the second.
25
+ # error.
25
26
  #
26
27
  # A binary that exists but refuses to load fails here too, and its error
27
28
  # says why. An Alpine install does this: RubyGems matches an unversioned
@@ -41,6 +42,12 @@ module Duckling
41
42
  # internal_error() class in lib.rs.
42
43
  class ShapeError < RuntimeError; end
43
44
 
45
+ # Raised when `reference_zone:` is given on a host with no tz database at
46
+ # all (no zoneinfo files, no tzinfo-data gem). Deliberately outside
47
+ # ArgumentError: it reports the deployment, so input-validation rescues do
48
+ # not swallow it. See docs/tz-database-axis.md.
49
+ class TZDataUnavailable < RuntimeError; end
50
+
44
51
  # Native.parse already releases the GVL around the native call, but a bare
45
52
  # GVL release alone does not hand control back to an Async::Reactor —
46
53
  # Ruby 3.4's Fiber::Scheduler#blocking_operation_wait auto-offload path
@@ -64,7 +71,7 @@ module Duckling
64
71
  # thread-termination backtrace to stderr — Thread#value still re-raises it
65
72
  # to the caller as ordinary control flow.
66
73
  #
67
- # reference_time: is coerced here, not in the native extension:
74
+ # reference_time: is coerced here because the native extension cannot:
68
75
  # Native.parse's Magnus binding only accepts a strict kind_of?(Time) (issue
69
76
  # #45), which rejects ActiveSupport::TimeWithZone and stdlib DateTime even
70
77
  # though both carry the same to_i/utc_offset a real Time does — #to_time
@@ -79,13 +86,12 @@ module Duckling
79
86
  #
80
87
  # A fixed offset and a zone that disagree at the reference instant have no
81
88
  # principled resolution — silently preferring either would resolve results
82
- # against an offset the caller never asked for — so that combination raises
83
- # rather than guessing.
89
+ # against an offset the caller never asked for — so that combination raises.
84
90
  #
85
91
  # reference_zone: only reinterprets result offsets after the fact; it does
86
92
  # NOT anchor the parse. Given without reference_time:, relative expressions
87
- # ("tomorrow") still anchor on the machine-local clock, not on "now" in that
88
- # zone, so on a US host reference_zone: "Asia/Tokyo" can land on the wrong
93
+ # ("tomorrow") still anchor on the machine-local clock. They do not anchor
94
+ # on "now" in that zone, so on a US host reference_zone: "Asia/Tokyo" can land on the wrong
89
95
  # calendar day. Pass a reference_time: in the zone to anchor as well.
90
96
  def self.parse(text, locale: "en", dims: ["time"], reference_time: nil, with_latent: false, reference_zone: nil)
91
97
  reference_time = reference_time.to_time if reference_time && !reference_time.is_a?(Time) && reference_time.respond_to?(:to_time)
@@ -112,7 +118,8 @@ module Duckling
112
118
 
113
119
  # Reinterprets every TimePoint::Naive (wall-clock) leaf of each :time entity
114
120
  # against `reference_zone`, using the real IANA offset for that leaf's own
115
- # date rather than the single fixed offset reference_time: carries.
121
+ # date. reference_time: carries a single fixed offset, which cannot be right
122
+ # for every leaf.
116
123
  #
117
124
  # TimePoint::Instant leaves are left strictly alone: the wrapped crate
118
125
  # already collapsed their relative arithmetic against one FixedOffset before
@@ -121,7 +128,7 @@ module Duckling
121
128
  #
122
129
  # Walks the externally-tagged shape ext/duckling/src/lib.rs's patch_time_value
123
130
  # produces, and raises on any tag it doesn't recognize: a shape drift on the
124
- # Rust side must fail loudly here rather than quietly returning results
131
+ # Rust side must fail loudly here. The outcome to prevent is quiet results
125
132
  # resolved against the wrong offset.
126
133
  def self.apply_reference_zone(entities, reference_zone)
127
134
  return entities unless reference_zone
@@ -131,8 +138,8 @@ module Duckling
131
138
 
132
139
  # Zone-object core of apply_reference_zone. parse calls this directly with
133
140
  # the TZInfo::Timezone it already resolved for offset validation, so the
134
- # zone is looked up once per call rather than once for validation and again
135
- # for reinterpretation.
141
+ # zone is looked up once per call. Validation and reinterpretation share the
142
+ # lookup.
136
143
  def self.reinterpret_entities!(entities, zone)
137
144
  entities.each do |entity|
138
145
  next unless entity[:dim] == :time
@@ -160,7 +167,7 @@ module Duckling
160
167
 
161
168
  # An Interval's from/to are Option<TimePoint> on the Rust side, and serde
162
169
  # emits Option::None as a present key holding nil — hence the nil tolerance
163
- # in reinterpret_time_point!, rather than a missing-key check here.
170
+ # in reinterpret_time_point!. A missing-key check here would reject that shape.
164
171
  def self.reinterpret_interval_endpoints!(endpoints, zone)
165
172
  reinterpret_time_point!(endpoints[:from], zone)
166
173
  reinterpret_time_point!(endpoints[:to], zone)
@@ -227,19 +234,19 @@ module Duckling
227
234
  # `time` is necessarily the one whose gap it landed in.
228
235
  #
229
236
  # The ±1-day scan window is centered on the skipped wall clock itself read
230
- # as UTC not on the UTC midnight of its date. The transition's UTC instant
237
+ # as UTC. The UTC midnight of its date would be the wrong anchor. The transition's UTC instant
231
238
  # is the wall clock minus a zone offset, and offsets never reach a day, so
232
239
  # this window always contains it; a midnight-anchored window does not. A
233
240
  # gap late in the local day in a negative-offset zone (America/Nuuk springs
234
241
  # forward at 23:00 local) has its transition instant past the *next* UTC
235
242
  # midnight, outside a midnight-anchored window — `find` returned nil and
236
- # this method crashed instead of resolving.
243
+ # this method crashed.
237
244
  #
238
- # Read from the transition rather than assumed to be 3600. ActiveSupport's
245
+ # Read from the transition. Do not assume 3600. ActiveSupport's
239
246
  # TimeWithZone#get_period_and_ensure_valid_local_time instead hardcodes
240
247
  # `@time += 1.hour` and retries: for Australia/Lord_Howe's 30-minute gap
241
- # that lands half an hour past the gap's end (02:15 → 03:15 rather than
242
- # 02:45). Every one-hour gap — i.e. every zone in current use but Lord Howe
248
+ # that lands half an hour past the gap's end (02:15 → 03:15; the correct
249
+ # answer is 02:45). Every one-hour gap — i.e. every zone in current use but Lord Howe
243
250
  # — resolves identically either way.
244
251
  def self.gap_delta(zone, time)
245
252
  wall_clock = Time.utc(time.year, time.month, time.day, time.hour, time.min, time.sec)
@@ -252,7 +259,16 @@ module Duckling
252
259
  def self.timezone_for(reference_zone)
253
260
  TZInfo::Timezone.get(reference_zone)
254
261
  rescue TZInfo::InvalidTimezoneIdentifier
255
- raise ArgumentError, "invalid reference_zone: #{reference_zone.inspect}"
262
+ raise ArgumentError,
263
+ "invalid reference_zone: #{reference_zone.inspect} " \
264
+ "(#{TZInfoCapabilities.unknown_identifier_diagnosis})"
265
+ rescue TZInfo::DataSourceNotFound => error
266
+ # tzinfo raises this before any identifier lookup, so restate it in
267
+ # terms of the keyword the caller passed, naming both fixes.
268
+ raise TZDataUnavailable,
269
+ "cannot resolve reference_zone: #{reference_zone.inspect} — this host has no tz database. " \
270
+ "Add `gem \"tzinfo-data\"` to bundle the IANA data with your app, or install the system " \
271
+ "tzdata package to provide zoneinfo files. (#{error.message.lines.first.to_s.strip})"
256
272
  end
257
273
  private_class_method :timezone_for
258
274
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: duckling
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Caleb Buxton
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-05 00:00:00.000000000 Z
11
+ date: 2026-08-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys
@@ -38,20 +38,6 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '2.0'
41
- - !ruby/object:Gem::Dependency
42
- name: tzinfo-data
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - "~>"
46
- - !ruby/object:Gem::Version
47
- version: '1.2024'
48
- type: :runtime
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - "~>"
53
- - !ruby/object:Gem::Version
54
- version: '1.2024'
55
41
  - !ruby/object:Gem::Dependency
56
42
  name: rake-compiler
57
43
  requirement: !ruby/object:Gem::Requirement
@@ -114,11 +100,13 @@ files:
114
100
  - README.md
115
101
  - Rakefile
116
102
  - docs/2026-07-01-roadmap.md
103
+ - docs/tz-database-axis.md
117
104
  - ext/duckling/Cargo.toml
118
105
  - ext/duckling/extconf.rb
119
106
  - ext/duckling/src/lib.rs
120
107
  - ext/duckling/src/ruby_value.rs
121
108
  - lib/duckling.rb
109
+ - lib/duckling/tzinfo_capabilities.rb
122
110
  - lib/duckling/version.rb
123
111
  homepage: https://github.com/cpb/duckling
124
112
  licenses: