Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions spec/bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
92 changes: 92 additions & 0 deletions spec/bridge/conformance/cases/_cnpj-corpus.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

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<string | number>();
// `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];
};
20 changes: 20 additions & 0 deletions spec/bridge/conformance/cases/is-valid-cnpj.ts
Original file line number Diff line number Diff line change
@@ -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;
},
};
88 changes: 88 additions & 0 deletions spec/bridge/source/_internals/cnpj.ts
Original file line number Diff line number Diff line change
@@ -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;
};
75 changes: 75 additions & 0 deletions spec/bridge/source/is-valid-cnpj.ts
Original file line number Diff line number Diff line change
@@ -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);
};
Loading