Skip to content

Commit fa121dd

Browse files
committed
Close 1.0 readiness gaps: download path test, MF2 gap docs, Format.Options public, locale-id path guard
1 parent 8c4235d commit fa121dd

12 files changed

Lines changed: 295 additions & 21 deletions

File tree

.github/workflows/upload-locales.yml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ on:
88

99
env:
1010
MIX_ENV: dev
11+
# Toolchain pins — also part of the dependency cache key, since
12+
# cached BEAMs are only valid for the OTP/Elixir that built them.
13+
ELIXIR_VERSION: "1.19"
14+
OTP_VERSION: "28"
1115
R2_BUCKET: content
1216
# Prefix within the bucket where locale ETF files are stored.
1317
R2_PREFIX: locales
@@ -97,8 +101,8 @@ jobs:
97101
if: steps.check_existing.outputs.skip != 'true'
98102
uses: erlef/setup-beam@v1
99103
with:
100-
elixir-version: "1.19"
101-
otp-version: "28"
104+
elixir-version: ${{ env.ELIXIR_VERSION }}
105+
otp-version: ${{ env.OTP_VERSION }}
102106

103107
- name: Restore dependency cache
104108
if: steps.check_existing.outputs.skip != 'true'
@@ -107,9 +111,9 @@ jobs:
107111
path: |
108112
deps
109113
_build
110-
key: mix-locales-${{ hashFiles('mix.lock') }}
114+
key: mix-locales-otp${{ env.OTP_VERSION }}-elixir${{ env.ELIXIR_VERSION }}-${{ hashFiles('mix.lock') }}
111115
restore-keys: |
112-
mix-locales-
116+
mix-locales-otp${{ env.OTP_VERSION }}-elixir${{ env.ELIXIR_VERSION }}-
113117
114118
- name: Install dependencies
115119
if: steps.check_existing.outputs.skip != 'true'

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66

77
## [Unreleased]
88

9+
### Added
10+
11+
* The locale download base URL can be overridden with `config :localize, locale_base_url: "..."` for deployments mirroring the locale files; downloads are verified against the bundled hash manifest regardless of source.
12+
13+
### Changed
14+
15+
* `Localize.Number.Format.Options` is documented again: `validate_options/2` is the supported way to pre-resolve formatting options for hot loops (as the Performance guide describes), so the module is part of the public API.
16+
17+
* The TR35 conformance guide documents the MessageFormat 2 working-group suite exclusions precisely: unknown-function fallback, duplicate declaration/option validation, select and digit-size option validation, `u:dir`/`u:id` expression options, and the `:isolate` bidi strategy deviation.
18+
919
### Fixed
1020

1121
* Calendar- and provider-module probes in date/datetime formatting (era year, day of era, ISO week and day of year, day of week, CLDR calendar type) and `Localize.Locale.Provider.allow_download?/1` ensure the module is loaded before `function_exported?/3`, so a cold module no longer silently takes the gregorian or default fallback branch.
1222

23+
### Security
24+
25+
* `Localize.Locale.Provider.locale_file_name/1` rejects locale identifiers that do not have the shape of a CLDR locale id, as defense in depth for the cache-path and download-URL interpolations.
26+
1327
## [0.49.0] — July 12th, 2026
1428

1529
### Fixed

guides/conformance.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -481,15 +481,27 @@ Two areas are explicitly out of scope:
481481
| Feature | Status | Notes |
482482
|---------|--------|-------|
483483
| Syntax errors | Implemented | Parser returns `{:error, reason}`. |
484-
| Resolution errors (unknown function, unresolved variable) | Partial | Unknown functions fall back to string conversion rather than error. |
485-
| Data model errors | Partial | Duplicate declarations and options not explicitly validated. |
484+
| Resolution errors (unknown function, unresolved variable) | Partial | Unresolved variables error per spec; unknown functions fall back to formatting the operand rather than producing an unknown-function error. |
485+
| Data model errors | Partial | Duplicate declarations and duplicate option names are not validated. A declaration option referencing a not-yet-declared variable errors per spec. |
486486

487487
### MF2 Data Model
488488

