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 +4 -4
- data/AGENTS.md +41 -11
- data/CHANGELOG.md +67 -0
- data/README.md +42 -2
- data/Rakefile +18 -16
- data/docs/tz-database-axis.md +454 -0
- data/ext/duckling/Cargo.toml +7 -0
- data/lib/duckling/tzinfo_capabilities.rb +59 -0
- data/lib/duckling/version.rb +1 -1
- data/lib/duckling.rb +33 -17
- metadata +4 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b5a2e1267025c49fdc9d0217f04a93543acc76d1a064eb8257cae7ca7c39c1fa
|
|
4
|
+
data.tar.gz: e2cfc90eb5fb08211176cc6d5b95c370ddd92d39abf6a209f871799dabc5023e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
40
|
-
| `Rakefile` | `task default: %i[standard compile test]` — runs StandardRB lint, compiles the Rust extension, then Minitest. `test` also declares an explicit `
|
|
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
|
|
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 `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
125
|
-
# directory
|
|
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
|
|
167
|
-
#
|
|
168
|
-
# rake process, so it
|
|
169
|
-
#
|
|
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
|
|
188
|
-
# runners
|
|
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
|
|
218
|
-
#
|
|
219
|
-
#
|
|
220
|
-
#
|
|
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:
|
|
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.
|
data/ext/duckling/Cargo.toml
CHANGED
|
@@ -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
|
data/lib/duckling/version.rb
CHANGED
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
135
|
-
#
|
|
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
|
|
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
|
|
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
|
|
243
|
+
# this method crashed.
|
|
237
244
|
#
|
|
238
|
-
# Read from the transition
|
|
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
|
|
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,
|
|
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.
|
|
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-
|
|
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:
|