korea-fixtures 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # korea-fixtures
2
+
3
+ Deterministic, **checksum-valid-but-fake** Korean test data + validators — for tests, fixtures, and
4
+ demos. Seeded and reproducible. **Zero dependencies.** (`kfix` CLI + a tiny library.)
5
+
6
+ ```bash
7
+ npx kfix gen brn --seed 42 --count 3
8
+ # 332-81-02957
9
+ # 299-85-28847
10
+ # 874-05-50925 (re-run with the same seed -> byte-identical)
11
+
12
+ npx kfix validate brn 124-81-00997
13
+ # {"valid":false,"type":"brn","failedCheck":"checkDigit","expected":8,"actual":7}
14
+ ```
15
+
16
+ ## Why
17
+
18
+ Korean developers need *valid-format* test fixtures — 사업자등록번호, 계좌번호, 카드번호 — every day,
19
+ and today they copy-paste checksum gists per identifier. The major fakers don't cover them: Faker
20
+ (`ko_KR`), faker-js (`ko`), and Datafaker (`ko.yml`) ship Korean names but **no business-registration
21
+ number, no account number, no checksums**. This is that missing piece: one seeded package that both
22
+ **generates** correct-by-construction fake identifiers and **validates** them with "which check failed"
23
+ diagnostics — the deterministic, dependency-free shape that CI fixtures want.
24
+
25
+ The checksum algorithms are pinned against real public entities (Samsung Electronics
26
+ 사업자등록번호 `124-81-00998`, 법인등록번호 `130111-0006246`; the Luhn vector `4242…4242`).
27
+
28
+ ## Supported identifiers
29
+
30
+ | type | generate | validate | check |
31
+ |---|:--:|:--:|---|
32
+ | `brn` — 사업자등록번호 | ✅ | ✅ | weighted mod-10 + half-carry |
33
+ | `corp` — 법인등록번호 | ✅ | ✅ | alternating 1/2 weights, mod-10 |
34
+ | `card` — 카드번호 | ✅ | ✅ | Luhn |
35
+ | `account` — 계좌번호 | ✅ | ✅ | 14 banks, longest-prefix detection (best-effort, no national checksum) |
36
+ | `card` issuers | ✅ | ✅ | 8 Korean issuers, Luhn always correct |
37
+ | `phone` — 휴대폰 | ✅ | ✅ | format (010/011/016-019) |
38
+ | `postal` — 우편번호 | ✅ | ✅ | 5-digit (post-2015), format-only |
39
+ | `plate` — 차량번호 | ✅ | ✅ | 12가3456 / 123가4567, valid plate syllable |
40
+ | `address` — 도로명주소 | ✅ | ✅ | format-only |
41
+ | `rrn` — 주민등록번호 | ❌ **validate-only** | ✅ | mod-11 + date/gender |
42
+
43
+ > **주민등록번호 generation is intentionally unsupported.** RRNs identify real people; we validate
44
+ > them (typo/format checks) but never fabricate them. That posture is a feature, not a gap.
45
+
46
+ ## Library
47
+
48
+ ```js
49
+ import { generate, validate } from "korea-fixtures";
50
+
51
+ generate("brn", { seed: 42, count: 1000 }); // -> [{ type, value:"332-81-02957", raw }, ...] reproducible
52
+ validate("account", "3333-12-3456789"); // -> { valid, type, bank, bankName } | { valid:false, failedCheck }
53
+ ```
54
+
55
+ Seeds may be numbers or strings (e.g. a release tag); the same seed always yields identical output,
56
+ so generated datasets are golden-pinnable in CI.
57
+
58
+ **TypeScript:** types ship with the package (`index.d.ts`) — `generate`, `validate`, the `GenerateType`
59
+ / `ValidateType` unions, and the result shapes are fully typed, no `@types` needed.
60
+
61
+ ## Honest scope
62
+
63
+ - **Account formats are best-effort.** Most Korean bank accounts have no public checksum, so `account`
64
+ validity means "matches a known bank's prefix + length". The bank table is small and easy to override
65
+ — not authoritative.
66
+ - **Card BINs are plausible, not issuer-authoritative** (the Luhn check digit is always correct).
67
+ - This is a **library first** — correct-by-construction breadth + determinism + bidirectional
68
+ diagnostics. A paid fixture-pack / CI service could layer on top later.
69
+
70
+ ## Develop
71
+
72
+ ```bash
73
+ node --test # 20 tests: golden checksums + round-trip + determinism + identifier formats + .d.ts sync
74
+ ```
75
+
76
+ Zero runtime dependencies; published files are `src/`, `bin/`, `index.d.ts`, `README.md` (see `files`).
77
+
78
+ MIT.
package/bin/cli.js ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ import { generate, validate, GENERATE_TYPES, VALIDATE_TYPES } from "../src/index.js";
3
+
4
+ function opt(args, name, def) {
5
+ const i = args.indexOf(`--${name}`);
6
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : def;
7
+ }
8
+
9
+ function usage() {
10
+ console.log(`korea-fixtures (kfix) — deterministic, checksum-valid-but-fake Korean test data
11
+
12
+ kfix gen <type> [--seed S] [--count N] generate identifiers (reproducible per seed)
13
+ kfix validate <type> <value> validate one identifier (JSON result)
14
+ kfix types list supported types
15
+
16
+ generate: ${GENERATE_TYPES.join(", ")}
17
+ validate: ${VALIDATE_TYPES.join(", ")} (rrn is validate-only by design)`);
18
+ }
19
+
20
+ const [cmd, ...rest] = process.argv.slice(2);
21
+
22
+ try {
23
+ if (cmd === "gen" || cmd === "generate") {
24
+ const type = rest[0];
25
+ if (!type) { usage(); process.exit(2); }
26
+ const seed = opt(rest, "seed", "0");
27
+ const count = parseInt(opt(rest, "count", "1"), 10);
28
+ for (const item of generate(type, { seed, count })) console.log(item.value);
29
+ } else if (cmd === "validate") {
30
+ const [type, value] = rest;
31
+ if (!type || value === undefined) { usage(); process.exit(2); }
32
+ const result = validate(type, value);
33
+ console.log(JSON.stringify(result));
34
+ process.exit(result.valid ? 0 : 1);
35
+ } else if (cmd === "types") {
36
+ console.log("generate:", GENERATE_TYPES.join(", "));
37
+ console.log("validate:", VALIDATE_TYPES.join(", "));
38
+ } else {
39
+ usage();
40
+ process.exit(cmd ? 2 : 0);
41
+ }
42
+ } catch (e) {
43
+ console.error("error:", e.message);
44
+ process.exit(2);
45
+ }
package/index.d.ts ADDED
@@ -0,0 +1,78 @@
1
+ // Type declarations for korea-fixtures.
2
+
3
+ /** Identifiers that can be generated (RRN is excluded — validate-only). */
4
+ export type GenerateType =
5
+ | "brn"
6
+ | "corp"
7
+ | "card"
8
+ | "phone"
9
+ | "account"
10
+ | "postal"
11
+ | "plate"
12
+ | "address";
13
+
14
+ /** Identifiers that can be validated (includes rrn). */
15
+ export type ValidateType = GenerateType | "rrn";
16
+
17
+ export interface GeneratedItem {
18
+ type: GenerateType;
19
+ /** Formatted identifier (e.g. "124-81-00998"). */
20
+ value: string;
21
+ /** Digits-only / unformatted form. */
22
+ raw: string;
23
+ /** present for type "account". */
24
+ bank?: string;
25
+ bankName?: string;
26
+ /** present for type "card". */
27
+ issuer?: string;
28
+ }
29
+
30
+ export interface ValidationResult {
31
+ valid: boolean;
32
+ type: ValidateType;
33
+ /** which check failed when valid === false (e.g. "checkDigit", "length", "luhn", "month"). */
34
+ failedCheck?: string;
35
+ expected?: number;
36
+ actual?: number | string;
37
+ /** present for a valid "account". */
38
+ bank?: string;
39
+ bankName?: string;
40
+ /** advisory note (e.g. for rrn / format-only types). */
41
+ note?: string;
42
+ }
43
+
44
+ export interface GenerateOptions {
45
+ /** number or string seed; the same seed always yields identical output. Default 0. */
46
+ seed?: number | string;
47
+ /** how many to generate. Default 1. */
48
+ count?: number;
49
+ }
50
+
51
+ export interface Bank {
52
+ code: string;
53
+ name: string;
54
+ clearingCode?: string;
55
+ prefixes: string[];
56
+ length: number;
57
+ }
58
+
59
+ /** Generate `count` checksum-valid-but-fake identifiers of `type`, reproducibly from `seed`. */
60
+ export function generate(type: GenerateType, options?: GenerateOptions): GeneratedItem[];
61
+
62
+ /** Validate one identifier; returns the verdict with "which check failed" diagnostics. */
63
+ export function validate(type: ValidateType, value: string): ValidationResult;
64
+
65
+ export const GENERATE_TYPES: GenerateType[];
66
+ export const VALIDATE_TYPES: ValidateType[];
67
+ export const BANKS: Bank[];
68
+
69
+ /** Seeded deterministic PRNG. */
70
+ export function makeRng(seed: number | string): () => number;
71
+
72
+ export namespace checksum {
73
+ function toDigits(value: string | number): number[];
74
+ function brnCheck(digits: number[]): number;
75
+ function corpCheck(digits: number[]): number;
76
+ function rrnCheck(digits: number[]): number;
77
+ function luhnCheck(digits: number[]): number;
78
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "korea-fixtures",
3
+ "version": "0.1.0",
4
+ "description": "Deterministic, checksum-valid-but-fake Korean test data (사업자등록번호·법인등록번호·계좌번호·카드·휴대폰·우편번호·차량번호) + validators. Seeded & reproducible. Zero dependencies. TypeScript types included.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "types": "./index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "import": "./src/index.js"
12
+ }
13
+ },
14
+ "bin": {
15
+ "kfix": "./bin/cli.js"
16
+ },
17
+ "files": [
18
+ "src",
19
+ "bin",
20
+ "index.d.ts",
21
+ "README.md"
22
+ ],
23
+ "scripts": {
24
+ "test": "node --test",
25
+ "prepublishOnly": "node --test"
26
+ },
27
+ "keywords": [
28
+ "korea",
29
+ "korean",
30
+ "test-data",
31
+ "fixtures",
32
+ "faker",
33
+ "mock",
34
+ "사업자등록번호",
35
+ "계좌번호",
36
+ "checksum",
37
+ "deterministic"
38
+ ],
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/trevi00/korea-fixtures.git"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/trevi00/korea-fixtures/issues"
45
+ },
46
+ "homepage": "https://github.com/trevi00/korea-fixtures#readme",
47
+ "engines": {
48
+ "node": ">=18"
49
+ }
50
+ }
package/src/banks.js ADDED
@@ -0,0 +1,31 @@
1
+ // Best-effort Korean bank account formats. NOTE: most Korean bank accounts have NO public checksum,
2
+ // so account "validity" here means "matches a known bank's prefix + digit length", not a checksum.
3
+ // These prefixes/lengths are curated best-effort and intentionally easy to override — pass your own
4
+ // table to generate/validate if you need authoritative formats. `code` follows the common 표준코드.
5
+ export const BANKS = [
6
+ { code: "kb", name: "KB국민", clearingCode: "004", prefixes: ["604", "607", "904"], length: 12 },
7
+ { code: "shinhan", name: "신한", clearingCode: "088", prefixes: ["110"], length: 12 },
8
+ { code: "woori", name: "우리", clearingCode: "020", prefixes: ["1002"], length: 13 },
9
+ { code: "hana", name: "하나", clearingCode: "081", prefixes: ["3"], length: 14 },
10
+ { code: "nonghyup", name: "농협", clearingCode: "011", prefixes: ["301", "302", "351", "356"], length: 13 },
11
+ { code: "ibk", name: "IBK기업", clearingCode: "003", prefixes: ["010", "012"], length: 14 },
12
+ { code: "sc", name: "SC제일", clearingCode: "023", prefixes: ["100"], length: 11 },
13
+ { code: "suhyup", name: "수협", clearingCode: "007", prefixes: ["1010"], length: 13 },
14
+ { code: "post", name: "우체국", clearingCode: "071", prefixes: ["0"], length: 14 },
15
+ { code: "kakaobank", name: "카카오뱅크", clearingCode: "090", prefixes: ["3333", "7979"], length: 13 },
16
+ { code: "tossbank", name: "토스뱅크", clearingCode: "092", prefixes: ["1000", "1001"], length: 12 },
17
+ { code: "kbank", name: "케이뱅크", clearingCode: "089", prefixes: ["100"], length: 12 },
18
+ { code: "busan", name: "부산", clearingCode: "032", prefixes: ["101", "201"], length: 13 },
19
+ { code: "daegu", name: "iM뱅크(대구)", clearingCode: "031", prefixes: ["508"], length: 12 },
20
+ ];
21
+
22
+ export function findBank(code, banks = BANKS) {
23
+ return banks.find((b) => b.code === code);
24
+ }
25
+
26
+ /** Longest prefix of `bank` that `raw` starts with, or -1 if none. */
27
+ export function matchedPrefixLength(bank, raw) {
28
+ let best = -1;
29
+ for (const p of bank.prefixes) if (raw.startsWith(p) && p.length > best) best = p.length;
30
+ return best;
31
+ }
@@ -0,0 +1,49 @@
1
+ // Korean identifier checksums. Algorithms verified against real public entities:
2
+ // 사업자등록번호 124-81-00998 (Samsung Electronics) -> check 8
3
+ // 법인등록번호 130111-0006246 (Samsung Electronics) -> check 6
4
+ // Luhn 4242424242424242 -> check 2
5
+ // Each `*Check` returns the expected final check digit for the leading digits.
6
+
7
+ export function toDigits(value) {
8
+ return String(value).replace(/\D/g, "").split("").map(Number);
9
+ }
10
+
11
+ /** 사업자등록번호 (BRN): 10 digits, weighted mod-10 with a half-carry on the 9th digit. */
12
+ export function brnCheck(d) {
13
+ const w = [1, 3, 7, 1, 3, 7, 1, 3, 5];
14
+ let sum = 0;
15
+ for (let i = 0; i < 9; i++) sum += d[i] * w[i];
16
+ sum += Math.floor((d[8] * 5) / 10);
17
+ return (10 - (sum % 10)) % 10;
18
+ }
19
+
20
+ /** 법인등록번호 (corporate registration): 13 digits, alternating 1/2 weights, mod-10. */
21
+ export function corpCheck(d) {
22
+ const w = [1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2];
23
+ let sum = 0;
24
+ for (let i = 0; i < 12; i++) sum += d[i] * w[i];
25
+ return (10 - (sum % 10)) % 10;
26
+ }
27
+
28
+ /** 주민등록번호 (RRN): 13 digits, mod-11. Validation only — generation is intentionally unsupported. */
29
+ export function rrnCheck(d) {
30
+ const w = [2, 3, 4, 5, 6, 7, 8, 9, 2, 3, 4, 5];
31
+ let sum = 0;
32
+ for (let i = 0; i < 12; i++) sum += d[i] * w[i];
33
+ return (11 - (sum % 11)) % 10;
34
+ }
35
+
36
+ /** Luhn check digit for a card number (over all but the last digit). */
37
+ export function luhnCheck(d) {
38
+ let sum = 0;
39
+ const body = d.slice(0, -1);
40
+ for (let i = 0; i < body.length; i++) {
41
+ let x = body[body.length - 1 - i];
42
+ if (i % 2 === 0) {
43
+ x *= 2;
44
+ if (x > 9) x -= 9;
45
+ }
46
+ sum += x;
47
+ }
48
+ return (10 - (sum % 10)) % 10;
49
+ }
@@ -0,0 +1,106 @@
1
+ // Generators: each produces a checksum-valid-but-FAKE Korean identifier from a seeded rng.
2
+ // 주민등록번호 (RRN) is intentionally absent — it is validation-only (see validate.js / README).
3
+ import { digits, intBelow, pick } from "./prng.js";
4
+ import { brnCheck, corpCheck, luhnCheck, toDigits } from "./checksum.js";
5
+ import { BANKS } from "./banks.js";
6
+
7
+ // Plausible 사업자등록번호 middle pair (개인 01-79 / 영리법인 81-88 등). Fake but well-formed.
8
+ const BRN_MIDDLE = ["81", "82", "83", "85", "86", "01", "02", "05"];
9
+
10
+ // Best-effort Korean issuer BIN prefixes (fake-but-plausible; not authoritative). Luhn is always correct.
11
+ const CARD_ISSUERS = [
12
+ { name: "신한카드", bin: "4045" },
13
+ { name: "삼성카드", bin: "5365" },
14
+ { name: "KB국민카드", bin: "5444" },
15
+ { name: "현대카드", bin: "4061" },
16
+ { name: "롯데카드", bin: "5121" },
17
+ { name: "우리카드", bin: "4673" },
18
+ { name: "하나카드", bin: "5251" },
19
+ { name: "BC카드", bin: "9446" },
20
+ ];
21
+
22
+ const SIDO = ["서울특별시", "부산광역시", "인천광역시", "경기도 성남시", "대전광역시"];
23
+ const GU = ["강남구", "서초구", "마포구", "분당구", "유성구"];
24
+ const ROAD = ["테헤란로", "세종대로", "양재천로", "판교역로", "대학로"];
25
+
26
+ // License-plate Korean syllables actually used on modern plates (must match validate.js).
27
+ const PLATE_CHARS = "가나다라마거너더러머버서어저고노도로모보소오조구누두루무부수우주바사아자하허호".split("");
28
+
29
+ export function genBrn(rng) {
30
+ const nine = digits(rng, 3) + pick(rng, BRN_MIDDLE) + digits(rng, 4); // 9 digits
31
+ const d = toDigits(nine);
32
+ const check = brnCheck(d);
33
+ const raw = nine + check;
34
+ return { type: "brn", value: `${raw.slice(0, 3)}-${raw.slice(3, 5)}-${raw.slice(5)}`, raw };
35
+ }
36
+
37
+ export function genCorp(rng) {
38
+ const twelve = digits(rng, 12);
39
+ const check = corpCheck(toDigits(twelve));
40
+ const raw = twelve + check;
41
+ return { type: "corp", value: `${raw.slice(0, 6)}-${raw.slice(6)}`, raw };
42
+ }
43
+
44
+ export function genCard(rng) {
45
+ const issuer = pick(rng, CARD_ISSUERS);
46
+ const body = issuer.bin + digits(rng, 11); // 15 digits, missing the check digit
47
+ const check = luhnCheck(toDigits(body + "0"));
48
+ const raw = body + check;
49
+ return {
50
+ type: "card", issuer: issuer.name,
51
+ value: raw.replace(/(\d{4})(?=\d)/g, "$1-"), raw,
52
+ };
53
+ }
54
+
55
+ export function genPhone(rng) {
56
+ const raw = "010" + digits(rng, 8);
57
+ return { type: "phone", value: `${raw.slice(0, 3)}-${raw.slice(3, 7)}-${raw.slice(7)}`, raw };
58
+ }
59
+
60
+ export function genAccount(rng, banks = BANKS) {
61
+ const bank = pick(rng, banks);
62
+ const prefix = pick(rng, bank.prefixes);
63
+ const raw = (prefix + digits(rng, Math.max(0, bank.length - prefix.length))).slice(0, bank.length);
64
+ return { type: "account", bank: bank.code, bankName: bank.name, value: raw, raw };
65
+ }
66
+
67
+ export function genAddress(rng) {
68
+ const value = `${pick(rng, SIDO)} ${pick(rng, GU)} ${pick(rng, ROAD)} ${1 + intBelow(rng, 300)}`;
69
+ return { type: "address", value, raw: value };
70
+ }
71
+
72
+ export function genPostal(rng) {
73
+ // 5-digit postal code (post-2015). Plausible range, format-only.
74
+ const raw = String(1000 + intBelow(rng, 62000)).padStart(5, "0");
75
+ return { type: "postal", value: raw, raw };
76
+ }
77
+
78
+ export function genPlate(rng) {
79
+ const head = digits(rng, 2 + intBelow(rng, 2)); // 2 or 3 leading digits
80
+ const syllable = pick(rng, PLATE_CHARS);
81
+ const tail = digits(rng, 4);
82
+ const raw = `${head}${syllable}${tail}`;
83
+ return { type: "plate", value: raw, raw };
84
+ }
85
+
86
+ export const GENERATORS = {
87
+ brn: genBrn,
88
+ corp: genCorp,
89
+ card: genCard,
90
+ phone: genPhone,
91
+ account: genAccount,
92
+ postal: genPostal,
93
+ plate: genPlate,
94
+ address: genAddress,
95
+ };
96
+
97
+ export function generateOne(type, rng) {
98
+ const g = GENERATORS[type];
99
+ if (!g) {
100
+ if (type === "rrn") {
101
+ throw new Error("rrn generation is intentionally unsupported (validation only) — see README");
102
+ }
103
+ throw new Error(`unknown type: ${type} (known: ${Object.keys(GENERATORS).join(", ")})`);
104
+ }
105
+ return g(rng);
106
+ }
package/src/index.js ADDED
@@ -0,0 +1,29 @@
1
+ // korea-fixtures — deterministic, checksum-valid-but-fake Korean test data + validators.
2
+ import { makeRng } from "./prng.js";
3
+ import { generateOne, GENERATORS } from "./generate.js";
4
+ import { validateOne, VALIDATORS } from "./validate.js";
5
+
6
+ /** Types that can be generated (RRN is deliberately excluded — validate-only). */
7
+ export const GENERATE_TYPES = Object.keys(GENERATORS);
8
+ /** Types that can be validated (includes rrn). */
9
+ export const VALIDATE_TYPES = Object.keys(VALIDATORS);
10
+
11
+ /**
12
+ * Generate `count` checksum-valid-but-fake identifiers of `type`, reproducibly from `seed`.
13
+ * Returns an array of objects: { type, value, raw, ...meta }.
14
+ */
15
+ export function generate(type, { seed = 0, count = 1 } = {}) {
16
+ const rng = makeRng(seed);
17
+ const out = [];
18
+ for (let i = 0; i < count; i++) out.push(generateOne(type, rng));
19
+ return out;
20
+ }
21
+
22
+ /** Validate a single identifier; returns { valid, type, failedCheck?, expected?, actual? }. */
23
+ export function validate(type, value) {
24
+ return validateOne(type, value);
25
+ }
26
+
27
+ export { makeRng } from "./prng.js";
28
+ export * as checksum from "./checksum.js";
29
+ export { BANKS } from "./banks.js";
package/src/prng.js ADDED
@@ -0,0 +1,46 @@
1
+ // Seeded deterministic PRNG (mulberry32) — same seed => byte-identical output, so generated
2
+ // datasets are reproducible and golden-pinnable in CI. No dependency on Math.random().
3
+
4
+ export function mulberry32(seed) {
5
+ let a = seed >>> 0;
6
+ return function next() {
7
+ a |= 0;
8
+ a = (a + 0x6d2b79f5) | 0;
9
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
10
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
11
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
12
+ };
13
+ }
14
+
15
+ /** Turn an arbitrary string/number seed into a 32-bit integer seed (deterministic). */
16
+ export function hashSeed(seed) {
17
+ if (typeof seed === "number") return seed >>> 0;
18
+ const s = String(seed ?? "");
19
+ let h = 2166136261 >>> 0; // FNV-1a
20
+ for (let i = 0; i < s.length; i++) {
21
+ h ^= s.charCodeAt(i);
22
+ h = Math.imul(h, 16777619);
23
+ }
24
+ return h >>> 0;
25
+ }
26
+
27
+ export function makeRng(seed) {
28
+ return mulberry32(hashSeed(seed));
29
+ }
30
+
31
+ /** Integer in [0, n). */
32
+ export function intBelow(rng, n) {
33
+ return Math.floor(rng() * n);
34
+ }
35
+
36
+ /** A string of `count` random decimal digits. */
37
+ export function digits(rng, count) {
38
+ let out = "";
39
+ for (let i = 0; i < count; i++) out += intBelow(rng, 10);
40
+ return out;
41
+ }
42
+
43
+ /** Pick a random element. */
44
+ export function pick(rng, arr) {
45
+ return arr[intBelow(rng, arr.length)];
46
+ }
@@ -0,0 +1,112 @@
1
+ // Validators: pure functions identifier -> { valid, type, failedCheck?, expected?, actual? }.
2
+ // Bidirectional with generate.js (a generated value always validates), plus RRN validation
3
+ // (generation of RRN is unsupported by design; validation is provided).
4
+ import { toDigits, brnCheck, corpCheck, rrnCheck, luhnCheck } from "./checksum.js";
5
+ import { BANKS, matchedPrefixLength } from "./banks.js";
6
+
7
+ // License-plate Korean syllables actually used on modern Korean plates.
8
+ const PLATE_CHARS = "가나다라마거너더러머버서어저고노도로모보소오조구누두루무부수우주바사아자하허호";
9
+
10
+ function bad(type, failedCheck, extra = {}) {
11
+ return { valid: false, type, failedCheck, ...extra };
12
+ }
13
+
14
+ export function validateBrn(value) {
15
+ const d = toDigits(value);
16
+ if (d.length !== 10) return bad("brn", "length", { expected: 10, actual: d.length });
17
+ const exp = brnCheck(d);
18
+ if (exp !== d[9]) return bad("brn", "checkDigit", { expected: exp, actual: d[9] });
19
+ return { valid: true, type: "brn" };
20
+ }
21
+
22
+ export function validateCorp(value) {
23
+ const d = toDigits(value);
24
+ if (d.length !== 13) return bad("corp", "length", { expected: 13, actual: d.length });
25
+ const exp = corpCheck(d);
26
+ if (exp !== d[12]) return bad("corp", "checkDigit", { expected: exp, actual: d[12] });
27
+ return { valid: true, type: "corp" };
28
+ }
29
+
30
+ /** 주민등록번호 — validation only. Checks length, date, gender digit, and the mod-11 check digit. */
31
+ export function validateRrn(value) {
32
+ const d = toDigits(value);
33
+ if (d.length !== 13) return bad("rrn", "length", { expected: 13, actual: d.length });
34
+ const month = d[2] * 10 + d[3];
35
+ const day = d[4] * 10 + d[5];
36
+ if (month < 1 || month > 12) return bad("rrn", "month", { actual: month });
37
+ if (day < 1 || day > 31) return bad("rrn", "day", { actual: day });
38
+ if (d[6] < 1 || d[6] > 8) return bad("rrn", "genderDigit", { actual: d[6] });
39
+ const exp = rrnCheck(d);
40
+ if (exp !== d[12]) return bad("rrn", "checkDigit", { expected: exp, actual: d[12] });
41
+ return { valid: true, type: "rrn", note: "validation only; generation unsupported by design" };
42
+ }
43
+
44
+ export function validateCard(value) {
45
+ const d = toDigits(value);
46
+ if (d.length < 13 || d.length > 19) return bad("card", "length", { actual: d.length });
47
+ const exp = luhnCheck(d);
48
+ if (exp !== d[d.length - 1]) return bad("card", "luhn", { expected: exp, actual: d[d.length - 1] });
49
+ return { valid: true, type: "card" };
50
+ }
51
+
52
+ export function validatePhone(value) {
53
+ const raw = toDigits(value).join("");
54
+ if (!/^01[016789]\d{7,8}$/.test(raw)) return bad("phone", "format", { actual: raw });
55
+ return { valid: true, type: "phone" };
56
+ }
57
+
58
+ export function validateAccount(value, banks = BANKS) {
59
+ const raw = toDigits(value).join("");
60
+ // Among banks whose length matches, pick the one with the LONGEST matching prefix (most specific).
61
+ let best = null;
62
+ let bestLen = -1;
63
+ for (const b of banks) {
64
+ if (b.length !== raw.length) continue;
65
+ const len = matchedPrefixLength(b, raw);
66
+ if (len > bestLen) {
67
+ bestLen = len;
68
+ best = b;
69
+ }
70
+ }
71
+ if (!best || bestLen < 0) return bad("account", "noMatchingBankFormat", { actual: raw });
72
+ return { valid: true, type: "account", bank: best.code, bankName: best.name };
73
+ }
74
+
75
+ export function validatePostal(value) {
76
+ const raw = toDigits(value).join("");
77
+ if (raw.length !== 5) return bad("postal", "length", { expected: 5, actual: raw.length });
78
+ return { valid: true, type: "postal", note: "format-only (5-digit, post-2015)" };
79
+ }
80
+
81
+ export function validatePlate(value) {
82
+ const s = String(value || "").replace(/\s/g, "");
83
+ // 2-3 digits + one plate syllable + 4 digits, e.g. 12가3456 / 123가4567
84
+ const m = /^(\d{2,3})([가-힣])(\d{4})$/.exec(s);
85
+ if (!m) return bad("plate", "format", { actual: s });
86
+ if (!PLATE_CHARS.includes(m[2])) return bad("plate", "syllable", { actual: m[2] });
87
+ return { valid: true, type: "plate", note: "format-only" };
88
+ }
89
+
90
+ export function validateAddress(value) {
91
+ const s = String(value || "").trim();
92
+ if (!s) return bad("address", "empty");
93
+ return { valid: true, type: "address", note: "format-only (no checksum)" };
94
+ }
95
+
96
+ export const VALIDATORS = {
97
+ brn: validateBrn,
98
+ corp: validateCorp,
99
+ rrn: validateRrn,
100
+ card: validateCard,
101
+ phone: validatePhone,
102
+ account: validateAccount,
103
+ postal: validatePostal,
104
+ plate: validatePlate,
105
+ address: validateAddress,
106
+ };
107
+
108
+ export function validateOne(type, value) {
109
+ const v = VALIDATORS[type];
110
+ if (!v) throw new Error(`unknown type: ${type} (known: ${Object.keys(VALIDATORS).join(", ")})`);
111
+ return v(value);
112
+ }