Skip to content

feat(spec): one TypeScript utility, seven native languages, with the generated code committed - #580

Closed
hyanmandian wants to merge 13 commits into
mainfrom
claude/single-impl-multi-lang-fftjdk
Closed

hyanmandian wants to merge 13 commits into
mainfrom
claude/single-impl-multi-lang-fftjdk

Conversation

@hyanmandian

@hyanmandian hyanmandian commented Sep 21, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

Answers, with running code, the question: can we implement each utility once and ship it in every language, without changing what any published package does today?

Yes. spec/bridge is a compiler. You write a utility once, in ordinary TypeScript, and it emits idiomatic native code for TypeScript, Python, Go, Rust, Ruby, Java and C#, plus a C ABI over the Rust crate. No bindings, no embedded VM, no FFI, and nothing hand-written per language.

The generated output is committed, so the central claim can be read rather than taken on trust.

Nothing here ships in the npm package. src/ is untouched — the build, the bundle and the public API are exactly what they were, which the tree-shaking report confirms: "No bundle size impact. All 155 exports are the same size as on the base branch."

source/is-valid-cnpj.ts ─┐
source/format-cnpj.ts   ─┼─> compiler/frontend.ts ─> IR ─┬─> out/typescript
source/…                ─┘        (oxc parser)           ├─> out/python
                                                         ├─> out/go
                                                         ├─> out/rust  (+ the C ABI)
                                                         ├─> out/ruby
                                                         ├─> out/java
                                                         └─> out/csharp

Four utilities, one per capability

Chosen so that between them they use everything that is hard about doing this at all:

utility the capability it proves
isValidCnpj compiled regular expressions, check digits, JavaScript's own type coercion
formatCnpj a helper shared with isValidCnpj, written once and pruned per utility
getAddressInfoByCep async, HTTP, JSON, retries, an error hierarchy, three providers raced
getMunicipalities 5,571 baked rows and a pt-BR collation order no two targets agree on

946 lines of source in, 53,794 lines of generated code out, across 66 files.

What is shared, and what is not

The utility's implementation, not the published package. Every ecosystem keeps writing its own DX by hand — its naming, its option objects, its types, its docs — over whichever core it gets.

That decides the shape of the output. Each source file holds exactly one utility and each target emits exactly one self-contained unit, because the JavaScript output has to stay tree shakeable and a C symbol table has to stay flat. src/ here is one directory per utility with the shared parts under _internals/, and source/ is laid out the same way:

  • inline splices the helpers a module imports into the module, so the compiler sees one file and every target emits one unit that references nothing else.
  • prune then drops whatever that splicing brought in and the module does not reach.

You can read the result directly: out/typescript/_bridge/format-cnpj.ts is 103 lines — the utility, the mask helper it shares, the two character classes it uses, and not one line of isValidCnpj's check digits. out/typescript/_bridge/is-valid-cnpj.ts is the mirror image.

The three things that would otherwise differ

  • Regular expressions. \s is 25 code points in JavaScript, 6 in Go and Ruby, a Unicode property in Python and Rust; ^/$ are line anchors in Ruby; Java and C# expand \uXXXX before the regex engine sees it. Patterns are compiled at build time into explicit code point ranges and a backtrack-free matcher every runtime implements identically. The compiler refuses any pattern whose consecutive classes overlap, which is what makes the greedy scan provably correct.
  • Collation. Go and Rust ship no collator, Ruby compares bytes, and Python, Java and C# each resolve their own — seven different orders. So the localeCompare(…, "pt-BR") order is resolved once at build time against the JavaScript package's own comparator and baked into the dataset.
  • JavaScript's boundary quirks. formatCnpj(12_345_678) coerces, { obfuscate: 1 } is truthy, getAddressInfoByCep("01310-100") strips the mask. These are the contract, not accidents, so they live in a small portable std each target implements natively.

What the author never writes

The source is straight-line, synchronous TypeScript. Everything below is the emitter's, and the committed output is where to check it:

TypeScript C# Go Rust Java Python Ruby
waiting async/Promise async/Task blocking blocking blocking blocking blocking
raising exception class exception class (T, error) Result<T, runtime::Error> exception class exception class exception class
racing promises Tasks goroutines + channel threads + mpsc virtual threads threads + queue threads + queue

Parity is measured, not asserted — and the harness is generated too

Adding a utility is two files: the utility, and a list of the arguments to replay. Nothing else changes. The expectation comes from calling the package this repository ships; the table's columns come from the compiled signature; the program that replays them in each of the eight targets is generated from the same signature. There is no per-utility driver to write and none to review.

bash spec/bridge/conformance/run-all.sh — every target against a recording of the shipped package:

target isValidCnpj formatCnpj getAddressInfoByCep getMunicipalities
typescript 1725 / 1725 4025 / 4025 110 / 110 34 / 34
python 1725 / 1725 4025 / 4025 109 / 109 (1 n/e) 34 / 34
ruby 1725 / 1725 4025 / 4025 109 / 109 (1 n/e) 34 / 34
go 1707 / 1707 (18 n/e) 3983 / 3983 (42 n/e) 103 / 103 (7 n/e) 34 / 34
rust 1707 / 1707 (18 n/e) 3983 / 3983 (42 n/e) 103 / 103 (7 n/e) 34 / 34
java 1707 / 1707 (18 n/e) 3983 / 3983 (42 n/e) 103 / 103 (7 n/e) 34 / 34
csharp 1707 / 1707 (18 n/e) 3983 / 3983 (42 n/e) 103 / 103 (7 n/e) 34 / 34
c abi 1707 / 1707 (18 n/e) 3983 / 3983 (42 n/e) — —

"n/e" is not expressible: a call a JavaScript caller can make that has no form in that language — a number where the signature says string, null where it says a list of names. Each row records which targets can be handed it, so nothing is skipped for being inconvenient. The C ABI carries only the two utilities whose shapes fit pointers and integers; it refuses the other two and says why in the generated file.

