From f701ac065b28c27f8c42c3fa681989c2154b61da Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 04:00:11 +0000 Subject: [PATCH] feat(spec): port isValidCnpj to the bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01UX1gTGeMTyoXQyr1qUoQKd --- spec/bridge/README.md | 5 +- spec/bridge/conformance/cases/_cnpj-corpus.ts | 92 +++++++++++++++++++ .../bridge/conformance/cases/is-valid-cnpj.ts | 20 ++++ spec/bridge/source/_internals/cnpj.ts | 88 ++++++++++++++++++ spec/bridge/source/is-valid-cnpj.ts | 75 +++++++++++++++ 5 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 spec/bridge/conformance/cases/_cnpj-corpus.ts create mode 100644 spec/bridge/conformance/cases/is-valid-cnpj.ts create mode 100644 spec/bridge/source/_internals/cnpj.ts create mode 100644 spec/bridge/source/is-valid-cnpj.ts diff --git a/spec/bridge/README.md b/spec/bridge/README.md index 40a70c39b..cad288ba9 100644 --- a/spec/bridge/README.md +++ b/spec/bridge/README.md @@ -146,8 +146,9 @@ be handed it, so nothing is skipped for being inconvenient, and the count is vis ## Ported so far -Nothing yet: this is the engine on its own, and each utility is a change of its own on top of -it. `conformance/run-all.sh` says so rather than passing vacuously. +| utility | what makes it worth porting | +| ------------- | ----------------------------------------------------------------- | +| `isValidCnpj` | regular expressions, check digits, JavaScript's own type coercion | ## Adding a utility diff --git a/spec/bridge/conformance/cases/_cnpj-corpus.ts b/spec/bridge/conformance/cases/_cnpj-corpus.ts new file mode 100644 index 000000000..ac23e44dd --- /dev/null +++ b/spec/bridge/conformance/cases/_cnpj-corpus.ts @@ -0,0 +1,92 @@ +/** + * The CNPJ corpus both CNPJ recorders replay. + * + * It is not invented: every string literal the package's own CNPJ test files use is pulled out + * of them, so the cross language check runs the same inputs the JavaScript suite runs. On top + * of that go freshly generated CNPJs, masked variants of them, one-digit mutations, and the + * numbers a JavaScript caller can pass where a string is declared. + */ +import { readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = resolve(import.meta.dirname, "../../../.."); + +/** Pulls every string literal out of a test file, which is where the interesting inputs live. */ +const literalsOf = (path: string): string[] => { + const source = readFileSync(path, "utf8"); + const found = new Set(); + + for (const match of source.matchAll(/"((?:[^"\\\n]|\\.){0,40})"/g)) { + try { + found.add(JSON.parse(`"${match[1]}"`) as string); + } catch { + // A literal that is not valid JSON on its own is not an input worth replaying. + } + } + + return [...found]; +}; + +/** A deterministic pseudo random generator, so the corpus only changes when this file does. */ +const mulberry32 = (seed: number): (() => number) => { + let state = seed; + + return () => { + state = Math.imul(state + 0x6d_2b_79_f5, 1); + let t = Math.imul(state ^ (state >>> 15), 1 | state); + + t ^= t + Math.imul(t ^ (t >>> 7), 61 | t); + + return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296; + }; +}; + +const MASKS = [".", "-", "/", " ", " ", "", "!", "a"]; + +/** + * Builds the corpus. + * + * @param {Function} generateCnpj - The package's own generator. + * @returns {(string | number)[]} The corpus. + */ +export const cnpjCorpus = (generateCnpj: (version?: 1 | 2) => string): (string | number)[] => { + const random = mulberry32(0x62_72_31); + const corpus = new Set(); + // `generateCnpj` draws from `Math.random`, and a corpus that changes between runs makes a + // diverging target look like a flake. Seeding it makes the table the same every time. + const realRandom = Math.random; + + Math.random = random; + + for (const utility of ["is-valid-cnpj", "format-cnpj"]) { + for (const name of readdirSync(resolve(root, "src", utility))) { + if (!name.endsWith(".test.ts")) continue; + + for (const literal of literalsOf(resolve(root, "src", utility, name))) corpus.add(literal); + } + } + + for (let index = 0; index < 120; index++) { + const version = index % 2 === 0 ? 1 : 2; + const cnpj = generateCnpj(version); + + corpus.add(cnpj); + corpus.add(cnpj.toLowerCase()); + + const separators = [0, 1, 2, 3].map(() => MASKS[Math.floor(random() * MASKS.length)]); + + corpus.add( + `${cnpj.slice(0, 2)}${separators[0]}${cnpj.slice(2, 5)}${separators[1]}${cnpj.slice(5, 8)}${separators[2]}${cnpj.slice(8, 12)}${separators[3]}${cnpj.slice(12)}`, + ); + + const position = Math.floor(random() * cnpj.length); + + corpus.add(`${cnpj.slice(0, position)}${Math.floor(random() * 10)}${cnpj.slice(position + 1)}`); + } + + Math.random = realRandom; + + for (const number of [0, 4, 46, 468, 12_345_678, 12_345_678_000_195]) corpus.add(number); + + return [...corpus]; +}; diff --git a/spec/bridge/conformance/cases/is-valid-cnpj.ts b/spec/bridge/conformance/cases/is-valid-cnpj.ts new file mode 100644 index 000000000..c20febc6d --- /dev/null +++ b/spec/bridge/conformance/cases/is-valid-cnpj.ts @@ -0,0 +1,20 @@ +/** + * What `isValidCnpj` is replayed with: the whole CNPJ corpus, under every option set. + */ +import { type Recorder } from "../cases.ts"; +import { cnpjCorpus } from "./_cnpj-corpus.ts"; + +const OPTIONS = [undefined, { version: 1 }, { version: 2 }]; + +export const recorder: Recorder = { + module: "is-valid-cnpj", + inputs: (shipped) => { + const corpus = cnpjCorpus(shipped["generateCnpj"] as (version?: 1 | 2) => string); + const found: unknown[][] = []; + + for (const input of corpus) + for (const options of OPTIONS) found.push(options === undefined ? [input] : [input, options]); + + return found; + }, +}; diff --git a/spec/bridge/source/_internals/cnpj.ts b/spec/bridge/source/_internals/cnpj.ts new file mode 100644 index 000000000..2b3d7535a --- /dev/null +++ b/spec/bridge/source/_internals/cnpj.ts @@ -0,0 +1,88 @@ +/** + * What the CNPJ utilities share: the shapes a CNPJ can be written in, and its check digits. + * + * Every name here is spliced into the utility that imports it and emitted as one of its own + * private declarations, so this file is where a CNPJ rule is written once — not a module any + * target ends up depending on. + * + * @see Official: https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/documentos-tecnicos/cnpj/manual-dv-cnpj.pdf + */ + +/** The weights of the first check digit, most significant first. */ +export const FIRST_DIGIT_WEIGHTS = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; + +/** The weights of the second check digit, most significant first. */ +export const SECOND_DIGIT_WEIGHTS = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; + +/** A version 2 CNPJ: letters allowed everywhere but the two check digits. */ +export const ALPHANUMERIC_FORMAT = + /^[0-9A-Z]{2}[\s.\-/]*[0-9A-Z]{3}[\s.\-/]*[0-9A-Z]{3}[\s.\-/]*[0-9A-Z]{4}[\s.\-/]*[0-9]{2}$/; + +/** A version 1 CNPJ: digits only, with the usual mask characters between the groups. */ +export const NUMERIC_FORMAT = /^\d{2}[\s.\-/]*\d{3}[\s.\-/]*\d{3}[\s.\-/]*\d{4}[\s.\-/]*\d{2}$/; + +/** Whether a value holds a letter, which is what tells the two versions apart. */ +export const LETTER = /[A-Z]/; + +/** Everything a version 2 CNPJ is not made of. */ +export const NON_ALPHANUMERIC = /[^A-Za-z0-9]/g; + +/** Everything a version 1 CNPJ is not made of. */ +export const NON_DIGIT = /\D/g; + +/** + * Computes one CNPJ check digit from the base and its weight vector. + * + * @param {string} base - The sanitized CNPJ, of which only the base is read. + * @param {number[]} weights - The weight vector of the digit being computed. + * @returns {number} The check digit. + */ +export const checkDigit = (base: string, weights: number[]): number => { + let sum = 0; + + for (let index = 0; index < weights.length; index++) { + sum += (base.charCodeAt(index) - 48) * weights[index]; + } + + const remainder = sum % 11; + + if (remainder < 2) { + return 0; + } + + return 11 - remainder; +}; + +/** + * Whether both check digits of a sanitized 14 character CNPJ match its base. + * + * @param {string} cnpj - The sanitized CNPJ. + * @returns {boolean} True when both check digits match. + */ +export const hasValidChecksum = (cnpj: string): boolean => { + if (cnpj.charCodeAt(12) - 48 !== checkDigit(cnpj, FIRST_DIGIT_WEIGHTS)) { + return false; + } + + return cnpj.charCodeAt(13) - 48 === checkDigit(cnpj, SECOND_DIGIT_WEIGHTS); +}; + +/** + * Whether every character of the value is the same one. + * + * @param {string} value - The value. + * @returns {boolean} True when the value repeats one character. + */ +export const isRepeated = (value: string): boolean => { + if (value.length === 0) { + return false; + } + + for (let index = 1; index < value.length; index++) { + if (value.charCodeAt(index) !== value.charCodeAt(0)) { + return false; + } + } + + return true; +}; diff --git a/spec/bridge/source/is-valid-cnpj.ts b/spec/bridge/source/is-valid-cnpj.ts new file mode 100644 index 000000000..9861fff17 --- /dev/null +++ b/spec/bridge/source/is-valid-cnpj.ts @@ -0,0 +1,75 @@ +/** + * `isValidCnpj`, written once. + * + * This is ordinary TypeScript inside the portable subset the bridge accepts: annotated + * parameters and returns, regex literals it compiles rather than passes through, and the same + * boundary guards the handwritten package has. `spec/bridge/README.md` documents the subset; + * the compiler rejects anything outside it instead of guessing. + * + * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cnpj + * @see Official: https://www.gov.br/receitafederal/pt-br/acesso-a-informacao/acoes-e-programas/programas-e-atividades/cnpj-alfanumerico + */ +import { + ALPHANUMERIC_FORMAT, + LETTER, + NON_ALPHANUMERIC, + NON_DIGIT, + NUMERIC_FORMAT, + hasValidChecksum, + isRepeated, +} from "./_internals/cnpj.ts"; + +/** Options of `isValidCnpj`. */ +export type IsValidCnpjOptions = { + /** Which CNPJ format to accept: `1` numeric only, `2` alphanumeric (default: `1`). */ + version?: 1 | 2; +}; + +/** + * Validates if a CNPJ (Cadastro Nacional da Pessoa Jurídica) is valid. + * + * Supports both numeric (version 1) and alphanumeric (version 2) CNPJ formats, and accepts the + * usual mask characters (`.`, `-`, `/`) and whitespace around and between groups. + * + * @param {string} cnpj - The CNPJ value to be validated. + * @param {IsValidCnpjOptions} [options] - Optional options. + * @returns {boolean} True if the CNPJ is valid, false otherwise. + * + * @example + * ```typescript + * isValidCnpj("12.345.678/0001-95"); // true + * isValidCnpj("q0slfmbd7vx439", { version: 2 }); // true + * isValidCnpj("00000000000000"); // false (reserved number) + * ``` + */ +export const isValidCnpj = (cnpj: string, options?: IsValidCnpjOptions): boolean => { + if (typeof cnpj !== "string") { + return false; + } + + const trimmed = cnpj.trim(); + + if (options?.version === 2) { + const cleaned = cnpj.replaceAll(NON_ALPHANUMERIC, "").toUpperCase(); + + if (LETTER.test(cleaned)) { + if (!ALPHANUMERIC_FORMAT.test(trimmed.toUpperCase())) { + return false; + } + + return hasValidChecksum(cleaned); + } + } + + const numeric = cnpj.replaceAll(NON_DIGIT, ""); + + if (!NUMERIC_FORMAT.test(trimmed)) { + return false; + } + + if (isRepeated(numeric)) { + return false; + } + + return hasValidChecksum(numeric); +};