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); +};