From 51f45bfbbe8ea208d85b485b50eb0281097dbdf7 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Sun, 17 May 2026 19:38:17 +0200 Subject: [PATCH 1/5] chore: drop dead imports map; expose parse? on Validator The package.json `imports` map referenced `./dist/_*/*.js` for subpath imports. dist is built with relative paths (no `#util/*` references survive bundling), and `bun test` resolves `#util/*` via tsconfig paths, so the map was a no-op for both consumers and local dev. The Validator type previously used a conditional intersection that exposed `parse: undefined` on the unparametrized form. That forced consumers (and the parse-test discovery loop) to fall back to ad-hoc `(value as { parse?: unknown }).parse` casts. Move `parse?` onto the base shape with a widened `ParsedIdentifier | null` return; the conditional intersection still narrows it to the precise `TParsed` for typed validators. --- __test__/parse.test.ts | 3 +-- package.json | 4 ---- src/types.ts | 15 ++++++++++++++- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/__test__/parse.test.ts b/__test__/parse.test.ts index 7f5ffb0..6c72329 100644 --- a/__test__/parse.test.ts +++ b/__test__/parse.test.ts @@ -40,8 +40,7 @@ const isValidator = (value: unknown): value is Validator => const hasParse = ( value: unknown, ): value is ParseValidator => - isValidator(value) && - typeof (value as { parse?: unknown }).parse === "function"; + isValidator(value) && typeof value.parse === "function"; const discovered: Discovered[] = []; diff --git a/package.json b/package.json index 4a74f30..ecfa729 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,6 @@ "main": "dist/index.js", "module": "dist/index.js", "types": "dist/index.d.ts", - "imports": { - "#checksums/*": "./dist/_checksums/*.js", - "#util/*": "./dist/_util/*.js" - }, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/src/types.ts b/src/types.ts index 2ff36e2..87b7163 100644 --- a/src/types.ts +++ b/src/types.ts @@ -209,8 +209,21 @@ export type Validator< * passed to validate() for confirmation. */ candidatePattern?: string; + + /** + * Extract structured data (e.g. birth date, + * gender) from a known-valid identifier. + * Available on personal IDs that encode such + * data. Returns null if the input cannot be + * parsed. + * + * On the base `Validator` type this is optional + * with a widened return; narrow it by typing the + * validator as `Validator` etc. + */ + parse?: (value: string) => ParsedIdentifier | null; } & ([TParsed] extends [undefined] - ? { parse?: undefined } + ? unknown : { parse: (value: string) => TParsed | null }); export type ParsableValidator< From eb91a44ef1074de557dd7dc0b8f44361ba3ee6eb Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Sun, 17 May 2026 20:00:30 +0200 Subject: [PATCH 2/5] fix: address no-non-null-assertion violations Replace 127 non-null assertions across validators and scripts with either narrowed accessors or `// SAFETY:` comments paired with a local eslint disable. The most common patterns: - Weighted-sum loops `for (let i = 0; i < weights.length; i++)` rewritten to `for (const [i, weight] of weights.entries())` so the weight is intrinsically narrowed. - `arr[randomInt(0, arr.length - 1)]` and `str[randomInt(...)]` routed through new `randomPick(arr)` and `randomChar(str)` helpers in `_util/generate`, which throw on empty input instead of returning `undefined`. - Length-checked accesses (`v[0]!` after `v.length === N`) and required regex captures (`match[1]!` after `if (match)`) keep the assertion but pick up an explicit `// SAFETY:` comment so the rule can be enabled globally without losing the audit trail. Behavior is preserved: 4228 tests still pass. --- scripts/oracle.ts | 5 ++- scripts/sync-exports.ts | 3 +- src/_checksums/mod112.ts | 13 ++++--- src/_util/generate.ts | 38 ++++++++++++++++++ src/ad/nrt.ts | 16 ++++++-- src/ar/cbu.ts | 4 +- src/ar/cuit.ts | 22 +++++++---- src/at/vnr.ts | 10 ++--- src/az/voen.ts | 2 + src/bd/nid.ts | 15 +++++-- src/bic.ts | 36 +++++++++-------- src/by/unp.ts | 13 +++++-- src/cl/rut.ts | 6 ++- src/cn/ric.ts | 2 + src/cn/uscc.ts | 28 ++++++++++---- src/co/nit.ts | 8 +++- src/cr/cpf.ts | 7 ++-- src/cu/ni.ts | 4 ++ src/de/handelsreg.ts | 17 ++++++-- src/de/idnr.ts | 3 ++ src/de/stnr.ts | 4 ++ src/de/svnr.ts | 9 ++++- src/do/rnc.ts | 4 +- src/ec/ruc.ts | 4 +- src/ee/registrikood.ts | 7 ++-- src/es/cif.ts | 5 +-- src/es/nie.ts | 15 ++++--- src/gb/nino.ts | 21 +++++----- src/gb/sedol.ts | 26 +++++++------ src/gh/tin.ts | 7 ++-- src/hk/hkid.ts | 3 +- src/in/gstin.ts | 4 +- src/in/pan.ts | 11 ++++-- src/isin.ts | 14 +++++-- src/kw/civil.ts | 5 +-- src/mx/curp.ts | 12 ++++-- src/mx/rfc.ts | 7 ++-- src/nz/ird.ts | 8 ++-- src/pa/ruc.ts | 23 ++++++++++- src/patterns.ts | 84 +++++++++++++++++++--------------------- src/pe/ruc.ts | 13 +++---- src/pk/cnic.ts | 2 + src/pt/cc.ts | 10 +++-- src/ru/inn.ts | 4 +- src/sg/uen.ts | 63 ++++++++++++++++++++---------- src/tw/ubn.ts | 4 +- src/ua/edrpou.ts | 5 ++- src/us/ein.ts | 14 +++---- src/us/itin.ts | 15 ++++--- src/us/rtn.ts | 27 +++++++------ src/uy/rut.ts | 4 +- src/ve/rif.ts | 17 +++++--- 52 files changed, 453 insertions(+), 250 deletions(-) diff --git a/scripts/oracle.ts b/scripts/oracle.ts index 190e0d4..bda73a2 100644 --- a/scripts/oracle.ts +++ b/scripts/oracle.ts @@ -552,7 +552,10 @@ const CUSTOM_ARB: Record> = { const inferArb = (v: Validator): fc.Arbitrary => { const lens = v.lengths; if (lens && lens.length > 0) { - if (lens.length === 1) return digs(lens[0]!); + const first = lens[0]; + if (lens.length === 1 && first !== undefined) { + return digs(first); + } return fc.oneof(...lens.map((l) => digs(l))); } return digs(10); diff --git a/scripts/sync-exports.ts b/scripts/sync-exports.ts index c4f2268..1ef75c3 100644 --- a/scripts/sync-exports.ts +++ b/scripts/sync-exports.ts @@ -21,7 +21,8 @@ const isExcluded = (relPath: string): boolean => { if (relPath.startsWith("_util/")) return true; if (relPath.startsWith("_checksums/")) return true; - const filename = relPath.split("/").at(-1)!; + const filename = relPath.split("/").at(-1); + if (filename === undefined) return false; if (filename === "mod.ts") return true; if (filename === "index.ts") return true; if (filename === "types.ts") return true; diff --git a/src/_checksums/mod112.ts b/src/_checksums/mod112.ts index becb29f..ddd5a8d 100644 --- a/src/_checksums/mod112.ts +++ b/src/_checksums/mod112.ts @@ -23,10 +23,11 @@ export const mod112checkChar = ( payload: string, ): string => { let sum = 0; - for (let i = 0; i < 17; i++) { - sum += Number(payload[i]) * WEIGHTS[i]!; + for (const [i, weight] of WEIGHTS.entries()) { + sum += Number(payload[i]) * weight; } - return CHECK_CHARS[sum % 11]!; + // SAFETY: sum % 11 is in 0..10 and CHECK_CHARS has 11 chars. + return CHECK_CHARS[sum % 11] ?? ""; }; /** @@ -34,7 +35,7 @@ export const mod112checkChar = ( * check character (last char is 0-9 or X). */ export const mod112validate = (value: string): boolean => { - const payload = value.slice(0, 17); - const check = value[17]!.toUpperCase(); - return mod112checkChar(payload) === check; + const check = value[17]?.toUpperCase(); + if (check === undefined) return false; + return mod112checkChar(value.slice(0, 17)) === check; }; diff --git a/src/_util/generate.ts b/src/_util/generate.ts index 799aac2..ace27cb 100644 --- a/src/_util/generate.ts +++ b/src/_util/generate.ts @@ -22,3 +22,41 @@ export const randomInt = ( max: number, ): number => min + Math.floor(Math.random() * (max - min + 1)); + +/** + * Pick a random element from a non-empty array. + * Throws if the array is empty so generators that + * declare a fixed pool fail loudly instead of + * returning `undefined`. + */ +export const randomPick = (values: readonly T[]): T => { + if (values.length === 0) { + throw new Error("randomPick called with empty array"); + } + const idx = randomInt(0, values.length - 1); + // SAFETY: idx is bounded by values.length and the + // array is non-empty (checked above). + const value = values[idx]; + if (value === undefined) { + throw new Error("randomPick produced undefined"); + } + return value; +}; + +/** + * Pick a random character from a non-empty string. + * Same contract as `randomPick` for arrays. + */ +export const randomChar = (chars: string): string => { + if (chars.length === 0) { + throw new Error("randomChar called with empty string"); + } + const idx = randomInt(0, chars.length - 1); + // SAFETY: idx is bounded by chars.length and the + // string is non-empty (checked above). + const ch = chars[idx]; + if (ch === undefined) { + throw new Error("randomChar produced undefined"); + } + return ch; +}; diff --git a/src/ad/nrt.ts b/src/ad/nrt.ts index 9649642..efd37a6 100644 --- a/src/ad/nrt.ts +++ b/src/ad/nrt.ts @@ -1,8 +1,8 @@ +const NRT_PREFIXES = "CDEGOPU"; + /** Generate a random valid Andorra NRT. */ const generate = (): string => { - const prefixes = "CDEGOPU"; - const prefix = - prefixes[randomInt(0, prefixes.length - 1)]!; + const prefix = randomChar(NRT_PREFIXES); const digits = randomDigits(6); const suffix = String.fromCodePoint( 65 + randomInt(0, 25), @@ -26,7 +26,11 @@ const generate = (): string => { */ import { clean } from "#util/clean"; -import { randomDigits, randomInt } from "#util/generate"; +import { + randomChar, + randomDigits, + randomInt, +} from "#util/generate"; import { err } from "#util/result"; import { isdigits } from "#util/strings"; @@ -45,7 +49,11 @@ const validate = (value: string): ValidateResult => { "Andorra NRT must be 8 characters", ); } + // SAFETY: v.length === 8 guarantees both positions + // exist; oxlint cannot see the length guard. + // eslint-disable-next-line no-non-null-assertion const prefix = v[0]!; + // eslint-disable-next-line no-non-null-assertion const tail = v[7]!; const digits = v.slice(1, 7); if (!/^[A-Z]$/.test(prefix) || !/^[A-Z]$/.test(tail)) { diff --git a/src/ar/cbu.ts b/src/ar/cbu.ts index 216fc19..92f17e3 100644 --- a/src/ar/cbu.ts +++ b/src/ar/cbu.ts @@ -28,8 +28,8 @@ const calcCheck = ( weights: readonly number[], ): number => { let sum = 0; - for (let i = 0; i < digits.length; i++) { - sum += Number(digits[i]) * weights[i]!; + for (const [i, weight] of weights.entries()) { + sum += Number(digits[i]) * weight; } return (10 - (sum % 10)) % 10; }; diff --git a/src/ar/cuit.ts b/src/ar/cuit.ts index 3cb994c..ec62ac3 100644 --- a/src/ar/cuit.ts +++ b/src/ar/cuit.ts @@ -17,7 +17,7 @@ */ import { clean } from "#util/clean"; -import { randomDigits } from "#util/generate"; +import { randomDigits, randomPick } from "#util/generate"; import { err } from "#util/result"; import { isdigits } from "#util/strings"; @@ -36,7 +36,17 @@ const VALID_TYPES = new Set([ "55", ]); -const WEIGHTS = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2]; +const WEIGHTS = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2] as const; + +const GENERATE_TYPES = [ + "20", + "23", + "24", + "27", + "30", + "33", + "34", +] as const; const compact = (value: string): string => clean(value, " -.").trim(); @@ -49,8 +59,8 @@ const compact = (value: string): string => */ const calcCheckDigit = (body: string): number => { let sum = 0; - for (let i = 0; i < 10; i++) { - sum += Number(body[i]) * WEIGHTS[i]!; + for (const [i, weight] of WEIGHTS.entries()) { + sum += Number(body[i]) * weight; } const remainder = 11 - (sum % 11); if (remainder === 11) return 0; @@ -98,9 +108,7 @@ const format = (value: string): string => { /** Generate a random valid CUIT. */ const generate = (): string => { - const types = ["20", "23", "24", "27", "30", "33", "34"]; - const type = - types[Math.floor(Math.random() * types.length)]!; + const type = randomPick(GENERATE_TYPES); const body = type + randomDigits(8); const check = calcCheckDigit(body); return body + String(check); diff --git a/src/at/vnr.ts b/src/at/vnr.ts index a18af93..433f394 100644 --- a/src/at/vnr.ts +++ b/src/at/vnr.ts @@ -25,7 +25,7 @@ import type { ValidateResult, Validator } from "../types"; * 0,1,2, then 4,5,6,7,8,9). Check digit is at * position 3 and equals the weighted sum mod 11. */ -const WEIGHTS = [3, 7, 9, 5, 8, 4, 2, 1, 6]; +const WEIGHTS = [3, 7, 9, 5, 8, 4, 2, 1, 6] as const; const compact = (value: string): string => clean(value, " -/").trim(); @@ -62,8 +62,8 @@ const validate = (value: string): ValidateResult => { // number is invalid. const digits = v.slice(0, 3) + v.slice(4); let sum = 0; - for (let i = 0; i < 9; i++) { - sum += Number(digits[i]) * WEIGHTS[i]!; + for (const [i, weight] of WEIGHTS.entries()) { + sum += Number(digits[i]) * weight; } const check = sum % 11; if (check === 10) { @@ -98,8 +98,8 @@ const generate = (): string => { const digits = `${serial}${day}${month}${year}`; let sum = 0; - for (let i = 0; i < 9; i++) { - sum += Number(digits[i]) * (WEIGHTS[i] ?? 0); + for (const [i, weight] of WEIGHTS.entries()) { + sum += Number(digits[i]) * weight; } const check = sum % 11; diff --git a/src/az/voen.ts b/src/az/voen.ts index dbe833d..3abe35b 100644 --- a/src/az/voen.ts +++ b/src/az/voen.ts @@ -63,6 +63,8 @@ const validate = (value: string): ValidateResult => { ); } + // SAFETY: regex above guarantees length === 10. + // eslint-disable-next-line no-non-null-assertion const lastDigit = v[9]!; if (lastDigit !== "1" && lastDigit !== "2") { return err( diff --git a/src/bd/nid.ts b/src/bd/nid.ts index 6bed16f..7389ca8 100644 --- a/src/bd/nid.ts +++ b/src/bd/nid.ts @@ -1,9 +1,16 @@ +const RMO_VALUES = [ + "1", + "2", + "3", + "4", + "5", + "9", +] as const; + /** Generate a random valid Bangladesh NID (13-digit). */ const generate = (): string => { const district = randomDigits(2); - const rmoValues = ["1", "2", "3", "4", "5", "9"]; - const rmo = - rmoValues[randomInt(0, rmoValues.length - 1)]!; + const rmo = randomPick(RMO_VALUES); const rest = randomDigits(10); return `${district}${rmo}${rest}`; }; @@ -31,7 +38,7 @@ const generate = (): string => { */ import { clean } from "#util/clean"; -import { randomDigits, randomInt } from "#util/generate"; +import { randomDigits, randomPick } from "#util/generate"; import { err } from "#util/result"; import { isdigits } from "#util/strings"; diff --git a/src/bic.ts b/src/bic.ts index d93af3d..d0e8530 100644 --- a/src/bic.ts +++ b/src/bic.ts @@ -1,28 +1,26 @@ +const BIC_ALPHANUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +const BIC_COUNTRIES = [ + "DE", + "GB", + "US", + "FR", + "CH", + "NL", + "AT", +] as const; + /** Generate a random valid BIC (8-character). */ const generate = (): string => { const randomLetter = (): string => String.fromCodePoint(65 + randomInt(0, 25)); - const randomAlphaNum = (): string => { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - return chars[randomInt(0, chars.length - 1)]!; - }; - const countries = [ - "DE", - "GB", - "US", - "FR", - "CH", - "NL", - "AT", - ]; - const country = - countries[randomInt(0, countries.length - 1)]!; + const country = randomPick(BIC_COUNTRIES); const bank = randomLetter() + randomLetter() + randomLetter() + randomLetter(); - const location = randomAlphaNum() + randomAlphaNum(); + const location = + randomChar(BIC_ALPHANUM) + randomChar(BIC_ALPHANUM); return `${bank}${country}${location}`; }; @@ -39,7 +37,11 @@ const generate = (): string => { */ import { clean } from "#util/clean"; -import { randomInt } from "#util/generate"; +import { + randomChar, + randomInt, + randomPick, +} from "#util/generate"; import { err } from "#util/result"; import type { ValidateResult, Validator } from "./types"; diff --git a/src/by/unp.ts b/src/by/unp.ts index 52cb501..469423b 100644 --- a/src/by/unp.ts +++ b/src/by/unp.ts @@ -52,16 +52,19 @@ const compact = (value: string): string => { const calcCheckDigit = (number: string): number | null => { let work = number; if (!isdigits(number)) { - const idx = ALPHA_SET.indexOf(number[1]!); + const second = number[1]; + if (second === undefined) return null; + const idx = ALPHA_SET.indexOf(second); if (idx === -1) return null; work = number[0] + String(idx) + number.slice(2); } let sum = 0; - for (let i = 0; i < 8; i++) { - const ch = work[i]!; + for (const [i, weight] of WEIGHTS.entries()) { + const ch = work[i]; + if (ch === undefined) return null; const val = ALPHABET.indexOf(ch); if (val === -1) return null; - sum += (WEIGHTS[i] ?? 0) * val; + sum += weight * val; } const remainder = sum % 11; if (remainder > 9) return null; @@ -93,6 +96,8 @@ const validate = (value: string): ValidateResult => { " digits or letters from ABCEHKMOPT", ); } + // SAFETY: v.length === 9 guarantees v[0] exists. + // eslint-disable-next-line no-non-null-assertion if (!VALID_FIRST.includes(v[0]!)) { return err( "INVALID_COMPONENT", diff --git a/src/cl/rut.ts b/src/cl/rut.ts index 536dc8f..69e65a9 100644 --- a/src/cl/rut.ts +++ b/src/cl/rut.ts @@ -35,10 +35,12 @@ const compact = (value: string): string => { * 11 -> "0", 10 -> "K", else the digit as string. */ const calcCheckDigit = (body: string): string => { - const weights = [2, 3, 4, 5, 6, 7]; + const weights = [2, 3, 4, 5, 6, 7] as const; let sum = 0; for (let i = body.length - 1, w = 0; i >= 0; i--, w++) { - sum += Number(body[i]) * weights[w % 6]!; + // SAFETY: w % weights.length is a valid index. + // eslint-disable-next-line no-non-null-assertion + sum += Number(body[i]) * weights[w % weights.length]!; } const remainder = 11 - (sum % 11); if (remainder === 11) return "0"; diff --git a/src/cn/ric.ts b/src/cn/ric.ts index 77af54e..49536f4 100644 --- a/src/cn/ric.ts +++ b/src/cn/ric.ts @@ -67,6 +67,8 @@ const validate = (value: string): ValidateResult => { "RIC must contain only digits (plus check char)", ); } + // SAFETY: 18-character branch guarantees v[17] exists. + // eslint-disable-next-line no-non-null-assertion const lastChar = v[17]!; if (!isdigits(lastChar) && lastChar !== "X") { return err( diff --git a/src/cn/uscc.ts b/src/cn/uscc.ts index 554108a..d411cd5 100644 --- a/src/cn/uscc.ts +++ b/src/cn/uscc.ts @@ -10,7 +10,11 @@ */ import { clean } from "#util/clean"; -import { randomDigits, randomInt } from "#util/generate"; +import { + randomChar, + randomDigits, + randomInt, +} from "#util/generate"; import { err } from "#util/result"; import { isdigits } from "#util/strings"; @@ -28,10 +32,14 @@ const compact = (value: string): string => const calcCheckChar = (value: string): string => { let total = 0; - for (let i = 0; i < 17; i++) { - const idx = ALPHABET.indexOf(value[i]!); - total += idx * WEIGHTS[i]!; + for (const [i, weight] of WEIGHTS.entries()) { + const ch = value[i]; + if (ch === undefined) continue; + total += ALPHABET.indexOf(ch) * weight; } + // SAFETY: (31 - total % 31) % 31 is in 0..30 and + // ALPHABET has 31 characters. + // eslint-disable-next-line no-non-null-assertion return ALPHABET[(31 - (total % 31)) % 31]!; }; @@ -45,8 +53,8 @@ const validate = (value: string): ValidateResult => { ); } - for (let i = 0; i < 18; i++) { - if (!ALPHABET.includes(v[i]!)) { + for (const ch of v) { + if (!ALPHABET.includes(ch)) { return err( "INVALID_FORMAT", "USCC contains invalid character", @@ -75,12 +83,16 @@ const format = (value: string): string => compact(value); /** Generate a random valid USCC. */ const generate = (): string => { + // SAFETY: indices 1..9 are within ALPHABET (31 chars). + // eslint-disable-next-line no-non-null-assertion const reg = ALPHABET[randomInt(1, 9)]!; + // eslint-disable-next-line no-non-null-assertion const etype = ALPHABET[randomInt(1, 9)]!; const region = randomDigits(6); let org = ""; - for (let i = 0; i < 9; i++) - org += ALPHABET[randomInt(0, ALPHABET.length - 1)]!; + for (let i = 0; i < 9; i++) { + org += randomChar(ALPHABET); + } const payload = reg + etype + region + org; return payload + calcCheckChar(payload); }; diff --git a/src/co/nit.ts b/src/co/nit.ts index cb17864..f874f80 100644 --- a/src/co/nit.ts +++ b/src/co/nit.ts @@ -30,9 +30,13 @@ const compact = (value: string): string => const calcCheckDigit = (body: string): number => { let sum = 0; const len = body.length; - for (let i = 0; i < len; i++) { - sum += Number(body[len - 1 - i]) * WEIGHTS[i]!; + for (const [i, weight] of WEIGHTS.entries()) { + if (i >= len) break; + sum += Number(body[len - 1 - i]) * weight; } + // SAFETY: sum % 11 is in 0..10 and CHECK_DIGITS has + // 11 entries. + // eslint-disable-next-line no-non-null-assertion return CHECK_DIGITS[sum % 11]!; }; diff --git a/src/cr/cpf.ts b/src/cr/cpf.ts index 5f17521..72baabb 100644 --- a/src/cr/cpf.ts +++ b/src/cr/cpf.ts @@ -38,9 +38,10 @@ const compact = (value: string): string => { const parts = number.split("-"); if (parts.length === 3) { - const p0 = parts[0]!.padStart(2, "0"); - const p1 = parts[1]!.padStart(4, "0"); - const p2 = parts[2]!.padStart(4, "0"); + const [raw0, raw1, raw2] = parts; + const p0 = (raw0 ?? "").padStart(2, "0"); + const p1 = (raw1 ?? "").padStart(4, "0"); + const p2 = (raw2 ?? "").padStart(4, "0"); number = p0 + p1 + p2; } else { number = number.replaceAll("-", ""); diff --git a/src/cu/ni.ts b/src/cu/ni.ts index 5f391b8..a6a1131 100644 --- a/src/cu/ni.ts +++ b/src/cu/ni.ts @@ -62,6 +62,8 @@ const validate = (value: string): ValidateResult => { const yy = Number(v.slice(0, 2)); const mm = Number(v.slice(2, 4)); const dd = Number(v.slice(4, 6)); + // SAFETY: regex check above guarantees v.length === 11. + // eslint-disable-next-line no-non-null-assertion const year = centuryOffset(v[6]!) + yy; if (!isValidDate(year, mm, dd)) { @@ -88,6 +90,8 @@ const parse = (value: string): ParsedPersonId | null => { const yy = Number(v.slice(0, 2)); const mm = Number(v.slice(2, 4)); const dd = Number(v.slice(4, 6)); + // SAFETY: regex check above guarantees v.length === 11. + // eslint-disable-next-line no-non-null-assertion const year = centuryOffset(v[6]!) + yy; const genderDigit = Number(v[9]); diff --git a/src/de/handelsreg.ts b/src/de/handelsreg.ts index 8b2492a..3193de6 100644 --- a/src/de/handelsreg.ts +++ b/src/de/handelsreg.ts @@ -1,7 +1,14 @@ +const GENERATE_TYPES = [ + "HRA", + "HRB", + "GNR", + "PR", + "VR", +] as const; + /** Generate a random valid Handelsregisternummer. */ const generate = (): string => { - const types = ["HRA", "HRB", "GNR", "PR", "VR"]; - const type = types[randomInt(0, types.length - 1)]!; + const type = randomPick(GENERATE_TYPES); const number = randomDigits(5); return `${type} ${number}`; }; @@ -25,7 +32,7 @@ const generate = (): string => { */ import { clean } from "#util/clean"; -import { randomDigits, randomInt } from "#util/generate"; +import { randomDigits, randomPick } from "#util/generate"; import { err } from "#util/result"; import type { ValidateResult, Validator } from "../types"; @@ -62,7 +69,11 @@ const validate = (value: string): ValidateResult => { ); } + // SAFETY: both capture groups are required, so a + // successful match always populates [1] and [2]. + // eslint-disable-next-line no-non-null-assertion const type = match[1]!; + // eslint-disable-next-line no-non-null-assertion const number = match[2]!; if (!REGISTER_TYPES.has(type)) { diff --git a/src/de/idnr.ts b/src/de/idnr.ts index 0ce5727..2330d21 100644 --- a/src/de/idnr.ts +++ b/src/de/idnr.ts @@ -103,6 +103,9 @@ const generate = (): string => { while (pool.length < 10) pool.push(randomInt(0, 9)); for (let i = pool.length - 1; i > 0; i--) { const j = randomInt(0, i); + // SAFETY: i and j are valid indices of `pool` + // (i > 0, j ∈ [0, i]). + // eslint-disable-next-line no-non-null-assertion [pool[i], pool[j]] = [pool[j]!, pool[i]!]; } if (pool[0] === 0) continue; diff --git a/src/de/stnr.ts b/src/de/stnr.ts index 453c40a..6d82652 100644 --- a/src/de/stnr.ts +++ b/src/de/stnr.ts @@ -166,9 +166,13 @@ const format = (value: string): string => { // category: literal digits, F, B, U, P const segments: string[] = []; let segStart = 0; + // SAFETY: fmt is a non-empty literal from COMPILED. + // eslint-disable-next-line no-non-null-assertion let prevCat = charCat(fmt[0]!); for (let i = 1; i < fmt.length; i++) { + // SAFETY: i < fmt.length. + // eslint-disable-next-line no-non-null-assertion const cat = charCat(fmt[i]!); if (cat !== prevCat) { segments.push(v.slice(segStart, i)); diff --git a/src/de/svnr.ts b/src/de/svnr.ts index 17d9812..d3cb454 100644 --- a/src/de/svnr.ts +++ b/src/de/svnr.ts @@ -39,6 +39,8 @@ const computeCheck = (v: string): number => { digits.push(Number(v[i])); } + // SAFETY: caller guarantees v.length === 12. + // eslint-disable-next-line no-non-null-assertion const lv = letterValue(v[8]!); digits.push(Math.floor(lv / 10)); digits.push(lv % 10); @@ -47,8 +49,11 @@ const computeCheck = (v: string): number => { digits.push(Number(v[10])); let sum = 0; - for (let i = 0; i < 12; i++) { - const product = digits[i]! * WEIGHTS[i]!; + for (const [i, weight] of WEIGHTS.entries()) { + // SAFETY: WEIGHTS.length === 12 and we just pushed + // 12 entries onto `digits`. + // eslint-disable-next-line no-non-null-assertion + const product = digits[i]! * weight; if (product >= 10) { sum += Math.floor(product / 10) + (product % 10); } else { diff --git a/src/do/rnc.ts b/src/do/rnc.ts index 31a0781..bc39b5f 100644 --- a/src/do/rnc.ts +++ b/src/do/rnc.ts @@ -38,8 +38,8 @@ const compact = (value: string): string => */ const calcRncCheckDigit = (value: string): string => { let sum = 0; - for (let i = 0; i < 8; i++) { - sum += Number(value[i]) * RNC_WEIGHTS[i]!; + for (const [i, weight] of RNC_WEIGHTS.entries()) { + sum += Number(value[i]) * weight; } const remainder = sum % 11; return String(((10 - remainder) % 9) + 1); diff --git a/src/ec/ruc.ts b/src/ec/ruc.ts index 7edc04f..256c61d 100644 --- a/src/ec/ruc.ts +++ b/src/ec/ruc.ts @@ -46,8 +46,8 @@ const mod11Checksum = ( weights: readonly number[], ): number => { let sum = 0; - for (let i = 0; i < weights.length; i++) { - sum += Number(digits[i]) * weights[i]!; + for (const [i, weight] of weights.entries()) { + sum += Number(digits[i]) * weight; } return sum % 11; }; diff --git a/src/ee/registrikood.ts b/src/ee/registrikood.ts index 9f401e5..935cc99 100644 --- a/src/ee/registrikood.ts +++ b/src/ee/registrikood.ts @@ -8,7 +8,7 @@ */ import { clean } from "#util/clean"; -import { randomDigits, randomInt } from "#util/generate"; +import { randomDigits, randomPick } from "#util/generate"; import { err } from "#util/result"; import { isdigits } from "#util/strings"; @@ -52,10 +52,11 @@ const validate = (value: string): ValidateResult => { const format = (value: string): string => compact(value); +const REGISTRIKOOD_FIRSTS = [1, 7, 8, 9] as const; + /** Generate a random valid Estonian Registrikood. */ const generate = (): string => { - const firsts = [1, 7, 8, 9]; - const first = String(firsts[randomInt(0, 3)]!); + const first = String(randomPick(REGISTRIKOOD_FIRSTS)); const payload = first + randomDigits(6); return payload + String(twoPassCheck(payload)); }; diff --git a/src/es/cif.ts b/src/es/cif.ts index 60a8407..c9bc91d 100644 --- a/src/es/cif.ts +++ b/src/es/cif.ts @@ -10,7 +10,7 @@ */ import { clean } from "#util/clean"; -import { randomDigits, randomInt } from "#util/generate"; +import { randomChar, randomDigits } from "#util/generate"; import { err } from "#util/result"; import { isdigits } from "#util/strings"; @@ -72,8 +72,7 @@ const format = (value: string): string => compact(value); /** Generate a random valid Spanish CIF. */ const generate = (): string => { - const letter = - CIF_PREFIXES[randomInt(0, CIF_PREFIXES.length - 1)]!; + const letter = randomChar(CIF_PREFIXES); const payload = randomDigits(7); const check = cifChecksum(payload); if ("KPQS".includes(letter)) diff --git a/src/es/nie.ts b/src/es/nie.ts index 7cb492e..c14e1a8 100644 --- a/src/es/nie.ts +++ b/src/es/nie.ts @@ -9,7 +9,7 @@ */ import { clean } from "#util/clean"; -import { randomDigits, randomInt } from "#util/generate"; +import { randomDigits, randomPick } from "#util/generate"; import { err } from "#util/result"; import { isdigits } from "#util/strings"; @@ -66,13 +66,18 @@ const validate = (value: string): ValidateResult => { const format = (value: string): string => compact(value); +const NIE_PREFIXES = ["X", "Y", "Z"] as const; +const NIE_PREFIX_VALUES: Record< + (typeof NIE_PREFIXES)[number], + number +> = { X: 0, Y: 1, Z: 2 }; + /** Generate a random valid Spanish NIE. */ const generate = (): string => { - const prefixes = ["X", "Y", "Z"] as const; - const vals: Record = { X: 0, Y: 1, Z: 2 }; - const prefix = prefixes[randomInt(0, 2)]!; + const prefix = randomPick(NIE_PREFIXES); const digits = randomDigits(7); - const num = vals[prefix]! * 10000000 + Number(digits); + const num = + NIE_PREFIX_VALUES[prefix] * 10000000 + Number(digits); return prefix + digits + CHECK_LETTERS.charAt(num % 23); }; diff --git a/src/gb/nino.ts b/src/gb/nino.ts index 4ed27e7..1f5ec3b 100644 --- a/src/gb/nino.ts +++ b/src/gb/nino.ts @@ -1,21 +1,19 @@ +const NINO_VALID_FIRST = "ABCEGHJKLMNOPRSTWXYZ"; +const NINO_VALID_SECOND = "ABCEGHJKLMNPRSTWXYZ"; +const NINO_SUFFIXES = "ABCD"; + /** Generate a random valid UK NINO. */ const generate = (): string => { - const validFirst = "ABCEGHJKLMNOPRSTWXYZ"; - const validSecond = "ABCEGHJKLMNPRSTWXYZ"; - const suffixes = "ABCD"; let first: string; let second: string; let prefix: string; do { - first = - validFirst[randomInt(0, validFirst.length - 1)]!; - second = - validSecond[randomInt(0, validSecond.length - 1)]!; + first = randomChar(NINO_VALID_FIRST); + second = randomChar(NINO_VALID_SECOND); prefix = first + second; } while (INVALID_PREFIX.has(prefix)); const digits = randomDigits(6); - const suffix = - suffixes[randomInt(0, suffixes.length - 1)]!; + const suffix = randomChar(NINO_SUFFIXES); return `${prefix}${digits}${suffix}`; }; @@ -31,7 +29,7 @@ const generate = (): string => { */ import { clean } from "#util/clean"; -import { randomDigits, randomInt } from "#util/generate"; +import { randomChar, randomDigits } from "#util/generate"; import { err } from "#util/result"; import type { ValidateResult, Validator } from "../types"; @@ -85,7 +83,10 @@ const validate = (value: string): ValidateResult => { ); } + // SAFETY: regex above guarantees v.length === 9. + // eslint-disable-next-line no-non-null-assertion const first = v[0]!; + // eslint-disable-next-line no-non-null-assertion const second = v[1]!; const prefix = v.slice(0, 2); diff --git a/src/gb/sedol.ts b/src/gb/sedol.ts index 7a99f07..3737d1d 100644 --- a/src/gb/sedol.ts +++ b/src/gb/sedol.ts @@ -11,7 +11,7 @@ */ import { clean } from "#util/clean"; -import { randomInt } from "#util/generate"; +import { randomChar } from "#util/generate"; import { err } from "#util/result"; import { isdigits } from "#util/strings"; @@ -36,14 +36,14 @@ const calcCheckDigit = (number: string): string => { ); } let sum = 0; - for (let i = 0; i < 6; i++) { - const ch = number[i]!; + for (const [i, weight] of WEIGHTS.entries()) { + const ch = number[i]; + if (ch === undefined) continue; const val = ALPHABET.indexOf(ch); if (val < 0) { throw new Error(`Invalid SEDOL character: ${ch}`); } - // SAFETY: weights length matches loop bound - sum += WEIGHTS[i]! * val; + sum += weight * val; } return String((10 - (sum % 10)) % 10); }; @@ -74,6 +74,8 @@ const validate = (value: string): ValidateResult => { // Old-style SEDOLs are fully numeric; new-style // SEDOLs start with a letter. A number that starts // with a digit but contains letters is invalid. + // SAFETY: v.length === 7 here. + // eslint-disable-next-line no-non-null-assertion if (isdigits(v[0]!) && !isdigits(v)) { return err( "INVALID_FORMAT", @@ -94,15 +96,15 @@ const validate = (value: string): ValidateResult => { const format = (value: string): string => compact(value); +const SEDOL_CONSONANTS = "BCDFGHJKLMNPQRSTVWXYZ"; +const SEDOL_CHARS = "0123456789BCDFGHJKLMNPQRSTVWXYZ"; + /** Generate a random valid SEDOL. */ const generate = (): string => { - const consonants = "BCDFGHJKLMNPQRSTVWXYZ"; - const chars = "0123456789BCDFGHJKLMNPQRSTVWXYZ"; - const first = - consonants[randomInt(0, consonants.length - 1)]!; - let payload = first; - for (let i = 1; i < 6; i++) - payload += chars[randomInt(0, chars.length - 1)]!; + let payload = randomChar(SEDOL_CONSONANTS); + for (let i = 1; i < 6; i++) { + payload += randomChar(SEDOL_CHARS); + } return payload + calcCheckDigit(payload); }; diff --git a/src/gh/tin.ts b/src/gh/tin.ts index e898746..034588d 100644 --- a/src/gh/tin.ts +++ b/src/gh/tin.ts @@ -15,7 +15,7 @@ */ import { clean } from "#util/clean"; -import { randomDigits, randomInt } from "#util/generate"; +import { randomDigits, randomPick } from "#util/generate"; import { err } from "#util/result"; import type { ValidateResult, Validator } from "../types"; @@ -65,11 +65,12 @@ const validate = (value: string): ValidateResult => { const format = (value: string): string => compact(value); +const TIN_PREFIXES = ["P", "C", "G", "Q", "V"] as const; + /** Generate a random valid Ghana TIN. */ const generate = (): string => { - const prefixes = ["P", "C", "G", "Q", "V"] as const; for (;;) { - const p = prefixes[randomInt(0, 4)]!; + const p = randomPick(TIN_PREFIXES); const d = randomDigits(9); const partial = p + d; const c = partial + calcCheckDigit(partial); diff --git a/src/hk/hkid.ts b/src/hk/hkid.ts index d00bdb5..aa02cec 100644 --- a/src/hk/hkid.ts +++ b/src/hk/hkid.ts @@ -32,7 +32,8 @@ const computeCheck = (body: string): string => { let sum = 0; for (let i = 0; i < 8; i++) { - const ch = padded[i]!; + const ch = padded[i]; + if (ch === undefined) continue; let val: number; if (ch === " ") { val = 36; diff --git a/src/in/gstin.ts b/src/in/gstin.ts index fab3045..13d19b4 100644 --- a/src/in/gstin.ts +++ b/src/in/gstin.ts @@ -70,7 +70,9 @@ const luhn36Checksum = (value: string): number => { let sum = 0; let double = false; for (let i = value.length - 1; i >= 0; i--) { - let v = charValue(value[i]!); + const ch = value[i]; + if (ch === undefined) continue; + let v = charValue(ch); if (double) { v *= 2; sum += Math.floor(v / BASE) + (v % BASE); diff --git a/src/in/pan.ts b/src/in/pan.ts index d81792f..444df11 100644 --- a/src/in/pan.ts +++ b/src/in/pan.ts @@ -4,8 +4,7 @@ const generate = (): string => { String.fromCodePoint(65 + randomInt(0, 25)); const first3 = randomLetter() + randomLetter() + randomLetter(); - const holderType = - HOLDER_TYPES[randomInt(0, HOLDER_TYPES.length - 1)]!; + const holderType = randomChar(HOLDER_TYPES); const fifth = randomLetter(); const digits = randomDigits(4); const last = randomLetter(); @@ -27,7 +26,11 @@ const generate = (): string => { */ import { clean } from "#util/clean"; -import { randomDigits, randomInt } from "#util/generate"; +import { + randomChar, + randomDigits, + randomInt, +} from "#util/generate"; import { err } from "#util/result"; import type { ValidateResult, Validator } from "../types"; @@ -53,6 +56,8 @@ const validate = (value: string): ValidateResult => { "PAN must be 5 letters + 4 digits + 1 letter", ); } + // SAFETY: regex above guarantees v.length === 10. + // eslint-disable-next-line no-non-null-assertion if (!HOLDER_TYPES.includes(v[3]!)) { return err( "INVALID_COMPONENT", diff --git a/src/isin.ts b/src/isin.ts index cb2a55e..44c586d 100644 --- a/src/isin.ts +++ b/src/isin.ts @@ -12,7 +12,7 @@ import { luhnChecksum } from "#checksums/luhn"; import { clean } from "#util/clean"; -import { randomInt } from "#util/generate"; +import { randomInt, randomPick } from "#util/generate"; import { err } from "#util/result"; import { charValue, isalnum } from "#util/strings"; @@ -73,12 +73,18 @@ const format = (value: string): string => { return `${v.slice(0, 2)} ${v.slice(2, 6)} ${v.slice(6, 10)} ${v.slice(10)}`; }; +const ISIN_COUNTRIES = [ + "US", + "GB", + "DE", + "FR", + "JP", +] as const; + /** Generate a random valid ISIN. */ const generate = (): string => { - const countries = ["US", "GB", "DE", "FR", "JP"]; for (;;) { - const cc = - countries[randomInt(0, countries.length - 1)]!; + const cc = randomPick(ISIN_COUNTRIES); let nsin = ""; for (let i = 0; i < 9; i++) nsin += String(randomInt(0, 9)); diff --git a/src/kw/civil.ts b/src/kw/civil.ts index fa46630..82dceb4 100644 --- a/src/kw/civil.ts +++ b/src/kw/civil.ts @@ -39,9 +39,8 @@ const compact = (value: string): string => const calcCheckDigit = (digits: string): number => { let sum = 0; - for (let i = 0; i < 11; i++) { - // SAFETY: loop bound guarantees valid index - sum += Number(digits[i]) * WEIGHTS[i]!; + for (const [i, weight] of WEIGHTS.entries()) { + sum += Number(digits[i]) * weight; } return (11 - (sum % 11)) % 11; }; diff --git a/src/mx/curp.ts b/src/mx/curp.ts index 746e646..82352df 100644 --- a/src/mx/curp.ts +++ b/src/mx/curp.ts @@ -72,8 +72,8 @@ const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const ALPHABET = "0123456789ABCDEFGHIJKLMN&OPQRSTUVWXYZ"; const CHAR_MAP = new Map(); -for (let i = 0; i < ALPHABET.length; i++) { - CHAR_MAP.set(ALPHABET[i]!, i); +for (const [i, ch] of [...ALPHABET].entries()) { + CHAR_MAP.set(ch, i); } const compact = (value: string): string => @@ -87,7 +87,9 @@ const compact = (value: string): string => const calcCheckDigit = (value: string): string => { let sum = 0; for (let i = 0; i < 17; i++) { - sum += (CHAR_MAP.get(value[i]!) ?? 0) * (18 - i); + const ch = value[i]; + if (ch === undefined) continue; + sum += (CHAR_MAP.get(ch) ?? 0) * (18 - i); } return String((10 - (sum % 10)) % 10); }; @@ -129,6 +131,8 @@ const validate = (value: string): ValidateResult => { const mm = Number(v.slice(6, 8)); const dd = Number(v.slice(8, 10)); + // SAFETY: regex validation ensures v.length === 18. + // eslint-disable-next-line no-non-null-assertion const year = resolveCurpYear(yy, v[16]!); if (!isValidDate(year, mm, dd)) { return err( @@ -173,6 +177,8 @@ const parse = (value: string): ParsedPersonId | null => { const mm = Number(v.slice(6, 8)); const dd = Number(v.slice(8, 10)); + // SAFETY: regex validation ensures v.length === 18. + // eslint-disable-next-line no-non-null-assertion const year = resolveCurpYear(yy, v[16]!); const genderChar = v[10]; diff --git a/src/mx/rfc.ts b/src/mx/rfc.ts index d138015..550161e 100644 --- a/src/mx/rfc.ts +++ b/src/mx/rfc.ts @@ -27,8 +27,8 @@ import type { ValidateResult, Validator } from "../types"; const ALPHABET = "0123456789ABCDEFGHIJKLMN&OPQRSTUVWXYZ Ñ"; const CHAR_MAP = new Map(); -for (let i = 0; i < ALPHABET.length; i++) { - CHAR_MAP.set(ALPHABET[i]!, i); +for (const [i, ch] of [...ALPHABET].entries()) { + CHAR_MAP.set(ch, i); } const compact = (value: string): string => @@ -49,7 +49,8 @@ const calcCheckDigit = (value: string): string => { const padded = value.length === 11 ? ` ${value}` : value; let sum = 0; for (let i = 0; i < 12; i++) { - const ch = padded[i]!; + const ch = padded[i]; + if (ch === undefined) continue; const v = CHAR_MAP.get(ch) ?? 0; sum += v * (13 - i); } diff --git a/src/nz/ird.ts b/src/nz/ird.ts index 79a5ef2..8a69271 100644 --- a/src/nz/ird.ts +++ b/src/nz/ird.ts @@ -28,15 +28,15 @@ const SECONDARY_WEIGHTS = [7, 4, 3, 2, 5, 2, 7, 6] as const; const calcCheckDigit = (payload: string): number | null => { const padded = payload.padStart(8, "0"); let sum = 0; - for (let i = 0; i < 8; i++) { - sum += PRIMARY_WEIGHTS[i]! * Number(padded[i]); + for (const [i, weight] of PRIMARY_WEIGHTS.entries()) { + sum += weight * Number(padded[i]); } let remainder = ((-sum % 11) + 11) % 11; if (remainder !== 10) return remainder; sum = 0; - for (let i = 0; i < 8; i++) { - sum += SECONDARY_WEIGHTS[i]! * Number(padded[i]); + for (const [i, weight] of SECONDARY_WEIGHTS.entries()) { + sum += weight * Number(padded[i]); } remainder = ((-sum % 11) + 11) % 11; if (remainder === 10) return null; diff --git a/src/pa/ruc.ts b/src/pa/ruc.ts index 820baee..d4e517e 100644 --- a/src/pa/ruc.ts +++ b/src/pa/ruc.ts @@ -122,8 +122,12 @@ const computeDV = ( let buf: string; let isOld = false; + // SAFETY: length check above ensures 3..4 segments. + // eslint-disable-next-line no-non-null-assertion const s0 = segments[0]!; + // eslint-disable-next-line no-non-null-assertion const s1 = segments[1]!; + // eslint-disable-next-line no-non-null-assertion const s2 = segments[2]!; if (raw[0] === "E") { @@ -139,6 +143,8 @@ const computeDV = ( s2; } else if (s1 === "NT") { // NT designation (xxNT-xxx-xxx) + // SAFETY: NT branch requires len === 4 (checked above). + // eslint-disable-next-line no-non-null-assertion const s3 = segments[3]!; const prefix = s0.slice(0, -2); buf = @@ -220,8 +226,12 @@ const computeDV = ( s1 + z(6 - s2.length) + s2; + // SAFETY: juridical branch produces buf of length 20. isOld = - buf[3] === "0" && buf[4] === "0" && buf[5]! < "5"; + buf[3] === "0" && + buf[4] === "0" && + // eslint-disable-next-line no-non-null-assertion + buf[5]! < "5"; } // Apply legacy cross-reference for old format @@ -270,7 +280,10 @@ const validate = (value: string): ValidateResult => { ); } + // SAFETY: both DV regex capture groups are required. + // eslint-disable-next-line no-non-null-assertion const rucPart = dvMatch[1]!; + // eslint-disable-next-line no-non-null-assertion const dvPart = dvMatch[2]!; // The RUC part should be hyphen-separated segments @@ -318,7 +331,10 @@ const format = (value: string): string => { const dvMatch = v.match(/^(.+?)-?\s*DV[:\s]*(\d{2})$/); if (!dvMatch) return v; + // SAFETY: both DV regex capture groups are required. + // eslint-disable-next-line no-non-null-assertion const rucPart = dvMatch[1]!.replace(/-+$/, ""); + // eslint-disable-next-line no-non-null-assertion const dvPart = dvMatch[2]!; const segments = rucPart.split("-"); return `${segments.join("-")} DV ${dvPart}`; @@ -330,7 +346,10 @@ const generate = (): string => { const volume = String(randomInt(1, 9999)); const folio = String(randomInt(1, 99999)); const segments = [province, volume, folio]; - const dv = computeDV(segments, segments.join("-"))!; + const dv = computeDV(segments, segments.join("-")); + if (dv === null) { + throw new Error("Failed to compute Panama RUC DV"); + } return `${segments.join("-")} DV${dv}`; }; diff --git a/src/patterns.ts b/src/patterns.ts index edb1be7..74e6f64 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -66,10 +66,9 @@ const inferLengths = (v: Validator): number[] => { * "25 123 891" → [2, 3, 3]. */ const inferGroups = (v: Validator): number[] | null => { - if (!v.examples || v.examples.length === 0) { - return null; - } - const compact = v.compact(v.examples[0]!); + const first = v.examples?.[0]; + if (first === undefined) return null; + const compact = v.compact(first); const formatted = v.format(compact); if (formatted === compact) return null; @@ -98,31 +97,35 @@ const inferGroups = (v: Validator): number[] | null => { * (e.g., "CZ" for DIČ, "CHE" for Swiss UID). */ const inferPrefix = (v: Validator): string | null => { - if (!v.examples || v.examples.length === 0) { - return null; - } - const compact = v.compact(v.examples[0]!); + const examples = v.examples; + const first = examples?.[0]; + if (!examples || first === undefined) return null; + const compact = v.compact(first); // Prefix already present in compact form. // But only if ALL examples share the same prefix // (e.g., "CHE" for Swiss UID). If examples have // different prefixes (IBAN: GB/DE/FR), there's // no fixed prefix — it's part of the value. const compactMatch = compact.match(/^([A-Z]+)\d/); - if (compactMatch && v.examples!.length > 1) { - const pfx = compactMatch[1]!; - const allSame = v.examples!.every((ex) => - v.compact(ex).startsWith(pfx), + const compactPrefix = compactMatch?.[1]; + if (compactPrefix !== undefined && examples.length > 1) { + const allSame = examples.every((ex) => + v.compact(ex).startsWith(compactPrefix), ); - if (allSame) return pfx; - } else if (compactMatch && v.examples!.length === 1) { - return compactMatch[1]!; + if (allSame) return compactPrefix; + } else if (compactPrefix !== undefined && examples.length === 1) { + return compactPrefix; } // Prefix added only by format() // (e.g., "DE" for de.vat, "CZ" for cz.dic) const formatted = v.format(compact); const fmtMatch = formatted.match(/^([A-Z]+)[\s\-./]?\d/); - if (fmtMatch && !compact.startsWith(fmtMatch[1]!)) { - return fmtMatch[1]!; + const fmtPrefix = fmtMatch?.[1]; + if ( + fmtPrefix !== undefined && + !compact.startsWith(fmtPrefix) + ) { + return fmtPrefix; } return null; }; @@ -159,10 +162,7 @@ const inferCharClass = (v: Validator): string => { return charClassFor(combined); }; -type PerGroupInfo = { - sizes: number[]; - classes: string[]; -}; +type PerGroup = { size: number; charClass: string }; /** * Infer per-group sizes AND character classes from @@ -177,13 +177,12 @@ type PerGroupInfo = { * are treated as prefixes and excluded (handled * by inferPrefix separately). */ -const inferPerGroupInfo = ( +const inferPerGroup = ( v: Validator, -): PerGroupInfo | null => { - if (!v.examples || v.examples.length === 0) { - return null; - } - const compact = v.compact(v.examples[0]!); +): PerGroup[] | null => { + const first = v.examples?.[0]; + if (first === undefined) return null; + const compact = v.compact(first); const formatted = v.format(compact); if (formatted === compact) return null; @@ -207,28 +206,27 @@ const inferPerGroupInfo = ( // appear AFTER the first digit group (embedded // letters). Skip letter-only groups before the // first digit group (prefix like "CHE"). - const filtered: string[] = []; + const filtered: PerGroup[] = []; let seenDigitGroup = false; for (const p of parts) { if (p.length === 0) continue; if (/\d/.test(p)) { seenDigitGroup = true; - filtered.push(p); + filtered.push({ size: p.length, charClass: charClassFor(p) }); } else if (seenDigitGroup) { - filtered.push(p); + filtered.push({ size: p.length, charClass: charClassFor(p) }); } } if (filtered.length <= 1) return null; - const classes = filtered.map(charClassFor); - const allSame = classes.every((c) => c === classes[0]); + const firstClass = filtered[0]?.charClass; + const allSame = filtered.every( + (g) => g.charClass === firstClass, + ); if (allSame) return null; - return { - sizes: filtered.map((p) => p.length), - classes, - }; + return filtered; }; /** @@ -246,10 +244,11 @@ const groupsToPattern = ( * character types (e.g., digits vs letters). */ const groupsToPatternPerClass = ( - groups: number[], - classes: string[], + groups: readonly { size: number; charClass: string }[], ): string => - groups.map((g, i) => `${classes[i]!}{${g}}`).join(SEP); + groups + .map(({ size, charClass }) => `${charClass}{${size}}`) + .join(SEP); /** * Derive a loose candidate-matching regex from @@ -272,13 +271,10 @@ export const toRegex = (v: Validator): RegExp => { // "12 010188 M 01 1" → [\d, \d, [A-Z], \d, \d]). // This prevents overly broad [A-Z0-9] from // matching all-caps prose as identifiers. - const perGroup = inferPerGroupInfo(v); + const perGroup = inferPerGroup(v); if (perGroup && lengths.length <= 1) { - pattern = groupsToPatternPerClass( - perGroup.sizes, - perGroup.classes, - ); + pattern = groupsToPatternPerClass(perGroup); } else if (groups && lengths.length <= 1) { pattern = groupsToPattern(groups, cc); } else if (lengths.length === 1) { diff --git a/src/pe/ruc.ts b/src/pe/ruc.ts index 55f35b4..f285925 100644 --- a/src/pe/ruc.ts +++ b/src/pe/ruc.ts @@ -15,7 +15,7 @@ */ import { clean } from "#util/clean"; -import { randomDigits } from "#util/generate"; +import { randomDigits, randomPick } from "#util/generate"; import { err } from "#util/result"; import { isdigits } from "#util/strings"; @@ -29,7 +29,8 @@ import type { ValidateResult, Validator } from "../types"; */ const VALID_PREFIXES = new Set(["10", "15", "17", "20"]); -const WEIGHTS = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2]; +const WEIGHTS = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2] as const; +const GENERATE_PREFIXES = ["10", "15", "17", "20"] as const; const compact = (value: string): string => clean(value, " -.").trim(); @@ -42,8 +43,8 @@ const compact = (value: string): string => */ const calcCheckDigit = (body: string): number => { let sum = 0; - for (let i = 0; i < 10; i++) { - sum += Number(body[i]) * WEIGHTS[i]!; + for (const [i, weight] of WEIGHTS.entries()) { + sum += Number(body[i]) * weight; } const remainder = 11 - (sum % 11); if (remainder >= 10) return remainder - 10; @@ -87,9 +88,7 @@ const format = (value: string): string => compact(value); /** Generate a random valid Peruvian RUC. */ const generate = (): string => { - const prefixes = ["10", "15", "17", "20"] as const; - const prefix = - prefixes[Math.floor(Math.random() * prefixes.length)]!; + const prefix = randomPick(GENERATE_PREFIXES); const body = prefix + randomDigits(8); return body + String(calcCheckDigit(body)); }; diff --git a/src/pk/cnic.ts b/src/pk/cnic.ts index 4005aa1..b7fd57a 100644 --- a/src/pk/cnic.ts +++ b/src/pk/cnic.ts @@ -53,6 +53,8 @@ const validate = (value: string): ValidateResult => { ); } // First digit must be a valid province code + // SAFETY: v.length === 13 above guarantees v[0] exists. + // eslint-disable-next-line no-non-null-assertion if (!VALID_PROVINCES.has(v[0]!)) { return err( "INVALID_COMPONENT", diff --git a/src/pt/cc.ts b/src/pt/cc.ts index f360fe1..dafb129 100644 --- a/src/pt/cc.ts +++ b/src/pt/cc.ts @@ -9,7 +9,7 @@ */ import { clean } from "#util/clean"; -import { randomDigits, randomInt } from "#util/generate"; +import { randomChar, randomDigits } from "#util/generate"; import { err } from "#util/result"; import type { ValidateResult, Validator } from "../types"; @@ -24,8 +24,10 @@ const compact = (value: string): string => const calcCheckDigit = (value: string): string => { let sum = 0; for (let i = value.length - 1; i >= 0; i--) { + const ch = value[i]; + if (ch === undefined) continue; const pos = value.length - 1 - i; - let n = ALPHABET.indexOf(value[i]!); + let n = ALPHABET.indexOf(ch); if (pos % 2 === 0) { n *= 2; if (n > 9) n = Math.floor(n / 10) + (n % 10); @@ -70,8 +72,8 @@ const format = (value: string): string => { /** Generate a random valid Portuguese CC number. */ const generate = (): string => { const digits = randomDigits(9); - const v0 = ALPHABET[randomInt(0, 35)]!; - const v1 = ALPHABET[randomInt(0, 35)]!; + const v0 = randomChar(ALPHABET); + const v1 = randomChar(ALPHABET); const body = digits + v0 + v1; const check = calcCheckDigit(body); return body + check; diff --git a/src/ru/inn.ts b/src/ru/inn.ts index 8e089fd..9029e77 100644 --- a/src/ru/inn.ts +++ b/src/ru/inn.ts @@ -28,8 +28,8 @@ const checkDigit = ( weights: readonly number[], ): number => { let sum = 0; - for (let i = 0; i < weights.length; i++) { - sum += Number(value[i]) * weights[i]!; + for (const [i, weight] of weights.entries()) { + sum += Number(value[i]) * weight; } return (sum % 11) % 10; }; diff --git a/src/sg/uen.ts b/src/sg/uen.ts index adb325b..9606154 100644 --- a/src/sg/uen.ts +++ b/src/sg/uen.ts @@ -72,6 +72,11 @@ const compact = (value: string): string => * Validate Business (ROB) UEN (9 chars): * 8 digits + 1 check letter. */ +const BUSINESS_WEIGHTS = [ + 10, 4, 9, 3, 8, 2, 7, 1, +] as const; +const BUSINESS_CHECK_ALPHA = "XMKECAWLJDB"; + const validateBusiness = (v: string): ValidateResult => { if (!isdigits(v.slice(0, 8))) { return err( @@ -79,6 +84,9 @@ const validateBusiness = (v: string): ValidateResult => { "Business UEN must start with 8 digits", ); } + // SAFETY: caller routes to this only when + // v.length === 9. + // eslint-disable-next-line no-non-null-assertion if (!/^[A-Z]$/.test(v[8]!)) { return err( "INVALID_FORMAT", @@ -86,13 +94,11 @@ const validateBusiness = (v: string): ValidateResult => { ); } - const weights = [10, 4, 9, 3, 8, 2, 7, 1]; - const checkAlpha = "XMKECAWLJDB"; let sum = 0; - for (let i = 0; i < 8; i++) { - sum += Number(v[i]) * weights[i]!; + for (const [i, weight] of BUSINESS_WEIGHTS.entries()) { + sum += Number(v[i]) * weight; } - const expected = checkAlpha[sum % 11]; + const expected = BUSINESS_CHECK_ALPHA[sum % 11]; if (v[8] !== expected) { return err( "INVALID_CHECKSUM", @@ -106,6 +112,11 @@ const validateBusiness = (v: string): ValidateResult => { * Validate Local Company (ROC) UEN (10 chars): * 9 digits (year prefix) + 1 check letter. */ +const LOCAL_WEIGHTS = [ + 10, 8, 6, 4, 9, 7, 5, 3, 1, +] as const; +const LOCAL_CHECK_ALPHA = "ZKCMDNERGWH"; + const validateLocalCompany = ( v: string, ): ValidateResult => { @@ -115,6 +126,9 @@ const validateLocalCompany = ( "Company UEN must have 9 digits", ); } + // SAFETY: caller routes to this only when + // v.length === 10. + // eslint-disable-next-line no-non-null-assertion if (!/^[A-Z]$/.test(v[9]!)) { return err( "INVALID_FORMAT", @@ -122,13 +136,11 @@ const validateLocalCompany = ( ); } - const weights = [10, 8, 6, 4, 9, 7, 5, 3, 1]; - const checkAlpha = "ZKCMDNERGWH"; let sum = 0; - for (let i = 0; i < 9; i++) { - sum += Number(v[i]) * weights[i]!; + for (const [i, weight] of LOCAL_WEIGHTS.entries()) { + sum += Number(v[i]) * weight; } - const expected = checkAlpha[sum % 11]; + const expected = LOCAL_CHECK_ALPHA[sum % 11]; if (v[9] !== expected) { return err( "INVALID_CHECKSUM", @@ -143,7 +155,14 @@ const validateLocalCompany = ( * R/S/T + 2 digits + 2-letter type + 4 digits * + 1 check letter. */ +const OTHER_WEIGHTS = [ + 4, 3, 5, 3, 10, 2, 2, 5, 7, +] as const; + const validateOther = (v: string): ValidateResult => { + // SAFETY: caller routes to this only when + // v.length === 10. + // eslint-disable-next-line no-non-null-assertion const prefix = v[0]!; if (prefix !== "R" && prefix !== "S" && prefix !== "T") { return err( @@ -171,17 +190,18 @@ const validateOther = (v: string): ValidateResult => { ); } - const weights = [4, 3, 5, 3, 10, 2, 2, 5, 7]; let sum = 0; - for (let i = 0; i < 9; i++) { - const idx = OTHER_ALPHA.indexOf(v[i]!); + for (const [i, weight] of OTHER_WEIGHTS.entries()) { + const ch = v[i]; + if (ch === undefined) continue; + const idx = OTHER_ALPHA.indexOf(ch); if (idx === -1) { return err( "INVALID_FORMAT", - `Unexpected character at position ${i + 1}: ${v[i]}`, + `Unexpected character at position ${i + 1}: ${ch}`, ); } - sum += idx * weights[i]!; + sum += idx * weight; } const expected = OTHER_ALPHA[(((sum - 5) % 11) + 11) % 11]; @@ -218,6 +238,8 @@ const validate = (value: string): ValidateResult => { // 10 chars: first char digit => local company, // otherwise "other" entity + // SAFETY: v.length === 10 here. + // eslint-disable-next-line no-non-null-assertion if (isdigits(v[0]!)) { return validateLocalCompany(v); } @@ -228,14 +250,15 @@ const format = (value: string): string => compact(value); /** Generate a random valid Singapore UEN (business format). */ const generate = (): string => { - const weights = [10, 4, 9, 3, 8, 2, 7, 1]; - const checkAlpha = "XMKECAWLJDB"; const body = randomDigits(8); let sum = 0; - for (let i = 0; i < 8; i++) { - sum += Number(body[i]) * weights[i]!; + for (const [i, weight] of BUSINESS_WEIGHTS.entries()) { + sum += Number(body[i]) * weight; } - const check = checkAlpha[sum % 11]!; + // SAFETY: sum % 11 is in 0..10 and BUSINESS_CHECK_ALPHA + // has 11 characters. + // eslint-disable-next-line no-non-null-assertion + const check = BUSINESS_CHECK_ALPHA[sum % 11]!; return body + check; }; diff --git a/src/tw/ubn.ts b/src/tw/ubn.ts index fd51ea7..32ad0f2 100644 --- a/src/tw/ubn.ts +++ b/src/tw/ubn.ts @@ -48,8 +48,8 @@ const validate = (value: string): ValidateResult => { } let sum = 0; - for (let i = 0; i < 8; i++) { - sum += digitSum(Number(v[i]) * WEIGHTS[i]!); + for (const [i, weight] of WEIGHTS.entries()) { + sum += digitSum(Number(v[i]) * weight); } const checksum = sum % 10; diff --git a/src/ua/edrpou.ts b/src/ua/edrpou.ts index 2107845..6dac8ff 100644 --- a/src/ua/edrpou.ts +++ b/src/ua/edrpou.ts @@ -27,8 +27,9 @@ const calcCheck = ( weights: readonly number[], ): number => { let sum = 0; - for (let i = 0; i < 7; i++) { - sum += Number(value[i]) * weights[i]!; + for (const [i, weight] of weights.entries()) { + if (i >= 7) break; + sum += Number(value[i]) * weight; } return sum % 11; }; diff --git a/src/us/ein.ts b/src/us/ein.ts index 62c3532..0e00802 100644 --- a/src/us/ein.ts +++ b/src/us/ein.ts @@ -1,6 +1,4 @@ -/** Generate a random valid U.S. EIN. */ -const generate = (): string => { - const prefixes = [ +const EIN_PREFIXES = [ "01", "02", "03", @@ -84,9 +82,11 @@ const generate = (): string => { "95", "98", "99", - ]; - const prefix = - prefixes[randomInt(0, prefixes.length - 1)]!; +] as const; + +/** Generate a random valid U.S. EIN. */ +const generate = (): string => { + const prefix = randomPick(EIN_PREFIXES); return prefix + randomDigits(7); }; @@ -102,7 +102,7 @@ const generate = (): string => { */ import { clean } from "#util/clean"; -import { randomDigits, randomInt } from "#util/generate"; +import { randomDigits, randomPick } from "#util/generate"; import { err } from "#util/result"; import { isdigits } from "#util/strings"; diff --git a/src/us/itin.ts b/src/us/itin.ts index 3240aca..839a755 100644 --- a/src/us/itin.ts +++ b/src/us/itin.ts @@ -1,13 +1,12 @@ +const ITIN_ALLOWED_GROUPS = Array.from( + { length: 30 }, + (_, i) => i + 70, +).filter((g) => g !== 89 && g !== 93); + /** Generate a random valid U.S. ITIN. */ const generate = (): string => { const area = "9" + randomDigits(2); - const allowed = Array.from( - { length: 30 }, - (_, i) => i + 70, - ).filter((g) => g !== 89 && g !== 93); - const group = String( - allowed[randomInt(0, allowed.length - 1)]!, - ); + const group = String(randomPick(ITIN_ALLOWED_GROUPS)); const serial = randomDigits(4); return `${area}${group}${serial}`; }; @@ -25,7 +24,7 @@ const generate = (): string => { */ import { clean } from "#util/clean"; -import { randomDigits, randomInt } from "#util/generate"; +import { randomDigits, randomPick } from "#util/generate"; import { err } from "#util/result"; import { isdigits } from "#util/strings"; diff --git a/src/us/rtn.ts b/src/us/rtn.ts index ce48a53..9e0e7eb 100644 --- a/src/us/rtn.ts +++ b/src/us/rtn.ts @@ -16,13 +16,17 @@ */ import { clean } from "#util/clean"; -import { randomDigits, randomInt } from "#util/generate"; +import { + randomDigits, + randomInt, + randomPick, +} from "#util/generate"; import { err } from "#util/result"; import { isdigits } from "#util/strings"; import type { ValidateResult, Validator } from "../types"; -const WEIGHTS = [3, 7, 1, 3, 7, 1, 3, 7, 1]; +const WEIGHTS = [3, 7, 1, 3, 7, 1, 3, 7, 1] as const; const compact = (value: string): string => clean(value, " -").trim(); @@ -57,8 +61,8 @@ const validate = (value: string): ValidateResult => { // Weighted checksum must be divisible by 10 let sum = 0; - for (let i = 0; i < 9; i++) { - sum += Number(v[i]) * WEIGHTS[i]!; + for (const [i, weight] of WEIGHTS.entries()) { + sum += Number(v[i]) * weight; } if (sum % 10 !== 0) { return err( @@ -82,16 +86,17 @@ const PREFIX_RANGES: readonly [number, number][] = [ /** Generate a random valid U.S. RTN. */ const generate = (): string => { - const range = - PREFIX_RANGES[randomInt(0, PREFIX_RANGES.length - 1)]!; - const prefix = String( - randomInt(range[0], range[1]), - ).padStart(2, "0"); + const [low, high] = randomPick(PREFIX_RANGES); + const prefix = String(randomInt(low, high)).padStart( + 2, + "0", + ); const mid = randomDigits(6); const body = prefix + mid; let sum = 0; - for (let i = 0; i < 8; i++) { - sum += Number(body[i]) * WEIGHTS[i]!; + for (const [i, weight] of WEIGHTS.entries()) { + if (i >= 8) break; + sum += Number(body[i]) * weight; } const check = (10 - (sum % 10)) % 10; return body + String(check); diff --git a/src/uy/rut.ts b/src/uy/rut.ts index bc04514..dbab440 100644 --- a/src/uy/rut.ts +++ b/src/uy/rut.ts @@ -39,8 +39,8 @@ const pymod = (a: number, b: number): number => const calcCheckDigit = (value: string): string | null => { let sum = 0; - for (let i = 0; i < 11; i++) { - sum += Number(value[i]) * WEIGHTS[i]!; + for (const [i, weight] of WEIGHTS.entries()) { + sum += Number(value[i]) * weight; } const result = pymod(-sum, 11); // A result of 10 means no valid single-digit check diff --git a/src/ve/rif.ts b/src/ve/rif.ts index b11319f..93f9ced 100644 --- a/src/ve/rif.ts +++ b/src/ve/rif.ts @@ -14,7 +14,7 @@ */ import { clean } from "#util/clean"; -import { randomDigits, randomInt } from "#util/generate"; +import { randomDigits, randomPick } from "#util/generate"; import { err } from "#util/result"; import { isdigits } from "#util/strings"; @@ -48,12 +48,14 @@ const calcCheckDigit = ( prefix: string, body: string, ): string => { - const pv = PREFIX_VALUES[prefix]!; + const pv = PREFIX_VALUES[prefix] ?? 0; let sum = 0; - for (let i = 0; i < 8; i++) { - sum += Number(body[i]) * WEIGHTS[i]!; + for (const [i, weight] of WEIGHTS.entries()) { + sum += Number(body[i]) * weight; } const digit = (pv + (sum % 11)) % 11; + // SAFETY: digit ∈ [0, 10] and CHECK_LOOKUP has 11 chars. + // eslint-disable-next-line no-non-null-assertion return CHECK_LOOKUP[digit]!; }; @@ -67,6 +69,8 @@ const validate = (value: string): ValidateResult => { ); } + // SAFETY: length check above guarantees v[0] exists. + // eslint-disable-next-line no-non-null-assertion const prefix = v[0]!; if (!(prefix in PREFIX_VALUES)) { return err( @@ -100,10 +104,11 @@ const format = (value: string): string => { return `${v[0]}-${v.slice(1, 9)}-${v.slice(9)}`; }; +const GENERATE_TYPES = ["V", "E", "J", "P", "G"] as const; + /** Generate a random valid Venezuelan RIF. */ const generate = (): string => { - const types = ["V", "E", "J", "P", "G"] as const; - const prefix = types[randomInt(0, types.length - 1)]!; + const prefix = randomPick(GENERATE_TYPES); const body = randomDigits(8); return ( prefix + body + String(calcCheckDigit(prefix, body)) From d1a2c42445fd036cd424a89ecffcfe149a024fe4 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Sun, 17 May 2026 20:02:14 +0200 Subject: [PATCH 3/5] chore(ci): enable no-non-null-assertion and oxfmt check The lint rule was silently disabled in `.oxlintrc.json`, so `bun run lint` reported clean while 127 forbidden assertions sat in source. Flip the rule to `error`, run `lint` with `--deny-warnings`, add a `format:check` script, and run both `bun run lint` and `bun run format:check` in CI so the gates fail loudly on regressions. oxfmt also touched 22 source/markdown files that had drifted from the configured style. Those are reformatting-only changes. --- .agents/skills/plan.md | 2 + .agents/skills/rabbit-round.md | 2 + .agents/skills/regression-hunt.md | 1 + .agents/skills/security-audit.md | 22 ++-- .claude/commands/plan.md | 2 + .claude/commands/rabbit-round.md | 2 + .claude/commands/regression-hunt.md | 1 + .claude/commands/security-audit.md | 22 ++-- .github/workflows/ci.yml | 1 + .oxlintrc.json | 2 +- __test__/parse.test.ts | 98 ++++++++-------- __test__/patterns.test.ts | 4 +- package.json | 13 ++- scripts/oracle.ts | 107 ++++++++++-------- src/at/vnr.ts | 5 +- src/bd/nid.ts | 9 +- src/bg/egn.ts | 3 +- src/bh/cpr.ts | 5 +- src/bz/tin.ts | 5 +- src/cu/ni.ts | 5 +- src/cz/rc.ts | 4 +- src/dk/cpr.ts | 5 +- src/it/codicefiscale.ts | 8 +- src/kr/rrn.ts | 5 +- src/kw/civil.ts | 9 +- src/lk/nic.ts | 5 +- src/luhn.ts | 9 +- src/mx/curp.ts | 9 +- src/patterns.ts | 19 +++- src/sg/uen.ts | 12 +- src/us/ein.ts | 166 ++++++++++++++-------------- 31 files changed, 278 insertions(+), 284 deletions(-) diff --git a/.agents/skills/plan.md b/.agents/skills/plan.md index dbdc5bf..3442215 100644 --- a/.agents/skills/plan.md +++ b/.agents/skills/plan.md @@ -66,9 +66,11 @@ creating duplicate systems. ## Scope **In scope:** + - ... **Out of scope:** + - ... ## Implementation diff --git a/.agents/skills/rabbit-round.md b/.agents/skills/rabbit-round.md index 7f78584..9096f09 100644 --- a/.agents/skills/rabbit-round.md +++ b/.agents/skills/rabbit-round.md @@ -57,6 +57,7 @@ CodeRabbit, Gemini, GitHub Copilot, Devin, Greptile, and similar bots. ## Decision Guidelines **Accept when the suggestion:** + - fixes a bug or real edge case - improves type safety - adds missing tests @@ -64,6 +65,7 @@ CodeRabbit, Gemini, GitHub Copilot, Devin, Greptile, and similar bots. - tightens security or validation appropriately **Push back when the suggestion:** + - assumes facts not true in this codebase - conflicts with canonical specs or official sources - adds complexity for little benefit diff --git a/.agents/skills/regression-hunt.md b/.agents/skills/regression-hunt.md index a178263..976f315 100644 --- a/.agents/skills/regression-hunt.md +++ b/.agents/skills/regression-hunt.md @@ -9,6 +9,7 @@ especially when the cause is not obvious yet. $ARGUMENTS — A short description of what regressed. Helpful extras when available: + - failing test name or file - error message or log line - expected behavior diff --git a/.agents/skills/security-audit.md b/.agents/skills/security-audit.md index 6ef09ea..d6a5e15 100644 --- a/.agents/skills/security-audit.md +++ b/.agents/skills/security-audit.md @@ -57,16 +57,18 @@ layer on repo-specific risks. - explicitly call out the domain assumptions you used 10. **Report findings by severity**: - - Critical - - High - - Medium - - Low - For each finding include: - - file and line - - issue - - likely impact - - recommended fix +- Critical +- High +- Medium +- Low + +For each finding include: + +- file and line +- issue +- likely impact +- recommended fix 11. **If there are no findings**, say so explicitly and mention what was checked - plus any residual gaps in verification. + plus any residual gaps in verification. diff --git a/.claude/commands/plan.md b/.claude/commands/plan.md index dbdc5bf..3442215 100644 --- a/.claude/commands/plan.md +++ b/.claude/commands/plan.md @@ -66,9 +66,11 @@ creating duplicate systems. ## Scope **In scope:** + - ... **Out of scope:** + - ... ## Implementation diff --git a/.claude/commands/rabbit-round.md b/.claude/commands/rabbit-round.md index 7f78584..9096f09 100644 --- a/.claude/commands/rabbit-round.md +++ b/.claude/commands/rabbit-round.md @@ -57,6 +57,7 @@ CodeRabbit, Gemini, GitHub Copilot, Devin, Greptile, and similar bots. ## Decision Guidelines **Accept when the suggestion:** + - fixes a bug or real edge case - improves type safety - adds missing tests @@ -64,6 +65,7 @@ CodeRabbit, Gemini, GitHub Copilot, Devin, Greptile, and similar bots. - tightens security or validation appropriately **Push back when the suggestion:** + - assumes facts not true in this codebase - conflicts with canonical specs or official sources - adds complexity for little benefit diff --git a/.claude/commands/regression-hunt.md b/.claude/commands/regression-hunt.md index a178263..976f315 100644 --- a/.claude/commands/regression-hunt.md +++ b/.claude/commands/regression-hunt.md @@ -9,6 +9,7 @@ especially when the cause is not obvious yet. $ARGUMENTS — A short description of what regressed. Helpful extras when available: + - failing test name or file - error message or log line - expected behavior diff --git a/.claude/commands/security-audit.md b/.claude/commands/security-audit.md index 6ef09ea..d6a5e15 100644 --- a/.claude/commands/security-audit.md +++ b/.claude/commands/security-audit.md @@ -57,16 +57,18 @@ layer on repo-specific risks. - explicitly call out the domain assumptions you used 10. **Report findings by severity**: - - Critical - - High - - Medium - - Low - For each finding include: - - file and line - - issue - - likely impact - - recommended fix +- Critical +- High +- Medium +- Low + +For each finding include: + +- file and line +- issue +- likely impact +- recommended fix 11. **If there are no findings**, say so explicitly and mention what was checked - plus any residual gaps in verification. + plus any residual gaps in verification. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d5838d..6083c1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,7 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - run: bun install --frozen-lockfile - run: bun run lint + - run: bun run format:check test: name: Test diff --git a/.oxlintrc.json b/.oxlintrc.json index 5592950..44b457c 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -5,7 +5,7 @@ "no-shadow": "error", "require-await": "error", "no-useless-catch": "error", - "no-non-null-assertion": "off", + "no-non-null-assertion": "error", "typescript/no-explicit-any": "error", "typescript/no-dynamic-delete": "error", "typescript/consistent-type-definitions": [ diff --git a/__test__/parse.test.ts b/__test__/parse.test.ts index 6c72329..605765e 100644 --- a/__test__/parse.test.ts +++ b/__test__/parse.test.ts @@ -31,10 +31,10 @@ type Discovered = { const isValidator = (value: unknown): value is Validator => Boolean( value && - typeof value === "object" && - "validate" in value && - "compact" in value && - "format" in value, + typeof value === "object" && + "validate" in value && + "compact" in value && + "format" in value, ); const hasParse = ( @@ -102,9 +102,9 @@ describe("cz.rc parse", () => { test("extracts male born 1971-03-19", () => { const result = validator.parse("7103192745"); expect(result).not.toBeNull(); - expect(result && "gender" in result && result.gender).toBe( - "male", - ); + expect( + result && "gender" in result && result.gender, + ).toBe("male"); expect(result?.birthDate.getFullYear()).toBe(1971); expect(result?.birthDate.getMonth()).toBe(2); expect(result?.birthDate.getDate()).toBe(19); @@ -113,9 +113,9 @@ describe("cz.rc parse", () => { test("extracts female (month + 50)", () => { const result = validator.parse("715319/1001"); expect(result).not.toBeNull(); - expect(result && "gender" in result && result.gender).toBe( - "female", - ); + expect( + result && "gender" in result && result.gender, + ).toBe("female"); expect(result?.birthDate.getMonth()).toBe(2); }); @@ -133,9 +133,9 @@ describe("sk.rc parse", () => { test("delegates to cz.rc parse", () => { const result = validator.parse("7103192745"); expect(result).not.toBeNull(); - expect(result && "gender" in result && result.gender).toBe( - "male", - ); + expect( + result && "gender" in result && result.gender, + ).toBe("male"); expect(result?.birthDate.getFullYear()).toBe(1971); }); }); @@ -168,9 +168,9 @@ describe("dk.cpr parse", () => { test("extracts male born 1862-10-21", () => { const result = validator.parse("2110625629"); expect(result).not.toBeNull(); - expect(result && "gender" in result && result.gender).toBe( - "male", - ); + expect( + result && "gender" in result && result.gender, + ).toBe("male"); expect(result?.birthDate.getFullYear()).toBe(1862); expect(result?.birthDate.getMonth()).toBe(9); expect(result?.birthDate.getDate()).toBe(21); @@ -186,9 +186,9 @@ describe("ee.ik parse", () => { test("extracts male born 1968-05-28", () => { const result = validator.parse("36805280109"); expect(result).not.toBeNull(); - expect(result && "gender" in result && result.gender).toBe( - "male", - ); + expect( + result && "gender" in result && result.gender, + ).toBe("male"); expect(result?.birthDate.getFullYear()).toBe(1968); expect(result?.birthDate.getMonth()).toBe(4); expect(result?.birthDate.getDate()).toBe(28); @@ -204,9 +204,9 @@ describe("fi.hetu parse", () => { test("extracts female born 1952-10-13", () => { const result = validator.parse("131052-308T"); expect(result).not.toBeNull(); - expect(result && "gender" in result && result.gender).toBe( - "female", - ); + expect( + result && "gender" in result && result.gender, + ).toBe("female"); expect(result?.birthDate.getFullYear()).toBe(1952); expect(result?.birthDate.getMonth()).toBe(9); expect(result?.birthDate.getDate()).toBe(13); @@ -223,9 +223,9 @@ describe("it.codicefiscale parse", () => { test("extracts male born 1983-11-18", () => { const result = validator.parse("RCCMNL83S18D969H"); expect(result).not.toBeNull(); - expect(result && "gender" in result && result.gender).toBe( - "male", - ); + expect( + result && "gender" in result && result.gender, + ).toBe("male"); expect(result?.birthDate.getFullYear()).toBe(1983); expect(result?.birthDate.getMonth()).toBe(10); expect(result?.birthDate.getDate()).toBe(18); @@ -246,9 +246,9 @@ describe("no.fodselsnummer parse", () => { test("extracts female born 1986-10-15", () => { const result = validator.parse("15108695088"); expect(result).not.toBeNull(); - expect(result && "gender" in result && result.gender).toBe( - "female", - ); + expect( + result && "gender" in result && result.gender, + ).toBe("female"); expect(result?.birthDate.getFullYear()).toBe(1986); expect(result?.birthDate.getMonth()).toBe(9); expect(result?.birthDate.getDate()).toBe(15); @@ -259,14 +259,15 @@ describe("pl.pesel parse", () => { const validator = discovered.find( (entry) => entry.name === "pl.pesel", )?.validator; - if (!validator) throw new Error("pl.pesel not discovered"); + if (!validator) + throw new Error("pl.pesel not discovered"); test("extracts male born 1944-05-14", () => { const result = validator.parse("44051401359"); expect(result).not.toBeNull(); - expect(result && "gender" in result && result.gender).toBe( - "male", - ); + expect( + result && "gender" in result && result.gender, + ).toBe("male"); expect(result?.birthDate.getFullYear()).toBe(1944); expect(result?.birthDate.getMonth()).toBe(4); expect(result?.birthDate.getDate()).toBe(14); @@ -293,9 +294,9 @@ describe("ro.cnp parse", () => { test("extracts male born 1963-06-15", () => { const result = validator.parse("1630615123457"); expect(result).not.toBeNull(); - expect(result && "gender" in result && result.gender).toBe( - "male", - ); + expect( + result && "gender" in result && result.gender, + ).toBe("male"); expect(result?.birthDate.getFullYear()).toBe(1963); expect(result?.birthDate.getMonth()).toBe(5); expect(result?.birthDate.getDate()).toBe(15); @@ -315,9 +316,9 @@ describe("se.personnummer parse", () => { expect(result?.birthDate.getFullYear()).toBe(1988); expect(result?.birthDate.getMonth()).toBe(2); expect(result?.birthDate.getDate()).toBe(20); - expect(result && "gender" in result && result.gender).toBe( - "male", - ); + expect( + result && "gender" in result && result.gender, + ).toBe("male"); }); }); @@ -330,9 +331,9 @@ describe("si.emso parse", () => { test("extracts male born 2006-01-01", () => { const result = validator.parse("0101006500006"); expect(result).not.toBeNull(); - expect(result && "gender" in result && result.gender).toBe( - "male", - ); + expect( + result && "gender" in result && result.gender, + ).toBe("male"); expect(result?.birthDate.getFullYear()).toBe(2006); expect(result?.birthDate.getMonth()).toBe(0); expect(result?.birthDate.getDate()).toBe(1); @@ -348,9 +349,9 @@ describe("fr.nir parse", () => { test("extracts female born 1995-11-01", () => { const result = validator.parse("295117823456784"); expect(result).not.toBeNull(); - expect(result && "gender" in result && result.gender).toBe( - "female", - ); + expect( + result && "gender" in result && result.gender, + ).toBe("female"); expect(result?.birthDate.getFullYear()).toBe(1995); expect(result?.birthDate.getMonth()).toBe(10); expect(result?.birthDate.getDate()).toBe(1); @@ -363,9 +364,9 @@ describe("fr.nir parse", () => { base.toString() + check.toString().padStart(2, "0"); const result = validator.parse(full); expect(result).not.toBeNull(); - expect(result && "gender" in result && result.gender).toBe( - "male", - ); + expect( + result && "gender" in result && result.gender, + ).toBe("male"); expect(result?.birthDate.getFullYear()).toBe(1985); expect(result?.birthDate.getMonth()).toBe(4); }); @@ -375,7 +376,8 @@ describe("kw.civil parse", () => { const validator = discovered.find( (entry) => entry.name === "kw.civil", )?.validator; - if (!validator) throw new Error("kw.civil not discovered"); + if (!validator) + throw new Error("kw.civil not discovered"); test("extracts birth date without gender", () => { const result = validator.parse("289011200032"); diff --git a/__test__/patterns.test.ts b/__test__/patterns.test.ts index 7ddff12..007e30b 100644 --- a/__test__/patterns.test.ts +++ b/__test__/patterns.test.ts @@ -89,7 +89,9 @@ describe("toRegex", () => { const matches = (s: string) => new RegExp(source, "g").test(s); expect(matches("OF NOVEMBER 6")).toBe(false); - expect(matches("AS OF NOVEMBER 6, 2024, BY")).toBe(false); + expect(matches("AS OF NOVEMBER 6, 2024, BY")).toBe( + false, + ); expect(matches("AMENDMENT DATED NOVEMBER 6")).toBe( false, ); diff --git a/package.json b/package.json index ecfa729..6b42f88 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "type": "git", "url": "https://github.com/stella/stdnum" }, - "packageManager": "bun@1.3.12", "files": [ "src", "dist" @@ -899,6 +898,9 @@ "default": "./dist/za/idnr.js" } }, + "publishConfig": { + "access": "public" + }, "scripts": { "build": "tsdown", "prepublishOnly": "bun run typecheck && bun run build && bun run sync-exports:check", @@ -910,8 +912,9 @@ "oracle": "bun scripts/oracle.ts --mode=gate", "oracle:gate": "bun scripts/oracle.ts --mode=gate", "oracle:survey": "bun scripts/oracle.ts --mode=survey", - "lint": "oxlint .", + "lint": "oxlint . --deny-warnings", "format": "oxfmt .", + "format:check": "oxfmt --check .", "typecheck": "tsc -p tsconfig.json" }, "devDependencies": { @@ -929,10 +932,8 @@ "typescript": "^5.9.3", "validate-polish": "^2.1.40" }, - "publishConfig": { - "access": "public" - }, "engines": { "node": ">= 18" - } + }, + "packageManager": "bun@1.3.12" } diff --git a/scripts/oracle.ts b/scripts/oracle.ts index bda73a2..67caeb1 100644 --- a/scripts/oracle.ts +++ b/scripts/oracle.ts @@ -187,8 +187,10 @@ const alnumStr = (min: number, max: number) => const letters = (chars: string) => fc.constantFrom(...chars.split("")); -const p2 = (n: number): string => String(n).padStart(2, "0"); -const p3 = (n: number): string => String(n).padStart(3, "0"); +const p2 = (n: number): string => + String(n).padStart(2, "0"); +const p3 = (n: number): string => + String(n).padStart(3, "0"); const isValidDateParts = ( year: number, @@ -216,33 +218,35 @@ const validDateParts = (minYear: number, maxYear: number) => const rcShape = (length: 9 | 10): fc.Arbitrary => (length === 9 - ? validDateParts(1900, 1953).chain(({ year, month, day }) => - fc - .tuple( - fc.constantFrom(month, month + 50), - fc.integer({ min: 0, max: 999 }), - ) - .map(([rawMonth, serial]) => { - const yy = p2(year % 100); - return `${yy}${p2(rawMonth)}${p2(day)}${p3(serial)}`; - }), + ? validDateParts(1900, 1953).chain( + ({ year, month, day }) => + fc + .tuple( + fc.constantFrom(month, month + 50), + fc.integer({ min: 0, max: 999 }), + ) + .map(([rawMonth, serial]) => { + const yy = p2(year % 100); + return `${yy}${p2(rawMonth)}${p2(day)}${p3(serial)}`; + }), ) - : validDateParts(1954, 2053).chain(({ year, month, day }) => - fc - .tuple( - fc.constantFrom( - month, - month + 20, - month + 50, - month + 70, - ), - fc.integer({ min: 0, max: 999 }), - fc.integer({ min: 0, max: 9 }), - ) - .map(([rawMonth, serial, check]) => { - const yy = p2(year % 100); - return `${yy}${p2(rawMonth)}${p2(day)}${p3(serial)}${check}`; - }), + : validDateParts(1954, 2053).chain( + ({ year, month, day }) => + fc + .tuple( + fc.constantFrom( + month, + month + 20, + month + 50, + month + 70, + ), + fc.integer({ min: 0, max: 999 }), + fc.integer({ min: 0, max: 9 }), + ) + .map(([rawMonth, serial, check]) => { + const yy = p2(year % 100); + return `${yy}${p2(rawMonth)}${p2(day)}${p3(serial)}${check}`; + }), )) as fc.Arbitrary; const bgEgnShape = validDateParts(1800, 2099).chain( @@ -302,21 +306,29 @@ const lvVatShape = fc.oneof( }), ), fc - .tuple(fc.constantFrom("3"), fc.integer({ min: 2, max: 9 }), digs(9)) - .map(([first, second, rest]) => `${first}${second}${rest}`), + .tuple( + fc.constantFrom("3"), + fc.integer({ min: 2, max: 9 }), + digs(9), + ) + .map( + ([first, second, rest]) => `${first}${second}${rest}`, + ), ); -const cyVatShape = fc.oneof( - fc.integer({ min: 60_000_000, max: 99_999_999 }).map((n) => - String(n), - ), - fc - .integer({ min: 0, max: 99_999_999 }) - .map((n) => String(n).padStart(8, "0")) - .filter((digits) => !digits.startsWith("12")), -).chain((digits) => - letters(L).map((letter) => `${digits}${letter}`), -); +const cyVatShape = fc + .oneof( + fc + .integer({ min: 60_000_000, max: 99_999_999 }) + .map((n) => String(n)), + fc + .integer({ min: 0, max: 99_999_999 }) + .map((n) => String(n).padStart(8, "0")) + .filter((digits) => !digits.startsWith("12")), + ) + .chain((digits) => + letters(L).map((letter) => `${digits}${letter}`), + ); // ─── Custom arb overrides ─────────────────── // Where inferArb (lengths-based) is insufficient. @@ -957,10 +969,7 @@ const SURVEY_ONLY_ENTRIES = new Set([ "validate-polish:pl.pesel", ]); -const tierFor = ( - source: string, - key: string, -): OracleMode => +const tierFor = (source: string, key: string): OracleMode => SURVEY_ONLY_SOURCES.has(source) || SURVEY_ONLY_ENTRIES.has(`${source}:${key}`) ? "survey" @@ -1143,7 +1152,9 @@ const mutateAt = ( for (let d = 0; d <= 9; d++) { const r = String(d); if (r === ch) continue; - out.push(value.slice(0, index) + r + value.slice(index + 1)); + out.push( + value.slice(0, index) + r + value.slice(index + 1), + ); } return out; }; @@ -1322,7 +1333,9 @@ const run = () => { return result.valid ? [result.compact] : []; }); if (valid.length === 0) { - console.log(` SKIP ${d.key}: no valid values found`); + console.log( + ` SKIP ${d.key}: no valid values found`, + ); continue; } const toTest = valid.slice(0, 50); diff --git a/src/at/vnr.ts b/src/at/vnr.ts index 433f394..11930b2 100644 --- a/src/at/vnr.ts +++ b/src/at/vnr.ts @@ -11,10 +11,7 @@ */ import { clean } from "#util/clean"; -import { - randomDigits, - randomInt, -} from "#util/generate"; +import { randomDigits, randomInt } from "#util/generate"; import { err } from "#util/result"; import { isdigits } from "#util/strings"; diff --git a/src/bd/nid.ts b/src/bd/nid.ts index 7389ca8..e3f2c17 100644 --- a/src/bd/nid.ts +++ b/src/bd/nid.ts @@ -1,11 +1,4 @@ -const RMO_VALUES = [ - "1", - "2", - "3", - "4", - "5", - "9", -] as const; +const RMO_VALUES = ["1", "2", "3", "4", "5", "9"] as const; /** Generate a random valid Bangladesh NID (13-digit). */ const generate = (): string => { diff --git a/src/bg/egn.ts b/src/bg/egn.ts index 7e8103d..39e98b1 100644 --- a/src/bg/egn.ts +++ b/src/bg/egn.ts @@ -149,7 +149,8 @@ const egn: Validator = { candidatePattern: "\\d{10}", country: "BG", entityType: "person", - sourceUrl: "https://www.grao.bg/normact/NaredbaFunkcESGR.pdf", + sourceUrl: + "https://www.grao.bg/normact/NaredbaFunkcESGR.pdf", examples: ["7523169263"] as const, compact, format, diff --git a/src/bh/cpr.ts b/src/bh/cpr.ts index cbe0a37..6543c5c 100644 --- a/src/bh/cpr.ts +++ b/src/bh/cpr.ts @@ -17,10 +17,7 @@ */ import { clean } from "#util/clean"; -import { - randomDigits, - randomInt, -} from "#util/generate"; +import { randomDigits, randomInt } from "#util/generate"; import { err } from "#util/result"; import { isdigits } from "#util/strings"; diff --git a/src/bz/tin.ts b/src/bz/tin.ts index 685d1d6..f5a6ddd 100644 --- a/src/bz/tin.ts +++ b/src/bz/tin.ts @@ -26,10 +26,7 @@ */ import { clean } from "#util/clean"; -import { - randomDigits, - randomInt, -} from "#util/generate"; +import { randomDigits, randomInt } from "#util/generate"; import { err } from "#util/result"; import type { ValidateResult, Validator } from "../types"; diff --git a/src/cu/ni.ts b/src/cu/ni.ts index a6a1131..717117e 100644 --- a/src/cu/ni.ts +++ b/src/cu/ni.ts @@ -17,10 +17,7 @@ import { clean } from "#util/clean"; import { isValidDate } from "#util/date"; -import { - randomDigits, - randomInt, -} from "#util/generate"; +import { randomDigits, randomInt } from "#util/generate"; import { err } from "#util/result"; import type { diff --git a/src/cz/rc.ts b/src/cz/rc.ts index 6377228..ff1fbcd 100644 --- a/src/cz/rc.ts +++ b/src/cz/rc.ts @@ -45,7 +45,9 @@ const decodeMonth = ( length: number, ): number | null => { const candidates = - length === 10 && year >= 2004 ? [0, 50, 20, 70] : [0, 50]; + length === 10 && year >= 2004 + ? [0, 50, 20, 70] + : [0, 50]; for (const offset of candidates) { const month = rawMonth - offset; if (month >= 1 && month <= 12) { diff --git a/src/dk/cpr.ts b/src/dk/cpr.ts index 87906c0..c452614 100644 --- a/src/dk/cpr.ts +++ b/src/dk/cpr.ts @@ -10,10 +10,7 @@ import { clean } from "#util/clean"; import { isValidDate } from "#util/date"; -import { - randomDigits, - randomInt, -} from "#util/generate"; +import { randomDigits, randomInt } from "#util/generate"; import { err } from "#util/result"; import { isdigits } from "#util/strings"; diff --git a/src/it/codicefiscale.ts b/src/it/codicefiscale.ts index 746883c..b9d4593 100644 --- a/src/it/codicefiscale.ts +++ b/src/it/codicefiscale.ts @@ -281,8 +281,7 @@ const generate = (): string => { "A"; const day = - randomInt(1, 28) + - (randomInt(0, 1) === 0 ? 0 : 40); + randomInt(1, 28) + (randomInt(0, 1) === 0 ? 0 : 40); body += String(day).padStart(2, "0"); body += randomLetter(); body += String(randomInt(0, 999)).padStart(3, "0"); @@ -291,10 +290,7 @@ const generate = (): string => { for (let i = 0; i < body.length; i++) { const ch = body[i]; if (ch === undefined) continue; - sum += - i % 2 === 0 - ? (ODD[ch] ?? 0) - : (EVEN[ch] ?? 0); + sum += i % 2 === 0 ? (ODD[ch] ?? 0) : (EVEN[ch] ?? 0); } return `${body}${CHECK_LETTERS.charAt(sum % 26)}`; diff --git a/src/kr/rrn.ts b/src/kr/rrn.ts index c84fea0..65ca7b4 100644 --- a/src/kr/rrn.ts +++ b/src/kr/rrn.ts @@ -11,10 +11,7 @@ import { clean } from "#util/clean"; import { isValidDate } from "#util/date"; -import { - randomDigits, - randomInt, -} from "#util/generate"; +import { randomDigits, randomInt } from "#util/generate"; import { err } from "#util/result"; import { isdigits } from "#util/strings"; diff --git a/src/kw/civil.ts b/src/kw/civil.ts index 82dceb4..144fb12 100644 --- a/src/kw/civil.ts +++ b/src/kw/civil.ts @@ -14,10 +14,7 @@ import { clean } from "#util/clean"; import { isValidDate } from "#util/date"; -import { - randomDigits, - randomInt, -} from "#util/generate"; +import { randomDigits, randomInt } from "#util/generate"; import { err } from "#util/result"; import { isdigits } from "#util/strings"; @@ -104,9 +101,7 @@ const format = (value: string): string => { * Gender is not encoded in the number. * Returns null if the value is not valid. */ -const parse = ( - value: string, -): ParsedBirthDate | null => { +const parse = (value: string): ParsedBirthDate | null => { const result = validate(value); if (!result.valid) return null; diff --git a/src/lk/nic.ts b/src/lk/nic.ts index ab9f1e3..2acc399 100644 --- a/src/lk/nic.ts +++ b/src/lk/nic.ts @@ -14,10 +14,7 @@ import { clean } from "#util/clean"; import { isValidDate } from "#util/date"; -import { - randomDigits, - randomInt, -} from "#util/generate"; +import { randomDigits, randomInt } from "#util/generate"; import { err } from "#util/result"; import { isdigits } from "#util/strings"; diff --git a/src/luhn.ts b/src/luhn.ts index c6b9b6e..7d69332 100644 --- a/src/luhn.ts +++ b/src/luhn.ts @@ -60,15 +60,10 @@ const luhn: Validator = { name: "Luhn", localName: "Luhn", abbreviation: "Luhn", - aliases: [ - "Luhn", - "Luhn algorithm", - "mod 10", - ] as const, + aliases: ["Luhn", "Luhn algorithm", "mod 10"] as const, candidatePattern: "\\d{2,}", entityType: "any", - sourceUrl: - "https://en.wikipedia.org/wiki/Luhn_algorithm", + sourceUrl: "https://en.wikipedia.org/wiki/Luhn_algorithm", examples: ["4111111111111111", "18"] as const, compact, format, diff --git a/src/mx/curp.ts b/src/mx/curp.ts index 82352df..33ae1dc 100644 --- a/src/mx/curp.ts +++ b/src/mx/curp.ts @@ -202,12 +202,11 @@ const generate = (): string => { const yy = String(year % 100).padStart(2, "0"); const gender = Math.random() < 0.5 ? "H" : "M"; const state = - STATE_CODE_LIST[randomInt(0, STATE_CODE_LIST.length - 1)] ?? - "NE"; + STATE_CODE_LIST[ + randomInt(0, STATE_CODE_LIST.length - 1) + ] ?? "NE"; const centuryChar = - year >= 2000 - ? randomLetter() - : String(randomInt(0, 9)); + year >= 2000 ? randomLetter() : String(randomInt(0, 9)); const body = `${randomLetter()}${randomLetter()}` + `${randomLetter()}${randomLetter()}` + diff --git a/src/patterns.ts b/src/patterns.ts index 74e6f64..1e76b60 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -113,7 +113,10 @@ const inferPrefix = (v: Validator): string | null => { v.compact(ex).startsWith(compactPrefix), ); if (allSame) return compactPrefix; - } else if (compactPrefix !== undefined && examples.length === 1) { + } else if ( + compactPrefix !== undefined && + examples.length === 1 + ) { return compactPrefix; } // Prefix added only by format() @@ -177,9 +180,7 @@ type PerGroup = { size: number; charClass: string }; * are treated as prefixes and excluded (handled * by inferPrefix separately). */ -const inferPerGroup = ( - v: Validator, -): PerGroup[] | null => { +const inferPerGroup = (v: Validator): PerGroup[] | null => { const first = v.examples?.[0]; if (first === undefined) return null; const compact = v.compact(first); @@ -212,9 +213,15 @@ const inferPerGroup = ( if (p.length === 0) continue; if (/\d/.test(p)) { seenDigitGroup = true; - filtered.push({ size: p.length, charClass: charClassFor(p) }); + filtered.push({ + size: p.length, + charClass: charClassFor(p), + }); } else if (seenDigitGroup) { - filtered.push({ size: p.length, charClass: charClassFor(p) }); + filtered.push({ + size: p.length, + charClass: charClassFor(p), + }); } } diff --git a/src/sg/uen.ts b/src/sg/uen.ts index 9606154..75549a1 100644 --- a/src/sg/uen.ts +++ b/src/sg/uen.ts @@ -72,9 +72,7 @@ const compact = (value: string): string => * Validate Business (ROB) UEN (9 chars): * 8 digits + 1 check letter. */ -const BUSINESS_WEIGHTS = [ - 10, 4, 9, 3, 8, 2, 7, 1, -] as const; +const BUSINESS_WEIGHTS = [10, 4, 9, 3, 8, 2, 7, 1] as const; const BUSINESS_CHECK_ALPHA = "XMKECAWLJDB"; const validateBusiness = (v: string): ValidateResult => { @@ -112,9 +110,7 @@ const validateBusiness = (v: string): ValidateResult => { * Validate Local Company (ROC) UEN (10 chars): * 9 digits (year prefix) + 1 check letter. */ -const LOCAL_WEIGHTS = [ - 10, 8, 6, 4, 9, 7, 5, 3, 1, -] as const; +const LOCAL_WEIGHTS = [10, 8, 6, 4, 9, 7, 5, 3, 1] as const; const LOCAL_CHECK_ALPHA = "ZKCMDNERGWH"; const validateLocalCompany = ( @@ -155,9 +151,7 @@ const validateLocalCompany = ( * R/S/T + 2 digits + 2-letter type + 4 digits * + 1 check letter. */ -const OTHER_WEIGHTS = [ - 4, 3, 5, 3, 10, 2, 2, 5, 7, -] as const; +const OTHER_WEIGHTS = [4, 3, 5, 3, 10, 2, 2, 5, 7] as const; const validateOther = (v: string): ValidateResult => { // SAFETY: caller routes to this only when diff --git a/src/us/ein.ts b/src/us/ein.ts index 0e00802..a303bf8 100644 --- a/src/us/ein.ts +++ b/src/us/ein.ts @@ -1,87 +1,87 @@ const EIN_PREFIXES = [ - "01", - "02", - "03", - "04", - "05", - "06", - "10", - "11", - "12", - "13", - "14", - "15", - "16", - "20", - "21", - "22", - "23", - "24", - "25", - "26", - "27", - "30", - "31", - "32", - "33", - "34", - "35", - "36", - "37", - "38", - "39", - "40", - "41", - "42", - "43", - "44", - "45", - "46", - "47", - "48", - "50", - "51", - "52", - "53", - "54", - "55", - "56", - "57", - "58", - "59", - "60", - "61", - "62", - "63", - "64", - "65", - "66", - "67", - "68", - "71", - "72", - "73", - "74", - "75", - "76", - "77", - "80", - "81", - "82", - "83", - "84", - "85", - "86", - "87", - "88", - "90", - "91", - "92", - "93", - "94", - "95", - "98", - "99", + "01", + "02", + "03", + "04", + "05", + "06", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "20", + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "30", + "31", + "32", + "33", + "34", + "35", + "36", + "37", + "38", + "39", + "40", + "41", + "42", + "43", + "44", + "45", + "46", + "47", + "48", + "50", + "51", + "52", + "53", + "54", + "55", + "56", + "57", + "58", + "59", + "60", + "61", + "62", + "63", + "64", + "65", + "66", + "67", + "68", + "71", + "72", + "73", + "74", + "75", + "76", + "77", + "80", + "81", + "82", + "83", + "84", + "85", + "86", + "87", + "88", + "90", + "91", + "92", + "93", + "94", + "95", + "98", + "99", ] as const; /** Generate a random valid U.S. EIN. */ From 6663364ca9807981c42a4edbfca35b3459b26f74 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Sun, 17 May 2026 20:02:58 +0200 Subject: [PATCH 4/5] chore(release): cut v1.0.0 The npm registry pins `^0.0.1` to exactly `0.0.1` (a long-standing semver quirk for major 0/minor 0 ranges). Anyone who installed the package at `^0.0.1` is stuck on the very first publish, even though the codebase has had public-facing changes since. Skipping straight to 1.0.0 frees consumers from that lock and gives later releases a normal semver runway. No public-API breakage compared to the pre-1.0 line; the changes described in CHANGELOG are improvements on top of the same surface. --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ SECURITY.md | 2 +- package.json | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ba21e0..b346b87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,39 @@ The format is based on and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.0] - 2026-05-17 + +### Changed + +- Bumped to 1.0.0 to opt out of the npm pinning quirk + where `^0.0.1` resolves only to `0.0.1`. The public + surface and feature set are unchanged from the + pre-1.0 line. +- `Validator` now exposes `parse?` on the base type + with a widened `ParsedIdentifier | null` return. + Producers that type as `Validator` or + `Validator` still narrow the return + type as before. + +### Fixed + +- Treat `oxlint` as a real CI gate: the + `no-non-null-assertion` rule was silently disabled in + the lint config. It is now enforced; existing + violations were resolved with structural narrowing + (most weighted-sum loops now use array iterators) or + documented `// SAFETY:` comments where the existence + is genuinely guaranteed. +- `format:check` is now wired into CI so formatter + drift cannot land unnoticed. + +### Removed + +- Dead `imports` map (`#checksums/*`, `#util/*`) from + `package.json`. Consumers never hit it: built output + uses relative imports and dev/test resolution goes + through `tsconfig.json` `paths`. + ## [0.1.0] - 2026-03-18 ### Added @@ -24,4 +57,5 @@ and this project adheres to artifacts. - Per-identifier entry points for tree-shaking. +[1.0.0]: https://github.com/stella/stdnum/releases/tag/v1.0.0 [0.1.0]: https://github.com/stella/stdnum/releases/tag/v0.1.0 diff --git a/SECURITY.md b/SECURITY.md index a3df187..074b42a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,7 @@ | Version | Supported | | ------- | --------- | -| 0.1.x | Yes | +| 1.x | Yes | ## Reporting a Vulnerability diff --git a/package.json b/package.json index 6b42f88..d50f5c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@stll/stdnum", - "version": "0.0.1", + "version": "1.0.0", "description": "Validate, compact, and format standard identifiers for Node.js and Bun. Pure TypeScript, zero dependencies, tree-shakeable per identifier.", "keywords": [ "credit-card", From 6045b0618df8f62c6af49f567fb1fc1c3c8f38db Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Sun, 17 May 2026 23:24:14 +0200 Subject: [PATCH 5/5] fix(pa/ruc): reject 3-segment NT input and correct SAFETY notes The early format guard only rejected 4-segment inputs whose second segment was not "NT", but allowed a 3-segment input with "NT" in position 1 (e.g. "1-NT-100"). The NT branch then accessed `segments[3]` and called `.length` on `undefined`, crashing instead of returning null/INVALID_FORMAT. Tighten the guard so the NT branch and a 4-segment shape are exactly equivalent, drop the misleading "len === 4 (checked above)" SAFETY note in favour of one matching the new guard, and replace the juridical "buf of length 20" SAFETY note with the actual invariant: s0 is zero-padded to at least 10 chars, so buf[3..6] are defined. Add a regression test for the previously-crashing 3-segment NT input. --- __test__/pa.test.ts | 1 + src/pa/ruc.ts | 21 +++++++++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/__test__/pa.test.ts b/__test__/pa.test.ts index e902e1d..e6feb76 100644 --- a/__test__/pa.test.ts +++ b/__test__/pa.test.ts @@ -30,6 +30,7 @@ describe("pa.ruc", () => { "abc", // no segments "155-1-100 DV00", // old-format wrong DV "a-b-c-d-e DV00", // 5 segments rejected + "1-NT-100 DV00", // 3 segments with NT in pos 1 rejected ]; for (const v of invalid) { diff --git a/src/pa/ruc.ts b/src/pa/ruc.ts index d4e517e..b6b821d 100644 --- a/src/pa/ruc.ts +++ b/src/pa/ruc.ts @@ -111,11 +111,14 @@ const computeDV = ( ): string | null => { const len = segments.length; - if ( - len < 3 || - len > 4 || - (len === 4 && segments[1] !== "NT") - ) { + if (len < 3 || len > 4) { + return null; + } + // The NT designation requires exactly 4 segments + // (xxNT-NT-xxx-xxx); any other 4-segment shape is + // unrecognized, and a 3-segment "NT" in position 1 + // is malformed. + if ((len === 4) !== (segments[1] === "NT")) { return null; } @@ -142,8 +145,8 @@ const computeDV = ( z(5 - s2.length) + s2; } else if (s1 === "NT") { - // NT designation (xxNT-xxx-xxx) - // SAFETY: NT branch requires len === 4 (checked above). + // NT designation (xxNT-NT-xxx-xxx) + // SAFETY: NT branch implies len === 4 (checked above). // eslint-disable-next-line no-non-null-assertion const s3 = segments[3]!; const prefix = s0.slice(0, -2); @@ -226,7 +229,9 @@ const computeDV = ( s1 + z(6 - s2.length) + s2; - // SAFETY: juridical branch produces buf of length 20. + // SAFETY: juridical branch zero-pads s0 to at least + // 10 chars (z(10 - s0.length)), so buf[3..6] are + // always defined. isOld = buf[3] === "0" && buf[4] === "0" &&