duckling 0.4.0 → 0.4.1
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/CHANGELOG.md +36 -12
- data/lib/duckling/version.rb +1 -1
- metadata +1 -4
- data/.claude/settings.json +0 -46
- data/AGENTS.md +0 -208
- data/CLAUDE.md +0 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '059bb11a0d905a85d7577db06b62d8971c2896bda2433d0bbeb8fd0a2393c9b2'
|
|
4
|
+
data.tar.gz: 9101b8b87ada67851e4468270d73131be44dca36ea3c1f72a99bc403f5ef46e7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fe7ad8e2c972e6070f2a2bea717e30f7d66d021b0ad3bf618466acb29b3d8bbc51efec151424f2ce3d8b08f0c0edbf5a2b78b667b7ffc8f2f81ff380f08ecf96
|
|
7
|
+
data.tar.gz: 83fa337d4259faa6063c4322fac789c9b98e57bd7f2c8c3dc7e7142515179d2db69bb8700755c11c29078904af9bc7c6a99d01c1ffec2e6610dff5b6b4c2460a
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.1] - 2026-08-10
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- The packaged gem no longer includes agent- and development-tooling files.
|
|
14
|
+
0.3.0 and 0.4.0 shipped `.claude/settings.json`, `AGENTS.md`, and
|
|
15
|
+
`CLAUDE.md`: the gemspec built its file list from `git ls-files` with a
|
|
16
|
+
reject-list of paths to exclude, so every newly tracked dotfile or tool
|
|
17
|
+
directory was packaged by default. The gemspec now allow-lists what ships
|
|
18
|
+
(`lib/`, `ext/`, `docs/` other than `docs/benchmarks/`, and a named set of
|
|
19
|
+
root files), so anything new stays out of the gem unless it is added
|
|
20
|
+
deliberately. Regression coverage checks the gemspec's file list in the
|
|
21
|
+
main suite and the built artifacts in `test/gem/packaged_gem_test.rb`.
|
|
22
|
+
None of the previously shipped files contained secrets — they are
|
|
23
|
+
development configuration and documentation, all public in the repository
|
|
24
|
+
— so the already-published 0.3.0/0.4.0 gems are unaffected in behavior and
|
|
25
|
+
have been left in place.
|
|
26
|
+
|
|
27
|
+
## [0.4.0] - 2026-08-10
|
|
28
|
+
|
|
9
29
|
### Changed
|
|
10
30
|
|
|
11
31
|
- **On a stock Debian/Ubuntu host, roughly a hundred IANA zone identifiers
|
|
@@ -63,6 +83,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
63
83
|
work; steady-state parsing is unaffected either way, since `reference_zone:`
|
|
64
84
|
resolution goes through the same tzinfo call once a database is loaded.
|
|
65
85
|
|
|
86
|
+
### Added
|
|
87
|
+
|
|
88
|
+
- `Duckling::TZDataUnavailable`, raised when `reference_zone:` is given on a
|
|
89
|
+
host with no tz database at all — no zoneinfo files and no `tzinfo-data`
|
|
90
|
+
gem, as in a scratch or distroless container. Newly reachable because of the
|
|
91
|
+
dependency change above; previously a database always existed. It names both
|
|
92
|
+
fixes, where the underlying tzinfo error mentioned neither this gem nor
|
|
93
|
+
`reference_zone:`. Deliberately not an `ArgumentError`: it reports the
|
|
94
|
+
deployment's state, not a bad argument, so code rescuing `ArgumentError`
|
|
95
|
+
around caller-supplied zone names does not swallow it. Every other keyword
|
|
96
|
+
works without a tz database.
|
|
97
|
+
|
|
98
|
+
## [0.3.0] - 2026-08-04
|
|
99
|
+
|
|
100
|
+
### Changed
|
|
101
|
+
|
|
66
102
|
- **Breaking:** `reference_time:` now requires a Ruby `Time` object (or
|
|
67
103
|
`nil`), not a Unix-seconds Integer. This lets the caller's `utc_offset` be
|
|
68
104
|
preserved into offset-aware `Instant` results (e.g. `"in one hour"`),
|
|
@@ -95,18 +131,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
95
131
|
entirely — check `interval[:to].nil?` rather than `interval.key?(:to)`
|
|
96
132
|
to detect an unbounded endpoint.
|
|
97
133
|
|
|
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
|
-
|
|
110
134
|
## [0.2.0] - 2026-07-01
|
|
111
135
|
|
|
112
136
|
## What's Changed
|
data/lib/duckling/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: duckling
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Caleb Buxton
|
|
@@ -88,11 +88,8 @@ extensions:
|
|
|
88
88
|
- ext/duckling/extconf.rb
|
|
89
89
|
extra_rdoc_files: []
|
|
90
90
|
files:
|
|
91
|
-
- ".claude/settings.json"
|
|
92
|
-
- AGENTS.md
|
|
93
91
|
- Brewfile
|
|
94
92
|
- CHANGELOG.md
|
|
95
|
-
- CLAUDE.md
|
|
96
93
|
- CODE_OF_CONDUCT.md
|
|
97
94
|
- Cargo.lock
|
|
98
95
|
- Cargo.toml
|
data/.claude/settings.json
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"extraKnownMarketplaces": {
|
|
3
|
-
"cpb": {
|
|
4
|
-
"source": {
|
|
5
|
-
"source": "github",
|
|
6
|
-
"repo": "cpb/claude-plugins"
|
|
7
|
-
}
|
|
8
|
-
}
|
|
9
|
-
},
|
|
10
|
-
"enabledPlugins": {
|
|
11
|
-
"cpb@cpb": true
|
|
12
|
-
},
|
|
13
|
-
"hooks": {
|
|
14
|
-
"PreToolUse": [
|
|
15
|
-
{
|
|
16
|
-
"matcher": ".*",
|
|
17
|
-
"hooks": [
|
|
18
|
-
{
|
|
19
|
-
"type": "command",
|
|
20
|
-
"command": "exec \"${CLAUDE_PROJECT_DIR}/bin/check-worktree\""
|
|
21
|
-
}
|
|
22
|
-
]
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
"matcher": "Edit|Write",
|
|
26
|
-
"hooks": [
|
|
27
|
-
{
|
|
28
|
-
"type": "command",
|
|
29
|
-
"command": "[ \"${CLAUDE_CODE_REMOTE:-}\" = true ] || exit 0; exec \"${CLAUDE_PROJECT_DIR}/bin/claude-code-web-setup\""
|
|
30
|
-
}
|
|
31
|
-
]
|
|
32
|
-
}
|
|
33
|
-
],
|
|
34
|
-
"PostToolUse": [
|
|
35
|
-
{
|
|
36
|
-
"matcher": "Edit|Write",
|
|
37
|
-
"hooks": [
|
|
38
|
-
{
|
|
39
|
-
"type": "command",
|
|
40
|
-
"command": "exec \"${CLAUDE_PROJECT_DIR}/bin/lint\""
|
|
41
|
-
}
|
|
42
|
-
]
|
|
43
|
-
}
|
|
44
|
-
]
|
|
45
|
-
}
|
|
46
|
-
}
|
data/AGENTS.md
DELETED
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
# AGENTS.md
|
|
2
|
-
|
|
3
|
-
Orientation for AI agents working in this repo. Read this before doing
|
|
4
|
-
exploratory reads — it should answer "where does X live" and "how do I build
|
|
5
|
-
the extension" without you having to re-derive them.
|
|
6
|
-
|
|
7
|
-
## What this is
|
|
8
|
-
|
|
9
|
-
`duckling` is a Ruby gem that wraps the Rust [`wafer-inc/duckling`](https://github.com/wafer-inc/duckling)
|
|
10
|
-
NER/entity-extraction engine via [Magnus](https://github.com/matsadler/magnus)
|
|
11
|
-
and `rb-sys`, so Ruby code can extract entities (times, numbers, money,
|
|
12
|
-
emails, etc.) without running a separate HTTP service.
|
|
13
|
-
|
|
14
|
-
**Current state:** the Ruby API surface (`lib/duckling.rb`), gemspec, Rakefile, and native extension are all built and live. The release pipeline cross-compiles, verifies, and smoke-tests precompiled binary gems for `x86_64-linux`/`x86_64-darwin`/`arm64-darwin`/`aarch64-linux` — see "Build model" under "Rust/Magnus wiring" and "Gem release conventions" below.
|
|
15
|
-
|
|
16
|
-
Building those gems is not publishing them. They reach consumers only once a `vX.Y.Z` tag drives `release.yml` all the way through `gem push`, and this file cannot tell you whether that has happened for any given version. Ask RubyGems instead:
|
|
17
|
-
|
|
18
|
-
```
|
|
19
|
-
gem list -r --all --exact duckling
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
Each version lists the platforms published under it. A version showing no platforms beyond `ruby` ships no precompiled gem, whatever this file says. Trusting a prose claim here over that command is what let four releases go out source-only without anyone noticing.
|
|
23
|
-
|
|
24
|
-
If you notice this file describing a not-yet-built piece as current, or vice versa, fix it in the same PR (see "Keeping this file current").
|
|
25
|
-
|
|
26
|
-
## Directory layout
|
|
27
|
-
|
|
28
|
-
| Path | Purpose |
|
|
29
|
-
|---|---|
|
|
30
|
-
| `ext/duckling/` | Native Rust extension. Holds `extconf.rb` (build wiring), `Cargo.toml` (crate config), and `src/` (Rust source). See "Rust/Magnus wiring" below. |
|
|
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
|
-
| `lib/duckling/version.rb` | `Duckling::VERSION` constant — single source of truth for the gem version, read by `duckling.gemspec` and the release pipeline. |
|
|
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. |
|
|
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. |
|
|
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`). |
|
|
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. |
|
|
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. |
|
|
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. |
|
|
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. |
|
|
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. |
|
|
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`. |
|
|
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`. |
|
|
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. |
|
|
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. |
|
|
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. |
|
|
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. |
|
|
54
|
-
| `.github/workflows/benchmark.yml` | Reusable workflow that compiles the native extension and records a `docs/benchmarks/github-actions/<version>.json` data point via `bundle exec rake benchmark:record_pr`. Called from `release.yml` in parallel with cross-compilation. Also standalone-dispatchable via `gh workflow run benchmark.yml --ref <branch>` to populate benchmark data without releasing. |
|
|
55
|
-
| `.github/workflows/benchmark-branch.yml` | Records a benchmark data point for the current branch's own code, committing/pushing straight back to that branch (unlike `benchmark.yml`, which always branches off `origin/main`). For capturing data points that reflect unmerged PR branches. Standalone `workflow_dispatch` only. |
|
|
56
|
-
| `.github/scripts/apply-tag-ruleset.sh` | Idempotent `gh api` script that creates/updates the GitHub tag ruleset restricting `v*.*.*` tag creation/update to repo admins. Source of truth for that ruleset's config — re-run it to change the config rather than editing it by hand in the GitHub UI. |
|
|
57
|
-
|
|
58
|
-
## Build and test commands
|
|
59
|
-
|
|
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.
|
|
61
|
-
- **`bin/console`** — loads the gem and drops you into IRB for interactive experimentation.
|
|
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.
|
|
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`.
|
|
64
|
-
- **`rake` / `bundle exec rake`** — default task: `standard` (StandardRB lint) + `compile` (builds the Rust extension via `RbSys::ExtensionTask`) + `test` (Minitest).
|
|
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.
|
|
66
|
-
- **`rake dev compile test`** — explicit one-off dev-profile build via the `:dev` task, for use without `.env.local` in place (e.g. before running `bin/setup`, or in an environment where you don't want it seeded).
|
|
67
|
-
- **`rake 'native_gem[<platform>]'`** — cross-compiles a precompiled fat gem into `pkg/` for any platform in `CrossTargets::PLATFORMS` (`x86_64-linux`, `x86_64-darwin`, `arm64-darwin`, `aarch64-linux`) via `bundle exec rb-sys-dock --platform <platform> --ruby-versions <CrossTargets::RUBY_ABIS> --build`, the same command `.github/workflows/cross-gem.yml` runs in CI. Requires Docker (not just Rust) — `rb-sys-dock` builds inside the `rbsys/<platform>` container images, which bundle their own Rust + cross-toolchain (including osxcross for darwin), so this works on a Linux dev machine too. Not part of `task default`. **From a git worktree** (how `bin/worktree` sets up every branch here) the task builds from a throwaway plain clone under `tmp/`, since `rb-sys-dock` mounts only the working directory and a worktree's `.git` is a file naming a path outside it — every git command in the container would otherwise fail, including `duckling.gemspec`'s `git ls-files`, yielding a gem with binaries and no `lib/*.rb`. Two consequences of the clone: the gem carries **committed** state rather than the working tree, and the build doesn't reuse this checkout's Cargo target cache.
|
|
68
|
-
- **`ruby test/gem/packaged_gem_test.rb`** — checks whatever precompiled gems are in `pkg/`, without installing them; skips any platform it has no gem for. Populate `pkg/` with `rake 'native_gem[<platform>]'` or `gh run download <run-id> -D pkg`. Plain `ruby`, no bundler needed.
|
|
69
|
-
- **`ruby test/gem/installed_gem_test.rb`** — requires and calls an *installed* duckling gem. Must run with plain `ruby`: under rake or bundler this checkout's own `lib/` shadows the installed gem. See the file header for the plain-Ruby-container one-liner that rehearses a Rust-free install.
|
|
70
|
-
- **`bin/benchmark`** — thin wrapper around the `benchmark`/`benchmark:record`/`benchmark:record_pr` Rake tasks (below), matching `bin/test`'s `CLAUDE_CODE_REMOTE` JIT-setup convention.
|
|
71
|
-
- **`rake benchmark`** — runs the `benchmark-ips` suite in `benchmark/parse_benchmark.rb` (ips + GC/allocation pressure + 10-thread concurrency scenarios) against a forced release-profile build (via the `:benchmark_env` task, which unsets `RB_SYS_CARGO_PROFILE` and reenables `:compile` regardless of a local `.env.local`); console output only, no files written. Not part of `task default:` — too slow (~25-30s) for every `bundle exec rake`.
|
|
72
|
-
- **`rake "benchmark:record"`** — runs `benchmark`, then writes `docs/benchmarks/<environment>/<version>.json` (environment auto-detected: `github-actions` if `GITHUB_ACTIONS=true`, `claude-code-web` if `CLAUDE_CODE_REMOTE=true`, else `local-<ruby-minor>` e.g. `local-3.4`, derived from `RUBY_VERSION` — see `Report.detect_environment`; version from `Duckling::VERSION`) and regenerates `docs/benchmarks/README.md` from the full history.
|
|
73
|
-
- **`rake "benchmark:record_pr"`** — guarded by the existing `release:guard_clean` task (refuses to run against a dirty working tree); checks out a fresh branch off `origin/main`, runs `benchmark:record` there, commits, pushes, and opens+auto-merges a PR via `gh` — then restores the original branch. Meant to be run from three places: the tag-triggered release pipeline (`.github/workflows/release.yml`), a Claude Code Web session, and a local dev machine (needs `gh auth login`) — each contributes that environment's own data point via its own PR, independent of the CHANGELOG PR.
|
|
74
|
-
- **`bin/benchmark-replay <ref> <ruby-version>`** — not part of routine recording; for backfilling a `docs/benchmarks/local-<minor>/` bucket that's missing a data point for a version already recorded under a different Ruby (issue #76), or correcting a similar mixup if it recurs. Creates a scratch `git worktree` at the current branch tip (so it starts with today's `benchmark/`/`Rakefile`/`Gemfile`/gemspec), overlays just `lib/` + `ext/duckling/` from `<ref>` via `git checkout <ref> -- lib ext/duckling`, builds and runs `bundle exec rake benchmark:record` under `<ruby-version>` via `RBENV_VERSION=<ruby-version> rbenv exec`, copies the resulting JSON back into this checkout's `docs/benchmarks/local-<minor>/`, and removes the scratch worktree on exit. Does not regenerate `docs/benchmarks/README.md` itself (run multiple replays first, then regenerate once — see the script's own usage output for the one-liner). Requires the target Ruby version already installed via `rbenv`.
|
|
75
|
-
|
|
76
|
-
## Public and internal APIs
|
|
77
|
-
|
|
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.
|
|
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).
|
|
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.
|
|
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`).
|
|
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.
|
|
86
|
-
- **`Duckling::PanickingNativeFake`** — test-only mock for testing panic propagation. Not part of the public API; never use in production code.
|
|
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
|
-
|
|
94
|
-
## Test guide
|
|
95
|
-
|
|
96
|
-
The test suite covers several distinct concerns:
|
|
97
|
-
|
|
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.
|
|
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.
|
|
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).
|
|
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.
|
|
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).
|
|
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).
|
|
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`).
|
|
108
|
-
- **The built and installed gems**: `test/gem/`, which the default `test` glob excludes because each suite needs a gem handed to it rather than this checkout's compiled extension. `packaged_gem_test.rb` reads gems out of `pkg/` (platform, `lib/duckling.rb` presence, per-ABI binary set, `file(1)` architecture, both bounds of `required_ruby_version`) with one explicitly named test per platform, skipping platforms it has no gem for — plus one test that `pkg/` isn't empty, so a moved build output can't leave everything skipping and the job green. `installed_gem_test.rb` requires and calls a gem that's actually installed, which is the only thing that finds an ABI mismatch: metadata checks all pass on a gem whose binary was built against a different Ruby. Neither takes any environment variables, and neither may run through rake or bundler — `Minitest::TestTask` puts `lib/` on the load path and the `Gemfile` declares `gemspec`, either of which shadows the installed gem with this checkout's source.
|
|
109
|
-
- **Fiber scheduler dispatch**: `test/falcon_fiber_blocking_test.rb` (Fiber::Scheduler integration, thread-per-call behavior), `test/thread_pool_dispatch_test.rb` (plain thread-pool callers skip per-call thread spawn).
|
|
110
|
-
- **Panic/error propagation and stderr behavior**: `test/native_panic_test.rb` (panic handling), `test/parse_error_stderr_test.rb` (stderr output verification).
|
|
111
|
-
- **Benchmark report generation**: `test/benchmark_report_test.rb` (report.rb logic, environment detection, docs generation).
|
|
112
|
-
|
|
113
|
-
## Rust/Magnus wiring
|
|
114
|
-
|
|
115
|
-
- **Rust crate location**: `ext/duckling/` (crate name `duckling` — same name as the wrapped `duckling` crates.io dependency; this is fine since Cargo namespaces a package's own compile target separately from its dependency names, and extension code only ever references the dependency via `duckling::parse(...)`, never `crate::`-vs-`duckling::` ambiguity).
|
|
116
|
-
- **The wrapped crate**: `wafer-inc/duckling`, published on crates.io as `duckling` (pure-Rust deps: regex, chrono, serde, serde_json, once_cell, smallvec — no bindgen/libclang required); see `ext/duckling/Cargo.toml` for the pinned version constraint. Its main entrypoint is `duckling::parse(text, locale, dims, context, options) -> Vec<Entity>`; in release builds it wraps the parse in `catch_unwind` and returns `vec![]` on panic.
|
|
117
|
-
- **`extconf.rb` wiring**: `rb_sys/mkmf`'s `create_rust_makefile` ties Cargo into the Ruby `mkmf` build:
|
|
118
|
-
```ruby
|
|
119
|
-
require "mkmf"
|
|
120
|
-
require "rb_sys/mkmf"
|
|
121
|
-
|
|
122
|
-
create_rust_makefile("duckling/duckling")
|
|
123
|
-
```
|
|
124
|
-
The `"duckling/duckling"` argument controls the output path: the compiled artifact lands at `lib/duckling/duckling.bundle` (macOS) / `lib/duckling/duckling.so` (Linux) — `lib/duckling.rb`'s fallback path. Cross-compiled fat gems instead get one binary per Ruby ABI under `lib/duckling/<major.minor>/`, which the loader prefers.
|
|
125
|
-
- **`Cargo.toml`**: `cdylib` crate type, depends on `magnus` (`"0.8"`, with `features = ["chrono"]` — this is what makes `chrono::DateTime<FixedOffset>`/`NaiveDateTime` implement magnus's `IntoValue`/`TryConvert` at all, so a parsed time entity's `:value` can be handed to Ruby as a real `Time` object instead of a formatted string), the wrapped `duckling` crate (`"0.4"`), `chrono` (also a direct dependency in its own right, for `FixedOffset`/`TimeZone` in `src/lib.rs`), and `rb-sys` (`default-features = false, features = ["stable-api-compiled-fallback"]` — avoids needing libclang/bindgen on the build machine; note this does *not* make one binary portable across Ruby minor versions, see "Build model" below); see `ext/duckling/Cargo.toml` for exact version constraints.
|
|
126
|
-
- **Do not use `magnus = "0.9"`** — despite what some early design docs assumed, 0.9 has never been published to crates.io (only 0.8.2 is released as of this writing); pinning `"0.9"` will fail to resolve. The 0.8.2 API creates symbols via `ruby.to_symbol("key")`, not the 0.9-only `ruby.sym("key")`. Everything else (scan_args, get_kwargs, function!, RHash::aset, Ruby::ary_new, hash_new, chrono FixedOffset IntoValue) is unchanged between 0.8.2 and 0.9. Before trusting a magnus API claim from design docs, spot-check it against the actual published source (`~/.cargo/registry/src/index.crates.io-*/magnus-0.8.2/`).
|
|
127
|
-
- **Build model**: ships as a `ruby` source gem plus precompiled `x86_64-linux`, `x86_64-darwin`, `arm64-darwin`, and `aarch64-linux` binary gems (the two darwin platforms are unversioned — RubyGems' darwin platform matching treats a `nil` OS version as a wildcard, so each installs on any Darwin major version including newer ones) — installers on those four platforms need no Rust toolchain. `Rakefile` uses `RbSys::ExtensionTask` (not plain `Rake::ExtensionTask`) with just `ext.lib_dir = "lib/duckling"` — no `ext.cross_compile`/`ext.cross_platform` assignment. `RbSys::ExtensionTask#init` (`rb_sys/extensiontask.rb`) already derives both from the `RUBY_TARGET` env var (`@cross_compile = ENV.key?("RUBY_TARGET")`, `@cross_platform = [ENV["RUBY_TARGET"]].compact`), which `rb-sys-dock` sets to exactly one platform per container before running `bundle exec rake native:$RUBY_TARGET gem` inside it — so plain `bundle exec rake` (no `RUBY_TARGET`) builds an ordinary single-platform native extension, and each cross-compile container only ever registers *its own* platform's cross task graph. The actual cross-compilation happens in `.github/workflows/cross-gem.yml` via `oxidize-rb/actions/cross-gem`, which wraps `rb-sys-dock`/`rake-compiler-dock` and runs the Cargo build inside `rbsys/<platform>` Docker containers (bundling their own Rust + osxcross — no host Rust toolchain needed for cross-compiling). Each precompiled gem is a **fat gem** carrying one binary per Ruby ABI (`lib/duckling/3.2/duckling.so`, `lib/duckling/3.3/…`, …), and `lib/duckling.rb` requires the running Ruby's directory, falling back to the plain `lib/duckling/duckling` path a source build or a local `rake compile` produces. The ABI list, and each platform's expected architecture and runner, live in `cross_targets.rb`; `cross-gem.yml` restates them because YAML can't require Ruby, and `test/duckling_ci_test.rb` fails when that restatement drifts.
|
|
128
|
-
- **Each Ruby ABI needs its own binary.** Despite the name, `rb-sys`'s `stable-api-compiled-fallback` resolves Ruby's internal object layout at *compile time*, against the headers of the Ruby it builds against; it does not dispatch on the layout at runtime. Run a binary on a different Ruby minor and it misreads objects: magnus's `Time::try_convert` (`magnus-0.8.2/src/time.rs:238-241`, which gates on `RTypedData::from_value`) rejects a genuine `Time` with the tautological-looking `TypeError: no implicit conversion of Time into Time`. The symptom is narrow enough to pass unnoticed — `require` succeeds, and every dimension that doesn't cross a Ruby object into Rust still parses, so only `reference_time:`/`reference_zone:` fail. Reading a gem's metadata cannot detect this; only loading the gem and calling it can, which is what the `smoke` job does.
|
|
129
|
-
- **Rubies newer than every built ABI take the source gem.** The rbsys images only carry toolchains up to a given Ruby (check `/usr/local/rake-compiler/config.yml` inside the image). `required_ruby_version` is therefore capped just below the next minor, so RubyGems skips the precompiled gem on a Ruby it has no binary for and resolves the source gem, which compiles a matching one. Uncapping it would install a gem that raises `LoadError` on require.
|
|
130
|
-
- **The ABI list grows at the top and shrinks at the bottom.** Each ABI adds a full binary — roughly 3 MB packed, 10 MB unpacked, with nothing shared between them — so the gem grows linearly: the four-ABI gems `cross-gem.yml` builds today run 11–12.5 MB apiece. Add a Ruby minor once the `rbsys` image carries it (that is the whole cost of supporting it: consumers on an unlisted Ruby otherwise fall back to the source gem and need a Rust toolchain at install time, which fails a stock Heroku build). Drop the oldest when it goes EOL, by raising `duckling.gemspec`'s `required_ruby_version` floor and removing it from `CrossTargets::RUBY_ABIS`. That keeps the window at three or four ABIs rather than letting it accumulate.
|
|
131
|
-
- **A one-binary-per-platform "thin" gem is not an option.** CRuby's C API is source-stable; its ABI is not stable across minor versions, which is why the per-ABI split exists at all and why every comparable native gem ships the same shape. An ABI mismatch does not break every call — primitives and values the extension *constructs* can survive one, and only inbound conversions like `Time` fail — so it is possible to build a boundary that appears portable across Rubies by passing only integers. Such a boundary is portable by accident, with no compile-time check holding it in place, and its next failure mode can be memory corruption rather than a clean `TypeError`. Keep one binary per ABI.
|
|
132
|
-
- **musl consumers get the glibc build**: `Gem::Platform#===` deliberately matches an unversioned `-linux` gem against a `-linux-musl` runtime (the `["musl#{@version}", ...]` clause in `rubygems/platform.rb`), so Alpine installs `x86_64-linux`/`aarch64-linux` and then fails at `require` time on the missing glibc — it does not fall back to the source gem. A `required_rubygems_version` floor does not change this; only publishing `-musl` platform gems does, and this repo does not. Until it does, an Alpine consumer's own fix is to take the source gem explicitly — `gem install --platform ruby duckling`, or `bundle config set force_ruby_platform true` — which needs a Rust toolchain in the image.
|
|
133
|
-
- **The `ext.cross_compiling` block in `Rakefile` is load-bearing** — it sets `required_ruby_version` on each native gem to the gemspec's own floor plus `CrossTargets::ABI_CEILING`. rake-compiler otherwise derives that field from the Ruby versions it cross-compiled against (`define_native_tasks` in `rake/extensiontask.rb`) and writes its own floor over the gemspec's. Both bounds matter and fail in opposite directions: too narrow and RubyGems refuses the precompiled gem and silently resolves the source gem (the toolchain-on-the-install-machine outcome these gems exist to avoid); too wide and it installs a gem carrying no binary for that Ruby. Verify with `ruby -e 'require "rubygems/package"; puts Gem::Package.new(ARGV[0]).spec.required_ruby_version' <gem>` — `test/gem/packaged_gem_test.rb` asserts both bounds on every build.
|
|
134
|
-
- **The `fix_local_pass_cargo_target` task in `Rakefile` is load-bearing** — without it, cross-compiling `aarch64-linux` alongside `x86_64-linux` fails. To reproduce locally, run `rake 'native_gem[aarch64-linux]'` with Docker running — much faster than a CI round trip.
|
|
135
|
-
- **The bug**: rake-compiler always compiles two things — the real cross build for `RUBY_TARGET`, plus an extra "local" build for the host Ruby (`BaseExtensionTask#define`'s ungated `define_compile_tasks(nil, ...)` call, keyed to `RUBY_PLATFORM`, not gated by `cross_compile`). `Gem::PackageTask` (RubyGems stdlib) lists the local build's plain output path as a prerequisite of the final `.gem`. Every `rbsys/<platform>` Docker image (including `rbsys/aarch64-linux` — see `oxidize-rb/rb-sys`'s `docker/Dockerfile.aarch64-linux`) sets `RUST_TARGET`/`CARGO_BUILD_TARGET` container-wide, so the local build inherits them too.
|
|
136
|
-
- **Why only `aarch64-linux` breaks**: the Ruby driving rake inside every one of these containers is an x86_64 Linux Ruby, so the local build's artifact is always named `duckling.so`. On `x86_64-linux` its target is already correct. On both darwin platforms the gem needs `duckling.bundle` and never lists `duckling.so`, so the local build isn't a packaging prerequisite and never runs at all. Only on `aarch64-linux` do the names collide: packaging waits on the local build, which compiles for aarch64 but links with the host's plain `gcc` (rake-compiler picked that `gcc` for the host platform) — a mismatch that fails to link (`error adding symbols: file in wrong format`) and takes the whole build down.
|
|
137
|
-
- **Two fixes that did not work** — don't repeat them: gating on `ext.no_native` (wrong method — that gates `define_native_tasks`, not the ungated `define_compile_tasks(nil, ...)` call actually responsible); mutating `ENV["RUST_TARGET"]`/`ENV["CARGO_BUILD_TARGET"]` inside `extconf.rb` (too late — `extconf.rb` runs in its own subprocess, and its `ENV` changes never reach the parent `rake` process that runs `make`).
|
|
138
|
-
- **The fix that works**: in the `Rakefile`, when `ENV["RUBY_TARGET"]` disagrees with `RUBY_PLATFORM`, add a prerequisite to the local build's Makefile task (`Rake::Task["tmp/#{RUBY_PLATFORM}/duckling/#{RUBY_VERSION}/Makefile"].enhance`) that sets `ENV["CARGO_BUILD_TARGET"]` to the genuine host triple (from `rustc -vV`'s `host:` line) and clears `RUST_TARGET`. This runs in the parent `rake` process itself, so both the Makefile generation and the later `make` call see it. It also changes what the *cross* build reads, and only task ordering (cross Makefile generated first, baking its own `--target` in) keeps that harmless — if that order ever inverted, the cross build would emit a correctly named binary for the host architecture, which `test/gem/packaged_gem_test.rb`'s `file(1)` check is what catches.
|
|
139
|
-
- **Adding more platforms**: an entry in `CrossTargets::PLATFORMS` (architecture + a runner label that can execute it), then three restatements in `cross-gem.yml` — the `cross_gems` build matrix, the `smoke` matrix, and the `smoke` matrix's `runner` pair. `test/duckling_ci_test.rb` fails when any of the three is missed. First check whether the new platform's `rbsys/` image sets the same `RUST_TARGET`/`CARGO_BUILD_TARGET` env vars — most Linux-family images likely do, and the same bug can recur.
|
|
140
|
-
- **GVL release + thread-per-call dispatch**: `Duckling::Native.parse` (the Magnus-defined singleton method) releases the GVL around the native `duckling::parse` call via the raw `rb_sys::rb_thread_call_without_gvl` FFI — Magnus 0.8.2 has no safe wrapper for this. Inputs/outputs crossing the off-GVL callback are carried in a `ParsePayload` struct holding only plain owned Rust data (no `magnus::Value`/`magnus::Error` — see the GC-safety gotcha below), and the callback wraps the native call in `std::panic::catch_unwind` unconditionally, since the wrapped `duckling` crate's own panic guard is compiled out under `#[cfg(debug_assertions)]` (i.e. absent from this repo's `dev`-profile local default). A caught panic surfaces to the caller as a rescuable `RuntimeError` (`ruby.exception_runtime_error()`, not magnus's own `Error::from_panic` convention of the unrescuable `fatal`), with the original panic message preserved. A bare GVL release is *not* sufficient to unblock an `Async::Reactor`-scheduled Fiber on its own (Ruby 3.4's `Fiber::Scheduler#blocking_operation_wait` needs a flag `rb_thread_call_without_gvl` never sets) — `Duckling.parse` (`lib/duckling.rb`) additionally spawns a real background `Thread` per call *when a `Fiber.scheduler` is installed on the calling thread*, which is what actually lets the calling Fiber yield via `Thread#value`'s scheduler hooks; a plain thread pool caller (Puma/Sidekiq-style, no Fiber scheduler) already gets its concurrency from `Native.parse`'s GVL release alone, so `Duckling.parse` skips the thread spawn entirely for those callers, and the spawned thread (when it does run) disables `report_on_exception` so a rescued error doesn't also print a thread-termination backtrace to stderr. See the wiki's `research-async-reactor-blocking` for the full research trail.
|
|
141
|
-
- **Known gotchas**:
|
|
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.
|
|
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.
|
|
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.
|
|
145
|
-
- Cross-compiling locally (`rake 'native_gem[<platform>]'`) requires Docker.
|
|
146
|
-
|
|
147
|
-
## Gem release conventions
|
|
148
|
-
|
|
149
|
-
- **Versioning**: SemVer (`MAJOR.MINOR.PATCH`). Single source of truth: `Duckling::VERSION` in `lib/duckling/version.rb`.
|
|
150
|
-
- **Release process**: tag-triggered CI pipeline. `rake release` narrows the stock Bundler `release` task to tagging only (`release:guard_clean` + `release:source_control_push`) — building/publishing the `.gem` is CI's job, not the Rakefile's. Bump `Duckling::VERSION` in a PR, merge it to `main`, then run `rake release` (or push a matching `vX.Y.Z` git tag directly) for that merged commit to trigger the pipeline.
|
|
151
|
-
- **Pipeline steps** (on tag push):
|
|
152
|
-
1. CI gates (must be green before proceeding).
|
|
153
|
-
2. Cross-compile `x86_64-linux`, `x86_64-darwin`, `arm64-darwin`, and `aarch64-linux` fat gems via Docker containers, then install and call each one on a real runner per Ruby ABI (`cross-gem.yml`'s `smoke` job). Both must be green before publishing.
|
|
154
|
-
3. Verify the pushed tag matches `Duckling::VERSION` exactly; fail the build on mismatch.
|
|
155
|
-
4. Build the `ruby` source gem.
|
|
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).
|
|
157
|
-
6. Create a GitHub release with all of those gems attached.
|
|
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.
|
|
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.
|
|
160
|
-
- **Tag protection**: `v*.*.*` tags can only be created/updated by repo admins. Configured via `.github/scripts/apply-tag-ruleset.sh`.
|
|
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.
|
|
162
|
-
|
|
163
|
-
## `bin/` scripts (dev-workflow tooling; the gem ships none of them)
|
|
164
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
173
|
-
- `bin/check-worktree` — PreToolUse hook that blocks `Edit`/`Write` when on the `main` branch, steering you toward `bin/worktree add <branch>` instead.
|
|
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.
|
|
175
|
-
|
|
176
|
-
## Code comment conventions
|
|
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
|
-
|
|
185
|
-
Comments (in Ruby, Rust, and this file) are long-lived documentation, not a
|
|
186
|
-
transcript of the PR or session that wrote them. Prefer explaining the
|
|
187
|
-
durable *why* — the invariant, constraint, or measured behavior a future
|
|
188
|
-
reader needs — over narrating "issue #N added this" or "as of PR #M". If the
|
|
189
|
-
history genuinely matters (an empirical result, a design tradeoff explored
|
|
190
|
-
and rejected), link to its permanent home on the wiki rather than a bare
|
|
191
|
-
issue number, which reads as noise once the issue is closed and gives a
|
|
192
|
-
future reader nothing to follow.
|
|
193
|
-
|
|
194
|
-
## Keeping this file current
|
|
195
|
-
|
|
196
|
-
This file is manually maintained — there is no auto-generation. When you land
|
|
197
|
-
a PR that changes any of the following, **propose an update to AGENTS.md as
|
|
198
|
-
part of that PR** (don't leave it for someone else):
|
|
199
|
-
|
|
200
|
-
- Directory layout (new top-level dirs, moved files)
|
|
201
|
-
- Build/test commands (`bin/test`, `bin/lint`, `Rakefile` tasks)
|
|
202
|
-
- The Rust/Magnus wiring (`Cargo.toml`, `extconf.rb`, CI Rust toolchain setup, cross-compilation config) — keep the "Rust/Magnus wiring" section in sync with the actual, verified file contents
|
|
203
|
-
- The release process (`Rakefile` `release` task, `.github/workflows/release.yml`, `.github/workflows/cross-gem.yml`, `.github/workflows/benchmark.yml`) — keep "Gem release conventions" above in sync with the actual, verified workflow behavior
|
|
204
|
-
- Version numbers for tools/crates/gems — these belong in their own config files (`duckling.gemspec`, `ext/duckling/Cargo.toml`/`Cargo.lock`, `.standard.yml`, `hk.pkl`, CI workflow matrices), not here. If you need to reference a version, point to the file/field that holds it rather than copying the number, so this doc can't go stale when Dependabot or a manual bump changes it.
|
|
205
|
-
|
|
206
|
-
If you're an agent and notice this file is out of date with what you just
|
|
207
|
-
observed in the repo, fix it in the same PR rather than working around the
|
|
208
|
-
discrepancy silently.
|
data/CLAUDE.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
AGENTS.md
|