From 08f49c8f7664aa72ddbf6417cef573eb2a2afe35 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:02:47 -0300 Subject: [PATCH 1/6] feat(phone): add an obfuscate option to formatPhone A phone number is personal data under the LGPD, and formatCpf/formatCnpj were the only formatters able to print a value someone should recognize but not read. The option hides the subscriber number under every mask and keeps the last 2 digits, the count the gov.br account shows for the registered mobile ("*********00"). The prefix that names a region or a service instead of a subscriber stays too: the DDD, the 0800-like code and the 300X/400X root. A 3 digit public utility code identifies no one and is returned as it is, and a value the "service" mask does not recognize is hidden entirely, since it used to be returned raw. The option defaults to false and is read for truthiness, like formatCpf does, so the current output does not change. --- reports/api/brazilian-utils.api.md | 1 + src/format-phone/constants.ts | 77 ++++++++++++++--- src/format-phone/format-phone.test.ts | 116 +++++++++++++++++++++++++- src/format-phone/format-phone.ts | 83 +++++++++++++----- 4 files changed, 241 insertions(+), 36 deletions(-) diff --git a/reports/api/brazilian-utils.api.md b/reports/api/brazilian-utils.api.md index d24a1ffb0..231b7f808 100644 --- a/reports/api/brazilian-utils.api.md +++ b/reports/api/brazilian-utils.api.md @@ -305,6 +305,7 @@ export const formatPhone: (value: string | number, options?: FormatPhoneOptions) // @public export type FormatPhoneOptions = { mask?: PhoneMask; + obfuscate?: boolean; }; // @public diff --git a/src/format-phone/constants.ts b/src/format-phone/constants.ts index c332d91a1..b6a339330 100644 --- a/src/format-phone/constants.ts +++ b/src/format-phone/constants.ts @@ -20,21 +20,76 @@ export const DEFAULT_MASK = "sn"; /** Length of a bare Brazilian subscriber number, the boundary the `"auto"` mask reads. */ export const SN_LENGTH = 9; -export const MASK: Record = { - sn: "00000-0000", - nanp: "(00) 00000-0000", +/** The patterns `formatPhone` reads, one set for the plain output and one for the obfuscated. */ +export type PhoneMasks = { + /** The patterns of the `"sn"` and `"nanp"` masks. */ + national: Record; + /** The `"nanp"` pattern of a 10 digit landline. */ + nanpLandline: string; + /** The patterns of the `"international"` mask, without the `+55` prefix. */ + international: PhoneLineMasks; + /** The patterns of the `"service"` mask. */ + service: PhoneServiceMasks; }; -export const NANP_LANDLINE_MASK = "(00) 0000-0000"; +/** One pattern per kind of line, picked by the number of digits. */ +export type PhoneLineMasks = { + /** DDD + 8 digits. */ + landline: string; + /** DDD + 9 digits. */ + mobile: string; +}; -export const INTERNATIONAL_PREFIX = "+55"; +/** One pattern per family of service number. */ +export type PhoneServiceMasks = { + /** The `300X`/`400X` numbers. */ + abbreviated: string; + /** The Códigos Não Geográficos (`0300`, `0303`, `0500`, `0800`, `0900`). */ + nonGeographic: string; +}; -export const INTERNATIONAL_MASK = { - landline: "00 0000-0000", - mobile: "00 00000-0000", +export const MASKS: PhoneMasks = { + national: { + sn: "00000-0000", + nanp: "(00) 00000-0000", + }, + nanpLandline: "(00) 0000-0000", + international: { + landline: "00 0000-0000", + mobile: "00 00000-0000", + }, + service: { + abbreviated: "0000-0000", + nonGeographic: "0000 000 0000", + }, }; -export const SERVICE_MASK = { - abbreviated: "0000-0000", - nonGeographic: "0000 000 0000", +/** + * The gov.br account (acesso.gov.br) shows the registered mobile as "*********00": only the last + * 2 digits stay visible. These patterns keep that count and also keep the prefix that names a + * region or a service instead of a subscriber, the DDD, the `0800`-like code or the `300X`/`400X` + * root, e.g. "(11) *****-**21", "+55 11 ****-**00", "0800 *** **67" and "4004-**34". + */ +export const OBFUSCATED_MASKS: PhoneMasks = { + national: { + sn: "*****-**00", + nanp: "(00) *****-**00", + }, + nanpLandline: "(00) ****-**00", + international: { + landline: "00 ****-**00", + mobile: "00 *****-**00", + }, + service: { + abbreviated: "0000-**00", + nonGeographic: "0000 *** **00", + }, }; + +/** The obfuscated `"e164"` patterns, without the `+55` prefix: the plain output has no pattern. */ +export const OBFUSCATED_E164_MASK: PhoneLineMasks = { + landline: "00******00", + mobile: "00*******00", +}; + +export const INTERNATIONAL_PREFIX = "+55"; diff --git a/src/format-phone/format-phone.test.ts b/src/format-phone/format-phone.test.ts index 2f42af556..7f0b33301 100644 --- a/src/format-phone/format-phone.test.ts +++ b/src/format-phone/format-phone.test.ts @@ -167,6 +167,85 @@ describe("formatPhone", () => { expect(formatPhone("11988887777", { mask: "bogus" })).toBe("11988-8877"); }); + it("should hide the subscriber number except its last 2 digits under the national masks", () => { + expect(formatPhone("988887766", { obfuscate: true })).toBe("*****-**66"); + expect(formatPhone(988_887_766, { obfuscate: true })).toBe("*****-**66"); + expect(formatPhone("11988887766", { mask: "nanp", obfuscate: true })).toBe("(11) *****-**66"); + expect(formatPhone("1130001234", { mask: "nanp", obfuscate: true })).toBe("(11) ****-**34"); + expect(formatPhone("11988887766", { mask: "auto", obfuscate: true })).toBe("(11) *****-**66"); + expect(formatPhone("1130001234", { mask: "auto", obfuscate: true })).toBe("(11) ****-**34"); + expect(formatPhone("988887766", { mask: "auto", obfuscate: true })).toBe("*****-**66"); + }); + + it("should keep the country code and the DDD when obfuscating the international masks", () => { + expect(formatPhone("11988887766", { mask: "international", obfuscate: true })).toBe( + "+55 11 *****-**66", + ); + expect(formatPhone("+55 11 3000-1234", { mask: "international", obfuscate: true })).toBe( + "+55 11 ****-**34", + ); + expect(formatPhone("5511988887766", { mask: "auto", obfuscate: true })).toBe( + "+55 11 *****-**66", + ); + expect(formatPhone("11988887766", { mask: "e164", obfuscate: true })).toBe("+5511*******66"); + expect(formatPhone("1130001234", { mask: "e164", obfuscate: true })).toBe("+5511******34"); + }); + + it("should keep the service prefix and the last 2 digits when obfuscating a service number", () => { + expect(formatPhone("08001234567", { mask: "service", obfuscate: true })).toBe("0800 *** **67"); + expect(formatPhone("03031234567", { mask: "auto", obfuscate: true })).toBe("0303 *** **67"); + expect(formatPhone("40041234", { mask: "service", obfuscate: true })).toBe("4004-**34"); + expect(formatPhone("30031234", { mask: "auto", obfuscate: true })).toBe("3003-**34"); + expect(formatPhone("08001234567", { mask: "e164", obfuscate: true })).toBe("0800 *** **67"); + expect(formatPhone("40041234", { mask: "international", obfuscate: true })).toBe("4004-**34"); + }); + + it("should return a public utility code whole when obfuscating", () => { + expect(formatPhone("190", { mask: "service", obfuscate: true })).toBe("190"); + expect(formatPhone("190", { mask: "auto", obfuscate: true })).toBe("190"); + }); + + it("should hide entirely a value the service mask does not recognize when obfuscating", () => { + expect(formatPhone("11988887766", { mask: "service", obfuscate: true })).toBe("***********"); + expect(formatPhone("08", { mask: "service", obfuscate: true })).toBe("**"); + expect(formatPhone("11988887766", { mask: "service" })).toBe("11988887766"); + }); + + it("should obfuscate a partial value as far as it goes", () => { + expect(formatPhone("1198", { mask: "nanp", obfuscate: true })).toBe("(11) **"); + expect(formatPhone("11988", { mask: "international", obfuscate: true })).toBe("+55 11 ***"); + expect(formatPhone("0800123", { mask: "service", obfuscate: true })).toBe("0800 ***"); + expect(formatPhone("", { mask: "e164", obfuscate: true })).toBe(""); + expect(formatPhone("", { mask: "international", obfuscate: true })).toBe(""); + expect(formatPhone("", { mask: "service", obfuscate: true })).toBe(""); + }); + + it("should drop what does not fit the 11 national digits when obfuscating the e164 mask", () => { + expect(formatPhone("119888877660000", { mask: "e164", obfuscate: true })).toBe( + "+5511*******66", + ); + expect(formatPhone("119888877660000", { mask: "e164" })).toBe("+55119888877660000"); + }); + + it("should obfuscate on any truthy obfuscate value", () => { + // @ts-expect-error: intentionally not a boolean + expect(formatPhone("11988887766", { mask: "nanp", obfuscate: 1 })).toBe("(11) *****-**66"); + // @ts-expect-error: intentionally not a boolean + expect(formatPhone("11988887766", { mask: "e164", obfuscate: "yes" })).toBe("+5511*******66"); + }); + + it("should behave exactly as without the option when obfuscate is falsy or absent", () => { + expect(formatPhone("11988887766", { mask: "nanp", obfuscate: false })).toBe("(11) 98888-7766"); + expect(formatPhone("11988887766", { mask: "e164", obfuscate: false })).toBe("+5511988887766"); + expect(formatPhone("08001234567", { mask: "service", obfuscate: false })).toBe("0800 123 4567"); + // @ts-expect-error: intentionally not a boolean + expect(formatPhone("11988887766", { mask: "international", obfuscate: 0 })).toBe( + "+55 11 98888-7766", + ); + // @ts-expect-error: intentionally not a boolean + expect(formatPhone("988887766", { obfuscate: null })).toBe("98888-7766"); + }); + it("should return an empty string for nullish values", () => { // @ts-expect-error: intentionally invalid input expect(formatPhone(null)).toBe(""); @@ -219,14 +298,39 @@ describe("formatPhone", () => { ); }); - test("should never throw and always return the phone number as a string", () => { + test("should show only the DDD and the last 2 digits of a generated number when obfuscating", () => { fc.assert( - fc.property(fc.string({ unit: "grapheme" }), fc.integer(), (text, number) => { - expect(typeof formatPhone(text)).toBe("string"); - expect(typeof formatPhone(number)).toBe("string"); + fc.property(fc.gen(), fc.constantFrom(...geographic), (g, type) => { + const phone = g(phones, type); + const visible = `${phone.slice(0, 2)}${phone.slice(-2)}`; + const obfuscated = formatPhone(phone, { mask: "auto", obfuscate: true }); + + expect(obfuscated).toMatch(/^\(\d{2}\) \*{4,5}-\*{2}\d{2}$/); + expect(obfuscated.replaceAll(/\D/g, "")).toBe(visible); + expect(obfuscated).toHaveLength(formatPhone(phone, { mask: "auto" }).length); + expect(formatPhone(phone, { mask: "e164", obfuscate: true })).toMatch( + /^\+55\d{2}\*{6,7}\d{2}$/, + ); }), ); }); + + test("should never throw and always return the phone number as a string", () => { + fc.assert( + fc.property( + fc.string({ unit: "grapheme" }), + fc.integer(), + fc.anything(), + (text, number, options) => { + expect(typeof formatPhone(text)).toBe("string"); + expect(typeof formatPhone(number)).toBe("string"); + expect(typeof formatPhone(text, { mask: "auto", obfuscate: true })).toBe("string"); + // @ts-expect-error: intentionally invalid options + expect(typeof formatPhone(text, options)).toBe("string"); + }, + ), + ); + }); }); }); @@ -243,4 +347,8 @@ describe("formatPhone types", () => { "auto" | "e164" | "international" | "service" | "sn" | "nanp" >(); }); + + test("should type the obfuscate option as an optional boolean", () => { + expectTypeOf().toEqualTypeOf(); + }); }); diff --git a/src/format-phone/format-phone.ts b/src/format-phone/format-phone.ts index 92bf9ac1b..05cedc631 100644 --- a/src/format-phone/format-phone.ts +++ b/src/format-phone/format-phone.ts @@ -10,13 +10,14 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d import { isValidServicePhone } from "../is-valid-service-phone/is-valid-service-phone"; import { DEFAULT_MASK, - INTERNATIONAL_MASK, INTERNATIONAL_PREFIX, - MASK, - NANP_LANDLINE_MASK, + MASKS, type NationalMask, + OBFUSCATED_E164_MASK, + OBFUSCATED_MASKS, PHONE_MASKS, - SERVICE_MASK, + type PhoneLineMasks, + type PhoneMasks, SN_LENGTH, } from "./constants"; @@ -27,6 +28,8 @@ export type PhoneMask = "auto" | "e164" | "international" | "service" | "sn" | " export type FormatPhoneOptions = { /** Which mask to apply, or `"auto"` to pick one from the value (default: `"sn"`). */ mask?: PhoneMask; + /** Whether to hide the subscriber number with `*`, except its last 2 digits (default: `false`, read for truthiness). */ + obfuscate?: boolean; }; const matchesPrefix = (digits: string, prefixes: readonly string[]): boolean => @@ -37,37 +40,51 @@ const matchesPrefix = (digits: string, prefixes: readonly string[]): boolean => * below, so it matches none of them and is returned as it came, which is exactly what both * masks would print for it anyway (their first separator only appears once the value is longer * than the prefix that selects the mask). + * + * Under `obfuscate` such a value has no prefix that tells which digits are safe to show, so it + * is hidden entirely, unless it is one of the 3 digit public utility codes (`190`), which + * identify no subscriber. * @param {string} digits - The digits of a service number. + * @param {boolean} obfuscate - Whether to hide the digits after the prefix, except the last 2. * @returns {string} The digits under the mask of their service number family. */ -const formatService = (digits: string): string => { +const formatService = (digits: string, obfuscate: boolean): string => { + const masks = obfuscate ? OBFUSCATED_MASKS.service : MASKS.service; + if (matchesPrefix(digits, SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES)) { - return format({ value: digits, pattern: SERVICE_MASK.nonGeographic }); + return format({ value: digits, pattern: masks.nonGeographic }); } if (matchesPrefix(digits, SERVICE_PHONE_ABBREVIATED_ROOTS)) { - return format({ value: digits, pattern: SERVICE_MASK.abbreviated }); + return format({ value: digits, pattern: masks.abbreviated }); } - return digits; + return obfuscate && !isValidServicePhone(digits) ? "*".repeat(digits.length) : digits; }; -const formatInternational = (national: string): string => { +const resolveLinePattern = (national: string, masks: PhoneLineMasks): string => + national.length > PHONE_NATIONAL_MIN_LENGTH ? masks.mobile : masks.landline; + +const formatInternational = (national: string, masks: PhoneMasks): string => { if (!national) return ""; - const pattern = - national.length > PHONE_NATIONAL_MIN_LENGTH - ? INTERNATIONAL_MASK.mobile - : INTERNATIONAL_MASK.landline; + const pattern = resolveLinePattern(national, masks.international); return `${INTERNATIONAL_PREFIX} ${format({ value: national, pattern })}`; }; -const formatE164 = (national: string): string => - national ? `${INTERNATIONAL_PREFIX}${national}` : ""; +const formatE164 = (national: string, obfuscate: boolean): string => { + if (!national) return ""; -const resolveNationalPattern = (digits: string, mask: NationalMask): string => - mask === "nanp" && digits.length === PHONE_NATIONAL_MIN_LENGTH ? NANP_LANDLINE_MASK : MASK[mask]; + const pattern = resolveLinePattern(national, OBFUSCATED_E164_MASK); + + return `${INTERNATIONAL_PREFIX}${obfuscate ? format({ value: national, pattern }) : national}`; +}; + +const resolveNationalPattern = (digits: string, mask: NationalMask, masks: PhoneMasks): string => + mask === "nanp" && digits.length === PHONE_NATIONAL_MIN_LENGTH + ? masks.nanpLandline + : masks.national[mask]; const resolveAutoMask = (digits: string, serviceDigits: string): Exclude => { if (isValidServicePhone(serviceDigits)) return "service"; @@ -112,9 +129,20 @@ const isPhoneMask = (value: unknown): value is PhoneMask => PHONE_MASKS.has(valu * do not rely on the default, since the default `"sn"` mask assumes no DDD is present. A `mask` * outside the union falls back to the default `"sn"` instead of throwing. * + * `options.obfuscate` hides the subscriber number under every mask, for the places where a + * number is shown to someone who should only recognize it (LGPD, art. 6º III, necessidade). The + * gov.br account shows the registered mobile as `"*********00"`, only the last 2 digits, and + * this keeps that count. The prefix that names a region or a service instead of a subscriber + * also stays: the DDD, the `0800`-like code and the `300X`/`400X` root. A 3 digit public + * utility code (`190`) identifies no one and is returned as it is, and a value the `"service"` + * mask does not recognize is hidden entirely. The patterns have a fixed number of slots, so + * under `"e164"` anything past the 11th national digit is dropped. + * * @param {string|number} value - The phone number to format, either as a string or a number. * @param {FormatPhoneOptions} [options] - Optional formatting options. * @param {"auto"|"sn"|"nanp"|"e164"|"international"|"service"} options.mask - The mask to apply for formatting the phone number (default: `"sn"`). + * @param {boolean} options.obfuscate - If truthy, hides the subscriber number except its last 2 + * digits. Read for truthiness, so a non-boolean such as `1` obfuscates too. * @returns {string} The formatted phone number as a string. * * @example @@ -128,11 +156,21 @@ const isPhoneMask = (value: unknown): value is PhoneMask => PHONE_MASKS.has(valu * formatPhone("11987654321", { mask: "e164" }); // "+5511987654321" * formatPhone("11987654321", { mask: "international" }); // "+55 11 98765-4321" * formatPhone("40041234", { mask: "service" }); // "4004-1234" + * formatPhone("987654321", { obfuscate: true }); // "*****-**21" + * formatPhone("11987654321", { mask: "auto", obfuscate: true }); // "(11) *****-**21" + * formatPhone("1130000000", { mask: "auto", obfuscate: true }); // "(11) ****-**00" + * formatPhone("5511987654321", { mask: "auto", obfuscate: true }); // "+55 11 *****-**21" + * formatPhone("11987654321", { mask: "e164", obfuscate: true }); // "+5511*******21" + * formatPhone("08001234567", { mask: "auto", obfuscate: true }); // "0800 *** **67" + * formatPhone("40041234", { mask: "service", obfuscate: true }); // "4004-**34" * formatPhone("11987654321"); // "11987-6543" (BEWARE: default "sn" truncates a DDD-prefixed number) * ``` * * @see Official: https://www.itu.int/rec/T-REC-E.164 * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 + * @see Official: https://acesso.gov.br/faq/_perguntasdafaq/formarrecuperarconta.html + * The gov.br account FAQ, whose "Recuperar senha com celular" screen shows the registered mobile + * as `"*********00"`, the convention `obfuscate` follows for the number of visible digits. */ export const formatPhone = (value: string | number, options?: FormatPhoneOptions): string => { const enhancedValue = sanitizeToDigits(value); @@ -142,18 +180,21 @@ export const formatPhone = (value: string | number, options?: FormatPhoneOptions const requested: PhoneMask = isPhoneMask(givenMask) ? givenMask : DEFAULT_MASK; const mask = requested === "auto" ? resolveAutoMask(enhancedValue, serviceDigits) : requested; - if (mask === "service") return formatService(serviceDigits); + const obfuscate = Boolean(options?.obfuscate); + const masks = obfuscate ? OBFUSCATED_MASKS : MASKS; + + if (mask === "service") return formatService(serviceDigits, obfuscate); if (mask === "e164" || mask === "international") { - if (isValidServicePhone(serviceDigits)) return formatService(serviceDigits); + if (isValidServicePhone(serviceDigits)) return formatService(serviceDigits, obfuscate); const national = normalizePhone(enhancedValue); - return mask === "e164" ? formatE164(national) : formatInternational(national); + return mask === "e164" ? formatE164(national, obfuscate) : formatInternational(national, masks); } return format({ value: enhancedValue, - pattern: resolveNationalPattern(enhancedValue, mask), + pattern: resolveNationalPattern(enhancedValue, mask, masks), }); }; From eab2fe9be9ec6ea82e6902364c917bfb8c80d930 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:02:59 -0300 Subject: [PATCH 2/6] feat(format): add an obfuscate option to formatPis, formatCnh and formatVoterId No authority publishes a masking rule for the PIS, the CNH or the voter id, so the option applies the one Lei 12.309/2010, art. 87, par. 5, sets for the CPF ("ocultar os tres primeiros digitos e os dois digitos verificadores") to the three numbers that share its structure, a base number followed by check digits: the first 3 digits and the check digits are hidden, and the federative union code of a voter id stays visible. formatVoterId gains its first options argument, FormatVoterIdOptions. Every option defaults to false and is read for truthiness, so the current output does not change. formatCns and formatPassport are left out: neither has a published convention, and neither has the base plus check digits layout the CPF rule transfers to. --- reports/api/brazilian-utils.api.md | 9 +++++- src/format-cnh/constants.ts | 9 ++++++ src/format-cnh/format-cnh.test.ts | 26 +++++++++++++++- src/format-cnh/format-cnh.ts | 15 ++++++++- src/format-pis/constants.ts | 9 ++++++ src/format-pis/format-pis.test.ts | 26 +++++++++++++++- src/format-pis/format-pis.ts | 15 ++++++++- src/format-voter-id/constants.ts | 16 ++++++++++ src/format-voter-id/format-voter-id.test.ts | 34 +++++++++++++++++++-- src/format-voter-id/format-voter-id.ts | 29 +++++++++++++----- src/index.test.ts | 2 ++ src/index.ts | 2 +- 12 files changed, 176 insertions(+), 16 deletions(-) create mode 100644 src/format-cnh/constants.ts create mode 100644 src/format-pis/constants.ts create mode 100644 src/format-voter-id/constants.ts diff --git a/reports/api/brazilian-utils.api.md b/reports/api/brazilian-utils.api.md index 231b7f808..1c86f12e4 100644 --- a/reports/api/brazilian-utils.api.md +++ b/reports/api/brazilian-utils.api.md @@ -214,6 +214,7 @@ export const formatCnh: (value: string | number, options?: FormatCnhOptions) => // @public export type FormatCnhOptions = { pad?: boolean; + obfuscate?: boolean; }; // @public @@ -314,6 +315,7 @@ export const formatPis: (value: string | number, options?: FormatPisOptions) => // @public export type FormatPisOptions = { pad?: boolean; + obfuscate?: boolean; }; // @public @@ -333,7 +335,12 @@ export type FormatSuframaOptions = { }; // @public -export const formatVoterId: (value: string | number) => string; +export const formatVoterId: (value: string | number, options?: FormatVoterIdOptions) => string; + +// @public +export type FormatVoterIdOptions = { + obfuscate?: boolean; +}; // @public export const generateBoleto: (params?: GenerateBoletoParams) => string; diff --git a/src/format-cnh/constants.ts b/src/format-cnh/constants.ts new file mode 100644 index 000000000..7bef84033 --- /dev/null +++ b/src/format-cnh/constants.ts @@ -0,0 +1,9 @@ +export const PATTERN = "000000000-00"; + +/** + * No authority publishes a masking rule for the CNH registry number, so this applies the rule + * Lei nº 12.309/2010, art. 87, § 5º, sets for the CPF ("ocultar os três primeiros dígitos e os + * dois dígitos verificadores") to a number with the same structure: the first 3 digits and the + * 2 check digits are hidden, e.g. "***456789-**". + */ +export const OBFUSCATED_PATTERN = "***000000-**"; diff --git a/src/format-cnh/format-cnh.test.ts b/src/format-cnh/format-cnh.test.ts index 51e0c9e5d..728495434 100644 --- a/src/format-cnh/format-cnh.test.ts +++ b/src/format-cnh/format-cnh.test.ts @@ -36,6 +36,21 @@ describe("formatCnh", () => { expect(formatCnh()).toBe(""); }); + it("should hide the first 3 digits and the 2 check digits when obfuscate is truthy", () => { + expect(formatCnh("98765432119", { obfuscate: true })).toBe("***654321-**"); + expect(formatCnh(98_765_432_119, { obfuscate: true })).toBe("***654321-**"); + expect(formatCnh("9876", { obfuscate: true })).toBe("***6"); + expect(formatCnh("9876", { pad: true, obfuscate: true })).toBe("***000098-**"); + // @ts-expect-error: intentionally not a boolean + expect(formatCnh("98765432119", { obfuscate: "yes" })).toBe("***654321-**"); + }); + + it("should behave exactly as without the option when obfuscate is falsy", () => { + expect(formatCnh("98765432119", { obfuscate: false })).toBe("987654321-19"); + // @ts-expect-error: intentionally not a boolean + expect(formatCnh("9876", { pad: true, obfuscate: "" })).toBe("000000098-76"); + }); + describe("properties", () => { const upToACnh = digitsUpTo(11); @@ -47,6 +62,14 @@ describe("formatCnh", () => { expectMatchesPattern(formatCnh, /^\d{9}-\d{2}$/, digits(11)); }); + test("should produce the documented obfuscated shape for a full CNH", () => { + expectMatchesPattern( + (value: string) => formatCnh(value, { obfuscate: true }), + /^\*{3}\d{6}-\*{2}$/, + digits(11), + ); + }); + test("should left pad a shorter value up to the CNH length", () => { expectPadsToLength(formatCnh, parseCnh, upToACnh, 11); }); @@ -64,7 +87,8 @@ describe("formatCnh types", () => { expectTypeOf(formatCnh).returns.toEqualTypeOf(); }); - test("should type the pad option as an optional boolean", () => { + test("should type the pad and obfuscate options as optional booleans", () => { expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); }); }); diff --git a/src/format-cnh/format-cnh.ts b/src/format-cnh/format-cnh.ts index 2086a9f8c..188cddd6c 100644 --- a/src/format-cnh/format-cnh.ts +++ b/src/format-cnh/format-cnh.ts @@ -1,11 +1,14 @@ import { format } from "../_internals/format/format"; import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { OBFUSCATED_PATTERN, PATTERN } from "./constants"; /** Options of `formatCnh`. */ export type FormatCnhOptions = { /** Whether to left pad the value with zeros up to the number of slots in the pattern (default: `false`). */ pad?: boolean; + /** Whether to hide the first 3 digits and the 2 check digits with `*` (default: `false`, read for truthiness like `pad`). */ + obfuscate?: boolean; }; /** @@ -14,19 +17,29 @@ export type FormatCnhOptions = { * @param {string|number} value - The CNH number to be formatted. * @param {FormatCnhOptions} [options] - Optional options. * @param {boolean} [options.pad] - Whether to pad the value with leading zeros. + * @param {boolean} [options.obfuscate] - If truthy, hides the first 3 digits and the 2 check + * digits. Read for truthiness, the way `pad` is, so a non-boolean such as `1` obfuscates too. * @returns {string} The formatted CNH, or an empty string when there is nothing to format. * * @example * ```typescript * formatCnh("12345678900"); // "123456789-00" * formatCnh("8900", { pad: true }); // "000000089-00" + * formatCnh("12345678900", { obfuscate: true }); // "***456789-**" * ``` * * Resolução CONTRAN nº 886/2021, art. 4º I, defines the CNH registry number as 9 characters plus * 2 security check digits, which is the layout this mask reproduces; no official text publishes * the check-digit weights used to compute them. * + * No authority publishes a masking rule for the CNH either, so `obfuscate` applies the one Lei nº + * 12.309/2010, art. 87, § 5º, sets for the CPF, a number with the same structure: the first 3 + * digits and the 2 check digits are hidden. + * * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/Resolucao8862021F.pdf + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm + * Lei nº 12.309/2010, art. 87, § 5º: "ocultar os três primeiros dígitos e os dois dígitos + * verificadores do CPF", the rule `obfuscate` borrows. */ export const formatCnh = (value: string | number, options?: FormatCnhOptions): string => isNullish(value) @@ -34,5 +47,5 @@ export const formatCnh = (value: string | number, options?: FormatCnhOptions): s : format({ pad: options?.pad, value: sanitizeToDigits(value), - pattern: "000000000-00", + pattern: (options?.obfuscate ?? false) ? OBFUSCATED_PATTERN : PATTERN, }); diff --git a/src/format-pis/constants.ts b/src/format-pis/constants.ts new file mode 100644 index 000000000..8dcfcca4b --- /dev/null +++ b/src/format-pis/constants.ts @@ -0,0 +1,9 @@ +export const PATTERN = "000.00000.00-0"; + +/** + * No authority publishes a masking rule for the PIS/PASEP/NIS, so this applies the rule Lei + * nº 12.309/2010, art. 87, § 5º, sets for the CPF ("ocultar os três primeiros dígitos e os dois + * dígitos verificadores") to a number with the same structure: the first 3 digits and the + * single check digit are hidden, e.g. "***.45678.90-*". + */ +export const OBFUSCATED_PATTERN = "***.00000.00-*"; diff --git a/src/format-pis/format-pis.test.ts b/src/format-pis/format-pis.test.ts index 188b3d52c..261b7238e 100644 --- a/src/format-pis/format-pis.test.ts +++ b/src/format-pis/format-pis.test.ts @@ -91,6 +91,21 @@ describe("formatPis", () => { expect(formatPis()).toBe(""); }); + it("should hide the first 3 digits and the check digit when obfuscate is truthy", () => { + expect(formatPis("12056412547", { obfuscate: true })).toBe("***.56412.54-*"); + expect(formatPis(12_056_412_547, { obfuscate: true })).toBe("***.56412.54-*"); + expect(formatPis("1205", { obfuscate: true })).toBe("***.5"); + expect(formatPis("1205", { pad: true, obfuscate: true })).toBe("***.00001.20-*"); + // @ts-expect-error: intentionally not a boolean + expect(formatPis("12056412547", { obfuscate: 1 })).toBe("***.56412.54-*"); + }); + + it("should behave exactly as without the option when obfuscate is falsy", () => { + expect(formatPis("12056412547", { obfuscate: false })).toBe("120.56412.54-7"); + // @ts-expect-error: intentionally not a boolean + expect(formatPis("1205", { pad: true, obfuscate: 0 })).toBe("000.00001.20-5"); + }); + describe("properties", () => { const upToAPis = digitsUpTo(11); @@ -102,6 +117,14 @@ describe("formatPis", () => { expectMatchesPattern(formatPis, /^\d{3}\.\d{5}\.\d{2}-\d$/, digits(11)); }); + test("should produce the documented obfuscated shape for a full PIS", () => { + expectMatchesPattern( + (value: string) => formatPis(value, { obfuscate: true }), + /^\*{3}\.\d{5}\.\d{2}-\*$/, + digits(11), + ); + }); + test("should left pad a shorter value up to the PIS length", () => { expectPadsToLength(formatPis, parsePis, upToAPis, PIS_LENGTH); }); @@ -119,7 +142,8 @@ describe("formatPis types", () => { expectTypeOf(formatPis).returns.toEqualTypeOf(); }); - test("should type the pad option as an optional boolean", () => { + test("should type the pad and obfuscate options as optional booleans", () => { expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); }); }); diff --git a/src/format-pis/format-pis.ts b/src/format-pis/format-pis.ts index 822241d6f..1d7026695 100644 --- a/src/format-pis/format-pis.ts +++ b/src/format-pis/format-pis.ts @@ -1,11 +1,14 @@ import { format } from "../_internals/format/format"; import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { OBFUSCATED_PATTERN, PATTERN } from "./constants"; /** Options of `formatPis`. */ export type FormatPisOptions = { /** Whether to left pad the value with zeros up to the number of slots in the pattern (default: `false`). */ pad?: boolean; + /** Whether to hide the first 3 digits and the check digit with `*` (default: `false`, read for truthiness like `pad`). */ + obfuscate?: boolean; }; /** @@ -14,6 +17,8 @@ export type FormatPisOptions = { * @param {string|number} value - The PIS number to be formatted. It can be a string or a number. * @param {FormatPisOptions} [options] - Optional formatting options. * @param {boolean} options.pad - If true, pads the value with leading zeros if necessary. + * @param {boolean} options.obfuscate - If truthy, hides the first 3 digits and the check digit. + * Read for truthiness, the way `pad` is, so a non-boolean such as `1` obfuscates too. * @returns {string} The formatted PIS number as a string. * * @example @@ -21,11 +26,19 @@ export type FormatPisOptions = { * formatPis("12345678901"); // "123.45678.90-1" * formatPis(12345678901); // "123.45678.90-1" * formatPis("123456789", { pad: true }); // "001.23456.78-9" + * formatPis("12345678901", { obfuscate: true }); // "***.45678.90-*" * ``` * + * No authority publishes a masking rule for the PIS, so `obfuscate` applies the one Lei nº + * 12.309/2010, art. 87, § 5º, sets for the CPF, a number with the same structure: the first 3 + * digits and the check digit are hidden. + * * @see Official: https://www.gov.br/inss/pt-br/direitos-e-deveres/inscricao-e-contribuicao/inscricao * @see Official: https://www.gov.br/esocial/pt-br/documentacao-tecnica/manuais/mos-manual-de-orientacao-do-esocial-vs-2-4.pdf * @see Official: https://www.sirc.gov.br/wp-content/uploads/manual_sirc_recomendacoes_tecnicas_v7.pdf + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm + * Lei nº 12.309/2010, art. 87, § 5º: "ocultar os três primeiros dígitos e os dois dígitos + * verificadores do CPF", the rule `obfuscate` borrows. * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/pis.py */ export const formatPis = (value: string | number, options?: FormatPisOptions): string => @@ -34,5 +47,5 @@ export const formatPis = (value: string | number, options?: FormatPisOptions): s : format({ pad: options?.pad, value: sanitizeToDigits(value), - pattern: "000.00000.00-0", + pattern: (options?.obfuscate ?? false) ? OBFUSCATED_PATTERN : PATTERN, }); diff --git a/src/format-voter-id/constants.ts b/src/format-voter-id/constants.ts new file mode 100644 index 000000000..8e3519d0b --- /dev/null +++ b/src/format-voter-id/constants.ts @@ -0,0 +1,16 @@ +export const PATTERNS = { + standard: "0000 0000 00 00", + extended: "0000 0000 0 00 00", +}; + +/** + * No authority publishes a masking rule for the título de eleitor, so this applies the rule Lei + * nº 12.309/2010, art. 87, § 5º, sets for the CPF ("ocultar os três primeiros dígitos e os dois + * dígitos verificadores") to a number with the same structure: the first 3 digits of the + * sequential number and the 2 check digits are hidden, e.g. "***4 5678 01 **". The federative + * union code stays visible. + */ +export const OBFUSCATED_PATTERNS = { + standard: "***0 0000 00 **", + extended: "***0 0000 0 00 **", +}; diff --git a/src/format-voter-id/format-voter-id.test.ts b/src/format-voter-id/format-voter-id.test.ts index b7e6ae193..11d9a9d45 100644 --- a/src/format-voter-id/format-voter-id.test.ts +++ b/src/format-voter-id/format-voter-id.test.ts @@ -3,7 +3,7 @@ import * as fc from "fast-check"; import { anyValue, digits, digitsUpTo } from "../_internals/test/arbitraries"; import { expectAlwaysReturnsType } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; -import { formatVoterId } from "./format-voter-id"; +import { type FormatVoterIdOptions, formatVoterId } from "./format-voter-id"; describe("formatVoterId", () => { it("should format voter ids", () => { @@ -43,6 +43,23 @@ describe("formatVoterId", () => { expect(formatVoterId("123456788019")).toBe("1234 5678 80 19"); }); + it("should hide the first 3 digits and the 2 check digits when obfuscate is truthy", () => { + expect(formatVoterId("123456780124", { obfuscate: true })).toBe("***4 5678 01 **"); + expect(formatVoterId(123_456_780_124, { obfuscate: true })).toBe("***4 5678 01 **"); + expect(formatVoterId("1234567880191", { obfuscate: true })).toBe("***4 5678 8 01 **"); + expect(formatVoterId("1234567880399", { obfuscate: true })).toBe("***4 5678 80 **"); + expect(formatVoterId("12345", { obfuscate: true })).toBe("***4 5"); + // @ts-expect-error: intentionally not a boolean + expect(formatVoterId("123456780124", { obfuscate: 1 })).toBe("***4 5678 01 **"); + }); + + it("should behave exactly as without the option when obfuscate is falsy", () => { + expect(formatVoterId("123456780124", { obfuscate: false })).toBe("1234 5678 01 24"); + expect(formatVoterId("1234567880191", {})).toBe("1234 5678 8 01 91"); + // @ts-expect-error: intentionally not a boolean + expect(formatVoterId("1234567880191", { obfuscate: 0 })).toBe("1234 5678 8 01 91"); + }); + it("should return an empty string for null or undefined", () => { // @ts-expect-error: intentionally invalid input expect(formatVoterId(null)).toBe(""); @@ -64,8 +81,14 @@ describe("formatVoterId", () => { test("should use the grouping documented for each of the two lengths", () => { fc.assert( fc.property(digits(12), digits(9), fc.constantFrom("01", "02"), (short, sequential, uf) => { + const extended = `${sequential}${uf}00`; + expect(formatVoterId(short)).toMatch(/^\d{4} \d{4} \d{2} \d{2}$/); - expect(formatVoterId(`${sequential}${uf}00`)).toMatch(/^\d{4} \d{4} \d \d{2} \d{2}$/); + expect(formatVoterId(extended)).toMatch(/^\d{4} \d{4} \d \d{2} \d{2}$/); + expect(formatVoterId(short, { obfuscate: true })).toMatch(/^\*{3}\d \d{4} \d{2} \*{2}$/); + expect(formatVoterId(extended, { obfuscate: true })).toMatch( + /^\*{3}\d \d{4} \d \d{2} \*{2}$/, + ); }), ); }); @@ -85,8 +108,13 @@ describe("formatVoterId", () => { }); describe("formatVoterId types", () => { - test("should take a string or number value and return a string", () => { + test("should take a string or number value and options and return a string", () => { expectTypeOf(formatVoterId).parameter(0).toEqualTypeOf(); + expectTypeOf(formatVoterId).parameter(1).toEqualTypeOf(); expectTypeOf(formatVoterId).returns.toEqualTypeOf(); }); + + test("should type the obfuscate option as an optional boolean", () => { + expectTypeOf().toEqualTypeOf(); + }); }); diff --git a/src/format-voter-id/format-voter-id.ts b/src/format-voter-id/format-voter-id.ts index e6054adb3..7ab0ce13a 100644 --- a/src/format-voter-id/format-voter-id.ts +++ b/src/format-voter-id/format-voter-id.ts @@ -1,13 +1,16 @@ import { NINE_DIGIT_FEDERATIVE_UNION_CODES } from "../_internals/constants/voter-id"; import { format } from "../_internals/format/format"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; - -const PATTERN = "0000 0000 00 00"; - -const EXTENDED_PATTERN = "0000 0000 0 00 00"; +import { OBFUSCATED_PATTERNS, PATTERNS } from "./constants"; const LENGTH = 12; +/** Options of `formatVoterId`. */ +export type FormatVoterIdOptions = { + /** Whether to hide the first 3 digits and the 2 check digits with `*` (default: `false`, read for truthiness). */ + obfuscate?: boolean; +}; + /** * Formats a Brazilian voter id (título de eleitor) for display. * @@ -17,14 +20,23 @@ const LENGTH = 12; * the two states whose voter ids may carry a 9-digit sequential number. * * @param {string|number} value - The voter id value to be formatted. + * @param {FormatVoterIdOptions} [options] - Optional formatting options. + * @param {boolean} [options.obfuscate] - If truthy, hides the first 3 digits and the 2 check + * digits. Read for truthiness, so a non-boolean such as `1` obfuscates too. * @returns {string} The formatted voter id string. * * @example * ```typescript * formatVoterId("123456780124"); // "1234 5678 01 24" * formatVoterId("1234567880191"); // "1234 5678 8 01 91" + * formatVoterId("123456780124", { obfuscate: true }); // "***4 5678 01 **" + * formatVoterId("1234567880191", { obfuscate: true }); // "***4 5678 8 01 **" * ``` * + * No authority publishes a masking rule for the voter id, so `obfuscate` applies the one Lei nº + * 12.309/2010, art. 87, § 5º, sets for the CPF, a number with the same structure: the first 3 + * digits and the 2 check digits are hidden, and the federative union code stays visible. + * * The 13-digit São Paulo/Minas Gerais grouping is brutils parity, not published by the TSE. A * 14-or-more-digit input is read the same way as a 13-digit one: it is grouped as a São Paulo or * Minas Gerais id whenever its 10th and 11th digits are "01"/"02". Both patterns have a fixed @@ -36,14 +48,17 @@ const LENGTH = 12; * client, so it has to be opened in a browser. * * @see Official: https://www.tse.jus.br/legislacao/compilada/res/2021/resolucao-no-23-659-de-26-de-outubro-de-2021 + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm + * Lei nº 12.309/2010, art. 87, § 5º: "ocultar os três primeiros dígitos e os dois dígitos + * verificadores do CPF", the rule `obfuscate` borrows. * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/voter_id.py */ -export const formatVoterId = (value: string | number): string => { +export const formatVoterId = (value: string | number, options?: FormatVoterIdOptions): string => { const digits = sanitizeToDigits(value); const federativeUnion = digits.slice(9, 11); const isExtended = digits.length > LENGTH && NINE_DIGIT_FEDERATIVE_UNION_CODES.includes(federativeUnion); - const pattern = isExtended ? EXTENDED_PATTERN : PATTERN; + const patterns = (options?.obfuscate ?? false) ? OBFUSCATED_PATTERNS : PATTERNS; - return format({ value: digits, pattern }); + return format({ value: digits, pattern: isExtended ? patterns.extended : patterns.standard }); }; diff --git a/src/index.test.ts b/src/index.test.ts index 136f06759..63934009b 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -36,6 +36,7 @@ import { type FormatPisOptions, type FormatProcessoJuridicoOptions, type FormatSuframaOptions, + type FormatVoterIdOptions, type GenerateBoletoParams, type GenerateCnpjParams, type GenerateLicensePlateFormat, @@ -341,6 +342,7 @@ describe("Public API", () => { FormatPisOptions: FormatPisOptions; FormatProcessoJuridicoOptions: FormatProcessoJuridicoOptions; FormatSuframaOptions: FormatSuframaOptions; + FormatVoterIdOptions: FormatVoterIdOptions; GenerateBoletoParams: GenerateBoletoParams; GenerateCnpjParams: GenerateCnpjParams; GenerateLicensePlateFormat: GenerateLicensePlateFormat; diff --git a/src/index.ts b/src/index.ts index 4e3f7e32a..d865a49ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,7 +54,7 @@ export { formatProcessoJuridico, } from "./format-processo-juridico/format-processo-juridico"; export { type FormatSuframaOptions, formatSuframa } from "./format-suframa/format-suframa"; -export { formatVoterId } from "./format-voter-id/format-voter-id"; +export { type FormatVoterIdOptions, formatVoterId } from "./format-voter-id/format-voter-id"; export { type GenerateBoletoParams, generateBoleto } from "./generate-boleto/generate-boleto"; export { generateCep } from "./generate-cep/generate-cep"; export { generateCnh } from "./generate-cnh/generate-cnh"; From e974e4b11e9cfa42057539046b454aa003a12ca4 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:03:29 -0300 Subject: [PATCH 3/6] docs: document the obfuscate options Each entry says which characters stay visible and where the choice comes from: the gov.br account screens for phones, and Lei 12.309/2010 for the numbers that share the structure of a CPF. English and Portuguese. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RLkm9YrtAifc6XCLFVEsdH --- docs/pt-br/utilities.md | 32 ++++++++++++++++++++++++++++---- docs/utilities.md | 32 ++++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index 1d56b36b0..ac8d75b4e 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -721,11 +721,13 @@ Fonte: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legis Formata um número de telefone de acordo com os padrões brasileiros. Se `value` incluir o DDD, informe `{ mask: 'auto' }` ou `'nanp'`: a máscara padrão `"sn"` assume que não há DDD e o trunca. -- **Opções** (`FormatPhoneOptions`): `mask` (`PhoneMask`, padrão `"sn"`) escolhe um dos padrões abaixo. Uma `mask` desconhecida recai para `"sn"`. +- **Opções** (`FormatPhoneOptions`): `mask` (`PhoneMask`, padrão `"sn"`) escolhe um dos padrões abaixo. Uma `mask` desconhecida recai para `"sn"`. `obfuscate` (padrão `false`) esconde o número do assinante em todas as máscaras. - `"sn"`: apenas o número assinante, 9 dígitos. `"nanp"`: DDD mais número assinante, 11 dígitos para celular e 10 para fixo; outros tamanhos mantêm o agrupamento de 11 dígitos. - `"e164"` e `"international"` removem antes o código de país, como `parsePhone`, e recaem para `"service"` para um número de serviço. - `"service"`: os Códigos Não Geográficos (`0800 123 4567`) e os números abreviados `300X`/`400X` (`4004-1234`). - `"auto"`: `"service"` para um número de serviço, `"international"` quando `value` traz código de país, senão `"nanp"` para mais de 9 dígitos, ou `"sn"`. +- O `obfuscate` mantém os 2 últimos dígitos, a contagem que a conta gov.br usa para o celular cadastrado, e mantém o prefixo que indica uma região ou um serviço, e não um assinante: o DDD, o código do tipo `0800` e a raiz `300X`/`400X`. +- Um código de utilidade pública de 3 dígitos (`190`) não identifica ninguém e é devolvido como está; um valor que a máscara `"service"` não reconhece é escondido por inteiro. Os padrões ofuscados têm um número fixo de posições, então em `"e164"` o que passa do 11º dígito nacional é descartado. ```javascript import { formatPhone } from '@brazilian-utils/brazilian-utils'; @@ -741,10 +743,17 @@ formatPhone('08001234567', { mask: 'service' }); // 0800 123 4567 formatPhone('40041234', { mask: 'service' }); // 4004-1234 formatPhone('+5511987654321', { mask: 'auto' }); // +55 11 98765-4321 ("auto" detecta o prefixo +55 e escolhe "international") formatPhone('5508001234567', { mask: 'auto' }); // 0800 123 4567 ("auto" lê o número 0800, não um +55 08) +formatPhone('987654321', { obfuscate: true }); // *****-**21 +formatPhone('11987654321', { mask: 'auto', obfuscate: true }); // (11) *****-**21 +formatPhone('1130000000', { mask: 'auto', obfuscate: true }); // (11) ****-**00 +formatPhone('+5511987654321', { mask: 'auto', obfuscate: true }); // +55 11 *****-**21 +formatPhone('11987654321', { mask: 'e164', obfuscate: true }); // +5511*******21 +formatPhone('08001234567', { mask: 'service', obfuscate: true }); // 0800 *** **67 +formatPhone('40041234', { mask: 'service', obfuscate: true }); // 4004-**34 formatPhone('11900000000'); // 11900-0000 (CUIDADO: a máscara padrão "sn" trunca um número com DDD) ``` -Fonte: [ITU-T E.164](https://www.itu.int/rec/T-REC-E.164), [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749). +Fonte: [ITU-T E.164](https://www.itu.int/rec/T-REC-E.164), [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [conta gov.br](https://acesso.gov.br/faq/_perguntasdafaq/formarrecuperarconta.html) para quantos dígitos de um celular ficam visíveis. ### parsePhone @@ -1009,13 +1018,16 @@ isValidPis('12056412547'); // false Formata um PIS. -- **Opções** (`FormatPisOptions`): `pad` completa o valor com zeros à esquerda até 11 dígitos antes de aplicar a máscara (padrão `false`). +- **Opções** (`FormatPisOptions`): `pad` completa o valor com zeros à esquerda até 11 dígitos antes de aplicar a máscara (padrão `false`); `obfuscate` esconde os 3 primeiros dígitos e o dígito verificador. +- O `obfuscate` é aplicado depois do `pad`. +- Nenhuma autoridade publica uma regra de mascaramento para o PIS, então o `obfuscate` usa a que a Lei nº 12.309/2010, art. 87, § 5º define para o CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores"), um número com a mesma estrutura. ```javascript import { formatPis } from '@brazilian-utils/brazilian-utils'; formatPis('12345678901'); // 123.45678.90-1 formatPis('123456789', { pad: true }); // 001.23456.78-9 +formatPis('12345678901', { obfuscate: true }); // ***.45678.90-* ``` ### parsePis @@ -1038,6 +1050,8 @@ import { generatePis } from '@brazilian-utils/brazilian-utils'; generatePis(); // '91077906857' ``` +Fonte: [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm), a regra de mascaramento do CPF que o `obfuscate` toma emprestada. + ## Processo jurídico ### isValidProcessoJuridico @@ -1992,13 +2006,16 @@ Fonte: [Resolução CONTRAN nº 886/2021, art. 4º](https://www.gov.br/transport Formata uma CNH. -- **Opções** (`FormatCnhOptions`): `pad` completa o valor com zeros à esquerda até os 11 dígitos antes de aplicar a máscara (padrão `false`). +- **Opções** (`FormatCnhOptions`): `pad` completa o valor com zeros à esquerda até os 11 dígitos antes de aplicar a máscara (padrão `false`); `obfuscate` esconde os 3 primeiros dígitos e os 2 dígitos verificadores. +- O `obfuscate` é aplicado depois do `pad`. +- Nenhuma autoridade publica uma regra de mascaramento para a CNH, então o `obfuscate` usa a que a Lei nº 12.309/2010, art. 87, § 5º define para o CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores"), um número com a mesma estrutura. ```javascript import { formatCnh } from '@brazilian-utils/brazilian-utils'; formatCnh('02650306461'); // 026503064-61 formatCnh('2650306461', { pad: true }); // 026503064-61 +formatCnh('02650306461', { obfuscate: true }); // ***503064-** ``` ### parseCnh @@ -2021,6 +2038,8 @@ import { generateCnh } from '@brazilian-utils/brazilian-utils'; generateCnh(); // '02650306461' ``` +Fonte: [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm), a regra de mascaramento do CPF que o `obfuscate` toma emprestada. + ## Natureza jurídica ### isValidLegalNature @@ -2185,13 +2204,16 @@ Fonte: [Resolução TSE nº 23.659/2021, art. 36](https://www.tse.jus.br/legisla Formata um título de eleitor com o agrupamento de 12 dígitos `0000 0000 00 00`. +- **Opções** (`FormatVoterIdOptions`): `obfuscate` esconde os 3 primeiros dígitos e os 2 dígitos verificadores, deixando visível o código da unidade federativa. - O agrupamento de 13 dígitos `0000 0000 0 00 00` só é usado quando o valor tem mais de 12 dígitos e o código da UF (o 10º e o 11º dígitos) é `01` ou `02`. - Os dígitos além da última posição do padrão são descartados. +- Nenhuma autoridade publica uma regra de mascaramento para o título de eleitor, então o `obfuscate` usa a que a Lei nº 12.309/2010, art. 87, § 5º define para o CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores"), um número com a mesma estrutura. ```javascript import { formatVoterId } from '@brazilian-utils/brazilian-utils'; formatVoterId('123456780175'); // '1234 5678 01 75' +formatVoterId('123456780175', { obfuscate: true }); // '***4 5678 01 **' formatVoterId('1234567880191'); // '1234 5678 8 01 91' (título de 13 dígitos SP/MG) ``` @@ -2221,6 +2243,8 @@ generateVoterId('SP'); // título de eleitor aleatório válido de São Paulo generateVoterId('XX'); // usa "ZZ" em vez de lançar erro ``` +Fonte: [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm), a regra de mascaramento do CPF que o `obfuscate` toma emprestada. + ## CNS ### isValidCns diff --git a/docs/utilities.md b/docs/utilities.md index 8bba70b00..e60ac4886 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -721,11 +721,13 @@ Source: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legi Format a phone number according to Brazilian patterns. If `value` includes a DDD, pass `{ mask: 'auto' }` or `'nanp'`: the default `"sn"` mask assumes no DDD and truncates one. -- **Options** (`FormatPhoneOptions`): `mask` (`PhoneMask`, default `"sn"`) picks one of the patterns below. An unknown `mask` falls back to `"sn"`. +- **Options** (`FormatPhoneOptions`): `mask` (`PhoneMask`, default `"sn"`) picks one of the patterns below. An unknown `mask` falls back to `"sn"`. `obfuscate` (default `false`) hides the subscriber number under every mask. - `"sn"`: subscriber number only, 9 digits. `"nanp"`: DDD plus subscriber number, 11 digits for a mobile and 10 for a landline; any other length keeps the 11 digit grouping. - `"e164"` and `"international"` drop the country code first, as `parsePhone` does, and fall back to `"service"` for a service number. - `"service"`: the Códigos Não Geográficos (`0800 123 4567`) and the abbreviated `300X`/`400X` numbers (`4004-1234`). - `"auto"`: `"service"` for a service number, `"international"` when `value` carries a country code, otherwise `"nanp"` for more than 9 digits, else `"sn"`. +- `obfuscate` keeps the last 2 digits, the count the gov.br account shows for a registered mobile, and keeps the prefix that names a region or a service instead of a subscriber: the DDD, the `0800`-like code and the `300X`/`400X` root. +- A 3 digit public utility code (`190`) identifies no one and is returned as it is; a value the `"service"` mask does not recognize is hidden entirely. The obfuscated patterns have a fixed number of slots, so under `"e164"` anything past the 11th national digit is dropped. ```javascript import { formatPhone } from '@brazilian-utils/brazilian-utils'; @@ -741,10 +743,17 @@ formatPhone('08001234567', { mask: 'service' }); // 0800 123 4567 formatPhone('40041234', { mask: 'service' }); // 4004-1234 formatPhone('+5511987654321', { mask: 'auto' }); // +55 11 98765-4321 ("auto" detects the +55 prefix and picks "international") formatPhone('5508001234567', { mask: 'auto' }); // 0800 123 4567 ("auto" reads the 0800 number, not a +55 08 one) +formatPhone('987654321', { obfuscate: true }); // *****-**21 +formatPhone('11987654321', { mask: 'auto', obfuscate: true }); // (11) *****-**21 +formatPhone('1130000000', { mask: 'auto', obfuscate: true }); // (11) ****-**00 +formatPhone('+5511987654321', { mask: 'auto', obfuscate: true }); // +55 11 *****-**21 +formatPhone('11987654321', { mask: 'e164', obfuscate: true }); // +5511*******21 +formatPhone('08001234567', { mask: 'service', obfuscate: true }); // 0800 *** **67 +formatPhone('40041234', { mask: 'service', obfuscate: true }); // 4004-**34 formatPhone('11900000000'); // 11900-0000 (BEWARE: default "sn" truncates a DDD-prefixed number) ``` -Source: [ITU-T E.164](https://www.itu.int/rec/T-REC-E.164), [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749). +Source: [ITU-T E.164](https://www.itu.int/rec/T-REC-E.164), [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749), [conta gov.br](https://acesso.gov.br/faq/_perguntasdafaq/formarrecuperarconta.html) for how many digits of a mobile stay visible. ### parsePhone @@ -1009,13 +1018,16 @@ isValidPis('12056412547'); // false Format a PIS. -- **Options** (`FormatPisOptions`): `pad` left-pads the value with zeros to 11 digits before masking (default `false`). +- **Options** (`FormatPisOptions`): `pad` left-pads the value with zeros to 11 digits before masking (default `false`); `obfuscate` hides the first 3 digits and the check digit. +- `obfuscate` is applied after `pad`. +- No authority publishes a masking rule for the PIS, so `obfuscate` applies the one Lei nº 12.309/2010, art. 87, § 5º sets for the CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores"), a number with the same structure. ```javascript import { formatPis } from '@brazilian-utils/brazilian-utils'; formatPis('12345678901'); // 123.45678.90-1 formatPis('123456789', { pad: true }); // 001.23456.78-9 +formatPis('12345678901', { obfuscate: true }); // ***.45678.90-* ``` ### parsePis @@ -1038,6 +1050,8 @@ import { generatePis } from '@brazilian-utils/brazilian-utils'; generatePis(); // '91077906857' ``` +Source: [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm), the CPF masking rule `obfuscate` borrows. + ## Processo jurídico ### isValidProcessoJuridico @@ -1992,13 +2006,16 @@ Source: [Resolução CONTRAN nº 886/2021, art. 4º](https://www.gov.br/transpor Format a CNH. -- **Options** (`FormatCnhOptions`): `pad` left-pads the value with zeros to the full 11 digits before masking (default `false`). +- **Options** (`FormatCnhOptions`): `pad` left-pads the value with zeros to the full 11 digits before masking (default `false`); `obfuscate` hides the first 3 digits and the 2 check digits. +- `obfuscate` is applied after `pad`. +- No authority publishes a masking rule for the CNH, so `obfuscate` applies the one Lei nº 12.309/2010, art. 87, § 5º sets for the CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores"), a number with the same structure. ```javascript import { formatCnh } from '@brazilian-utils/brazilian-utils'; formatCnh('02650306461'); // 026503064-61 formatCnh('2650306461', { pad: true }); // 026503064-61 +formatCnh('02650306461', { obfuscate: true }); // ***503064-** ``` ### parseCnh @@ -2021,6 +2038,8 @@ import { generateCnh } from '@brazilian-utils/brazilian-utils'; generateCnh(); // '02650306461' ``` +Source: [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm), the CPF masking rule `obfuscate` borrows. + ## Legal nature ### isValidLegalNature @@ -2185,13 +2204,16 @@ Source: [Resolução TSE nº 23.659/2021, art. 36](https://www.tse.jus.br/legisl Format a voter ID number with the 12-digit grouping `0000 0000 00 00`. +- **Options** (`FormatVoterIdOptions`): `obfuscate` hides the first 3 digits and the 2 check digits, leaving the federative union code visible. - The 13-digit grouping `0000 0000 0 00 00` is used only when the value has more than 12 digits and its UF code (the 10th and 11th digits) is `01` or `02`. - Digits past the last slot of the pattern are dropped. +- No authority publishes a masking rule for the voter ID, so `obfuscate` applies the one Lei nº 12.309/2010, art. 87, § 5º sets for the CPF ("ocultar os três primeiros dígitos e os dois dígitos verificadores"), a number with the same structure. ```javascript import { formatVoterId } from '@brazilian-utils/brazilian-utils'; formatVoterId('123456780175'); // '1234 5678 01 75' +formatVoterId('123456780175', { obfuscate: true }); // '***4 5678 01 **' formatVoterId('1234567880191'); // '1234 5678 8 01 91' (13-digit SP/MG voter id) ``` @@ -2221,6 +2243,8 @@ generateVoterId('SP'); // valid random voter ID for Sao Paulo generateVoterId('XX'); // falls back to "ZZ" instead of throwing ``` +Source: [Lei nº 12.309/2010, art. 87, § 5º](https://www.planalto.gov.br/ccivil_03/_ato2007-2010/2010/lei/l12309.htm), the CPF masking rule `obfuscate` borrows. + ## CNS ### isValidCns From 99360b286421eb93a49bc4173039625251074b8d Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:44:13 -0300 Subject: [PATCH 4/6] refactor(phone): build the e164 pattern only when obfuscating formatE164 resolved the obfuscated pattern on every call and then threw it away on the plain path, which reads as if the plain path used it. Return the plain form first so the pattern is only built when it is going to be used. No behaviour change: the plain branch already ignored the pattern. --- src/format-phone/format-phone.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/format-phone/format-phone.ts b/src/format-phone/format-phone.ts index 05cedc631..3d9a3dda8 100644 --- a/src/format-phone/format-phone.ts +++ b/src/format-phone/format-phone.ts @@ -76,9 +76,11 @@ const formatInternational = (national: string, masks: PhoneMasks): string => { const formatE164 = (national: string, obfuscate: boolean): string => { if (!national) return ""; + if (!obfuscate) return `${INTERNATIONAL_PREFIX}${national}`; + const pattern = resolveLinePattern(national, OBFUSCATED_E164_MASK); - return `${INTERNATIONAL_PREFIX}${obfuscate ? format({ value: national, pattern }) : national}`; + return `${INTERNATIONAL_PREFIX}${format({ value: national, pattern })}`; }; const resolveNationalPattern = (digits: string, mask: NationalMask, masks: PhoneMasks): string => From bf761bc25ef4ac08409ba5489534c1440a89563c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 02:23:42 +0000 Subject: [PATCH 5/6] docs(obfuscate): say which digits the masks really keep The formatPhone JSDoc and the utilities pages promised "only the last 2 digits" under every mask, but the default "sn" mask truncates a DDD-prefixed value first, so the visible pair is the 8th and 9th digit of the value, not its last two. Say so where the promise is made, next to the truncation warning the plain examples already carry, and pin it with a test. The same paragraph said a value the "service" mask does not recognize is "hidden entirely". Every digit is replaced by a "*", so the digits are hidden but the digit count is not, and that tells a reader whether the value was a mobile, a landline or a fragment. Describe it as it is. --- docs/pt-br/utilities.md | 7 +++++-- docs/utilities.md | 7 +++++-- src/format-phone/format-phone.test.ts | 6 ++++++ src/format-phone/format-phone.ts | 28 ++++++++++++++++----------- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index ac8d75b4e..4c3cb3d7d 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -726,8 +726,9 @@ Formata um número de telefone de acordo com os padrões brasileiros. Se `value` - `"e164"` e `"international"` removem antes o código de país, como `parsePhone`, e recaem para `"service"` para um número de serviço. - `"service"`: os Códigos Não Geográficos (`0800 123 4567`) e os números abreviados `300X`/`400X` (`4004-1234`). - `"auto"`: `"service"` para um número de serviço, `"international"` quando `value` traz código de país, senão `"nanp"` para mais de 9 dígitos, ou `"sn"`. -- O `obfuscate` mantém os 2 últimos dígitos, a contagem que a conta gov.br usa para o celular cadastrado, e mantém o prefixo que indica uma região ou um serviço, e não um assinante: o DDD, o código do tipo `0800` e a raiz `300X`/`400X`. -- Um código de utilidade pública de 3 dígitos (`190`) não identifica ninguém e é devolvido como está; um valor que a máscara `"service"` não reconhece é escondido por inteiro. Os padrões ofuscados têm um número fixo de posições, então em `"e164"` o que passa do 11º dígito nacional é descartado. +- O `obfuscate` mantém 2 dígitos, a contagem que a conta gov.br usa para o celular cadastrado, e mantém o prefixo que indica uma região ou um serviço, e não um assinante: o DDD, o código do tipo `0800` e a raiz `300X`/`400X`. +- Os 2 dígitos são os últimos que cabem na própria máscara, então na máscara padrão `"sn"` um valor com DDD é truncado antes, igual ao que acontece sem `obfuscate`, e o par visível é o 8º e o 9º dígito, e não os 2 últimos de `value`. +- Um código de utilidade pública de 3 dígitos (`190`) não identifica ninguém e é devolvido como está; num valor que a máscara `"service"` não reconhece cada dígito vira um `*`, o que esconde os dígitos, mas não quantos eram. Os padrões ofuscados têm um número fixo de posições, então em `"e164"` o que passa do 11º dígito nacional é descartado. ```javascript import { formatPhone } from '@brazilian-utils/brazilian-utils'; @@ -750,6 +751,8 @@ formatPhone('+5511987654321', { mask: 'auto', obfuscate: true }); // +55 11 **** formatPhone('11987654321', { mask: 'e164', obfuscate: true }); // +5511*******21 formatPhone('08001234567', { mask: 'service', obfuscate: true }); // 0800 *** **67 formatPhone('40041234', { mask: 'service', obfuscate: true }); // 4004-**34 +formatPhone('11988887766', { mask: 'service', obfuscate: true }); // *********** (não é número de serviço) +formatPhone('11987654321', { obfuscate: true }); // *****-**43 (CUIDADO: a "sn" trunca antes, então "43", e não "21") formatPhone('11900000000'); // 11900-0000 (CUIDADO: a máscara padrão "sn" trunca um número com DDD) ``` diff --git a/docs/utilities.md b/docs/utilities.md index e60ac4886..842e477c0 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -726,8 +726,9 @@ Format a phone number according to Brazilian patterns. If `value` includes a DDD - `"e164"` and `"international"` drop the country code first, as `parsePhone` does, and fall back to `"service"` for a service number. - `"service"`: the Códigos Não Geográficos (`0800 123 4567`) and the abbreviated `300X`/`400X` numbers (`4004-1234`). - `"auto"`: `"service"` for a service number, `"international"` when `value` carries a country code, otherwise `"nanp"` for more than 9 digits, else `"sn"`. -- `obfuscate` keeps the last 2 digits, the count the gov.br account shows for a registered mobile, and keeps the prefix that names a region or a service instead of a subscriber: the DDD, the `0800`-like code and the `300X`/`400X` root. -- A 3 digit public utility code (`190`) identifies no one and is returned as it is; a value the `"service"` mask does not recognize is hidden entirely. The obfuscated patterns have a fixed number of slots, so under `"e164"` anything past the 11th national digit is dropped. +- `obfuscate` keeps 2 digits, the count the gov.br account shows for a registered mobile, and keeps the prefix that names a region or a service instead of a subscriber: the DDD, the `0800`-like code and the `300X`/`400X` root. +- The 2 digits are the last ones the mask itself has room for, so under the default `"sn"` a DDD-prefixed value is truncated first, exactly as it is without `obfuscate`, and the visible pair is the 8th and 9th digit rather than the last 2 of `value`. +- A 3 digit public utility code (`190`) identifies no one and is returned as it is; a value the `"service"` mask does not recognize has every digit replaced by a `*`, which hides the digits but not how many there were. The obfuscated patterns have a fixed number of slots, so under `"e164"` anything past the 11th national digit is dropped. ```javascript import { formatPhone } from '@brazilian-utils/brazilian-utils'; @@ -750,6 +751,8 @@ formatPhone('+5511987654321', { mask: 'auto', obfuscate: true }); // +55 11 **** formatPhone('11987654321', { mask: 'e164', obfuscate: true }); // +5511*******21 formatPhone('08001234567', { mask: 'service', obfuscate: true }); // 0800 *** **67 formatPhone('40041234', { mask: 'service', obfuscate: true }); // 4004-**34 +formatPhone('11988887766', { mask: 'service', obfuscate: true }); // *********** (not a service number) +formatPhone('11987654321', { obfuscate: true }); // *****-**43 (BEWARE: "sn" truncates first, so "43", not "21") formatPhone('11900000000'); // 11900-0000 (BEWARE: default "sn" truncates a DDD-prefixed number) ``` diff --git a/src/format-phone/format-phone.test.ts b/src/format-phone/format-phone.test.ts index 7f0b33301..dd437c68e 100644 --- a/src/format-phone/format-phone.test.ts +++ b/src/format-phone/format-phone.test.ts @@ -177,6 +177,12 @@ describe("formatPhone", () => { expect(formatPhone("988887766", { mask: "auto", obfuscate: true })).toBe("*****-**66"); }); + it("should truncate a DDD-prefixed value under the default mask before obfuscating it", () => { + expect(formatPhone("11988887766", { obfuscate: true })).toBe("*****-**77"); + expect(formatPhone("11988887766", { mask: "sn", obfuscate: true })).toBe("*****-**77"); + expect(formatPhone("1130001234", { mask: "sn", obfuscate: true })).toBe("*****-**23"); + }); + it("should keep the country code and the DDD when obfuscating the international masks", () => { expect(formatPhone("11988887766", { mask: "international", obfuscate: true })).toBe( "+55 11 *****-**66", diff --git a/src/format-phone/format-phone.ts b/src/format-phone/format-phone.ts index 3d9a3dda8..121b7282f 100644 --- a/src/format-phone/format-phone.ts +++ b/src/format-phone/format-phone.ts @@ -28,7 +28,7 @@ export type PhoneMask = "auto" | "e164" | "international" | "service" | "sn" | " export type FormatPhoneOptions = { /** Which mask to apply, or `"auto"` to pick one from the value (default: `"sn"`). */ mask?: PhoneMask; - /** Whether to hide the subscriber number with `*`, except its last 2 digits (default: `false`, read for truthiness). */ + /** Whether to hide the subscriber number with `*`, except the last 2 digits the mask has room for (default: `false`, read for truthiness). */ obfuscate?: boolean; }; @@ -41,9 +41,9 @@ const matchesPrefix = (digits: string, prefixes: readonly string[]): boolean => * masks would print for it anyway (their first separator only appears once the value is longer * than the prefix that selects the mask). * - * Under `obfuscate` such a value has no prefix that tells which digits are safe to show, so it - * is hidden entirely, unless it is one of the 3 digit public utility codes (`190`), which - * identify no subscriber. + * Under `obfuscate` such a value has no prefix that tells which digits are safe to show, so + * every one of its digits becomes a `*` and only how many there were stays visible, unless it + * is one of the 3 digit public utility codes (`190`), which identify no subscriber. * @param {string} digits - The digits of a service number. * @param {boolean} obfuscate - Whether to hide the digits after the prefix, except the last 2. * @returns {string} The digits under the mask of their service number family. @@ -134,17 +134,21 @@ const isPhoneMask = (value: unknown): value is PhoneMask => PHONE_MASKS.has(valu * `options.obfuscate` hides the subscriber number under every mask, for the places where a * number is shown to someone who should only recognize it (LGPD, art. 6º III, necessidade). The * gov.br account shows the registered mobile as `"*********00"`, only the last 2 digits, and - * this keeps that count. The prefix that names a region or a service instead of a subscriber - * also stays: the DDD, the `0800`-like code and the `300X`/`400X` root. A 3 digit public - * utility code (`190`) identifies no one and is returned as it is, and a value the `"service"` - * mask does not recognize is hidden entirely. The patterns have a fixed number of slots, so - * under `"e164"` anything past the 11th national digit is dropped. + * this keeps that count. The 2 digits are the last ones the mask itself has room for, so under + * the default `"sn"` a DDD-prefixed value is truncated first, exactly as it is without + * `obfuscate`, and the visible pair is the 8th and 9th digit rather than the last 2 of `value`. + * The prefix that names a region or a service instead of a subscriber also stays: the DDD, the + * `0800`-like code and the `300X`/`400X` root. A 3 digit public utility code (`190`) identifies + * no one and is returned as it is, and a value the `"service"` mask does not recognize has + * every digit replaced by a `*`, which hides the digits but not how many there were. The + * patterns have a fixed number of slots, so under `"e164"` anything past the 11th national + * digit is dropped. * * @param {string|number} value - The phone number to format, either as a string or a number. * @param {FormatPhoneOptions} [options] - Optional formatting options. * @param {"auto"|"sn"|"nanp"|"e164"|"international"|"service"} options.mask - The mask to apply for formatting the phone number (default: `"sn"`). - * @param {boolean} options.obfuscate - If truthy, hides the subscriber number except its last 2 - * digits. Read for truthiness, so a non-boolean such as `1` obfuscates too. + * @param {boolean} options.obfuscate - If truthy, hides the subscriber number except the last 2 + * digits the mask has room for. Read for truthiness, so a non-boolean such as `1` obfuscates too. * @returns {string} The formatted phone number as a string. * * @example @@ -165,7 +169,9 @@ const isPhoneMask = (value: unknown): value is PhoneMask => PHONE_MASKS.has(valu * formatPhone("11987654321", { mask: "e164", obfuscate: true }); // "+5511*******21" * formatPhone("08001234567", { mask: "auto", obfuscate: true }); // "0800 *** **67" * formatPhone("40041234", { mask: "service", obfuscate: true }); // "4004-**34" + * formatPhone("11988887766", { mask: "service", obfuscate: true }); // "***********" (not a service number) * formatPhone("11987654321"); // "11987-6543" (BEWARE: default "sn" truncates a DDD-prefixed number) + * formatPhone("11987654321", { obfuscate: true }); // "*****-**43" (BEWARE: truncated too, so "43", not "21") * ``` * * @see Official: https://www.itu.int/rec/T-REC-E.164 From a48ebef6253cb1fc3e69ef148916b4b586bb15ad Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:09:55 -0300 Subject: [PATCH 6/6] fix(llms): keep "art." from cutting a description short The llms.txt index takes the first sentence of each docs paragraph and already protects "e.g." and "i.e." from being read as a sentence end. A Brazilian law article is cited the same way, so the formatCertidao entry stopped at "the printed mask of art." and shipped an unfinished sentence in the public index. Protect "art." too. --- scripts/llms.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/llms.ts b/scripts/llms.ts index 6b0ea9ce9..b49545ac3 100644 --- a/scripts/llms.ts +++ b/scripts/llms.ts @@ -57,15 +57,16 @@ function slugify(heading: string): string { const ABBREVIATION_PLACEHOLDER = String.fromCharCode(1); /** - * Extracts the first sentence of a paragraph, treating `e.g.`/`i.e.` as - * abbreviations rather than sentence boundaries. + * Extracts the first sentence of a paragraph, treating `e.g.`, `i.e.` and `art.` (the way a + * Brazilian law article is cited, as in "LGPD, art. 6º III") as abbreviations rather than + * sentence boundaries. * @param {string} paragraph - The paragraph to extract the first sentence from. * @returns {string} The first sentence of `paragraph`. */ function firstSentence(paragraph: string): string { const withoutLinks = paragraph.replaceAll(/\[([^\]]+)\]\([^)]+\)/g, "$1"); const protectedText = withoutLinks.replaceAll( - /\b(e\.g|i\.e)\./gi, + /\b(e\.g|i\.e|art)\./gi, (_match, abbr: string) => `${abbr}${ABBREVIATION_PLACEHOLDER}`, ); const match = /[\s\S]*?[.!?](?=\s|$)/.exec(protectedText);