bash spec/bridge/conformance/verify-typescript.sh — the generated TypeScript dropped into src/, running the package's own suite unmodified:

Test Files  4 passed (4)
     Tests  157 passed | 3 skipped | 3 todo (164)

Including the type-level assertions: expectTypeOf(getMunicipalities).parameter(0) has to be StateCode | undefined, getAddressInfoByCep has to resolve to AddressInfo, and the four CEP error classes have to extend one another.

Reading the generated output

Shortest first. Seven copies of the baked municipality table are 85% of the committed bytes and none of the interest, so skip those until last:

file what it shows
out/typescript/_bridge/format-cnpj.ts 103 lines: one utility, its shared helper, nothing else
out/rust/include/*.h the C ABI a hand-written binding reads, sentinels spelled out
out/go/get_address_info_by_cep/ (T, error) threaded through every call site, goroutines and a channel
out/rust/src/get_address_info_by_cep.rs the same utility as Result<T, runtime::Error>, with an mpsc channel
out/csharp/GetAddressInfoByCepUtility.cs the same utility again, async all the way down, from source that never says async

out/ is reproducible: a clean run-all.sh leaves it byte for byte as committed. Build artifacts (target/, classes/, obj/, bin/) stay ignored, and the tree is excluded from the linter and formatter, because linting emitted code is linting the emitters at one remove.

The other route, measured

spec/BINDINGS-INVESTIGATION.md measures one compiled core plus a hand-written binding per ecosystem, with the binding written the way a package writes it rather than the way a script does: ~1 ns in C#, 27 ns in Python, ~60 ns in Go, Java and Ruby, against a validator body of 47 ns. In Python and Ruby that is 21× and 18× faster than generated source.

JavaScript cannot take a binary core — the package is tree shakeable and a wasm module is not — so npm gets generated source whatever the other ecosystems choose. That is why both surfaces come out of one compiler.

What the subset cannot express yet

A survey of the 140 utilities this package ships says four capabilities are missing, and each is a decision rather than a detail:

missing utilities it blocks why it is not a detail
dates 11, including isBusinessDay, getHolidays, addBusinessDays, getBoletoInfo a date type and an arithmetic per target, plus a calendar whose rules move
randomness 15 generate* utilities untestable by replay: a recorded expectation needs a seeded generator every target agrees on
floats and Intl ~8, including formatCurrency, clampPrecision, convertCurrencyToWords rounding and currency formatting differ per target; the IR is integers only for exactly this reason
Unicode normalize removeAccents, sanitizeToAscii NFD tables ship with the host and are not the same everywhere; this one wants a baked table

Everything else in the survey is reachable with what is here. The rest of the honest edges are in the README.

Checklist

  • My commit/PR title follows Conventional Commits.
  • I added or updated tests covering this change (npm test) — not applicable: no change to src/. The new code has its own suites (spec/bridge/conformance/run-all.sh, spec/bridge/conformance/verify-typescript.sh) and npm test still passes.
  • I updated the documentation if this adds/changes a utility — not applicable, no published utility added or changed.
  • npm run check passes locally (format, lint, types).
  • I ran npm run build:llms if I touched docs/utilities.md — not applicable, docs/ untouched.
  • This change does not introduce a breaking change.
  • This change does not add any runtime dependency.

Additional context

Changes outside spec/:

  • vite.config.ts gains a lint override for spec/bridge/**/*.ts and keeps spec/bridge/out out of both the formatter and the linter. spec/bridge is a compiler: its emitters are one wide switch over the IR per language and their product is source code, so a handful of rules that keep library code readable work against them. Each one is disabled with the reason inline. Same pattern as the existing scripts/** and spec/** overrides.
  • CONTRIBUTING.md gains one paragraph saying what spec/ is and that nothing under src/ depends on it.

Reproducing the bridge needs the toolchains it checks: node, python3, ruby, go, cargo, javac and dotnet. It does not run in CI today — worth wiring up only if the approach is adopted.

This PR previously proposed the four utilities as a stack of four follow-ups (#581–#584). They are closed and collapsed into this one.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UX1gTGeMTyoXQyr1qUoQKd

Adds `spec/`, a prototype of describing a utility once as language neutral
data and emitting it to every language Brazilian Utils publishes. Nothing
under `src/` changes and nothing here ships in the npm package.

`spec/EXPLORATION.md` is the write-up: what it proves, what it does not
cover, why transpiling TypeScript and a shared native core were rejected,
and the suggested order of work.

What is in the prototype:

- `spec/utilities/*.json` specify `isValidCpf`, `isValidPis` and `formatCpf`
  as pipelines over explicit charsets and check digit algorithms. A utility
  has named profiles, and each package declares which one it implements
  today, so generated code reproduces current behaviour instead of
  replacing it.
- `spec/codegen/interpret.ts` is the reference implementation; the six
  emitters under `spec/codegen/targets/` turn the same specs into
  idiomatic TypeScript, Python, Go, Ruby, Rust and Java.
- `spec/vectors/conformance.json` holds 743 seeded inputs with the answer
  every profile gives, and the conformance drivers check each language
  against it.

Results, all reproducible with `bash spec/conformance/run-all.sh`:

- the spec agrees with the package this repository ships on 11,928 checks,
  hostile inputs included;
- every generated target passes its vectors;
- the spec agrees with the published Python, Ruby, Go and Rust packages on
  743/743 corpus inputs each, with two documented deviations in Python.

Findings worth acting on, recorded in the specs and the write-up: the six
packages already give four different answers for a masked CPF; `brutils`
accepts a CPF written in Arabic-Indic digits because it gates on
`str.isdigit()`; PIS diverges the other way, with four packages accepting
`"00000000000"` and rejecting masks; and regex shorthands (`\d`, `\s`,
Ruby's line anchors, Java's pre-lexing `\uXXXX`) do not survive a port,
which is why the IR carries code points instead of regexes.

Config: `vite.config.ts` gains a lint override for `spec/**` (console
output, running other toolchains from PATH, asserting the shape of its own
JSON) and keeps the generated vectors out of the formatter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1gTGeMTyoXQyr1qUoQKd
Answers the follow up to the first exploration: could the logic live in one
binary core — Rust, C, wasm — with every package binding to it instead of
carrying generated source? `spec/BINDINGS-INVESTIGATION.md` is the write-up;
`spec/bench` is the harness behind every number in it.

Generated source wins. One validator, one corpus of 1000 inputs, the same
profile in every arm:

- generated source runs at 0.36x (Java), 0.43x (Go), 0.55x (Ruby), 0.56x
  (Python) and 0.66x (Node) of the idiomatic handwritten code it replaces;
- UniFFI, the tool most often recommended for this, costs 9064 ns per call in
  Python — 3.7x slower than simply implementing the validator in Python, and
  17x slower than the same shared library over hand written ctypes;
- the boundary, not the work, is the cost: 104 ns per call in Go over wazero,
  521 ns in Ruby, 472 ns over ctypes in Python, and 30059 ns over wasmtime-py,
  against a validator whose body takes about 100 ns;
- batching one call over 1000 inputs makes a shared core fast everywhere
  (0.12x), which is the one workload where it earns its keep;
- for the npm package a wasm core costs 9291 bytes against 509 for the
  generated `isValidCpf`, with no tree shaking and asynchronous startup.

Two emitter rules make the source level result hold, and both are in this
commit. Before them the Python and Ruby emitters walked strings code point by
code point and were 2.6x and 4.1x slower than handwritten:

- `guard-shape` plus `sanitize` collapse into one anchored regex with capture
  groups, with the character classes written out code point by code point so
  the semantics stay the spec's;
- check digits are read as bytes and the verification is unrolled.

Python went 6485 to 1366 ns/op and Ruby 15051 to 2020, with conformance
unchanged at 2229/2229 for both and the JavaScript differential still at
11928/11928.

The handwritten arms also disagree with the spec on the same corpus (Python
333, Ruby 329, Go 334 valid, against 337), each language's own `strip` being a
different set of whitespace. Correctness is the reason to generate; the
performance is what makes it affordable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1gTGeMTyoXQyr1qUoQKd
@vercel

vercel Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
brazilian-utils Error Error Sep 21, 2026 11:56am UTC

@coderabbitai

coderabbitai Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 9a322d42-e4bc-4c6d-ae9e-578d0ba4ce0d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (69e9b1f) to head (94b3264).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #580   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          183       183           
  Lines         2069      2069           
  Branches       612       612           
=========================================
  Hits          2069      2069           
Flag Coverage Δ
node 100.00% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

Copy link
Copy Markdown
Contributor

Tree-shaking report

✅ No bundle size impact. All 155 exports are the same size as on the base branch (full import 648.9 KB, gzip 166.2 KB).

All exports (155)
Export Base Head Δ gzip
⚪ GetAddressInfoByCepError 966 B 966 B 0 B 600 B
⚪ GetAddressInfoByCepNotFoundError 1.0 KB 1.0 KB 0 B 618 B
⚪ GetAddressInfoByCepServiceError 1.0 KB 1.0 KB 0 B 617 B
⚪ GetAddressInfoByCepValidationError 1.0 KB 1.0 KB 0 B 620 B
⚪ GetCepInfoByAddressError 966 B 966 B 0 B 600 B
⚪ GetCepInfoByAddressNotFoundError 1.0 KB 1.0 KB 0 B 618 B
⚪ GetCepInfoByAddressValidationError 1.0 KB 1.0 KB 0 B 620 B
⚪ addBusinessDays 6.8 KB 6.8 KB 0 B 2.8 KB
⚪ capitalize 2.5 KB 2.5 KB 0 B 1.3 KB
⚪ convertCurrencyToWords 2.8 KB 2.8 KB 0 B 1.5 KB
⚪ convertDateToWords 3.2 KB 3.2 KB 0 B 1.7 KB
⚪ convertLicensePlateToMercosul 1.3 KB 1.3 KB 0 B 807 B
⚪ convertNumberToWords 2.4 KB 2.4 KB 0 B 1.3 KB
⚪ differenceInBusinessDays 6.9 KB 6.9 KB 0 B 2.9 KB
⚪ formatBoleto 1.4 KB 1.4 KB 0 B 837 B
⚪ formatCEP 1.2 KB 1.2 KB 0 B 778 B
⚪ formatCNPJ 1.4 KB 1.4 KB 0 B 854 B
⚪ formatCPF 1.3 KB 1.3 KB 0 B 806 B
⚪ formatCaepf 1.3 KB 1.3 KB 0 B 787 B
⚪ formatCei 1.3 KB 1.3 KB 0 B 785 B
⚪ formatCep 1.2 KB 1.2 KB 0 B 778 B
⚪ formatCertidao 1.3 KB 1.3 KB 0 B 789 B
⚪ formatCnae 1.2 KB 1.2 KB 0 B 782 B
⚪ formatCnh 1.3 KB 1.3 KB 0 B 780 B
⚪ formatCno 1.3 KB 1.3 KB 0 B 786 B
⚪ formatCnpj 1.4 KB 1.4 KB 0 B 854 B
⚪ formatCns 1.3 KB 1.3 KB 0 B 780 B
⚪ formatCpf 1.3 KB 1.3 KB 0 B 806 B
⚪ formatCurrency 1.8 KB 1.8 KB 0 B 1.0 KB
⚪ formatIban 1.1 KB 1.1 KB 0 B 696 B
⚪ formatLegalNature 1.2 KB 1.2 KB 0 B 777 B
⚪ formatLicensePlate 1.2 KB 1.2 KB 0 B 738 B
⚪ formatNcm 1.2 KB 1.2 KB 0 B 780 B
⚪ formatNfeKey 1.3 KB 1.3 KB 0 B 784 B
⚪ formatPassport 1.0 KB 1.0 KB 0 B 643 B
⚪ formatPhone 2.8 KB 2.8 KB 0 B 1.5 KB
⚪ formatPis 1.3 KB 1.3 KB 0 B 781 B
⚪ formatProcessoJuridico 1.3 KB 1.3 KB 0 B 785 B
⚪ formatVoterId 1.3 KB 1.3 KB 0 B 821 B
⚪ generateBoleto 2.0 KB 2.0 KB 0 B 1.1 KB
⚪ generateCNPJ 1.6 KB 1.6 KB 0 B 965 B
⚪ generateCPF 1.4 KB 1.4 KB 0 B 878 B
⚪ generateCep 984 B 984 B 0 B 610 B
⚪ generateCnh 1.4 KB 1.4 KB 0 B 828 B
⚪ generateCnpj 1.6 KB 1.6 KB 0 B 965 B
⚪ generateCpf 1.4 KB 1.4 KB 0 B 878 B
⚪ generateLegalNature 5.9 KB 5.9 KB 0 B 2.1 KB
⚪ generateLicensePlate 1.1 KB 1.1 KB 0 B 692 B
⚪ generatePassport 1.1 KB 1.1 KB 0 B 656 B
⚪ generatePhone 1.5 KB 1.5 KB 0 B 900 B
⚪ generatePis 1.2 KB 1.2 KB 0 B 744 B
⚪ generatePixPayload 6.3 KB 6.3 KB 0 B 2.8 KB
⚪ generateProcessoJuridico 1.4 KB 1.4 KB 0 B 870 B
⚪ generateRenavam 1.2 KB 1.2 KB 0 B 760 B
⚪ generateVoterId 1.7 KB 1.7 KB 0 B 1021 B
⚪ getAddressInfoByCep 4.1 KB 4.1 KB 0 B 1.9 KB
⚪ getAreaCodeInfo 3.9 KB 3.9 KB 0 B 1.4 KB
⚪ getAreaCodesByState 1.6 KB 1.6 KB 0 B 917 B
⚪ getBankByCode 38.6 KB 38.6 KB 0 B 9.8 KB
⚪ getBankByIspb 38.6 KB 38.6 KB 0 B 9.8 KB
⚪ getBanks 38.4 KB 38.4 KB 0 B 9.6 KB
⚪ getBoletoInfo 3.1 KB 3.1 KB 0 B 1.6 KB
⚪ getCbo 119.1 KB 119.1 KB 0 B 30.7 KB
⚪ getCepInfoByAddress 2.7 KB 2.7 KB 0 B 1.4 KB
⚪ getCertidaoInfo 1.8 KB 1.8 KB 0 B 1.0 KB
⚪ getCfop 68.9 KB 68.9 KB 0 B 6.9 KB
⚪ getCities 154.3 KB 154.3 KB 0 B 49.9 KB
⚪ getCnae 93.9 KB 93.9 KB 0 B 21.2 KB
⚪ getFormatLicensePlate 1.1 KB 1.1 KB 0 B 692 B
⚪ getHolidays 6.1 KB 6.1 KB 0 B 2.6 KB
⚪ getIbanInfo 1.6 KB 1.6 KB 0 B 955 B
⚪ getLegalNature 6.3 KB 6.3 KB 0 B 2.3 KB
⚪ getLegalNatures 5.9 KB 5.9 KB 0 B 2.1 KB
⚪ getLegalNaturesByCategory 6.5 KB 6.5 KB 0 B 2.4 KB
⚪ getMunicipalities 156.4 KB 156.4 KB 0 B 50.3 KB
⚪ getMunicipality 154.9 KB 154.9 KB 0 B 50.3 KB
⚪ getMunicipalityByCode 156.5 KB 156.5 KB 0 B 50.4 KB
⚪ getNfeKeyInfo 2.7 KB 2.7 KB 0 B 1.5 KB
⚪ getPixKeyInfo 4.5 KB 4.5 KB 0 B 2.0 KB
⚪ getPixPayloadInfo 2.9 KB 2.9 KB 0 B 1.4 KB
⚪ getStateByIbgeCode 3.2 KB 3.2 KB 0 B 1.1 KB
⚪ getStateCodeByName 3.2 KB 3.2 KB 0 B 1.1 KB
⚪ getStateNameByCode 3.1 KB 3.1 KB 0 B 1.0 KB
⚪ getStates 3.0 KB 3.0 KB 0 B 1017 B
⚪ getTimezoneByState 1.6 KB 1.6 KB 0 B 809 B
⚪ isBusinessDay 6.5 KB 6.5 KB 0 B 2.7 KB
⚪ isHoliday 6.4 KB 6.4 KB 0 B 2.7 KB
⚪ isValidBankAccount 7.4 KB 7.4 KB 0 B 2.8 KB
⚪ isValidBoleto 2.4 KB 2.4 KB 0 B 1.3 KB
⚪ isValidCEP 984 B 984 B 0 B 610 B
⚪ isValidCNPJ 1.6 KB 1.6 KB 0 B 914 B
⚪ isValidCPF 1.3 KB 1.3 KB 0 B 805 B
⚪ isValidCaepf 1.5 KB 1.5 KB 0 B 913 B
⚪ isValidCbo 119.2 KB 119.2 KB 0 B 30.7 KB
⚪ isValidCei 1.5 KB 1.5 KB 0 B 899 B
⚪ isValidCep 984 B 984 B 0 B 610 B
⚪ isValidCertidao 1.6 KB 1.6 KB 0 B 938 B
⚪ isValidCfop 68.9 KB 68.9 KB 0 B 6.9 KB
⚪ isValidCnae 94.0 KB 94.0 KB 0 B 21.2 KB
⚪ isValidCnh 1.4 KB 1.4 KB 0 B 856 B
⚪ isValidCno 1.5 KB 1.5 KB 0 B 901 B
⚪ isValidCnpj 1.6 KB 1.6 KB 0 B 914 B
⚪ isValidCns 1.5 KB 1.5 KB 0 B 925 B
⚪ isValidCpf 1.3 KB 1.3 KB 0 B 805 B
⚪ isValidCreditCard 1.4 KB 1.4 KB 0 B 868 B
⚪ isValidCsosn 1.2 KB 1.2 KB 0 B 737 B
⚪ isValidCst 1.8 KB 1.8 KB 0 B 1.0 KB
⚪ isValidEmail 1.0 KB 1.0 KB 0 B 622 B
⚪ isValidIE 5.7 KB 5.7 KB 0 B 2.1 KB
⚪ isValidIban 1.3 KB 1.3 KB 0 B 836 B
⚪ isValidIe 5.7 KB 5.7 KB 0 B 2.1 KB
⚪ isValidLandlinePhone 1.5 KB 1.5 KB 0 B 933 B
⚪ isValidLegalNature 5.8 KB 5.8 KB 0 B 2.1 KB
⚪ isValidLicensePlate 1.1 KB 1.1 KB 0 B 702 B
⚪ isValidMobilePhone 1.6 KB 1.6 KB 0 B 971 B
⚪ isValidNcm 114.2 KB 114.2 KB 0 B 24.6 KB
⚪ isValidNfeKey 2.7 KB 2.7 KB 0 B 1.5 KB
⚪ isValidPIS 1.2 KB 1.2 KB 0 B 785 B
⚪ isValidPassport 1.0 KB 1.0 KB 0 B 654 B
⚪ isValidPhone 2.6 KB 2.6 KB 0 B 1.3 KB
⚪ isValidPis 1.2 KB 1.2 KB 0 B 785 B
⚪ isValidPixKey 4.6 KB 4.6 KB 0 B 2.1 KB
⚪ isValidPixPayload 2.9 KB 2.9 KB 0 B 1.5 KB
⚪ isValidProcessoJuridico 1.3 KB 1.3 KB 0 B 787 B
⚪ isValidRegistroProfissional 1.6 KB 1.6 KB 0 B 964 B
⚪ isValidRenavam 1.3 KB 1.3 KB 0 B 815 B
⚪ isValidServicePhone 1.5 KB 1.5 KB 0 B 846 B
⚪ isValidVin 1.6 KB 1.6 KB 0 B 995 B
⚪ isValidVoterId 1.6 KB 1.6 KB 0 B 900 B
⚪ parseBoleto 1020 B 1020 B 0 B 634 B
⚪ parseCaepf 1003 B 1003 B 0 B 621 B
⚪ parseCbo 1002 B 1002 B 0 B 620 B
⚪ parseCei 1003 B 1003 B 0 B 619 B
⚪ parseCep 1002 B 1002 B 0 B 620 B
⚪ parseCertidao 1003 B 1003 B 0 B 621 B
⚪ parseCfop 1002 B 1002 B 0 B 620 B
⚪ parseCnae 1002 B 1002 B 0 B 620 B
⚪ parseCnh 1003 B 1003 B 0 B 621 B
⚪ parseCno 1003 B 1003 B 0 B 619 B
⚪ parseCnpj 1.1 KB 1.1 KB 0 B 669 B
⚪ parseCns 1003 B 1003 B 0 B 621 B
⚪ parseCpf 1003 B 1003 B 0 B 621 B
⚪ parseCurrency 1.4 KB 1.4 KB 0 B 881 B
⚪ parseIban 1.0 KB 1.0 KB 0 B 638 B
⚪ parseLegalNature 1002 B 1002 B 0 B 620 B
⚪ parseLicensePlate 1.0 KB 1.0 KB 0 B 638 B
⚪ parseNcm 1002 B 1002 B 0 B 620 B
⚪ parseNfeKey 1.0 KB 1.0 KB 0 B 659 B
⚪ parsePassport 1.0 KB 1.0 KB 0 B 637 B
⚪ parsePhone 1.1 KB 1.1 KB 0 B 707 B
⚪ parsePis 1003 B 1003 B 0 B 621 B
⚪ parseProcessoJuridico 1003 B 1003 B 0 B 621 B
⚪ parseVoterId 1.0 KB 1.0 KB 0 B 650 B
⚪ removeAccents 953 B 953 B 0 B 593 B
⚪ subBusinessDays 6.9 KB 6.9 KB 0 B 2.9 KB
How this is measured

Every export is imported alone into an esbuild consumer bundle (minified, tree-shaken) built from the head and from the base of this pull request; the sizes are the resulting bundles, gzip is their gzipped size. 🔴 marks a regression: a pre-existing export that grew more than 20% and more than 256 B, or the bundle importing every pre-existing export growing more than 5%. 🟡 is growth under the threshold, 🟢 a decrease, ⚪ no change, 🆕 an export that does not exist on the base (never a regression), 🗑️ an export that was removed. An intentional increase is accepted with the tree-shaking: accepted label.

@hyanmandian hyanmandian changed the title docs(spec): one implementation, every language — exploration and benchmarks feat(spec): write a utility once, ship it in seven languages — working POC Sep 21, 2026
Adds `spec/bridge`, a compiler that takes a utility written once in a portable
subset of TypeScript and emits idiomatic native code for TypeScript, Python, Go,
Rust, Ruby, Java and C#. Nothing per language is written by hand: each target is
an emitter plus a small runtime, both of which are utility agnostic.

Covered so far: `isValidCnpj`, `formatCnpj` and `getMunicipalities`.

How the hard parts are pinned:

- Regular expressions are compiled at build time into explicit code point classes
  and a backtrack free matcher, so `\s` means the same 25 code points everywhere
  instead of 6 in Go and a Unicode property in Python.
- Locale collation is resolved at build time too: `data/build.ts` bakes the
  pt-BR order the JavaScript package returns into the dataset, because no two of
  the seven targets ship the same collator and four ship none at all.
- The boundary quirks the JavaScript package has (`String(value)` on a number, a
  truthy non boolean option) are a small portable std every target implements.

Parity is measured, not asserted:

- `conformance/verify-typescript.sh` drops the generated TypeScript into `src/`
  and runs the package's own vitest suites against it: 4 files, 157 tests pass,
  the `getMunicipalities` type level tests included.
- `conformance/run-all.sh` replays 5,730 CNPJ expectations and an 11,176 line
  municipality dump, both recorded from the shipped package, through all seven
  targets. All seven match; Go, Rust, Java and C# skip the 18 cases where a
  `string | number` union has no static form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1gTGeMTyoXQyr1qUoQKd
Adds `getAddressInfoByCep` to `spec/bridge`, which completes the POC: the four
utilities it now covers use, between them, every hard thing a Brazilian Utils
function does — regular expressions, a 5,571 row dataset with a pt-BR collation
order, HTTP with retries, JSON, an error hierarchy, and three providers raced
against each other.

The source stays straight-line, synchronous TypeScript. Three things are now the
emitter's job rather than the author's:

- `async` is inferred. The compiler works out which functions wait on the
  network and colours the call graph per target: TypeScript and C# get
  async/await and a Promise/Task return, while Go, Rust, Ruby, Java and Python
  stay blocking.
- Raising is translated. `throw new GetAddressInfoByCepNotFoundError(…)` becomes
  a real exception class in five targets, `(T, error)` threaded through every
  call site in Go, and `Result<T, runtime::Error>` in Rust. The declared error
  hierarchy survives in all seven.
- Concurrency is one primitive. `startAll` becomes promises, Tasks, goroutines
  with a channel, threads with an mpsc channel, virtual threads, or threads with
  a queue, depending on the target.

Parity, all recorded from the package this repository ships:

- The package's own vitest suites pass against the generated TypeScript:
  4 files, 157 tests, the type level assertions and the CEP error class
  hierarchy included.
- `conformance/run-all.sh` replays the CNPJ vectors, an 11,176 line municipality
  dump and 110 CEP scenarios through all seven targets. The CEP scenarios are
  served over real HTTP by the same table that recorded them, so Go, Rust, Java,
  C#, Ruby and Python exercise their own HTTP clients, JSON readers and
  schedulers rather than a stub.
- All seven match. The only skips are inputs with no form in a statically typed
  language — a `string | number` union, and a `providers: null` that Python and
  Ruby cannot tell from an absent one. Both are listed in the README.

`spec/bridge/README.md` documents the accepted subset, the three things that
would otherwise differ between hosts, and every known gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1gTGeMTyoXQyr1qUoQKd
…t uses

The bindings investigation concluded that generated source beat every binding.
That conclusion came from the wrong measurements: it compared generated source
against `ctypes`, Fiddle and a wasm runtime, and never against the mechanism
each ecosystem actually ships native code with. Those differ by three orders of
magnitude, so the conclusion was wrong for four of the seven ecosystems.

Measured properly, per call, against a validator body of 47 ns:

| ecosystem | best        | mechanism                     | boundary |
| --------- | ----------- | ----------------------------- | -------- |
| C#        | 76 ns       | P/Invoke, SuppressGCTransition| ~1 ns    |
| Python    | 65 ns       | CPython extension module      | 27 ns    |
| Ruby      | 109 ns      | C extension                   | 65 ns    |
| Java      | 127 ns      | Panama FFM, trivial downcall  | 62 ns    |
| Go        | 119 ns      | cgo                           | 58 ns    |

Against each language's own generated source that is 21× for Python, 18× for
Ruby, 3.9× for Java, and 4.3× against handwritten for C#, which had no arm at
all before. The old numbers stand for what they measured: ctypes is 433 ns of
boundary, Fiddle 1,479 ns, wasmtime-py 30 µs.

Three details decide which half a binding lands in, and the harness shows each:
a `static final` MethodHandle and a global arena in Java (84 ns otherwise), a
real extension module rather than an FFI shim in Python and Ruby, and
`[SuppressGCTransition]` in C# — the one runtime where the naive version is
already fine.

What does not change is JavaScript: the package is tree shakeable and a binary
core is not, so npm keeps generated source whatever the others do. Go keeps it
too, because cgo costs cross compilation, static binaries and CGO_ENABLED=0 for
a 74 ns gain.

So the answer is a hybrid, and `spec/bridge` now emits both halves from the one
source: the Rust target also produces `#[no_mangle] extern "C"` wrappers, a C
header, and a crate that builds as `cdylib` and `staticlib`. The CNPJ vectors
replay through that ABI from C as an eighth conformance arm — 5,782 of 5,782,
the same 18 skips as the other statically typed targets — which is the same
surface a CPython extension, a Ruby C extension, a NuGet package over P/Invoke
and a JAR over Panama would each call.

Shapes the ABI refuses rather than guesses at, and says so in its own output: a
function that raises needs an out parameter, one that waits on the network needs
a callback, one that answers a list needs an iterator. `getMunicipalities` and
`getAddressInfoByCep` stay generated-source only for now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1gTGeMTyoXQyr1qUoQKd
The JSON-per-utility prototype under `spec/codegen` stopped scaling at the third utility and
`spec/bridge` replaced it, so the prototype, the schema and the utility files it read, and the
conformance runners built on it are removed. `spec/bench` goes with it: the harness existed to
answer whether a binary core beats generated source, it answered, and the answer is written
down in `spec/BINDINGS-INVESTIGATION.md`. Both are one `git checkout` away in this branch's
history, which the report now says.

What is left under `spec/` is the engine and the research that shaped it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1gTGeMTyoXQyr1qUoQKd
@hyanmandian hyanmandian changed the title feat(spec): write a utility once, ship it in seven languages — working POC feat(spec): a compiler that turns one TypeScript utility into seven languages Sep 21, 2026
…anguages

`spec/bridge` reads a utility written once, in a documented subset of TypeScript, and emits
idiomatic native code for TypeScript, Python, Go, Rust, Ruby, Java and C#, plus a C ABI over
the Rust crate for the ecosystems that would rather bind to a shared core than carry
generated source.

Three things would otherwise differ between the targets, and each is resolved at build time
rather than left to the host: regular expressions are compiled into explicit code point
classes and a backtrack-free matcher, collation is resolved once against the JavaScript
package's own comparator and baked into the dataset, and JavaScript's boundary coercions live
in a small portable standard library every target implements natively. Three more are the
emitter's job rather than the author's: async colouring, raising, and the one concurrency
primitive the subset has.

A utility is one file exporting one function, the way `src/` is one directory per utility.
What two of them share goes under `source/_internals/`, and `compiler/link.ts` inlines those
helpers into whichever utility imports them and prunes whatever it does not reach, so every
target still emits one self-contained unit. That is what keeps the JavaScript output tree
shakeable and the C symbol table flat.

Parity is measured rather than asserted, and nothing about it is hand-written per utility: a
utility says which arguments to replay, the expectation comes from calling the package this
repository ships, and the program that replays it in each language is generated from the
compiled signature. A row also records which targets can be handed the call at all, so a
number where a signature says `string` is reported as not expressible rather than quietly
dropped.

The engine lands on its own. Each utility is a change of its own on top of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1gTGeMTyoXQyr1qUoQKd
The first utility through the compiler, and the one that exercises the parts a validator
needs: two compiled regular expressions, a check digit pass over both CNPJ versions, and the
`typeof` guard that makes a number invalid in JavaScript.

The CNPJ rules that `formatCnpj` will also need — the two shapes a CNPJ can be written in,
the weight vectors, the check digits — go under `source/_internals/`, written once. The
compiler inlines them into this utility and prunes the rest, so the emitted module carries
what it uses and nothing else.

The corpus is every string literal in the package's own CNPJ test files, plus generated
CNPJs, masked variants, one-digit mutations and the numbers a JavaScript caller can pass
where a string is declared, under all three option sets. The generator is seeded, so the
table is the same on every run.

    typescript  1725/1725      python  1725/1725      ruby  1725/1725
    go          1707/1707      rust    1707/1707      java  1707/1707
    csharp      1707/1707      cabi    1707/1707

The eighteen the compiled targets do not run are the numeric inputs: they declare the
parameter as a string, so `isValidCnpj(12345678000195)` is a question that cannot be asked
there. The row records that rather than dropping it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1gTGeMTyoXQyr1qUoQKd
The second utility, and the one that shows what sharing costs. It reads the same two
character classes `isValidCnpj` reads, out of the same `source/_internals/cnpj.ts`, and adds
a mask helper of its own under `source/_internals/mask.ts` — written once, for every
`format*` utility that comes after it.

What each target ends up with is still one self-contained module: the generated
`format-cnpj` carries the mask helper and the two classes it uses, and not a line of the
check digit code, because the compiler prunes what the utility cannot reach. That is the
property the npm package needs, and it is why the helpers are inlined rather than emitted as
a shared module per target.

Three options that interact — `pad`, `version`, `obfuscate` — over the same corpus, so all
seven combinations are replayed against every input.

    typescript  4025/4025      python  4025/4025      ruby  4025/4025
    go          3983/3983      rust    3983/3983      java  3983/3983
    csharp      3983/3983      cabi    3983/3983

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1gTGeMTyoXQyr1qUoQKd
The utility that decides whether the engine is real. It reaches the network, parses JSON,
retries a transient failure, races three providers and raises one of four errors depending
on how they fail — and none of that is written per target.

The source is straight-line and synchronous. The compiler works out that the call graph
waits on the network and colours it: TypeScript and C# come out `async`, with a `Promise`
and a `Task`; Go, Rust, Ruby, Java and Python stay blocking. `throw` becomes a real
exception class in five targets, `(T, error)` in Go and `Result<T, runtime::Error>` in Rust,
with the declared hierarchy intact in all seven. `startAll` becomes promises, `Task`s,
goroutines and a channel, threads and an `mpsc`, virtual threads, and threads with a queue.

The recorder carries both sides of the same scenario table: recording stubs `fetch` the way
the JavaScript suite does, and replaying serves the same answers over real HTTP, so each
target runs through its own client. Fourteen scenarios cover a provider that answers, one
that misses, one that is down, a body that is not an object, fields that are not strings, a
masked CEP, and the flags that disagree with the body.

    typescript  110/110      python  109/109      ruby  109/109
    go          103/103      rust    103/103      java  103/103      csharp  103/103

Python and Ruby cannot be handed `{ providers: null }`, because `None` and `nil` are also
what an omitted option looks like; the five compiled targets cannot be handed a numeric CEP
or a `providers` that is not a list at all. The C ABI does not carry this one: a function
that raises and waits needs more than pointers and integers, and it is refused rather than
guessed at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1gTGeMTyoXQyr1qUoQKd
The last of the four, and the one that is about data rather than logic: 5,571 municipalities
per state, in the order `localeCompare(…, "pt-BR")` puts them.

That order is the whole difficulty. Go and Rust ship no collator, Ruby compares bytes, and
Python, Java and C# each resolve their own ICU or libc table, so sorting at run time would
produce seven different answers. `data/get-municipalities.ts` resolves both orders once —
the per state one and the combined one — against the JavaScript package's own comparator,
and bakes them into the table every emitter materialises natively. Nothing sorts at run
time.

Every municipality of every key is replayed, not a sample, so one name out of place in one
target fails the check. The keys include an unknown state, a lower case one, the empty
string and three inherited `Object` property names, because each of those has an answer of
its own.

    typescript  34/34      python  34/34      ruby  34/34      go  34/34
    rust        34/34      java    34/34      csharp  34/34

The C ABI does not carry this one: a function that answers a list needs an iterator rather
than a buffer, and it is refused rather than guessed at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1gTGeMTyoXQyr1qUoQKd

Copy link
Copy Markdown
Member Author

The red Vercel status is not this PR's

Vercel has reported failure on every head this branch has had — a17cc47, 3780bd6, 403fb0d and now 4de5b16 — and the deployment fails within seconds of the push, before anything in the diff could matter. Two things say it is not ours:

There is no fix to port. The failure is in the Vercel project's own build, outside this repository's diff, and it predates this branch. I have no Vercel access from here, so I cannot re-run the deployment or read npx vercel inspect … --logs; whoever owns the hyan-mandians-projects/brazilian-utils project will need to look at it, and it is worth doing independently of this PR since main is red for it right now.

Everything this PR can be responsible for is on GitHub Actions, and that is what I am watching. Nothing in the diff touches src/, the build or the bundle: it adds spec/bridge/, a lint and formatter override in vite.config.ts, and one paragraph in CONTRIBUTING.md.


Generated by Claude Code

…e read

`spec/bridge/out` was ignored, which meant the central claim — that the compiler emits code a
Go or Rust maintainer would accept — could only be checked by running it. The output is now
committed: 64 files, seven targets and the C ABI, every one of them headed `DO NOT EDIT` and
rewritten by `node compiler/cli.ts`.

What the targets' own toolchains build from it stays ignored, since those are binaries and
caches that `conformance/run-all.sh` recreates: `target/`, `classes/`, `obj/`, `bin/`,
`__pycache__/`, the Rust lockfile and the compiled C driver.

The generated tree is excluded from the linter for the same reason it was already excluded
from the formatter: linting emitted code is linting the emitters at one remove, and the
emitters are linted directly.

`README.md` gains a short index of which files are worth opening, because seven copies of the
baked municipality table are 85% of the bytes and none of the interest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1gTGeMTyoXQyr1qUoQKd
An emitter joins its sections with a blank line between them, so a module that reaches none of
the constants, or none of the errors, came out with a run of up to five empty lines where they
would have been. Harmless to a compiler and distracting to a reader, which matters now that
the output is committed to be read.

`cli.ts` collapses any run of three or more newlines as it writes, and leaves `.json`, `.toml`
and `go.mod` alone. Every target spells a string literal with escapes rather than a real
newline, so a run of blank lines in an emitted file is always layout and never content.

195 blank lines removed across the 66 generated files, nothing added, and all seven targets
plus the C ABI still match the JavaScript package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1gTGeMTyoXQyr1qUoQKd
@hyanmandian hyanmandian changed the title feat(spec): a compiler that turns one TypeScript utility into seven languages feat(spec): one TypeScript utility, seven native languages, with the generated code committed Sep 21, 2026
hyanmandian pushed a commit that referenced this pull request Sep 21, 2026
A separate branch, pull request #580, explored the same problem with a
different answer: a compiler to seven targets whose output leans on a
per-language runtime shipped beside it. That branch is closing, and one part
of it is worth more than the code — the measurements that answer a question
this engine never addressed, which is why generate source at all instead of
shipping one binary core and binding to it.

Two findings decide it, and only one is about speed. A tree-shakeable package
cannot take a binary core: one utility as generated source was 509 bytes that
disappear when unused, against 9,291 indivisible and asynchronous ones. Go's
cost for cgo is cross compilation, static binaries and `CGO_ENABLED=0`, not
its 58 ns. Neither is a preference between acceptable options.

The finding that does not decide it is kept too, because it is the one that
makes the position honest: for Python, Ruby, C# and Java a binding really is
cheaper than generated source, and that document's first version got it wrong
by measuring the bindings a script reaches for rather than the ones a package
ships. So the answer is scoped — JavaScript and Go must have generated source
on grounds unrelated to speed, and the other four are a choice this engine
makes for readability and the absence of a runtime rather than for throughput.

ADR 0012 records that, including what it costs: four emitters to maintain and
each language's semantics encoded four times. The document carries a header
saying the harness behind its numbers was retired with the branch, so they
should be reproduced before anything rests on them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013i7T7KEWrhJFNZ6qaVqJWf

Copy link
Copy Markdown
Member Author

Closing this in favour of #587, which takes the same problem with a different answer — but one part of this branch was worth more than the code, and has been carried over rather than lost with it.

BINDINGS-INVESTIGATION.md is now engine/docs/bindings-or-generated-source.md on that branch, with ADR 0012 recording the decision it supports. It answers a question #587 never addressed: why generate source at all, rather than build one binary core and bind to it. Two findings decide it and only one is about speed — a tree-shakeable package cannot take an indivisible 9,291-byte WebAssembly module when the generated utility is 509 bytes that disappear when unused, and Go's price for cgo is cross compilation and static binaries rather than its 58 ns. The correction in §2, that a CPython extension costs 27 ns where ctypes costs 433 ns, is kept too, because it is what makes the position honest: for Python, Ruby, C# and Java a binding really is the cheaper route, and generated source is chosen there for readability and the absence of a runtime, not for throughput.

Both headers say the harness was retired with this branch, so those numbers should be reproduced before anything load-bearing rests on them.

What is not carried over is the compiler, and the reason is visible in one line of its own output. is_valid_cnpj.go here builds runtime.PatternStep{Class: class0, Min: 2, Max: 2} and walks it at call time, out of a runtime/runtime.go shipped beside the generated code. #587 emits regexp.MustCompile(...) at package scope and ships no runtime at all. That is the trade this branch made — seven targets against idiomatic, dependency-free output — and it is not a trade #587 can take, because a runtime beside the output is the one thing its specification rules out. Worth noting that the pattern-walking shape is not hypothetically slow: #587 shipped exactly it in its Rust target, measured it at 79% of the call, and replaced it with a scanner decided at generation time.

The seven-target reach is the real loss. Java, C# and Ruby are not in #587, and the per-language semantics behind them were genuine work.


Generated by Claude Code

This branch had an error being deployed

1 failed deployment
Preview — 94b32644 Deployed Sep 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants