From bfe62a29e320a77634c0d513a7713266d42e1754 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 14:37:43 +0000 Subject: [PATCH 1/2] Fix user-facing correctness bugs found in audit - UPC-E check digit was computed with direct 7-digit weights instead of on the UPC-A expansion (per GS1), so computeCheckDigit/validate/buildSequential gave wrong answers and ~42% of generateRandom("upce") codes failed the library's own validation. - Unknown format strings (e.g. "UPCA") made validateBarcodeText return { ok: true } for anything; all functions now throw a clear error. - validateBarcodeText accepted text with stray letters ("hello 0360..."); computeCheckDigit silently truncated over-long payloads and turned letters into zeros. Only digits, spaces and hyphens are accepted now. - ISBN-13 now requires the 978/979 prefix; random ISBNs also use 979. - UPC-E number system must be 0/1; upcEExpand validates its inputs. - buildSequential rejects negative/fractional/NaN start and count and detects overflow up front. - Variable-length validation rejects empty text and non-ASCII Code 128/FNSKU. - package exports: CJS consumers got ESM typings (index.d.ts); now resolve index.d.cts. - README: wrong buildSequential example output, broken sponsor badge URL. - CI: npm ci + typecheck step. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015Mx4zmKAWVwBdSCtc7W97k --- .github/workflows/ci.yml | 3 +- README.md | 33 ++++++++++----- package.json | 14 +++++-- src/checkdigit.ts | 36 +++++++++++++---- src/formats.ts | 27 +++++++++++++ src/generators.ts | 76 +++++++++++++++++----------------- src/upce.ts | 18 +++++++++ src/validate.ts | 39 +++++++++++++----- test/barcode-core.test.ts | 85 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 262 insertions(+), 69 deletions(-) create mode 100644 src/upce.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e14aaee..b849d08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 20 - - run: npm install + - run: npm ci + - run: npm run typecheck - run: npm test - run: npm run build diff --git a/README.md b/README.md index 5f571dd..c267832 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI](https://github.com/x1ee7/barcode-core/actions/workflows/ci.yml/badge.svg)](https://github.com/x1ee7/barcode-core/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/@x1ee7/barcode-core.svg)](https://www.npmjs.com/package/@x1ee7/barcode-core) [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) -[![Sponsor](https://img.shields.io/badge/sponsor-%E2%9D%A4- db61a2)](https://github.com/sponsors/x1ee7) +[![Sponsor](https://img.shields.io/badge/sponsor-%E2%9D%A4-db61a2)](https://github.com/sponsors/x1ee7) **Zero-dependency UPC / EAN / ISBN / ITF-14 barcode check-digit math, validation, UPC-E expansion, and random / sequential code generation for @@ -46,15 +46,16 @@ computeCheckDigit("ean13", "978013110362"); // → "9780131103627" validateBarcodeText("upca", "036000291452"); // → { ok: true } validateBarcodeText("upca", "036000291450"); // → { ok: false, error: "invalid check digit — expected 2, got 0" } -// UPC-E (6-digit body + number system) → 11-digit UPC-A payload -upcEExpand("0", "123455"); // → "01234500005" +// UPC-E (number system + 6-digit body); check digit comes from the UPC-A expansion +computeCheckDigit("upce", "0123456"); // → "01234565" +upcEExpand("0", "123455"); // → "01234500005" // One random, structurally valid code generateRandom("ean13"); // → e.g. "4006381333931" // A contiguous run sharing a prefix, each with a valid check digit buildSequential("ean13", "978", 1, 3); -// → ["9780000000012", "9780000000029", "9780000000036"] +// → ["9780000000019", "9780000000026", "9780000000033"] ``` ## How the GS1 mod-10 check digit works @@ -70,27 +71,37 @@ checkDigit = (10 − (weightedSum mod 10)) mod 10 `computeCheckDigit(format, payload)` applies the correct positional weights for each format (exposed as `SPEC`) and returns the full code with the check -digit appended. `validateBarcodeText` recomputes it and also enforces the -expected digit count for fixed-length formats. +digit appended. Spaces and hyphens are ignored, short payloads are +left-padded with zeros, and anything else (letters, too many digits) throws. +`validateBarcodeText` recomputes the check digit and also enforces the +expected digit count, UPC-E number system 0/1, and the ISBN-13 978/979 +prefix. + +UPC-E is the one exception to the positional weights: per GS1, its check +digit is computed on the expanded UPC-A form, which `computeCheckDigit` and +`validateBarcodeText` handle for you. ## Supported formats **Fixed-length** (have a computable check digit): `upca`, `upce`, `ean13`, `ean8`, `isbn`, `itf14` -**Variable-length** (no fixed check digit — validation always passes): +**Variable-length** (no check digit — validation only rejects empty text, +and non-ASCII text for `code128` / `fnsku`): `code128`, `datamatrix`, `fnsku` +Every function throws a descriptive error for an unknown format string. + ## API | Export | Description | | --- | --- | -| `computeCheckDigit(format, payload)` | Returns payload + GS1 mod-10 check digit. Throws for variable-length formats. | -| `validateBarcodeText(format, text)` | `{ ok: true }` or `{ ok: false, error }`. Variable-length formats always pass. | +| `computeCheckDigit(format, payload)` | Returns payload + GS1 mod-10 check digit. Throws for variable-length formats, non-digits, or a payload longer than the format allows. | +| `validateBarcodeText(format, text)` | `{ ok: true }` or `{ ok: false, error }`. | | `normalizeText(format, text)` | Upper-cases except Data Matrix (case-sensitive). | -| `upcEExpand(numSystem, comp)` | Expands a UPC-E body to its 11-digit UPC-A payload. | +| `upcEExpand(numSystem, comp)` | Expands a UPC-E body to its 11-digit UPC-A payload. Throws unless `numSystem` is 0/1 and `comp` is 6 digits. | | `generateRandom(format)` | One random, structurally valid code. | -| `buildSequential(format, prefix, start, count)` | Contiguous run of check-digit-terminated codes. | +| `buildSequential(format, prefix, start, count)` | Contiguous run of check-digit-terminated codes. Throws up front on overflow, bad `start`/`count`, or a prefix that can't yield valid codes. | | `isVariableLength(format)` / `isCaseSensitive(format)` | Format predicates. | | `SPEC` | Payload length + positional weights per fixed-length format. | diff --git a/package.json b/package.json index 492d053..47be9e2 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,14 @@ "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } } }, "files": [ @@ -45,8 +50,9 @@ "scripts": { "build": "tsup src/index.ts --format esm,cjs --dts --clean", "test": "vitest run", + "typecheck": "tsc --noEmit", "test:watch": "vitest", - "prepublishOnly": "npm run test && npm run build" + "prepublishOnly": "npm run typecheck && npm run test && npm run build" }, "devDependencies": { "tsup": "^8.3.5", diff --git a/src/checkdigit.ts b/src/checkdigit.ts index c684008..cc86aae 100644 --- a/src/checkdigit.ts +++ b/src/checkdigit.ts @@ -1,15 +1,37 @@ -import { type BarcodeFormat, SPEC } from "./formats.js"; +import { type BarcodeFormat, SPEC, assertFormat, extractDigits } from "./formats.js"; +import { upcEExpand } from "./upce.js"; + +function mod10(digits: string, weights: number[]): string { + let sum = 0; + for (let i = 0; i < digits.length; i++) sum += parseInt(digits[i]!, 10) * weights[i]!; + return ((10 - (sum % 10)) % 10).toString(); +} /** * Compute the GS1 modulo-10 check digit and return the full code - * (payload + check digit). Throws for variable-length formats, which have - * no check digit. + * (payload + check digit). Spaces and hyphens are ignored and short payloads + * are left-padded with zeros. Throws for variable-length formats (which have + * no check digit), for non-digit characters, and for payloads longer than the + * format allows (pass the payload *without* its check digit). + * + * UPC-E is checked the GS1 way: the 7-digit payload (number system + 6-digit + * body) is expanded to UPC-A and the check digit is taken from that. */ export function computeCheckDigit(format: BarcodeFormat, payload: string): string { + assertFormat(format); const spec = SPEC[format]; if (!spec) throw new Error(`${format} has no check digit (variable-length)`); - const digits = payload.replace(/\D/g, "").padStart(spec.payloadLen, "0").slice(0, spec.payloadLen); - let sum = 0; - for (let i = 0; i < digits.length; i++) sum += parseInt(digits[i]!, 10) * spec.weights[i]!; - return digits + ((10 - (sum % 10)) % 10).toString(); + const raw = extractDigits(payload, `${format} payload`); + if (raw.length === 0) throw new Error(`${format} payload has no digits`); + if (raw.length > spec.payloadLen) { + throw new Error( + `${format} payload must be at most ${spec.payloadLen} digits (got ${raw.length}); omit the check digit`, + ); + } + const digits = raw.padStart(spec.payloadLen, "0"); + if (format === "upce") { + const upca = SPEC.upca!; + return digits + mod10(upcEExpand(digits[0]!, digits.slice(1)), upca.weights); + } + return digits + mod10(digits, spec.weights); } diff --git a/src/formats.ts b/src/formats.ts index 16a0fbd..d9b4ce1 100644 --- a/src/formats.ts +++ b/src/formats.ts @@ -17,6 +17,33 @@ export type BarcodeFormat = | "datamatrix" | "fnsku"; +/** Every supported format, in declaration order. */ +const FORMATS: ReadonlySet = new Set([ + "upca", "upce", "ean13", "ean8", "isbn", "itf14", "code128", "datamatrix", "fnsku", +]); + +/** + * Throw a descriptive error for an unknown format. Guards plain-JS callers + * against typos like "UPCA" silently validating or crashing. + */ +export function assertFormat(format: unknown): asserts format is BarcodeFormat { + if (typeof format !== "string" || !FORMATS.has(format)) { + throw new Error(`unknown barcode format '${String(format)}' (expected one of: ${[...FORMATS].join(", ")})`); + } +} + +/** + * Strip the separators people commonly type into numeric codes (spaces and + * hyphens) and return the bare digits. Throws on anything else, so stray + * letters are never silently discarded. + */ +export function extractDigits(text: string, what: string): string { + if (typeof text !== "string") throw new Error(`${what} must be a string (got ${typeof text})`); + const bad = text.match(/[^\d\s-]/); + if (bad) throw new Error(`${what} may only contain digits, spaces, and hyphens (found '${bad[0]}')`); + return text.replace(/\D/g, ""); +} + const VARIABLE_LENGTH: Set = new Set(["code128", "datamatrix", "fnsku"]); /** True for symbologies that carry an arbitrary-length payload (no check digit). */ diff --git a/src/generators.ts b/src/generators.ts index 2d4246d..ac73d03 100644 --- a/src/generators.ts +++ b/src/generators.ts @@ -1,52 +1,41 @@ -import { type BarcodeFormat, SPEC, isVariableLength } from "./formats.js"; +import { type BarcodeFormat, SPEC, assertFormat, extractDigits, isVariableLength } from "./formats.js"; import { computeCheckDigit } from "./checkdigit.js"; +import { validateBarcodeText } from "./validate.js"; -/** - * Expand a 6-digit UPC-E compressed body (plus number system) into its - * 11-digit UPC-A payload, following the GS1 zero-suppression rules. - */ -export function upcEExpand(numSystem: string, comp: string): string { - const last = comp[5]!; - if (last >= "0" && last <= "2") return numSystem + comp.slice(0, 2) + last + "0000" + comp.slice(2, 5); - if (last === "3") return numSystem + comp.slice(0, 3) + "00000" + comp.slice(3, 5); - if (last === "4") return numSystem + comp.slice(0, 4) + "00000" + comp[4]!; - return numSystem + comp.slice(0, 5) + "0000" + last; +export { upcEExpand } from "./upce.js"; + +const ALNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + +function randomDigits(n: number): string { + let out = ""; + for (let i = 0; i < n; i++) out += Math.floor(Math.random() * 10).toString(); + return out; } /** Generate a single random, structurally valid code for the given format. */ export function generateRandom(format: BarcodeFormat): string { - if (format === "fnsku") { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - let out = "X00"; - for (let i = 0; i < 7; i++) out += chars[Math.floor(Math.random() * chars.length)]; - return out; - } + assertFormat(format); if (isVariableLength(format)) { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - let out = ""; - for (let i = 0; i < 10; i++) out += chars[Math.floor(Math.random() * chars.length)]; + let out = format === "fnsku" ? "X00" : ""; + const len = format === "fnsku" ? 7 : 10; + for (let i = 0; i < len; i++) out += ALNUM[Math.floor(Math.random() * ALNUM.length)]; return out; } if (format === "upce") { - const ns = Math.random() < 0.5 ? "0" : "1"; - let comp = ""; - for (let i = 0; i < 6; i++) comp += Math.floor(Math.random() * 10).toString(); - const upca11 = upcEExpand(ns, comp); - let sum = 0; - for (let i = 0; i < 11; i++) sum += parseInt(upca11[i]!, 10) * (i % 2 === 0 ? 3 : 1); - const check = ((10 - (sum % 10)) % 10).toString(); - return ns + comp + check; + return computeCheckDigit(format, (Math.random() < 0.5 ? "0" : "1") + randomDigits(6)); } const spec = SPEC[format]!; - let payload = format === "isbn" ? "978" : ""; - while (payload.length < spec.payloadLen) payload += Math.floor(Math.random() * 10).toString(); - return computeCheckDigit(format, payload); + const prefix = format === "isbn" ? (Math.random() < 0.5 ? "978" : "979") : ""; + return computeCheckDigit(format, prefix + randomDigits(spec.payloadLen - prefix.length)); } /** * Build a contiguous run of `count` codes from a shared prefix, starting at * `start`, each terminated with a valid check digit. Throws for - * variable-length formats or an over-long prefix. + * variable-length formats, an over-long or non-numeric prefix, a negative or + * non-integer `start`/`count`, a run that would overflow the available + * digits, or a prefix that yields invalid codes (e.g. UPC-E number system + * other than 0/1, ISBN not starting 978/979). */ export function buildSequential( format: BarcodeFormat, @@ -54,18 +43,31 @@ export function buildSequential( start: number, count: number, ): string[] { + assertFormat(format); if (isVariableLength(format)) { throw new Error(`sequential mode is not supported for ${format}`); } + if (!Number.isSafeInteger(start) || start < 0) { + throw new Error(`start must be a non-negative integer (got ${start})`); + } + if (!Number.isSafeInteger(count) || count < 0) { + throw new Error(`count must be a non-negative integer (got ${count})`); + } const spec = SPEC[format]!; - const cleanPrefix = prefix.replace(/\D/g, ""); + const cleanPrefix = extractDigits(prefix, "prefix"); const suffixLen = spec.payloadLen - cleanPrefix.length; - if (suffixLen < 1) throw new Error(`prefix '${prefix}' too long for ${format} (max ${spec.payloadLen - 1})`); + if (suffixLen < 1) throw new Error(`prefix '${prefix}' too long for ${format} (max ${spec.payloadLen - 1} digits)`); + if (count > 0 && (start + count - 1).toString().length > suffixLen) { + throw new Error( + `sequential overflow: ${start + count - 1} does not fit in ${suffixLen} digit(s) after prefix '${prefix}'`, + ); + } const codes: string[] = []; for (let i = 0; i < count; i++) { - const suffix = (start + i).toString().padStart(suffixLen, "0"); - if (suffix.length > suffixLen) throw new Error("sequential overflow"); - codes.push(computeCheckDigit(format, cleanPrefix + suffix)); + const code = computeCheckDigit(format, cleanPrefix + (start + i).toString().padStart(suffixLen, "0")); + const check = validateBarcodeText(format, code); + if (!check.ok) throw new Error(`prefix '${prefix}' produces invalid ${format} codes: ${check.error}`); + codes.push(code); } return codes; } diff --git a/src/upce.ts b/src/upce.ts new file mode 100644 index 0000000..fb4ba25 --- /dev/null +++ b/src/upce.ts @@ -0,0 +1,18 @@ +/** + * Expand a 6-digit UPC-E compressed body (plus number system) into its + * 11-digit UPC-A payload, following the GS1 zero-suppression rules. + * Throws unless `numSystem` is "0" or "1" and `comp` is exactly 6 digits. + */ +export function upcEExpand(numSystem: string, comp: string): string { + if (numSystem !== "0" && numSystem !== "1") { + throw new Error(`UPC-E number system must be 0 or 1 (got '${numSystem}')`); + } + if (!/^\d{6}$/.test(comp)) { + throw new Error(`UPC-E body must be exactly 6 digits (got '${comp}')`); + } + const last = comp[5]!; + if (last >= "0" && last <= "2") return numSystem + comp.slice(0, 2) + last + "0000" + comp.slice(2, 5); + if (last === "3") return numSystem + comp.slice(0, 3) + "00000" + comp.slice(3, 5); + if (last === "4") return numSystem + comp.slice(0, 4) + "00000" + comp[4]!; + return numSystem + comp.slice(0, 5) + "0000" + last; +} diff --git a/src/validate.ts b/src/validate.ts index 0cace19..39aab03 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -1,26 +1,47 @@ -import { type BarcodeFormat, SPEC, isCaseSensitive } from "./formats.js"; +import { type BarcodeFormat, SPEC, assertFormat, isCaseSensitive } from "./formats.js"; import { computeCheckDigit } from "./checkdigit.js"; /** Upper-case the text unless the format is case-sensitive (Data Matrix). */ export function normalizeText(format: BarcodeFormat, text: string): string { + assertFormat(format); return isCaseSensitive(format) ? text : text.toUpperCase(); } +type Result = { ok: true } | { ok: false; error: string }; + /** - * Validate that `text` has the correct digit count and a valid check digit - * for the given format. Variable-length formats always pass. + * Validate `text` for the given format. + * + * Fixed-length formats: only digits (plus spaces/hyphens as separators), the + * exact digit count, a valid check digit, number system 0/1 for UPC-E and a + * 978/979 prefix for ISBN-13. + * + * Variable-length formats: non-empty; Code 128 and FNSKU must be ASCII. */ -export function validateBarcodeText( - format: BarcodeFormat, - text: string, -): { ok: true } | { ok: false; error: string } { - const spec = SPEC[format as keyof typeof SPEC]; - if (!spec) return { ok: true }; +export function validateBarcodeText(format: BarcodeFormat, text: string): Result { + assertFormat(format); + if (typeof text !== "string") return { ok: false, error: `text must be a string (got ${typeof text})` }; + const spec = SPEC[format]; + if (!spec) { + if (text.length === 0) return { ok: false, error: `${format} text is empty` }; + if (format !== "datamatrix" && /[^\x00-\x7F]/.test(text)) { + return { ok: false, error: `${format} can only encode ASCII characters` }; + } + return { ok: true }; + } + const bad = text.match(/[^\d\s-]/); + if (bad) return { ok: false, error: `${format} may only contain digits (found '${bad[0]}')` }; const totalLen = spec.payloadLen + 1; const digits = text.replace(/\D/g, ""); if (digits.length !== totalLen) { return { ok: false, error: `${format} requires exactly ${totalLen} digits (got ${digits.length})` }; } + if (format === "upce" && digits[0] !== "0" && digits[0] !== "1") { + return { ok: false, error: `upce number system must be 0 or 1 (got ${digits[0]})` }; + } + if (format === "isbn" && !/^97[89]/.test(digits)) { + return { ok: false, error: `isbn must start with 978 or 979 (got ${digits.slice(0, 3)})` }; + } const expected = computeCheckDigit(format, digits.slice(0, spec.payloadLen)).slice(-1); const actual = digits.slice(-1); if (expected !== actual) { diff --git a/test/barcode-core.test.ts b/test/barcode-core.test.ts index ca35300..b8e4dbf 100644 --- a/test/barcode-core.test.ts +++ b/test/barcode-core.test.ts @@ -71,3 +71,88 @@ describe("buildSequential", () => { expect(() => buildSequential("datamatrix", "1", 1, 1)).toThrow(/not supported/); }); }); + +describe("UPC-E check digit (computed on the UPC-A expansion)", () => { + it("matches the reference code 01234565", () => { + expect(computeCheckDigit("upce", "0123456")).toBe("01234565"); + expect(validateBarcodeText("upce", "01234565")).toEqual({ ok: true }); + }); + it("agrees with UPC-A for every body ending", () => { + for (const body of ["654320", "654323", "654324", "654329", "123450"]) { + const upce = computeCheckDigit("upce", "0" + body); + const upca = computeCheckDigit("upca", upcEExpand("0", body)); + expect(upce.slice(-1)).toBe(upca.slice(-1)); + } + }); + it("random and sequential UPC-E codes pass validation", () => { + for (let i = 0; i < 500; i++) { + const c = generateRandom("upce"); + expect(validateBarcodeText("upce", c)).toEqual({ ok: true }); + } + for (const c of buildSequential("upce", "0", 1, 50)) { + expect(validateBarcodeText("upce", c)).toEqual({ ok: true }); + } + }); + it("rejects number systems other than 0/1", () => { + expect(validateBarcodeText("upce", "51234565").ok).toBe(false); + expect(() => computeCheckDigit("upce", "5123456")).toThrow(/number system/); + expect(() => buildSequential("upce", "5", 1, 1)).toThrow(/number system/); + }); +}); + +describe("upcEExpand input checks", () => { + it("throws on a short body or bad number system", () => { + expect(() => upcEExpand("0", "12")).toThrow(/6 digits/); + expect(() => upcEExpand("2", "123456")).toThrow(/number system/); + }); +}); + +describe("unknown formats", () => { + it("throw everywhere instead of silently passing", () => { + const bad = "UPCA" as never; + expect(() => validateBarcodeText(bad, "garbage")).toThrow(/unknown barcode format/); + expect(() => computeCheckDigit(bad, "1")).toThrow(/unknown barcode format/); + expect(() => generateRandom(bad)).toThrow(/unknown barcode format/); + expect(() => buildSequential(bad, "", 0, 1)).toThrow(/unknown barcode format/); + }); +}); + +describe("input strictness", () => { + it("validate rejects stray letters but allows spaces/hyphens", () => { + expect(validateBarcodeText("upca", "hello 036000291452").ok).toBe(false); + expect(validateBarcodeText("isbn", "978-0-13-110362-7")).toEqual({ ok: true }); + }); + it("computeCheckDigit refuses to truncate or strip letters", () => { + expect(() => computeCheckDigit("upca", "036000291452")).toThrow(/at most 11/); + expect(() => computeCheckDigit("upca", "ABC")).toThrow(/only contain digits/); + expect(() => computeCheckDigit("upca", "")).toThrow(/no digits/); + }); + it("still left-pads short payloads", () => { + expect(computeCheckDigit("ean8", "1")).toHaveLength(8); + }); + it("ISBN requires 978/979", () => { + expect(validateBarcodeText("isbn", "0000000000000").ok).toBe(false); + expect(() => buildSequential("isbn", "", 1, 1)).toThrow(/978 or 979/); + for (let i = 0; i < 100; i++) expect(generateRandom("isbn")).toMatch(/^97[89]\d{10}$/); + }); + it("variable-length formats reject empty text and non-ASCII Code 128", () => { + expect(validateBarcodeText("code128", "").ok).toBe(false); + expect(validateBarcodeText("code128", "café").ok).toBe(false); + expect(validateBarcodeText("datamatrix", "café")).toEqual({ ok: true }); + }); +}); + +describe("buildSequential argument checks", () => { + it("rejects negative / fractional / NaN start and count", () => { + expect(() => buildSequential("upca", "123", -5, 2)).toThrow(/start/); + expect(() => buildSequential("upca", "123", 1.5, 1)).toThrow(/start/); + expect(() => buildSequential("upca", "123", 1, NaN)).toThrow(/count/); + }); + it("detects overflow before producing anything", () => { + expect(() => buildSequential("upca", "1234567890", 5, 6)).toThrow(/overflow/); + expect(buildSequential("upca", "1234567890", 5, 5)).toHaveLength(5); + }); + it("matches the README example", () => { + expect(buildSequential("ean13", "978", 1, 3)).toEqual(["9780000000019", "9780000000026", "9780000000033"]); + }); +}); From f8608f56159a41f56eb21f9642b98aa010584e24 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 14:50:25 +0000 Subject: [PATCH 2/2] Release 0.2.0 (stricter input validation is a breaking change under 0.x) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015Mx4zmKAWVwBdSCtc7W97k --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 804de5e..32c8993 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@upcgen/barcode-core", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@upcgen/barcode-core", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "devDependencies": { "tsup": "^8.3.5", diff --git a/package.json b/package.json index 47be9e2..a0c40e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@x1ee7/barcode-core", - "version": "0.1.0", + "version": "0.2.0", "description": "Zero-dependency UPC / EAN / ISBN / ITF-14 check-digit math, validation, UPC-E expansion, and random/sequential code generation. Powers upcgen.com in production.", "keywords": [ "barcode",