489489
| Feature | Status | Notes |
490490
|---------|--------|-------|
491491
| JSON interchange format | Implemented | `Localize.Message.JSON.to_json/2` and `from_json/1` for round-trip serialization to the TR35 §8 data model. |
492-
| Bidirectional text handling | Implemented | `:bidi` option (`:none`, `:isolate`, `:auto`) wraps placeholder output in Unicode isolate characters (FSI/PDI). Supports `u:dir` attribute for per-expression overrides. |
492+
| Bidirectional text handling | Partial | `:bidi` option (`:none`, `:isolate`, `:auto`) wraps placeholder output in Unicode isolate characters (FSI/PDI), and the `@u:dir` expression *attribute* overrides direction per expression. The `u:dir` / `u:id` expression *options* are not implemented, and the `:isolate` strategy isolates every placeholder where the WG default strategy leaves known-LTR placeholders unisolated. |
493+
494+
### MF2 known conformance gaps
495+
496+
The MessageFormat working group conformance suite runs against both the parser and the formatter (`test/localize/message/formatter_conformance_test.exs`); the cases the implementation cannot yet satisfy are excluded there, each with a documented reason. Beyond the Partial rows above, the excluded cases are:
497+
498+
* Unknown functions fall back to formatting the operand instead of producing an unknown-function error, and the WG `:test:select` / `:test:format` registry functions used by some suite cases are not implemented.
499+
500+
* Declarations bind the formatted string, so re-annotating an already-annotated variable in a later declaration fails.
501+
502+
* Select option validation (literal-only restrictions and operand restrictions on `:integer` / `:number` selection) is not implemented, number-literal operand validation (leading zero or plus sign) is not enforced, and invalid digit size option values are ignored instead of producing a bad-option error.
503+
504+
* `:currency` is not rejected as a selector (no bad-selector error), the `:offset` function's `signDisplay` option is not implemented, and variant keys are not NFC-normalized so duplicate variants differing only in normalization are not detected.
493505

494506
---
495507

lib/localize/collation.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ defmodule Localize.Collation do
7171
7272
* `backend` - `:nif` or `:elixir`. The default is `:elixir`.
7373
74+
## Return value convention
75+
76+
Unlike the rest of Localize, the functions in this module return bare values rather than `{:ok, result}` tuples: `compare/3` returns `:lt`, `:eq` or `:gt` (the shape `Enum.sort/2` and friends expect for a comparator), `sort/2` returns the sorted list, and `sort_key/2` returns a binary. This deviation is deliberate — collation functions are designed to be passed directly to `Enum` and used in hot paths, where a wrapping tuple would defeat their purpose. Consistent with that design, unrecognised option values fall back to their defaults rather than producing an error.
77+
7478
"""
7579

7680
alias Localize.Collation.{

lib/localize/locale/provider.ex

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ defmodule Localize.Locale.Provider do
3737
@locale_hashes_key {:localize, :locale_hashes}
3838
@locale_hashes_warned_key {:localize, :locale_hashes_warned}
3939

40+
@default_locale_base_url "https://elixir-localize.com/locales"
41+
4042
# ── Behaviour callbacks ────────────────────────────────────────
4143

4244
@doc """
@@ -543,6 +545,10 @@ defmodule Localize.Locale.Provider do
543545
@doc """
544546
Returns the base URL from which locale data files are downloaded.
545547
548+
The default is the Localize CDN. It can be overridden with the `:locale_base_url` application configuration key, for deployments that mirror the locale files on their own infrastructure (downloads are still verified against the bundled SHA-256 hash manifest regardless of the source):
549+
550+
config :localize, locale_base_url: "https://mirror.example.com/locales"
551+
546552
### Returns
547553
548554
* A string URL.
@@ -555,7 +561,7 @@ defmodule Localize.Locale.Provider do
555561
"""
556562
@spec base_url() :: String.t()
557563
def base_url do
558-
"https://elixir-localize.com/locales"
564+
Application.get_env(:localize, :locale_base_url, @default_locale_base_url)
559565
end
560566

