From 764c4846ab3b6386d9ad5acf1be91e6260334f5d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:07:18 +0000 Subject: [PATCH 1/5] fix(states): read the state code ignoring case in every state-taking util MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getMunicipalities, getCities, generateCpf, generateVoterId and isValidRegistroProfissional matched the state code exactly, so "sp" returned no municipality, drew a random região fiscal, fell back to "ZZ" or failed the UF check. They now read it through readStateCode (trim and upper case, then an exact state code), like getStateNameByCode, getTimezoneByState, getAreaCodesByState and getMunicipality already did; readHolidayStateCode builds on it. What an unknown code means is unchanged in each util. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RLkm9YrtAifc6XCLFVEsdH --- docs/pt-br/utilities.md | 10 +++---- docs/utilities.md | 10 +++---- .../read-holiday-state-code.ts | 13 ++------- .../read-state-code/read-state-code.test.ts | 28 +++++++++++++++++++ .../read-state-code/read-state-code.ts | 26 +++++++++++++++++ src/generate-cpf/generate-cpf.test.ts | 7 +++++ src/generate-cpf/generate-cpf.ts | 10 +++---- .../generate-voter-id.test.ts | 5 ++++ src/generate-voter-id/generate-voter-id.ts | 8 +++--- src/get-cities/get-cities.test.ts | 9 ++++++ src/get-cities/get-cities.ts | 14 +++++----- .../get-municipalities.test.ts | 9 ++++++ src/get-municipalities/get-municipalities.ts | 15 +++++----- .../is-valid-registro-profissional.test.ts | 11 ++++++++ .../is-valid-registro-profissional.ts | 6 ++-- 15 files changed, 136 insertions(+), 45 deletions(-) create mode 100644 src/_internals/read-state-code/read-state-code.test.ts create mode 100644 src/_internals/read-state-code/read-state-code.ts diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index 619055b2b..b604a745f 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -65,7 +65,7 @@ parseCpf('746.506.880-00'); // 74650688000 Gera um CPF válido aleatório. - O argumento opcional `state` (`StateCode`, ex. `"SP"`) fixa o dígito da região fiscal (o 9º) no código desse estado. -- Sem `state`, ou com um código desconhecido, um dígito de região fiscal aleatório é sorteado. +- `state` ignora maiúsculas/minúsculas e espaços nas pontas (`'sp'` é `'SP'`). Sem `state`, ou com um código desconhecido, um dígito de região fiscal aleatório é sorteado. ```javascript import { generateCpf } from '@brazilian-utils/brazilian-utils' @@ -1857,7 +1857,7 @@ Retorna os municípios brasileiros publicados pelo IBGE: todos os municípios, o - Cada município (`Municipality`) é `{ code, name, stateCode }`, onde `code` é o código IBGE de 7 dígitos. Ordenados por nome no locale "pt-BR". - Só um `stateCode` omitido (ou `undefined`) pede a lista completa: `null` e `''` retornam `[]`. -- `stateCode` diferencia maiúsculas de minúsculas: `'sp'`, como um código desconhecido, retorna `[]`. +- `stateCode` ignora maiúsculas/minúsculas e espaços nas pontas: `'sp'` retorna os municípios de São Paulo, como `'SP'` (até a 2.4.0 retornava `[]`). - Embute todos os 5571 municípios, os mesmos códigos da [Divisão Territorial Brasileira 2025](https://geoftp.ibge.gov.br/organizacao_do_territorio/estrutura_territorial/divisao_territorial/2025/DTB_2025.zip) do IBGE (data base 31/12/2025). Veja [Tamanho do bundle](pt-br/getting-started.md#tamanho-do-bundle) para carregá-lo sob demanda via `@brazilian-utils/brazilian-utils/get-municipalities`. ```javascript @@ -1918,7 +1918,7 @@ Retorna os nomes das cidades brasileiras: todas as cidades, ou só as de um esta - Ordenadas no locale "pt-BR". - Qualquer `state` falsy pede a lista completa, enquanto `getMunicipalities` retorna `[]`. -- `state` diferencia maiúsculas de minúsculas: `'sp'`, como um código desconhecido, retorna `[]`. +- `state` ignora maiúsculas/minúsculas e espaços nas pontas: `'sp'` retorna as cidades de São Paulo, como `'SP'` (até a 2.4.0 retornava `[]`). - Embute os 5571 nomes (~153,4 KB minificado, ~49,2 KB com gzip). Veja [Tamanho do bundle](pt-br/getting-started.md#tamanho-do-bundle) para carregá-la sob demanda via `@brazilian-utils/brazilian-utils/get-cities`. ```javascript @@ -2490,7 +2490,7 @@ parseVoterId('12345 01 59'); // '123450159' Gera um título de eleitor válido aleatório. O argumento opcional `state` (`StateCode`, ou `"ZZ"` para um título expedido no exterior) define o código de unidade federativa. -- Uma UF desconhecida, ou um valor que não seja string, usa `"ZZ"` (UF `28`). +- `state` ignora maiúsculas/minúsculas e espaços nas pontas (`'sp'` é `'SP'`). Uma UF desconhecida, ou um valor que não seja string, usa `"ZZ"` (UF `28`). - O resultado sempre tem 12 dígitos, com os zeros à esquerda do número sequencial; o mesmo título sem eles também é válido. ```javascript @@ -3691,7 +3691,7 @@ Fonte: [ISO/IEC 7812-1](https://www.iso.org/standard/70484.html). Verifica a estrutura de um número de registro em conselho profissional (registro/inscrição profissional). Só a quantidade de dígitos e a UF são conferidas, nunca o dígito verificador, nem no CRC. -- Recebe um objeto (`IsValidRegistroProfissionalParams`): `value`, `council` (`RegistroProfissionalCouncil`: `"OAB"`, `"CRM"`, `"CRO"`, `"CRP"` ou `"CRC"`) e `stateCode` opcional (UF esperada). +- Recebe um objeto (`IsValidRegistroProfissionalParams`): `value`, `council` (`RegistroProfissionalCouncil`: `"OAB"`, `"CRM"`, `"CRO"`, `"CRP"` ou `"CRC"`) e `stateCode` opcional (UF esperada, sem diferenciar maiúsculas/minúsculas e ignorando espaços nas pontas). - `"OAB"` e `"CRM"`: 4 a 6 dígitos mais a UF (`123456/SP`, `123456-SP`); `"CRO"`: 3 a 6 dígitos (`12345/SP`), ou a forma da Consolidação das Normas do CFO (Resolução CFO-63/2005), art. 115, § 1º: a sigla do Conselho Regional antes, ligada por hífen à categoria (`TPD`, `TSB`, `ASB`, `APD`, `CLM`/`CLF`, `LPM`/`LPF`, `PV`, `T`) quando houver, depois o número, seguido de `-IS` na secundária ou `-R` na remida (`CRO-SP 12345`, `CRO-SP-TPD 1234`, `CRO-SP 12345-IS`). Até a 2.4.0 essa forma era rejeitada. - `"CRP"`: código regional de 2 dígitos (`01` a `24`) mais 4 a 6 dígitos (`06/12345`); `stateCode` é ignorado. O sistema CFP tem 24 regionais; o CRP-25 (Amapá) é só uma proposta. - `"CRC"`: UF, 6 dígitos, tipo de registro (`O` ou `P`) e dígito verificador (`SP-123456/O-3`); transferência acrescenta `T` ou `S` e a UF destino (`SP-123456/O-3 T-MG`). `stateCode` confere a UF de origem. Essa forma e os registros `P`/`S` vêm do Manual de Registro de 2009; a Resolução CFC nº 1.707/2023, em vigor, só fixa uma numeração "única e sequencial em cada CRC" e o `T` da transferência, e o algoritmo do dígito verificador não é publicado. diff --git a/docs/utilities.md b/docs/utilities.md index f0a71f125..7dde22dc3 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -65,7 +65,7 @@ parseCpf('746.506.880-00'); // 74650688000 Generate a valid random CPF. - The optional `state` argument (`StateCode`, e.g. `"SP"`) fixes the região fiscal digit (the 9th) to that state's code. -- Without `state`, or with an unknown code, a random região fiscal digit is drawn. +- `state` ignores letter case and surrounding whitespace (`'sp'` is `'SP'`). Without `state`, or with an unknown code, a random região fiscal digit is drawn. ```javascript import { generateCpf } from '@brazilian-utils/brazilian-utils' @@ -1857,7 +1857,7 @@ Get the Brazilian municipalities published by the IBGE: every municipality, or o - Each municipality (`Municipality`) is `{ code, name, stateCode }`, where `code` is the 7-digit IBGE code. Sorted by name in the "pt-BR" locale. - Only an omitted (or `undefined`) `stateCode` asks for the full list: `null` and `''` return `[]`. -- `stateCode` is case-sensitive: `'sp'`, like an unknown code, returns `[]`. +- `stateCode` ignores letter case and surrounding whitespace: `'sp'` returns the São Paulo municipalities, as `'SP'` does (up to 2.4.0 it returned `[]`). - Embeds all 5571 municipalities, the same codes as the IBGE [Divisão Territorial Brasileira 2025](https://geoftp.ibge.gov.br/organizacao_do_territorio/estrutura_territorial/divisao_territorial/2025/DTB_2025.zip) (data base 31/12/2025). See [Bundle size](getting-started.md#bundle-size) to lazy-load it via `@brazilian-utils/brazilian-utils/get-municipalities`. ```javascript @@ -1918,7 +1918,7 @@ Get the names of Brazilian cities: every city, or only those of one state. **Dep - Sorted in the "pt-BR" locale. - Any falsy `state` asks for the full list, where `getMunicipalities` returns `[]`. -- `state` is case-sensitive: `'sp'`, like an unknown code, returns `[]`. +- `state` ignores letter case and surrounding whitespace: `'sp'` returns the São Paulo cities, as `'SP'` does (up to 2.4.0 it returned `[]`). - Embeds all 5571 names (~153.4 KB minified, ~49.2 KB gzipped). See [Bundle size](getting-started.md#bundle-size) to lazy-load it via `@brazilian-utils/brazilian-utils/get-cities`. ```javascript @@ -2490,7 +2490,7 @@ parseVoterId('12345 01 59'); // '123450159' Generate a valid random voter ID number. The optional `state` argument (`StateCode`, or `"ZZ"` for a voter ID issued abroad) sets the federative union code. -- An unknown state, or a value that is not a string, falls back to `"ZZ"` (UF `28`). +- `state` ignores letter case and surrounding whitespace (`'sp'` is `'SP'`). An unknown state, or a value that is not a string, falls back to `"ZZ"` (UF `28`). - The result always has 12 digits, the leading zeros of the sequential number included; the same ID without them is valid too. ```javascript @@ -3691,7 +3691,7 @@ Source: [ISO/IEC 7812-1](https://www.iso.org/standard/70484.html). Check the structure of a professional council registration number (registro/inscrição profissional). Only the digit count and the UF are checked, never a check digit, even for CRC. -- Takes an object (`IsValidRegistroProfissionalParams`): `value`, `council` (`"OAB"`, `"CRM"`, `"CRO"`, `"CRP"` or `"CRC"`, a `RegistroProfissionalCouncil`) and an optional `stateCode` (expected UF). +- Takes an object (`IsValidRegistroProfissionalParams`): `value`, `council` (`"OAB"`, `"CRM"`, `"CRO"`, `"CRP"` or `"CRC"`, a `RegistroProfissionalCouncil`) and an optional `stateCode` (expected UF, letter case and surrounding whitespace ignored). - `"OAB"` and `"CRM"`: 4 to 6 digits plus the UF (`123456/SP`, `123456-SP`); `"CRO"`: 3 to 6 digits (`12345/SP`), or the form of the Consolidação das Normas do CFO (Resolução CFO-63/2005), art. 115, § 1º: the sigla of the Conselho Regional first, joined by a hyphen to the category (`TPD`, `TSB`, `ASB`, `APD`, `CLM`/`CLF`, `LPM`/`LPF`, `PV`, `T`) when there is one, then the number, followed by `-IS` for a secundária or `-R` for a remida (`CRO-SP 12345`, `CRO-SP-TPD 1234`, `CRO-SP 12345-IS`). Up to 2.4.0 this form was rejected. - `"CRP"`: a 2-digit regional code (`01` to `24`) plus 4 to 6 digits (`06/12345`); `stateCode` is ignored. The CFP system has 24 regionals; the CRP-25 (Amapá) is only a proposal. - `"CRC"`: UF, 6 digits, tipo de registro (`O` or `P`) and check digit (`SP-123456/O-3`); a transfer appends `T` or `S` and the destination UF (`SP-123456/O-3 T-MG`). `stateCode` matches the originating UF. This shape and the `P`/`S` registrations come from the Manual de Registro of 2009; Resolução CFC nº 1.707/2023, in force, only sets a numbering "única e sequencial em cada CRC" and the `T` of the transfer, and the check digit algorithm is not published. diff --git a/src/_internals/read-holiday-state-code/read-holiday-state-code.ts b/src/_internals/read-holiday-state-code/read-holiday-state-code.ts index 576c86f46..97bf700ec 100644 --- a/src/_internals/read-holiday-state-code/read-holiday-state-code.ts +++ b/src/_internals/read-holiday-state-code/read-holiday-state-code.ts @@ -1,5 +1,5 @@ import { type StateCode } from "../constants/states"; -import { isStateCode } from "../is-state-code/is-state-code"; +import { readStateCode } from "../read-state-code/read-state-code"; /** * Reads the `stateCode` option of the holiday and business day utils. The match is @@ -22,12 +22,5 @@ import { isStateCode } from "../is-state-code/is-state-code"; * readHolidayStateCode(""); // null * ``` */ -export const readHolidayStateCode = (value: unknown): StateCode | undefined | null => { - if (value === undefined) return undefined; - - if (typeof value !== "string") return null; - - const normalized = value.trim().toUpperCase(); - - return isStateCode(normalized) ? normalized : null; -}; +export const readHolidayStateCode = (value: unknown): StateCode | undefined | null => + value === undefined ? undefined : readStateCode(value); diff --git a/src/_internals/read-state-code/read-state-code.test.ts b/src/_internals/read-state-code/read-state-code.test.ts new file mode 100644 index 000000000..1dc8fc012 --- /dev/null +++ b/src/_internals/read-state-code/read-state-code.test.ts @@ -0,0 +1,28 @@ +import { STATE_CODES } from "../constants/state-codes"; +import { describe, expect, test } from "../test/runtime"; +import { readStateCode } from "./read-state-code"; + +describe("readStateCode", () => { + test("should read every state code as it is", () => { + for (const code of STATE_CODES) { + expect(readStateCode(code)).toBe(code); + } + }); + + test("should ignore case and surrounding whitespace", () => { + expect(readStateCode("sp")).toBe("SP"); + expect(readStateCode(" Rj\t")).toBe("RJ"); + }); + + test("should return null for a string that is not a state code", () => { + for (const value of ["XX", "ZZ", "", " ", "S P", "SPA", "__proto__", "constructor"]) { + expect(readStateCode(value)).toBeNull(); + } + }); + + test("should return null for a value that is not a string", () => { + for (const value of [null, undefined, 35, {}, ["SP"], new String("SP")]) { + expect(readStateCode(value)).toBeNull(); + } + }); +}); diff --git a/src/_internals/read-state-code/read-state-code.ts b/src/_internals/read-state-code/read-state-code.ts new file mode 100644 index 000000000..b175e1995 --- /dev/null +++ b/src/_internals/read-state-code/read-state-code.ts @@ -0,0 +1,26 @@ +import { type StateCode } from "../constants/states"; +import { isStateCode } from "../is-state-code/is-state-code"; + +/** + * Reads a state code given by a caller: a string that, once its surrounding whitespace is removed + * and its letters are upper cased, is the two letter code of a Brazilian state. Anything else (an + * unknown string such as `"XX"`, an empty string, a prototype-chain key such as `"__proto__"`, a + * value that is not a string) is `null`, so the caller decides what an unknown state means. + * + * @param {unknown} value - The state code as the caller passed it. + * @returns {StateCode|null} The state code, or `null` when the value is not one. + * + * @example + * ```typescript + * readStateCode(" sp "); // "SP" + * readStateCode("XX"); // null + * readStateCode(35); // null + * ``` + */ +export const readStateCode = (value: unknown): StateCode | null => { + if (typeof value !== "string") return null; + + const normalized = value.trim().toUpperCase(); + + return isStateCode(normalized) ? normalized : null; +}; diff --git a/src/generate-cpf/generate-cpf.test.ts b/src/generate-cpf/generate-cpf.test.ts index fa44665d0..ccb013e26 100644 --- a/src/generate-cpf/generate-cpf.test.ts +++ b/src/generate-cpf/generate-cpf.test.ts @@ -8,6 +8,13 @@ import { isValidCpf } from "../is-valid-cpf/is-valid-cpf"; import { generateCpf } from "./generate-cpf"; describe("generateCpf", () => { + test("should read the state code ignoring case and surrounding whitespace", () => { + for (let run = 0; run < 20; run += 1) { + // @ts-expect-error: a lower case state code is read as its upper case form + expect(generateCpf(" sp ").charAt(8)).toBe(CPF_FISCAL_REGION_BY_STATE.SP); + } + }); + test(`should have the right length without mask (${CPF_LENGTH})`, () => { expect(generateCpf().length).toBe(CPF_LENGTH); }); diff --git a/src/generate-cpf/generate-cpf.ts b/src/generate-cpf/generate-cpf.ts index 525362a25..c5a32847a 100644 --- a/src/generate-cpf/generate-cpf.ts +++ b/src/generate-cpf/generate-cpf.ts @@ -3,6 +3,7 @@ import { CPF_BASE_LENGTH, CPF_FISCAL_REGION_BY_STATE } from "../_internals/const import { type StateCode } from "../_internals/constants/states"; import { generateRandomNumber } from "../_internals/generate-random-number/generate-random-number"; import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-digits"; +import { readStateCode } from "../_internals/read-state-code/read-state-code"; export type { StateCode } from "../_internals/constants/states"; @@ -16,11 +17,9 @@ export type { StateCode } from "../_internals/constants/states"; * @returns {string} The região fiscal digit of that state, or a random digit. */ const getStateCode = (state?: StateCode): string => { - if (typeof state === "string" && Object.hasOwn(CPF_FISCAL_REGION_BY_STATE, state)) { - return CPF_FISCAL_REGION_BY_STATE[state]; - } + const code = readStateCode(state); - return generateRandomNumber(1); + return code === null ? generateRandomNumber(1) : CPF_FISCAL_REGION_BY_STATE[code]; }; /** @@ -28,7 +27,8 @@ const getStateCode = (state?: StateCode): string => { * * Uses `Math.random()` internally, so it is not cryptographically secure, do not use for security purposes. * - * @param {StateCode} [state] - The Brazilian state code to generate a CPF for. An unknown state + * @param {StateCode} [state] - The Brazilian state code to generate a CPF for, letter case and + * surrounding whitespace ignored (`"sp"` is `"SP"`). An unknown state * draws a random região fiscal digit instead of throwing, a key of the prototype chain * (`"__proto__"`, `"constructor"`) and a value with no string conversion included. * @returns {string} A valid 11-digit CPF string without formatting. diff --git a/src/generate-voter-id/generate-voter-id.test.ts b/src/generate-voter-id/generate-voter-id.test.ts index 3d589cd87..162cd2ca8 100644 --- a/src/generate-voter-id/generate-voter-id.test.ts +++ b/src/generate-voter-id/generate-voter-id.test.ts @@ -8,6 +8,11 @@ import { isValidVoterId } from "../is-valid-voter-id/is-valid-voter-id"; import { generateVoterId } from "./generate-voter-id"; describe("generateVoterId", () => { + test("should read the state code ignoring case and surrounding whitespace", () => { + // @ts-expect-error: a lower case state code is read as its upper case form + expect(generateVoterId(" sp ").slice(8, 10)).toBe(UF_TO_VOTER_ID_CODE.SP); + }); + it("should generate valid voter ids", () => { for (let i = 0; i < 50; i++) { expect(isValidVoterId(generateVoterId())).toBe(true); diff --git a/src/generate-voter-id/generate-voter-id.ts b/src/generate-voter-id/generate-voter-id.ts index d4ecbf10e..89b0c97db 100644 --- a/src/generate-voter-id/generate-voter-id.ts +++ b/src/generate-voter-id/generate-voter-id.ts @@ -2,6 +2,7 @@ import { calculateVoterIdFirstDigit } from "../_internals/calculate-voter-id-fir import { calculateVoterIdSecondDigit } from "../_internals/calculate-voter-id-second-digit/calculate-voter-id-second-digit"; import { type StateCode } from "../_internals/constants/states"; import { generateRandomNumber } from "../_internals/generate-random-number/generate-random-number"; +import { readStateCode } from "../_internals/read-state-code/read-state-code"; import { UF_TO_VOTER_ID_CODE } from "../is-valid-voter-id/constants"; export type { StateCode } from "../_internals/constants/states"; @@ -16,9 +17,7 @@ export type { StateCode } from "../_internals/constants/states"; * @returns {string} The two digit federative union code of that state, or `"ZZ"`'s own code. */ const getFederativeUnion = (state: StateCode | "ZZ"): string => - typeof state === "string" && Object.hasOwn(UF_TO_VOTER_ID_CODE, state) - ? UF_TO_VOTER_ID_CODE[state] - : UF_TO_VOTER_ID_CODE.ZZ; + UF_TO_VOTER_ID_CODE[readStateCode(state) ?? "ZZ"]; /** * Generates a valid random Brazilian voter id (título de eleitor). @@ -26,7 +25,8 @@ const getFederativeUnion = (state: StateCode | "ZZ"): string => * Uses `Math.random()` internally, so it is not cryptographically secure, do not use for security purposes. * * @param {StateCode | "ZZ"} state - Optional. The Brazilian state code to generate a voter id - * for, or `"ZZ"` for a voter id issued abroad. Defaults to `"ZZ"` when omitted or unknown, a key + * for, or `"ZZ"` for a voter id issued abroad, letter case and surrounding whitespace ignored + * (`"sp"` is `"SP"`). Defaults to `"ZZ"` when omitted or unknown, a key * of the prototype chain (`"__proto__"`, `"constructor"`) and a value that is not a string * included, so a malformed state never throws. * @returns {string} A valid 12-digit voter id string without formatting, leading zeros kept. diff --git a/src/get-cities/get-cities.test.ts b/src/get-cities/get-cities.test.ts index b2f238557..a4feb4511 100644 --- a/src/get-cities/get-cities.test.ts +++ b/src/get-cities/get-cities.test.ts @@ -19,6 +19,15 @@ const KNOWN_STATE_CITY_COUNTS: Record = { }; describe("getCities", () => { + test("should read the state code ignoring case and surrounding whitespace", () => { + const saoPaulo = getCities("SP"); + + // @ts-expect-error: a lower case state code is read as its upper case form + expect(getCities("sp")).toEqual(saoPaulo); + // @ts-expect-error: a lower case state code is read as its upper case form + expect(getCities(" Sp\t")).toEqual(saoPaulo); + }); + it("should match a hand-written list of city names at the start and end of the sorted list", () => { const cities = getCities(); diff --git a/src/get-cities/get-cities.ts b/src/get-cities/get-cities.ts index 6f8150bc3..ae0cf3036 100644 --- a/src/get-cities/get-cities.ts +++ b/src/get-cities/get-cities.ts @@ -1,5 +1,6 @@ import { DATA as CITIES_DATA } from "../_internals/constants/municipalities"; import { type StateCode } from "../_internals/constants/states"; +import { readStateCode } from "../_internals/read-state-code/read-state-code"; export type { StateCode } from "../_internals/constants/states"; @@ -17,10 +18,9 @@ let allCitiesCache: string[] | undefined; * every city. The sibling `getMunicipalities` is stricter and only reads an omitted (or * `undefined`) state code that way, returning `[]` for `null` and `""`. * - * The state code is matched exactly, case included: `getCities("sp")` returns `[]` where - * `getCities("SP")` returns the 645 São Paulo cities. `getCities` and `getMunicipalities` are - * the only state-taking lookups that are case-sensitive; `getStateNameByCode`, - * `getTimezoneByState`, `getAreaCodesByState` and `getMunicipality` all fold case. + * The state code is matched ignoring letter case and surrounding whitespace, like every other + * state util: `getCities("sp")` returns the 645 São Paulo cities, as `"SP"` does. Up to 2.4.0 the + * match was case-sensitive and `"sp"` returned `[]`. * * @deprecated Use `getMunicipalities` instead. * @@ -30,7 +30,7 @@ let allCitiesCache: string[] | undefined; * @example * ```typescript * getCities("SP")[0]; // "Adamantina" - * getCities("sp"); // [] (the state code is case-sensitive here) + * getCities("sp").length; // 645 (case and surrounding whitespace are ignored) * getCities().length; // every city of every state * ``` * @@ -49,7 +49,7 @@ export const getCities = (state?: StateCode): string[] => { return [...allCitiesCache]; } - if (typeof state !== "string" || !Object.hasOwn(CITIES_DATA, state)) return []; + const code = readStateCode(state); - return CITIES_DATA[state].map(([name]) => name); + return code === null ? [] : CITIES_DATA[code].map(([name]) => name); }; diff --git a/src/get-municipalities/get-municipalities.test.ts b/src/get-municipalities/get-municipalities.test.ts index 37a778a66..e229b8e6c 100644 --- a/src/get-municipalities/get-municipalities.test.ts +++ b/src/get-municipalities/get-municipalities.test.ts @@ -18,6 +18,15 @@ const KNOWN_STATE_MUNICIPALITY_COUNTS: Partial> = { }; describe("getMunicipalities", () => { + test("should read the state code ignoring case and surrounding whitespace", () => { + const saoPaulo = getMunicipalities("SP"); + + // @ts-expect-error: a lower case state code is read as its upper case form + expect(getMunicipalities("sp")).toEqual(saoPaulo); + // @ts-expect-error: a lower case state code is read as its upper case form + expect(getMunicipalities(" Sp\t")).toEqual(saoPaulo); + }); + it("should return every municipality when no state is given", () => { expect(getMunicipalities().length).toBe(NUMBER_OF_BRAZILIAN_MUNICIPALITIES); }); diff --git a/src/get-municipalities/get-municipalities.ts b/src/get-municipalities/get-municipalities.ts index cc499172a..5d4f5c1a8 100644 --- a/src/get-municipalities/get-municipalities.ts +++ b/src/get-municipalities/get-municipalities.ts @@ -1,6 +1,7 @@ import { DATA as CITIES_DATA, type Municipality } from "../_internals/constants/municipalities"; import { STATE_CODES } from "../_internals/constants/state-codes"; import { type StateCode } from "../_internals/constants/states"; +import { readStateCode } from "../_internals/read-state-code/read-state-code"; export type { Municipality } from "../_internals/constants/municipalities"; export type { StateCode } from "../_internals/constants/states"; @@ -21,10 +22,10 @@ const buildMunicipalities = (stateCode: StateCode): Municipality[] => * looser and treats every falsy `state` as "no state given", so `getCities(null)` returns the * full list where `getMunicipalities(null)` returns `[]`. * - * The state code is matched exactly, case included: `getMunicipalities("sp")` returns `[]` where - * `getMunicipalities("SP")` returns the 645 São Paulo municipalities. `getMunicipalities` and - * `getCities` are the only state-taking lookups that are case-sensitive; `getStateNameByCode`, - * `getTimezoneByState`, `getAreaCodesByState` and `getMunicipality` all fold case. + * The state code is matched ignoring letter case and surrounding whitespace, like + * `getStateNameByCode`, `getTimezoneByState`, `getAreaCodesByState` and `getMunicipality`: + * `getMunicipalities("sp")` returns the 645 São Paulo municipalities, as `"SP"` does. Up to 2.4.0 + * the match was case-sensitive and `"sp"` returned `[]`. * * @param {StateCode} [stateCode] - The two letter code of the Brazilian state to filter by. * @returns {Municipality[]} A fresh array of fresh `Municipality` objects. Empty when @@ -35,7 +36,7 @@ const buildMunicipalities = (stateCode: StateCode): Municipality[] => * getMunicipalities("SP")[0]; // { code: "3500105", name: "Adamantina", stateCode: "SP" } * getMunicipalities().length; // every municipality of every state * getMunicipalities("ZZ"); // [] - * getMunicipalities("sp"); // [] (the state code is case-sensitive here) + * getMunicipalities(" sp ").length; // 645 (case and surrounding whitespace are ignored) * getMunicipalities(null); // [] (only an omitted state code asks for the full list) * ``` * @@ -51,7 +52,7 @@ export const getMunicipalities = (stateCode?: StateCode): Municipality[] => { ); } - if (typeof stateCode !== "string" || !Object.hasOwn(CITIES_DATA, stateCode)) return []; + const code = readStateCode(stateCode); - return buildMunicipalities(stateCode); + return code === null ? [] : buildMunicipalities(code); }; diff --git a/src/is-valid-registro-profissional/is-valid-registro-profissional.test.ts b/src/is-valid-registro-profissional/is-valid-registro-profissional.test.ts index 196e39781..e35901625 100644 --- a/src/is-valid-registro-profissional/is-valid-registro-profissional.test.ts +++ b/src/is-valid-registro-profissional/is-valid-registro-profissional.test.ts @@ -11,6 +11,17 @@ import { const STATE_CODES = DATA.map((state) => state.code); describe("isValidRegistroProfissional", () => { + test("should read the expected state code ignoring case and surrounding whitespace", () => { + expect( + // @ts-expect-error: a lower case state code is read as its upper case form + isValidRegistroProfissional({ value: "123456-SP", council: "OAB", stateCode: " sp " }), + ).toBe(true); + expect( + // @ts-expect-error: a lower case state code is read as its upper case form + isValidRegistroProfissional({ value: "123456-RJ", council: "OAB", stateCode: "sp" }), + ).toBe(false); + }); + describe("should return false", () => { test("for a CRO number of art. 115 whose sigla, category or suffix is not one it lists", () => { for (const value of [ diff --git a/src/is-valid-registro-profissional/is-valid-registro-profissional.ts b/src/is-valid-registro-profissional/is-valid-registro-profissional.ts index 5e2bddc6e..4fca51493 100644 --- a/src/is-valid-registro-profissional/is-valid-registro-profissional.ts +++ b/src/is-valid-registro-profissional/is-valid-registro-profissional.ts @@ -1,6 +1,7 @@ import { type StateCode } from "../_internals/constants/states"; import { isNullish } from "../_internals/is-nullish/is-nullish"; import { isStateCode } from "../_internals/is-state-code/is-state-code"; +import { readStateCode } from "../_internals/read-state-code/read-state-code"; import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; import { CRC_REGEX, @@ -107,7 +108,8 @@ const isKnownCroCategory = (category: string, suffix: string | undefined): boole * @param {IsValidRegistroProfissionalParams} params - The registration to be validated. * @param {string} params.value - The registration number, e.g. `"123456/SP"`. * @param {RegistroProfissionalCouncil} params.council - The issuing council. - * @param {string} [params.stateCode] - The expected UF, ignored for `"CRP"`. + * @param {string} [params.stateCode] - The expected UF, letter case and surrounding whitespace + * ignored (`"sp"` is `"SP"`); ignored for `"CRP"`. * @returns {boolean} True if the value has the structure of a registration number for the * given council, false otherwise. * @@ -184,5 +186,5 @@ export const isValidRegistroProfissional = (params: IsValidRegistroProfissionalP if (!isStateCode(uf)) return false; - return !stateCode || uf === stateCode; + return !stateCode || uf === readStateCode(stateCode); }; From c947d541d7654030aaf9540448370977b8d29e43 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:36:49 +0000 Subject: [PATCH 2/5] perf(states): check a state code against the util's own table generateCpf, generateVoterId, getMunicipalities and getCities are each keyed by the state codes already, so they normalize the code (normalizeStateCode, trim and upper case) and look it up in their own table (hasOwnKey) instead of bundling the list of the 27 codes: generateCpf and generateVoterId grow by 86 B instead of 240 B and 219 B. readStateCode, for the utils that need the list anyway, builds on normalizeStateCode. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RLkm9YrtAifc6XCLFVEsdH --- .../has-own-key/has-own-key.test.ts | 14 +++++++++ src/_internals/has-own-key/has-own-key.ts | 19 ++++++++++++ .../read-state-code/read-state-code.test.ts | 16 +++++++++- .../read-state-code/read-state-code.ts | 31 ++++++++++++++----- src/generate-cpf/generate-cpf.ts | 9 ++++-- src/generate-voter-id/generate-voter-id.ts | 10 ++++-- src/get-cities/get-cities.ts | 7 +++-- src/get-municipalities/get-municipalities.ts | 7 +++-- 8 files changed, 93 insertions(+), 20 deletions(-) create mode 100644 src/_internals/has-own-key/has-own-key.test.ts create mode 100644 src/_internals/has-own-key/has-own-key.ts diff --git a/src/_internals/has-own-key/has-own-key.test.ts b/src/_internals/has-own-key/has-own-key.test.ts new file mode 100644 index 000000000..115bce670 --- /dev/null +++ b/src/_internals/has-own-key/has-own-key.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "../test/runtime"; +import { hasOwnKey } from "./has-own-key"; + +describe("hasOwnKey", () => { + test("should find an own key of the table", () => { + expect(hasOwnKey({ SP: "8" }, "SP")).toBe(true); + }); + + test("should not find a key the table does not have, a prototype-chain key included", () => { + for (const key of ["RJ", "constructor", "__proto__", "toString"]) { + expect(hasOwnKey({ SP: "8" }, key)).toBe(false); + } + }); +}); diff --git a/src/_internals/has-own-key/has-own-key.ts b/src/_internals/has-own-key/has-own-key.ts new file mode 100644 index 000000000..6b1d5390a --- /dev/null +++ b/src/_internals/has-own-key/has-own-key.ts @@ -0,0 +1,19 @@ +/** + * Checks that a key is an own key of a table, narrowing it to the table's keys. The lookup is an + * own-property one, so a prototype-chain key such as `"__proto__"` or `"constructor"` is not a key + * of any table. + * + * @param {object} table - The table to look the key up in. + * @param {string} key - The key to look up. + * @returns {boolean} True when `key` is an own key of `table`. + * + * @example + * ```typescript + * hasOwnKey({ SP: "8" }, "SP"); // true + * hasOwnKey({ SP: "8" }, "constructor"); // false + * ``` + */ +export const hasOwnKey = ( + table: Table, + key: string, +): key is Extract => Object.hasOwn(table, key); diff --git a/src/_internals/read-state-code/read-state-code.test.ts b/src/_internals/read-state-code/read-state-code.test.ts index 1dc8fc012..9a2665708 100644 --- a/src/_internals/read-state-code/read-state-code.test.ts +++ b/src/_internals/read-state-code/read-state-code.test.ts @@ -1,6 +1,6 @@ import { STATE_CODES } from "../constants/state-codes"; import { describe, expect, test } from "../test/runtime"; -import { readStateCode } from "./read-state-code"; +import { normalizeStateCode, readStateCode } from "./read-state-code"; describe("readStateCode", () => { test("should read every state code as it is", () => { @@ -26,3 +26,17 @@ describe("readStateCode", () => { } }); }); + +describe("normalizeStateCode", () => { + test("should trim and upper case a string without checking it is a state", () => { + expect(normalizeStateCode(" sp ")).toBe("SP"); + expect(normalizeStateCode("xx")).toBe("XX"); + expect(normalizeStateCode("")).toBe(""); + }); + + test("should return an empty string for a value that is not a string", () => { + for (const value of [null, undefined, 35, {}, new String("SP")]) { + expect(normalizeStateCode(value)).toBe(""); + } + }); +}); diff --git a/src/_internals/read-state-code/read-state-code.ts b/src/_internals/read-state-code/read-state-code.ts index b175e1995..2c9b0a55a 100644 --- a/src/_internals/read-state-code/read-state-code.ts +++ b/src/_internals/read-state-code/read-state-code.ts @@ -2,10 +2,29 @@ import { type StateCode } from "../constants/states"; import { isStateCode } from "../is-state-code/is-state-code"; /** - * Reads a state code given by a caller: a string that, once its surrounding whitespace is removed - * and its letters are upper cased, is the two letter code of a Brazilian state. Anything else (an - * unknown string such as `"XX"`, an empty string, a prototype-chain key such as `"__proto__"`, a - * value that is not a string) is `null`, so the caller decides what an unknown state means. + * Normalizes a state code given by a caller: its surrounding whitespace removed and its letters + * upper cased, without checking that the result is a state. A util whose own table is keyed by + * the state codes checks the result against that table instead, and does not bundle the list of + * the 27 codes. A value that is not a string is the empty string, which is no state code. + * + * @param {unknown} value - The state code as the caller passed it. + * @returns {string} The normalized text, or `""` when the value is not a string. + * + * @example + * ```typescript + * normalizeStateCode(" sp "); // "SP" + * normalizeStateCode("xx"); // "XX" + * normalizeStateCode(35); // "" + * ``` + */ +export const normalizeStateCode = (value: unknown): string => + typeof value === "string" ? value.trim().toUpperCase() : ""; + +/** + * Reads a state code given by a caller: a string that, once normalized by `normalizeStateCode`, + * is the two letter code of a Brazilian state. Anything else (an unknown string such as `"XX"`, an + * empty string, a prototype-chain key such as `"__proto__"`, a value that is not a string) is + * `null`, so the caller decides what an unknown state means. * * @param {unknown} value - The state code as the caller passed it. * @returns {StateCode|null} The state code, or `null` when the value is not one. @@ -18,9 +37,7 @@ import { isStateCode } from "../is-state-code/is-state-code"; * ``` */ export const readStateCode = (value: unknown): StateCode | null => { - if (typeof value !== "string") return null; - - const normalized = value.trim().toUpperCase(); + const normalized = normalizeStateCode(value); return isStateCode(normalized) ? normalized : null; }; diff --git a/src/generate-cpf/generate-cpf.ts b/src/generate-cpf/generate-cpf.ts index c5a32847a..01a598718 100644 --- a/src/generate-cpf/generate-cpf.ts +++ b/src/generate-cpf/generate-cpf.ts @@ -2,8 +2,9 @@ import { calculateCpfCheckDigit } from "../_internals/calculate-cpf-check-digit/ import { CPF_BASE_LENGTH, CPF_FISCAL_REGION_BY_STATE } from "../_internals/constants/cpf"; import { type StateCode } from "../_internals/constants/states"; import { generateRandomNumber } from "../_internals/generate-random-number/generate-random-number"; +import { hasOwnKey } from "../_internals/has-own-key/has-own-key"; import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-digits"; -import { readStateCode } from "../_internals/read-state-code/read-state-code"; +import { normalizeStateCode } from "../_internals/read-state-code/read-state-code"; export type { StateCode } from "../_internals/constants/states"; @@ -17,9 +18,11 @@ export type { StateCode } from "../_internals/constants/states"; * @returns {string} The região fiscal digit of that state, or a random digit. */ const getStateCode = (state?: StateCode): string => { - const code = readStateCode(state); + const code = normalizeStateCode(state); - return code === null ? generateRandomNumber(1) : CPF_FISCAL_REGION_BY_STATE[code]; + return hasOwnKey(CPF_FISCAL_REGION_BY_STATE, code) + ? CPF_FISCAL_REGION_BY_STATE[code] + : generateRandomNumber(1); }; /** diff --git a/src/generate-voter-id/generate-voter-id.ts b/src/generate-voter-id/generate-voter-id.ts index 89b0c97db..da0fec0fd 100644 --- a/src/generate-voter-id/generate-voter-id.ts +++ b/src/generate-voter-id/generate-voter-id.ts @@ -2,7 +2,8 @@ import { calculateVoterIdFirstDigit } from "../_internals/calculate-voter-id-fir import { calculateVoterIdSecondDigit } from "../_internals/calculate-voter-id-second-digit/calculate-voter-id-second-digit"; import { type StateCode } from "../_internals/constants/states"; import { generateRandomNumber } from "../_internals/generate-random-number/generate-random-number"; -import { readStateCode } from "../_internals/read-state-code/read-state-code"; +import { hasOwnKey } from "../_internals/has-own-key/has-own-key"; +import { normalizeStateCode } from "../_internals/read-state-code/read-state-code"; import { UF_TO_VOTER_ID_CODE } from "../is-valid-voter-id/constants"; export type { StateCode } from "../_internals/constants/states"; @@ -16,8 +17,11 @@ export type { StateCode } from "../_internals/constants/states"; * @param {StateCode | "ZZ"} state - The state the voter id is generated for. * @returns {string} The two digit federative union code of that state, or `"ZZ"`'s own code. */ -const getFederativeUnion = (state: StateCode | "ZZ"): string => - UF_TO_VOTER_ID_CODE[readStateCode(state) ?? "ZZ"]; +const getFederativeUnion = (state: StateCode | "ZZ"): string => { + const code = normalizeStateCode(state); + + return hasOwnKey(UF_TO_VOTER_ID_CODE, code) ? UF_TO_VOTER_ID_CODE[code] : UF_TO_VOTER_ID_CODE.ZZ; +}; /** * Generates a valid random Brazilian voter id (título de eleitor). diff --git a/src/get-cities/get-cities.ts b/src/get-cities/get-cities.ts index ae0cf3036..6690bf7cd 100644 --- a/src/get-cities/get-cities.ts +++ b/src/get-cities/get-cities.ts @@ -1,6 +1,7 @@ import { DATA as CITIES_DATA } from "../_internals/constants/municipalities"; import { type StateCode } from "../_internals/constants/states"; -import { readStateCode } from "../_internals/read-state-code/read-state-code"; +import { hasOwnKey } from "../_internals/has-own-key/has-own-key"; +import { normalizeStateCode } from "../_internals/read-state-code/read-state-code"; export type { StateCode } from "../_internals/constants/states"; @@ -49,7 +50,7 @@ export const getCities = (state?: StateCode): string[] => { return [...allCitiesCache]; } - const code = readStateCode(state); + const code = normalizeStateCode(state); - return code === null ? [] : CITIES_DATA[code].map(([name]) => name); + return hasOwnKey(CITIES_DATA, code) ? CITIES_DATA[code].map(([name]) => name) : []; }; diff --git a/src/get-municipalities/get-municipalities.ts b/src/get-municipalities/get-municipalities.ts index 5d4f5c1a8..0bd9f4578 100644 --- a/src/get-municipalities/get-municipalities.ts +++ b/src/get-municipalities/get-municipalities.ts @@ -1,7 +1,8 @@ import { DATA as CITIES_DATA, type Municipality } from "../_internals/constants/municipalities"; import { STATE_CODES } from "../_internals/constants/state-codes"; import { type StateCode } from "../_internals/constants/states"; -import { readStateCode } from "../_internals/read-state-code/read-state-code"; +import { hasOwnKey } from "../_internals/has-own-key/has-own-key"; +import { normalizeStateCode } from "../_internals/read-state-code/read-state-code"; export type { Municipality } from "../_internals/constants/municipalities"; export type { StateCode } from "../_internals/constants/states"; @@ -52,7 +53,7 @@ export const getMunicipalities = (stateCode?: StateCode): Municipality[] => { ); } - const code = readStateCode(stateCode); + const code = normalizeStateCode(stateCode); - return code === null ? [] : buildMunicipalities(code); + return hasOwnKey(CITIES_DATA, code) ? buildMunicipalities(code) : []; }; From f462c71233d28b12800547237a8a88e2ce1d5df6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:52:54 +0000 Subject: [PATCH 3/5] ci(tests): run a Chromium browser test once more when it loses a test iframe Ported from #595 (3d1ca00), which targets main: Edge failed twice on this branch with "Cannot connect to the iframe" in assemble-boleto-bancario.test.ts, a file this stack does not touch. The change is identical to #595's, so it merges cleanly whichever of the two lands first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RLkm9YrtAifc6XCLFVEsdH --- .github/workflows/tests.yml | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 867cfa08b..942e81404 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -107,6 +107,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 strategy: + # A flake in one browser must not cancel the others, or its cause is lost. + fail-fast: false matrix: browser: [edge, chrome, firefox] @@ -119,8 +121,30 @@ jobs: - name: Setup uses: ./.github/actions/setup + # Chromium under the webdriverio provider sometimes loses a test iframe: the run either fails + # at once with "Cannot connect to the iframe" / "Failed to fetch dynamically imported module", + # or prints nothing more until the job timeout. A whole run takes 1 to 3 minutes, so a first + # attempt that times out after 5 minutes, or fails with one of those two errors, is run once + # more. Any other failure, including a failing assertion, fails the step on the first attempt. - name: Run tests in ${{ matrix.browser }} - run: vp test --browser.enabled --browser.name=${{ matrix.browser }} + env: + BROWSER: ${{ matrix.browser }} + run: | + attempt() { + timeout 300 vp test --browser.enabled --browser.name="$BROWSER" 2>&1 | tee browser-tests.log + return "${PIPESTATUS[0]}" + } + + attempt && exit 0 + status=$? + + if [ "$status" -eq 124 ] || grep -qE "Cannot connect to the iframe|Failed to fetch dynamically imported module" browser-tests.log; then + echo "::warning::The $BROWSER test run lost a test iframe (exit $status); running it once more." + attempt + exit $? + fi + + exit "$status" test-safari: name: Test on Browsers (safari) From 510b8a962138109df7f95a8121b12c571abfd1ac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:16:16 +0000 Subject: [PATCH 4/5] refactor(states): read the state code through the shared helpers everywhere getStateCapital, getMunicipality, getTimezoneByState, getStateNameByCode and getAreaCodesByState each trimmed and upper cased the state code by hand. They now use normalizeStateCode, and the ones keyed by the state codes look it up with hasOwnKey in their own table (getStateCapital no longer bundles the list of the 27 codes: -129 B). No behavior changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RLkm9YrtAifc6XCLFVEsdH --- .../get-area-codes-by-state.ts | 5 ++--- src/get-municipality/get-municipality.ts | 11 ++++------- src/get-state-capital/get-state-capital.ts | 9 ++++----- src/get-state-name-by-code/get-state-name-by-code.ts | 5 ++--- src/get-timezone-by-state/get-timezone-by-state.ts | 8 ++++---- 5 files changed, 16 insertions(+), 22 deletions(-) diff --git a/src/get-area-codes-by-state/get-area-codes-by-state.ts b/src/get-area-codes-by-state/get-area-codes-by-state.ts index ad2fb15d7..535372f13 100644 --- a/src/get-area-codes-by-state/get-area-codes-by-state.ts +++ b/src/get-area-codes-by-state/get-area-codes-by-state.ts @@ -1,4 +1,5 @@ import { AREA_CODE_SECONDARY_STATES, AREA_CODE_STATES } from "../_internals/constants/area-codes"; +import { normalizeStateCode } from "../_internals/read-state-code/read-state-code"; /** * Retrieves every DDD (area code) that serves a given Brazilian state, under the Plano Geral @@ -44,9 +45,7 @@ import { AREA_CODE_SECONDARY_STATES, AREA_CODE_STATES } from "../_internals/cons * Anexo of Resolução nº 263/2001, revoked, and still the table Anatel's page links to. */ export const getAreaCodesByState = (stateCode: string): number[] => { - if (typeof stateCode !== "string") return []; - - const normalized = stateCode.trim().toUpperCase(); + const normalized = normalizeStateCode(stateCode); const areaCodes: number[] = []; diff --git a/src/get-municipality/get-municipality.ts b/src/get-municipality/get-municipality.ts index e917877f2..547a64ffd 100644 --- a/src/get-municipality/get-municipality.ts +++ b/src/get-municipality/get-municipality.ts @@ -1,8 +1,9 @@ import { DATA as CITIES_DATA } from "../_internals/constants/municipalities"; -import { type StateCode } from "../_internals/constants/states"; +import { hasOwnKey } from "../_internals/has-own-key/has-own-key"; import { isNullish } from "../_internals/is-nullish/is-nullish"; import { normalizeMunicipalityName } from "../_internals/normalize-municipality-name/normalize-municipality-name"; import { readLookupDigits } from "../_internals/read-lookup-digits/read-lookup-digits"; +import { normalizeStateCode } from "../_internals/read-state-code/read-state-code"; /** The `getMunicipality` query by IBGE municipality code. */ export type GetMunicipalityByCodeParams = { @@ -70,19 +71,15 @@ const getMunicipalityByCode = (code: string | number): [string, string] | null = return entry ? [...entry] : null; }; -const isStateCode = (value: string): value is StateCode => Object.hasOwn(CITIES_DATA, value); - const getMunicipalityCodeByName = ({ municipalityName, uf, }: GetMunicipalityByNameParams): string | null => { - if (typeof uf !== "string") return null; - - const normalizedUf = uf.trim().toUpperCase(); + const normalizedUf = normalizeStateCode(uf); // Every real state code is exactly 2 uppercase letters, so a malformed `normalizedUf` (wrong // length, digits, ...) simply finds no match below; there is no need to pre-validate its shape. - if (!isStateCode(normalizedUf)) return null; + if (!hasOwnKey(CITIES_DATA, normalizedUf)) return null; // `removeAccents` (and so `normalizeMunicipalityName`) already folds a non-string or empty // `municipalityName` down to `""`, which no real municipality name normalizes to, so there is diff --git a/src/get-state-capital/get-state-capital.ts b/src/get-state-capital/get-state-capital.ts index bdb4f50ed..c24528292 100644 --- a/src/get-state-capital/get-state-capital.ts +++ b/src/get-state-capital/get-state-capital.ts @@ -1,6 +1,7 @@ import { type Municipality } from "../_internals/constants/municipalities"; import { STATE_CAPITALS } from "../_internals/constants/state-capitals"; -import { isStateCode } from "../_internals/is-state-code/is-state-code"; +import { hasOwnKey } from "../_internals/has-own-key/has-own-key"; +import { normalizeStateCode } from "../_internals/read-state-code/read-state-code"; export type { Municipality } from "../_internals/constants/municipalities"; @@ -29,11 +30,9 @@ export type { Municipality } from "../_internals/constants/municipalities"; * distância a Brasília, segundo os Municípios das Capitais - 2025". */ export const getStateCapital = (stateCode: string): Municipality | null => { - if (typeof stateCode !== "string") return null; + const normalized = normalizeStateCode(stateCode); - const normalized = stateCode.trim().toUpperCase(); - - if (!isStateCode(normalized)) return null; + if (!hasOwnKey(STATE_CAPITALS, normalized)) return null; const [name, code] = STATE_CAPITALS[normalized]; diff --git a/src/get-state-name-by-code/get-state-name-by-code.ts b/src/get-state-name-by-code/get-state-name-by-code.ts index a9eda4ac6..5cdfc4776 100644 --- a/src/get-state-name-by-code/get-state-name-by-code.ts +++ b/src/get-state-name-by-code/get-state-name-by-code.ts @@ -1,4 +1,5 @@ import { DATA, type StateName } from "../_internals/constants/states"; +import { normalizeStateCode } from "../_internals/read-state-code/read-state-code"; export type { StateName } from "../_internals/constants/states"; @@ -24,9 +25,7 @@ export type { StateName } from "../_internals/constants/states"; * ``` */ export const getStateNameByCode = (code: string): StateName | null => { - if (typeof code !== "string") return null; - - const normalized = code.trim().toUpperCase(); + const normalized = normalizeStateCode(code); const state = DATA.find((entry) => entry.code === normalized); diff --git a/src/get-timezone-by-state/get-timezone-by-state.ts b/src/get-timezone-by-state/get-timezone-by-state.ts index 5e31a910a..3927450d1 100644 --- a/src/get-timezone-by-state/get-timezone-by-state.ts +++ b/src/get-timezone-by-state/get-timezone-by-state.ts @@ -1,3 +1,5 @@ +import { hasOwnKey } from "../_internals/has-own-key/has-own-key"; +import { normalizeStateCode } from "../_internals/read-state-code/read-state-code"; import { STATE_TIMEZONES } from "./constants"; /** @@ -35,9 +37,7 @@ import { STATE_TIMEZONES } from "./constants"; * ``` */ export const getTimezoneByState = (stateCode: string): string | null => { - if (typeof stateCode !== "string") return null; + const normalized = normalizeStateCode(stateCode); - const normalized = stateCode.trim().toUpperCase(); - - return Object.hasOwn(STATE_TIMEZONES, normalized) ? STATE_TIMEZONES[normalized] : null; + return hasOwnKey(STATE_TIMEZONES, normalized) ? STATE_TIMEZONES[normalized] : null; }; From b1399961fc06188468b49ba0b57d932be2d3ba9e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:58:18 +0000 Subject: [PATCH 5/5] fix(ie): trim the state code, as the other utils that take a state do isValidIe upper cased the state code but kept its whitespace, so " sp " was refused while every other util of #604 read it as SP. It now reads the code through normalizeStateCode. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RLkm9YrtAifc6XCLFVEsdH --- docs/pt-br/utilities.md | 3 ++- docs/utilities.md | 3 ++- src/is-valid-ie/is-valid-ie.test.ts | 11 +++++++++++ src/is-valid-ie/is-valid-ie.ts | 15 +++++++-------- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index 919c0c335..a94bba369 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -3615,7 +3615,7 @@ removeAccents(''); // '' Valida uma inscrição estadual para um estado. **Descontinuada:** a forma posicional `isValidIe(stateCode, ie)` continua funcionando, mas está descontinuada; use a forma com objeto `isValidIe({ value, stateCode })`. -- Recebe um único objeto (`IsValidIeParams`): `value` é a inscrição e `stateCode` o estado ao qual ela pertence (um `StateCode`, sem diferenciar maiúsculas de minúsculas). +- Recebe um único objeto (`IsValidIeParams`): `value` é a inscrição e `stateCode` o estado ao qual ela pertence (um `StateCode`, sem diferenciar maiúsculas de minúsculas e ignorando espaços em volta). - Alguns estados têm casos especiais, um prefixo ou formato que a página do SINTEGRA não traz ou um desvio proposital dela (detalhes e fontes no JSDoc em `src/is-valid-ie`): - GO: os prefixos 10, 11, 15 e 20 a 29, a união de fontes que divergem: a norma (IN nº 946/09-GSF, art. 39, I, na redação da IN nº 1.535/22-GSE) traz 10, 20 e 11, a página do SINTEGRA 10, 11 e 20 a 29, o roteiro de crítica de 2012 10, 11 e 15. O dígito verificador segue o roteiro, como na 2.4.0: resto 1 dá 1 na faixa 10103105 a 10119997, e 11094402 aceita os dois dígitos, casos especiais que a página do SINTEGRA (2022) não tem. - MT: 11 dígitos, ou os 9 dígitos que a Portaria SEFAZ-MT nº 59/2025 (art. 8º, § 1º) prevê, lidos como a forma de 11 dígitos com dois zeros à esquerda (nenhum texto oficial traz a regra do dígito verificador da forma de 9 dígitos). @@ -3637,6 +3637,7 @@ isValidIe({ value: '110042490114', stateCode: 'SP' }); // true isValidIe({ value: 'P011004243002', stateCode: 'SP' }); // true (produtor rural) isValidIe({ value: '0187634580933', stateCode: 'AC' }); // false isValidIe({ value: '109161793', stateCode: 'go' }); // true (não diferencia maiúsculas de minúsculas) +isValidIe({ value: '109161793', stateCode: ' GO ' }); // true (espaços em volta são ignorados) isValidIe({ value: '200000004', stateCode: 'GO' }); // true (prefixo 20) isValidIe({ value: '130000019', stateCode: 'MT' }); // true (9 dígitos) ``` diff --git a/docs/utilities.md b/docs/utilities.md index d8aed590a..982bd33e5 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -3615,7 +3615,7 @@ removeAccents(''); // '' Check if an inscrição estadual (state registration) is valid for a state. **Deprecated:** the positional form `isValidIe(stateCode, ie)` still works but is deprecated; use the object form `isValidIe({ value, stateCode })`. -- Takes a single object (`IsValidIeParams`): `value` is the registration and `stateCode` the state it belongs to (a `StateCode`, case-insensitive). +- Takes a single object (`IsValidIeParams`): `value` is the registration and `stateCode` the state it belongs to (a `StateCode`, case-insensitive, with surrounding whitespace ignored). - Some states have special cases, a prefix or format the SINTEGRA page does not print or a deliberate deviation from it (details and sources in the JSDoc in `src/is-valid-ie`): - GO: the prefixes 10, 11, 15 and 20 to 29, the union of sources that disagree: the norm (IN nº 946/09-GSF, art. 39, I, as worded by IN nº 1.535/22-GSE) gives 10, 20 and 11, the SINTEGRA page 10, 11 and 20 to 29, the 2012 roteiro de crítica 10, 11 and 15. The check digit follows the roteiro, as in 2.4.0: a remainder of 1 gives 1 in the range 10103105 to 10119997, and 11094402 takes either digit, special cases the SINTEGRA page (2022) does not have. - MT: 11 digits, or the 9 digits Portaria SEFAZ-MT nº 59/2025 (art. 8º, § 1º) prescribes, read as the 11 digit form padded with two zeros (no official text gives the check digit rule of the 9 digit form). @@ -3637,6 +3637,7 @@ isValidIe({ value: '110042490114', stateCode: 'SP' }); // true isValidIe({ value: 'P011004243002', stateCode: 'SP' }); // true (produtor rural) isValidIe({ value: '0187634580933', stateCode: 'AC' }); // false isValidIe({ value: '109161793', stateCode: 'go' }); // true (case-insensitive) +isValidIe({ value: '109161793', stateCode: ' GO ' }); // true (surrounding whitespace ignored) isValidIe({ value: '200000004', stateCode: 'GO' }); // true (prefix 20) isValidIe({ value: '130000019', stateCode: 'MT' }); // true (9 digits) ``` diff --git a/src/is-valid-ie/is-valid-ie.test.ts b/src/is-valid-ie/is-valid-ie.test.ts index ee1fe9f09..be50dc4c8 100644 --- a/src/is-valid-ie/is-valid-ie.test.ts +++ b/src/is-valid-ie/is-valid-ie.test.ts @@ -967,6 +967,17 @@ describe("isValidIe", () => { expect(isValidIe({ value: "109161793", stateCode: "go" })).toBe(true); }); + test("should ignore whitespace around the state code, as the other utils that take a state do", () => { + // @ts-expect-error: intentionally invalid input + expect(isValidIe({ value: "110042490114", stateCode: " sp " })).toBe(true); + // @ts-expect-error: intentionally invalid input + expect(isValidIe({ value: "109161793", stateCode: "\tGo\n" })).toBe(true); + // @ts-expect-error: intentionally invalid input + expect(isValidIe(" rj ", "625X45372")).toBe(true); + // @ts-expect-error: intentionally invalid input + expect(isValidIe({ value: "110042490114", stateCode: " " })).toBe(false); + }); + test("should return false for missing arguments", () => { // @ts-expect-error: intentionally invalid input expect(isValidIe({ value: "110042490114", stateCode: null })).toBe(false); diff --git a/src/is-valid-ie/is-valid-ie.ts b/src/is-valid-ie/is-valid-ie.ts index 7d28249ea..bc426be17 100644 --- a/src/is-valid-ie/is-valid-ie.ts +++ b/src/is-valid-ie/is-valid-ie.ts @@ -1,6 +1,8 @@ import { type StateCode } from "../_internals/constants/states"; +import { hasOwnKey } from "../_internals/has-own-key/has-own-key"; import { isNullish } from "../_internals/is-nullish/is-nullish"; import { mod10 } from "../_internals/mod10/mod10"; +import { normalizeStateCode } from "../_internals/read-state-code/read-state-code"; import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { @@ -451,7 +453,7 @@ const validateTO: IeValidator = (ie: string) => { return Number.parseInt(ie.charAt(position), 10) === digit; }; -const IE_VALIDATORS: Record = { +const IE_VALIDATORS = { AC: validateAC, AL: validateAL, AP: validateAP, @@ -482,19 +484,15 @@ const IE_VALIDATORS: Record = { } satisfies Record; const validateIe = (stateCode: unknown, value: unknown): boolean => { - if (typeof stateCode !== "string") return false; if (typeof value !== "string") return false; - const normalizedStateCode = stateCode.toUpperCase(); + const normalizedStateCode = normalizeStateCode(stateCode); - const validator = Object.hasOwn(IE_VALIDATORS, normalizedStateCode) - ? IE_VALIDATORS[normalizedStateCode] - : undefined; - if (!validator) return false; + if (!hasOwnKey(IE_VALIDATORS, normalizedStateCode)) return false; const sanitize = normalizedStateCode === "SP" ? sanitizeToAlphanumeric : sanitizeToDigits; - return validator(sanitize(value)); + return IE_VALIDATORS[normalizedStateCode](sanitize(value)); }; /** @@ -594,6 +592,7 @@ const validateIe = (stateCode: unknown, value: unknown): boolean => { * isValidIe({ value: 'P011004243002', stateCode: 'SP' }); // true * isValidIe({ value: '12345', stateCode: 'RJ' }); // false * isValidIe({ value: '109161793', stateCode: 'go' as StateCode }); // true (case-insensitive) + * isValidIe({ value: '109161793', stateCode: ' GO ' as StateCode }); // true (surrounding whitespace ignored) * isValidIe({ value: '200000004', stateCode: 'GO' }); // true (prefix 20) * isValidIe({ value: '130000019', stateCode: 'MT' }); // true (9 digits) * ```