From b5fca35c42cb8e8cd483d20a8d34345bf00f8050 Mon Sep 17 00:00:00 2001 From: forzagreen Date: Fri, 21 Aug 2026 21:59:22 +0200 Subject: [PATCH 1/2] fix(fa-IR,id-ID,ja-JP,ko-KR,vi-VN): reject fractional amounts for currencies with no minor unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five currencies have no everyday minor unit, and all five mishandled a fractional amount. Reproduced on 5.1.2: ko-KR toCurrency(0.99) -> '영원' (zero won) vi-VN toCurrency(0.99) -> 'không đồng' (zero dong) fa-IR toCurrency(0.99) -> 'صفر ریال' (zero rial) id-ID toCurrency(0.99) -> 'nol rupiah' (zero rupiah) ja-JP toCurrency(0.99) -> '九十九銭' (99 sen) The first four destructured only `dollars` from parseCurrencyValue and never read `cents`, so the fractional part was silently discarded — the caller gets a well-formed string naming an amount that isn't the one they passed. ja-JP did read it, and spelled 銭 (sen), a unit demonetised in 1953. Both are "well-formed but wrong", the exact bug class this project's contract system exists to catch, and neither is detectable by fuzzing for malformed output. All five now throw RangeError — loud beats silent, the same philosophy checkMax already applies to its own precondition. Removes ja-JP's fixture cases that asserted the old fictitious-sen output. No API surface changes: these forms take no options today and still don't. --- scripts/generate-languages-md.js | 148 +++++++++++++++++-------------- src/fa-IR.js | 12 ++- src/id-ID.js | 11 ++- src/ja-JP.js | 27 +++--- src/ko-KR.js | 11 ++- src/vi-VN.js | 11 ++- test/fixtures/ja-JP.js | 14 +-- 7 files changed, 121 insertions(+), 113 deletions(-) diff --git a/scripts/generate-languages-md.js b/scripts/generate-languages-md.js index b6186e31..9fedfcae 100644 --- a/scripts/generate-languages-md.js +++ b/scripts/generate-languages-md.js @@ -13,7 +13,9 @@ */ import { writeFileSync, readdirSync } from 'node:fs' -import ts from 'typescript' +import { resolve } from 'node:path' +import { API } from 'typescript/unstable/sync' +import { isFunctionDeclaration, isIdentifier } from 'typescript/unstable/ast' import { getExportedForms } from '../test/helpers/language-helpers.js' import { getLanguageName } from '../test/helpers/language-naming.js' @@ -78,16 +80,16 @@ let optionsIndex = new Map() * markdown renderer expects: a string-literal union becomes * `('a'|'b')`, everything else uses its plain type name (`boolean`, `string`). * - * @param {import('typescript').TypeChecker} checker - * @param {import('typescript').Type} propType + * @param {import('typescript/unstable/sync').Checker} checker + * @param {import('typescript/unstable/sync').Type} propType * @returns {string} */ function toDocType(checker, propType) { // Optional props arrive as `T | undefined`; drop the undefined first. - const type = propType.getNonNullableType() - const parts = type.isUnion() ? type.types : [type] + const type = checker.getNonNullableType(propType) ?? propType + const parts = type.isUnionType() ? (type.getTypes() ?? []) : [type] - if (parts.length > 0 && parts.every(t => t.isStringLiteral())) { + if (parts.length > 0 && parts.every(t => t.isStringLiteralType())) { const literals = parts.map(t => `'${t.value}'`) return parts.length > 1 ? `(${literals.join('|')})` : literals[0] } @@ -101,80 +103,90 @@ function toDocType(checker, propType) { * checker (the same view TypeScript exposes to consumers), so the docs can't * drift from comment formatting the way the old regex scrape could. * + * Uses `typescript/unstable/sync`, the native-compiler ("tsgo") API that + * replaced the classic `ts.createProgram` surface in typescript@7 — the + * client spawns the bundled tsgo binary as a subprocess and talks to it + * per-request, so the `API` instance is closed once extraction is done. + * * @param {string[]} codes Language codes * @param {Map} mods Code -> module namespace (for `
Defaults` exports) * @returns {Map>} */ function buildOptionsIndex(codes, mods) { - const program = ts.createProgram( - codes.map(code => `./src/${code}.js`), - { - allowJs: true, - checkJs: false, - noEmit: true, - target: ts.ScriptTarget.ES2022, - module: ts.ModuleKind.NodeNext, - moduleResolution: ts.ModuleResolutionKind.NodeNext, - }, - ) - const checker = program.getTypeChecker() - const index = new Map() - - for (const code of codes) { - const sourceFile = program.getSourceFile(`./src/${code}.js`) - if (!sourceFile) { - throw new Error(`Could not load source for "${code}" (./src/${code}.js) — cannot extract options`) + const api = new API({ cwd: process.cwd() }) + try { + // src/tsconfig.json is the project this repo already maintains for + // checkJs coverage of src/**/*.js (see that file's own comment) — reusing + // it means compiler options can't drift between editor/CI type-checking + // and this doc generator. + const configFileName = resolve('src/tsconfig.json') + api.parseConfigFile(configFileName) + const openFiles = codes.map(code => resolve('src', `${code}.js`)) + const snapshot = api.updateSnapshot({ openFiles }) + const project = snapshot.getProject(configFileName) + if (!project) { + throw new Error(`Could not load project "${configFileName}" — cannot extract options`) } - const byFunction = new Map() + const checker = project.checker + const index = new Map() - ts.forEachChild(sourceFile, (node) => { - if (!ts.isFunctionDeclaration(node) || !node.name) return - const fnName = node.name.text - if (!(fnName in FORM_FUNCTIONS)) return - - const optionsParam = node.parameters.find( - p => ts.isIdentifier(p.name) && p.name.text === 'options', - ) - if (!optionsParam) return - - let type = checker.getTypeAtLocation(optionsParam) - if (type.isUnion()) { - type = type.types.find(t => !(t.flags & ts.TypeFlags.Undefined)) ?? type + for (const code of codes) { + const sourceFile = project.program.getSourceFile(resolve('src', `${code}.js`)) + if (!sourceFile) { + throw new Error(`Could not load source for "${code}" (src/${code}.js) — cannot extract options`) } - - // Defaults come from the options contract's `Defaults` export — - // imported, the single source of truth. A form taking options without it - // is a contract violation (the gate enforces this too), so fail loudly - // rather than scrape JSDoc or the function body. - const formDefaults = /** @type {Record | undefined} */ ( - mods.get(code)?.[`${FORM_FUNCTIONS[fnName]}Defaults`] - ) - if (formDefaults === undefined) { - throw new Error(`${code} ${fnName}() accepts options but doesn't export ${FORM_FUNCTIONS[fnName]}Defaults — every options-taking form must declare its contract`) - } - const options = (type.getProperties?.() ?? []).map((prop) => { - const name = prop.getName() - const description = ts - .displayPartsToString(prop.getDocumentationComment(checker)) - .trim() - .replace(/^-\s*/, '') - .trim() - return { - name, - type: toDocType(checker, checker.getTypeOfSymbolAtLocation(prop, optionsParam)), - defaultValue: Object.hasOwn(formDefaults, name) ? String(formDefaults[name]) : undefined, - description, - form: FORM_FUNCTIONS[fnName], + const byFunction = new Map() + + for (const node of sourceFile.statements) { + if (!isFunctionDeclaration(node) || !node.name) continue + const fnName = node.name.text + if (!(fnName in FORM_FUNCTIONS)) continue + + const optionsParam = node.parameters.find( + p => isIdentifier(p.name) && p.name.text === 'options', + ) + if (!optionsParam) continue + + const rawType = checker.getTypeAtLocation(optionsParam) + const type = (rawType && checker.getNonNullableType(rawType)) ?? rawType + + // Defaults come from the options contract's `Defaults` export — + // imported, the single source of truth. A form taking options without it + // is a contract violation (the gate enforces this too), so fail loudly + // rather than scrape JSDoc or the function body. + const formDefaults = /** @type {Record | undefined} */ ( + mods.get(code)?.[`${FORM_FUNCTIONS[fnName]}Defaults`] + ) + if (formDefaults === undefined) { + throw new Error(`${code} ${fnName}() accepts options but doesn't export ${FORM_FUNCTIONS[fnName]}Defaults — every options-taking form must declare its contract`) } - }) + const options = checker.getPropertiesOfType(type).map((prop) => { + const name = prop.name + const description = prop + .getDocumentationComment(checker) + .trim() + .replace(/^-\s*/, '') + .trim() + return { + name, + type: toDocType(checker, checker.getTypeOfSymbolAtLocation(prop, optionsParam)), + defaultValue: Object.hasOwn(formDefaults, name) ? String(formDefaults[name]) : undefined, + description, + form: FORM_FUNCTIONS[fnName], + } + }) + + if (options.length > 0) byFunction.set(fnName, options) + } - if (options.length > 0) byFunction.set(fnName, options) - }) + index.set(code, byFunction) + } - index.set(code, byFunction) + return index + } + finally { + api.close() } - - return index } /** diff --git a/src/fa-IR.js b/src/fa-IR.js index 968de0b6..05c7bb35 100644 --- a/src/fa-IR.js +++ b/src/fa-IR.js @@ -229,19 +229,23 @@ function toOrdinal(value) { /** * Converts a numeric value to Persian currency words (Rial). * - * Iranian Rial has no subunit in modern usage. - * (Historically dinar was 1/100 rial, but not used today) + * Iranian Rial has no everyday minor unit (the dinar was historically 1/100 + * rial), so a fractional amount throws RangeError rather than being silently + * discarded. * @param {number | string | bigint} value - The currency amount to convert * @returns {string} The amount in Persian currency words * @throws {TypeError} If value is not a valid numeric type - * @throws {Error} If value is not a valid number format + * @throws {RangeError} If the amount has a fractional part * @example * toCurrency(42) // 'چهل و دو ریال' * toCurrency(1000) // 'هزار ریال' * toCurrency(-5) // 'منفى پنج ریال' */ function toCurrency(value) { - const { isNegative, dollars: rial } = parseCurrencyValue(value) + const { isNegative, dollars: rial, cents } = parseCurrencyValue(value) + if (cents !== 0n) { + throw new RangeError('IRR has no minor unit — fractional amounts aren\'t representable') + } let result = '' if (isNegative) { diff --git a/src/id-ID.js b/src/id-ID.js index 4d0d802f..5cdb196c 100644 --- a/src/id-ID.js +++ b/src/id-ID.js @@ -287,20 +287,23 @@ function toOrdinal(value) { /** * Converts a numeric value to Indonesian currency words (Rupiah). * - * Indonesian Rupiah has no subunit in modern usage (sen are historical). - * Amounts are rounded to whole rupiah. + * Indonesian Rupiah has no everyday minor unit (sen are historical), so a + * fractional amount throws RangeError rather than being silently discarded. * @param {number | string | bigint} value - The currency amount to convert * @returns {string} The amount in Indonesian currency words * @throws {TypeError} If value is not a valid numeric type - * @throws {Error} If value is not a valid number format + * @throws {RangeError} If the amount has a fractional part * @example * toCurrency(42) // 'empat puluh dua rupiah' * toCurrency(1000) // 'seribu rupiah' * toCurrency(-5) // 'min lima rupiah' */ function toCurrency(value) { - const { isNegative, dollars: rupiah } = parseCurrencyValue(value) + const { isNegative, dollars: rupiah, cents } = parseCurrencyValue(value) checkMax(rupiah, currencyMax) + if (cents !== 0n) { + throw new RangeError('IDR has no minor unit — fractional amounts aren\'t representable') + } let result = '' if (isNegative) { diff --git a/src/ja-JP.js b/src/ja-JP.js index cf1c8528..5c86b370 100644 --- a/src/ja-JP.js +++ b/src/ja-JP.js @@ -71,7 +71,6 @@ const ORDINAL_PREFIX = '第' const YEN = '円' // Sen (1/100 yen) - historically used, now rare -const SEN = '銭' // Internal scale words (within 4-digit segments) const TEN = '十' @@ -314,38 +313,34 @@ function toOrdinal(value) { /** * Converts a numeric value to Japanese currency words (Yen). * - * Note: Sen (銭, 1/100 yen) is included for completeness but is rarely used - * in modern Japan. Most transactions are in whole yen. + * Yen has no everyday minor unit — a fractional amount throws RangeError + * rather than spelling a historical, no-longer-circulating 銭 (sen), which + * was demonetised in 1953. "Loud beats silent," the same philosophy checkMax + * already applies to its own precondition. * @param {number | string | bigint} value - The currency amount to convert * @returns {string} The amount in Japanese currency words * @throws {TypeError} If value is not a valid numeric type - * @throws {Error} If value is not a valid number format + * @throws {RangeError} If value exceeds the supported range, or has a fractional part * @example * toCurrency(42) // '四十二円' * toCurrency(1) // '一円' - * toCurrency(0.50) // '五十銭' - * toCurrency(42.50) // '四十二円五十銭' + * toCurrency(0) // '零円' */ function toCurrency(value) { - const { isNegative, dollars: yen, cents: sen } = parseCurrencyValue(value) + const { isNegative, dollars: yen, cents } = parseCurrencyValue(value) checkMax(yen, currencyMax) + if (cents !== 0n) { + throw new RangeError('JPY has no minor unit — fractional amounts aren\'t representable') + } // Build result let result = '' if (isNegative) result = NEGATIVE - // Yen part if (yen > 0n) { result += integerToWords(yen) + YEN } - - // Sen part (1/100 yen) - if (sen > 0n) { - result += integerToWords(sen) + SEN - } - - // Handle zero case - if (yen === 0n && sen === 0n) { + else { result += ZERO + YEN } diff --git a/src/ko-KR.js b/src/ko-KR.js index 07c47282..53910b33 100644 --- a/src/ko-KR.js +++ b/src/ko-KR.js @@ -313,20 +313,23 @@ function toOrdinal(value) { /** * Converts a numeric value to Korean currency words (Won). * - * Korean Won has no subunit (jeon are historical). - * Amounts are rounded to whole won. + * Korean Won has no everyday minor unit (jeon are historical), so a + * fractional amount throws RangeError rather than being silently discarded. * @param {number | string | bigint} value - The currency amount to convert * @returns {string} The amount in Korean currency words * @throws {TypeError} If value is not a valid numeric type - * @throws {Error} If value is not a valid number format + * @throws {RangeError} If the amount has a fractional part * @example * toCurrency(42) // '사십이원' * toCurrency(1000) // '천원' * toCurrency(-5) // '마이너스 오원' */ function toCurrency(value) { - const { isNegative, dollars: won } = parseCurrencyValue(value) + const { isNegative, dollars: won, cents } = parseCurrencyValue(value) checkMax(won, currencyMax) + if (cents !== 0n) { + throw new RangeError('KRW has no minor unit — fractional amounts aren\'t representable') + } let result = '' if (isNegative) { diff --git a/src/vi-VN.js b/src/vi-VN.js index b1d0079c..d856ea42 100644 --- a/src/vi-VN.js +++ b/src/vi-VN.js @@ -367,20 +367,23 @@ function toOrdinal(value) { /** * Converts a numeric value to Vietnamese currency words (Dong). * - * Vietnamese Dong has no subunit in modern usage (xu are historical). - * Amounts are rounded to whole đồng. + * Vietnamese Dong has no everyday minor unit (xu are historical), so a + * fractional amount throws RangeError rather than being silently discarded. * @param {number | string | bigint} value - The currency amount to convert * @returns {string} The amount in Vietnamese currency words * @throws {TypeError} If value is not a valid numeric type - * @throws {Error} If value is not a valid number format + * @throws {RangeError} If the amount has a fractional part * @example * toCurrency(42) // 'bốn mươi hai đồng' * toCurrency(1000) // 'một nghìn đồng' * toCurrency(-5) // 'âm năm đồng' */ function toCurrency(value) { - const { isNegative, dollars: dong } = parseCurrencyValue(value) + const { isNegative, dollars: dong, cents } = parseCurrencyValue(value) checkMax(dong, currencyMax) + if (cents !== 0n) { + throw new RangeError('VND has no minor unit — fractional amounts aren\'t representable') + } let result = '' if (isNegative) { diff --git a/test/fixtures/ja-JP.js b/test/fixtures/ja-JP.js index b38badea..3165608b 100644 --- a/test/fixtures/ja-JP.js +++ b/test/fixtures/ja-JP.js @@ -176,21 +176,9 @@ export const currency = [ [10000, '一万円'], [100000000, '一億円'], - // Sen (1/100 yen) - historical, but included for completeness - [0.01, '一銭'], - [0.02, '二銭'], - [0.10, '十銭'], - [0.50, '五十銭'], - [0.99, '九十九銭'], - - // Yen and sen - [1.01, '一円一銭'], - [42.50, '四十二円五十銭'], - [1000.99, '千円九十九銭'], - // Negative amounts [-1, 'マイナス一円'], - [-42.50, 'マイナス四十二円五十銭'], + [-42, 'マイナス四十二円'], // Edge cases [5.00, '五円'], From 5858dda0ee1e8b0e9c2e4dc20da0531bc398aee9 Mon Sep 17 00:00:00 2001 From: forzagreen Date: Fri, 21 Aug 2026 21:59:31 +0200 Subject: [PATCH 2/2] fix(core): migrate generate-languages-md.js to typescript@7's new compiler API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typescript@7.0.2 removed the classic ts.createProgram/getTypeChecker API that buildOptionsIndex() relied on to read JSDoc option types, so `npm run docs:languages` fails outright on main: TypeError: Cannot read properties of undefined (reading 'ES2022') Rewrite it against typescript/unstable/sync (the tsgo-backed replacement) and typescript/unstable/ast, reusing the project's existing src/tsconfig.json so compiler options can't drift from what CI's checkJs already enforces. The regenerated LANGUAGES.md differs only in union member ordering — tsgo returns union constituents sorted rather than in declaration order, so `'masculine' | 'feminine'` now renders as `'feminine' | 'masculine'`. Left as the new API reports it rather than re-sorted here, so the generator stays a faithful view of the checker. --- LANGUAGES.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/LANGUAGES.md b/LANGUAGES.md index 3bfcd111..b9d77653 100644 --- a/LANGUAGES.md +++ b/LANGUAGES.md @@ -124,9 +124,9 @@ toCurrency(value, { optionName: value }) |Option|Form|Type|Default|Description| |------|----|----|-------|-----------| -|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender| +|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender| |`negativeWord`|cardinal|`string`|`ناقص`|Custom word for negative numbers| -|`gender`|ordinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender| +|`gender`|ordinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender| ### Australian English (`en-AU`) @@ -138,7 +138,7 @@ toCurrency(value, { optionName: value }) |Option|Form|Type|Default|Description| |------|----|----|-------|-----------| -|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender| +|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender| |`andWord`|cardinal|`string`|`ו`|Custom conjunction word| ### Brazilian Portuguese (`pt-BR`) @@ -182,7 +182,7 @@ toCurrency(value, { optionName: value }) |Option|Form|Type|Default|Description| |------|----|----|-------|-----------| -|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender| +|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender| ### Dutch (Netherlands) (`nl-NL`) @@ -275,8 +275,8 @@ toCurrency(value, { optionName: value }) |Option|Form|Type|Default|Description| |------|----|----|-------|-----------| -|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender| -|`gender`|ordinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender| +|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender| +|`gender`|ordinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender| |`and`|currency|`boolean`|`true`|Use "con" between euros and cents| ### French (Belgium) (`fr-BE`) @@ -315,61 +315,61 @@ toCurrency(value, { optionName: value }) |Option|Form|Type|Default|Description| |------|----|----|-------|-----------| -|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Gender for numbers < 1000| +|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Gender for numbers < 1000| ### Lithuanian (Lithuania) (`lt-LT`) |Option|Form|Type|Default|Description| |------|----|----|-------|-----------| -|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Gender for numbers < 1000| +|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Gender for numbers < 1000| ### Mexican Spanish (`es-MX`) |Option|Form|Type|Default|Description| |------|----|----|-------|-----------| -|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender| -|`gender`|ordinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender| +|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender| +|`gender`|ordinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender| |`and`|currency|`boolean`|`true`|Use "con" between pesos and centavos| ### Polish (Poland) (`pl-PL`) |Option|Form|Type|Default|Description| |------|----|----|-------|-----------| -|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Gender for numbers < 1000| +|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Gender for numbers < 1000| ### Romanian (Romania) (`ro-RO`) |Option|Form|Type|Default|Description| |------|----|----|-------|-----------| -|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Gender for numbers| +|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Gender for numbers| ### Russian (Russia) (`ru-RU`) |Option|Form|Type|Default|Description| |------|----|----|-------|-----------| -|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender| +|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender| |`and`|currency|`boolean`|`true`|Use "и" between rubles and kopecks| ### Serbian (Cyrillic, Serbia) (`sr-Cyrl-RS`) |Option|Form|Type|Default|Description| |------|----|----|-------|-----------| -|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender| +|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender| |`and`|currency|`boolean`|`true`|Use "и" between dinars and para| ### Serbian (Latin, Serbia) (`sr-Latn-RS`) |Option|Form|Type|Default|Description| |------|----|----|-------|-----------| -|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender| +|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender| |`and`|currency|`boolean`|`true`|Use "i" between dinars and para| ### Spanish (United States) (`es-US`) |Option|Form|Type|Default|Description| |------|----|----|-------|-----------| -|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender| -|`gender`|ordinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender| +|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender| +|`gender`|ordinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender| |`and`|currency|`boolean`|`true`|Use "con" between dollars and cents| ### Turkish (Türkiye) (`tr-TR`) @@ -382,4 +382,4 @@ toCurrency(value, { optionName: value }) |Option|Form|Type|Default|Description| |------|----|----|-------|-----------| -|`gender`|cardinal|'masculine' \| 'feminine'|`masculine`|Grammatical gender| +|`gender`|cardinal|'feminine' \| 'masculine'|`masculine`|Grammatical gender|