561567
@doc """
@@ -569,6 +575,8 @@ defmodule Localize.Locale.Provider do
569575
570576
* A string file name of the form `"{locale_id}.etf"`.
571577
578+
Raises `ArgumentError` if the locale identifier does not have the shape of a CLDR locale id (letters, digits, `-` and `_` only). Locale ids reaching this function have already been validated, so this is a defense-in-depth guard: the file name is interpolated into cache paths and download URLs, and a tampered identifier must not be able to traverse outside them.
579+
572580
### Examples
573581
574582
iex> Localize.Locale.Provider.locale_file_name(:en)
@@ -577,7 +585,14 @@ defmodule Localize.Locale.Provider do
577585
"""
578586
@spec locale_file_name(locale_id()) :: String.t()
579587
def locale_file_name(locale_id) when is_atom(locale_id) do
580-
"#{locale_id}.etf"
588+
name = Atom.to_string(locale_id)
589+
590+
if String.match?(name, ~r/^[A-Za-z0-9_-]+$/) do
591+
name <> ".etf"
592+
else
593+
raise ArgumentError,
594+
"locale identifier #{inspect(locale_id)} is not a valid locale file name component"
595+
end
581596
end
582597

583598
@doc """

lib/localize/number/format/options.ex

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
defmodule Localize.Number.Format.Options do
2-
@moduledoc false
2+
@moduledoc """
3+
Validation and resolution of number formatting options.
34
4-
# Options validation and transformation for number formatting.
5-
# This is an internal module that validates user-supplied
6-
# options and produces a normalized options struct for the
7-
# formatter pipeline.
5+
`validate_options/2` resolves a keyword list of the options accepted by `Localize.Number.to_string/2` into a `t:Localize.Number.Format.Options.t/0` struct — locale validation, number system resolution, format pattern lookup, currency data loading and symbol resolution all happen once. Passing the resulting struct to `Localize.Number.to_string/2` bypasses that resolution on every call, which is significantly faster when formatting many numbers with the same locale and format. See the [Performance and optimization](number_formatting.html#performance-and-optimization) section of the Number Formatting guide for benchmarks.
6+
7+
"""
88

99
alias Localize.Number.{Format, Symbol, System}
1010

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Localize 1.0 Readiness — Fresh Assessment
2+
3+
Date: July 15, 2026. Baseline: v0.49.0 + unreleased (commit 8c4235d7), Elixir 1.20.1-otp-29 / Erlang 29.0.1. Method: full quality-gate run, `mix hex.build` package inspection, and four verification passes (API freeze, API hygiene/security, documentation, test wiring) re-checking every item in the July 4 plan (`RELEASE_1.0_READINESS.md`) against current source.
4+
5+
## Executive summary
6+
7+
The July 4 plan is almost entirely executed. Milestones 1–3 are done and verified: all six TR35 nonconformances (C1–C6) fixed, all consumer-breaking packaging bugs (B1/B2) fixed and verified in the built hex tarball, security items S1–S6 resolved, the API-freeze renames (B3/B4) shipped in 0.43.0 with deprecated delegates, and the hiding pass (B5) applied. All five quality gates pass clean and coverage is 91.23% (gated at Mix's default 90% threshold on the CI lint row). What remains before an RC is one API decision, four visibility-deviation confirmations, one genuine test gap (the download happy path), a handful of doc nits, and the `localize_mcp` release plumbing.
8+
9+
## Quality gates (July 15, Elixir 1.20.1-otp-29)
10+
11+
| Gate | Result |
12+
|---|---|
13+
| `mix format --check-formatted` | ✅ clean |
14+
| `mix compile --warnings-as-errors` | ✅ clean |
15+
| `mix test --cover` | ✅ 29,069 passed (945 doctests, 17 properties), 0 failures, 2 excluded; coverage 91.23% |
16+
| `mix dialyzer` | ✅ Total errors: 0 |
17+
| `MIX_ENV=release mix docs` | ✅ no localize-owned doc warnings (deps emit their own; see nits) |
18+
19+
`mix hex.build`: 264 files; `c_src/Makefile`, `c_src/env.mk`, `c_src/localize_nif.cpp` present (B1 verified at the artifact level), no stray `.o` files, `priv/localize/locale_hashes.etf` integrity manifest bundled (S1 verified), Elixir floor `~> 1.17` retained.
20+
21+
## Verified done since the July 4 baseline
22+
23+
* **C1–C6 conformance fixes** — shipped across 0.42.0–0.44.0 (compact plurals + operands, BCE era years, significant digits, day periods, locale week configuration, Annex C alias disambiguation).
24+
25+
* **B1 NIF packaging** — fixed and verified in the tarball. **B2 json_polyfill** — resolved by policy: `only: [:dev, :test]` stays until the announced OTP 26 drop on December 31, 2026.
26+
27+
* **B3 option unification**`Unit.to_string` and `Duration.to_string` both take `:format`; `:style` is a documented deprecated alias on Duration ("removed by Localize 1.0"). `Unit.display_name` and `Localize.quote/2` settled on the naming-vs-formatting split (0.43.0).
28+
29+
* **B4 known_/available_ semantics** — implemented in 0.43.0: `known_*` = locale-independent universe, `supported_*` = configuration, `*_for` = localized inventories. Deprecated delegates in place for every renamed function (removal window "no later than December 2026"). `Territory.known_styles/0` and public `Territory.known_territories/0` exist.
30+
31+
* **B5 hiding pass** — all `Localize.Utils.*`, `DataLoader`, `Substitution`, `FormatCache`, `Locale.Loader`, `LanguageTag.Parser`, `Message.Interpreter`, `LocaleDefaults`, `SupplementalData`, `Validity` are `@moduledoc false`; the keep-public list is public; `guides/conformance.md` no longer references hidden modules; the ex_doc group map covers everything except the bare `Localize` module (conventional).
32+
33+
* **Error-shape policy** — Language/Script `display_name` return `{:error, %InvalidValueError{}}` on bad options; `Localize.quote/2` errors on unknown format; all 5 missing bang variants exist; `Localize.validate_currency/1` delegate exists (all 0.43.0).
34+
35+
* **Security** — S1 hash manifest (0.44.0, fail-closed with `LocaleIntegrityError`); S2 corrupt cache decode → miss/stale at both sites; S3 atomic write-temp-then-rename; S4 `autoredirect: false`; S5 `existing_atom` for `:usage`; S6 resolved via Utils modules going internal. NIF-backend locale validation closed (0.43.0).
36+
37+
* **Docs** — all four planned guides exist (plural_rules, list_formatting, locale_validation, display_names); README feature list complete, install snippet hex-correct, all guide links hexdocs; CHANGELOG fully Keep-a-Changelog; `to_range_string/3` and `!/3` documented; doctests wired for 5 of the 6 flagged modules; conformance.md day-period rows accurate; collation known-failures comment refreshed to zero-threshold reality; `usage-rules.md` spot-checked accurate.
38+
39+
* **Test wiring** — RBNF reference data exercised for en/de/es/fr; MF2 working-group suite runs against the formatter (with documented exclusion groups); likely-subtags FAIL rows asserted with zero skips; every exception module's `message/1` exercised, multi-reason exceptions enumerated per reason atom (gettext msgid-drift guard); coverage ignore-list guarded against orphaned entries; OTP 26 CI rows present pending the December drop.
40+
41+
* **Credo strict** — zero findings, enforced in CI (0.45.0).
42+
43+
## Remaining before RC
44+
45+
### A. API decisions (freeze at 1.0)
46+
47+
1. **`Currency.currencies_for_locale/3` and `!/3` still take positional `only \\ :all, except \\ nil` filter args** (`lib/localize/currency.ex:639`, `:989`). The plan called for an options keyword list. This is the last remaining breaking-shaped API decision; decide (convert with deprecated positional heads, or explicitly accept the positional form) before RC.
48+
49+
2. **Four deviations from the frozen B5 visibility list** — RESOLVED July 15. Three confirmed correct: `List.Pattern` public (its struct is documented API in `Localize.List`), `Number.Format.Meta` public (its type appears in the public `Format.Compiler` specs), `Utils.Code` hidden (nothing public references it). One was wrong: `Number.Format.Options` was hidden while `validate_options/2` is a documented performance idiom in the Performance guide, the number cheatsheet and usage-rules.md — its moduledoc is restored and its doctest wired.
50+
51+
3. **Deprecated-delegate removal timing** — Duration `:style` says "removed by Localize 1.0"; the B4 delegates say "no later than December 2026". If 1.0 ships before December, decide whether 1.0 removes all of them in one breaking sweep (cleanest) or carries them to a 1.1.
52+
53+
### B. Test gaps
54+
55+
4. **`Provider.download_locale/1` happy path has no end-to-end test.** — DONE July 15: `test/localize/locale/download_test.exs` drives HTTP 200 → integrity verify → cache write → read-back against a local `:httpd` server, plus 404, tampered-content and missing-manifest-entry failure paths. Required a `:locale_base_url` config seam in `Provider.base_url/0` (also useful for self-hosted mirrors).
56+
57+
5. **MF2 formatter conformance exclusion groups** — DOCUMENTED July 15: `guides/conformance.md` Part 9 now lists every exclusion group precisely (Error Handling and Data Model rows corrected, new "MF2 known conformance gaps" section; bidi row downgraded to Partial since `u:dir`/`u:id` expression *options* are unimplemented — only the `@u:dir` attribute form works). Closing the gaps themselves remains an explicit 1.0 scope decision.
58+
59+
6. **Favor-region column parsed but never asserted** in `likely_subtags_test.exs` (`_remove_region` in every comprehension); the feature is unimplemented. Either implement + assert, or drop the parse and note it as out of scope.
60+
61+
### C. Documentation nits
62+
63+
7. **`Message.JSON.from_json/1` docstring example is wrong** — DONE July 15. The example had in fact already been corrected; only the doctest was still unwired and the test comment stale. `doctest Localize.Message.JSON` is now enabled and passing.
64+
65+
8. **Collation's bare-return deviation** — DONE July 15: the `Localize.Collation` moduledoc now has a "Return value convention" section explaining the bare returns and that unrecognised option values fall back to defaults (verified: they do not raise).
66+
67+
9. **README has no MCP section.** The skill is documented; `localize_mcp` (complete, 52 tests passing, 11 tools) is unpublished and unmentioned. Once it's on hex, add a section beside the skill one.
68+
69+
### D. Mechanical nits
70+
71+
10. ~~`mix.exs` deprecated `xref: [exclude: ...]` shape~~ — WITHDRAWN July 15: the warning in the gates log came from compiling the `earmark_parser` dep in the docs stage; localize's own mix.exs has no `xref` key and a fresh release-env compile is clean.
72+
73+
11. Two always-true type warnings in `test/localize/unit/data_test.exs` — DONE July 15 (`refute Enum.empty?(...)`).
74+
75+
12. `.github/workflows/upload-locales.yml` cache key — DONE July 15: OTP/Elixir pins moved to workflow `env` and included in the cache key and restore-keys.
76+
77+
13. S7 locale-id shape validation — DONE July 15: `Provider.locale_file_name/1` (the single choke point for both cache paths and download URLs) raises `ArgumentError` unless the id matches `^[A-Za-z0-9_-]+$`, with tests for traversal-shaped atoms.
78+
79+
## Companion deliverables
80+
81+
* **Claude Code skill** — shipped in 0.47.0, committed, README-documented, marketplace manifest in place. Done.
82+
83+
* **`localize_mcp`** — implemented (11 tools, 52 tests passing on 1.20.1-otp-29, version 0.1.0) but a single local "Initial commit": no GitHub remote, not published to hex. Remaining: create repo + push, release review per house checklist, publish, cross-link from README (item 9).
84+
85+
## Suggested order
86+
87+
Updated July 15: items 2, 4, 5 (documentation half), 7, 8, and 10–13 are done as annotated above. Remaining:
88+
89+
1. Item 1 (Currency positional args) and item 3 (delegate removal timing) — the two decisions that freeze.
90+
2. Scope decisions: close or accept the MF2 conformance gaps now documented in the guide (item 5), and implement-or-drop favor-region (item 6).
91+
3. Item 9 — `localize_mcp` release (repo, review, hex publish); README MCP section.
92+
4. Full house release review; 1.0.0-rc.1; soak; 1.0.0.
93+
94+
Nothing found in this pass contradicts the stability-period intent: items 1–3 are decisions plus small diffs, and everything else is additive tests/docs.

0 commit comments

Comments
 (0)