diff --git a/CHANGELOG.md b/CHANGELOG.md index 2386f7a..df8945a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- Fix the public API contract for 1.0 preparation and document alpha.3 upgrade/rollback without changing wire v1. No stable version is published by this change. +- Check the exact candidate archive against integrity-pinned npm alpha.3 and beta.2: all nine writer/reader pairs, historical and Billetsys vectors, errors, exports and strict TypeScript consumers. +- Reject canonical-reader regressions for case/whitespace as well as grouping, and verify the documented exception classes across all three packages. + ## 0.1.0-beta.2 - Record the twelve measured tasks from the single-participant manual-entry pilot. In this pilot the participant entered both the existing number and the short alias faster than RefMint. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d12ba21..53a8368 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,17 @@ For the existing-ID format or comparison, run `pnpm compare` after the build and The build keeps JSDoc in declarations and removes comments only from emitted JS. Keep the full-root and codec-only size figures separate; include compression costs. After runtime changes, refresh the historical comparison with `pnpm compare:previous PATH_TO_PREVIOUS_DIST FULL_COMMIT_SHA` as described in [comparison methods](docs/comparison.md#before-and-after-optimization). The recorded previous baseline is `daaffe813fd0351752bd158314917267acddc1b6`. Do not compare historical timings collected on different machines as a before/after speedup. +## API compatibility + +Preserve the [1.0 preparation contract](docs/stable-contract.md). Run +`pnpm test:compatibility` to compare the packed candidate against SHA-512-pinned +alpha.3 and beta.2; `pnpm test:package` runs the same matrix on its exact archive. +The two historical downloads require network access, fail closed on integrity +errors, and install offline with ignored scripts and no runtime dependencies. +Do not replace their pins or regenerate historical vectors to hide a break. +Keep the TypeScript consumer and both writer/reader directions covered. +Temporary consumers and archives are removed even after failures. + ## Publishing a prerelease Use a clean, reviewed commit and verify that its CI passed. Keep the version, changelog and evidence version in sync. A published npm name/version cannot be reused. For the beta candidate, keep both `publishConfig.tag` and the explicit publication tag set to `beta`. Do not move the existing `alpha` or `latest` tags. diff --git a/README.md b/README.md index 645a955..36eab66 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@ npm install --save-exact refmint@0.1.0-beta.2 [Beta release and checksum](https://github.com/PerchAnton/refmint/releases/tag/v0.1.0-beta.2) · [API compatibility and frozen v1 format](docs/beta-contract.md) · [Verified npm installation](docs/evidence/release-beta2.json). +[API contract for 1.0 preparation](docs/stable-contract.md) · +[Upgrade checks from alpha.3](docs/migration-alpha3-to-1.md). The package remains in beta; +the compatibility matrix compares published releases with an unpublished archive. + **Try an invoice lookup.** The [sample PDF](https://github.com/PerchAnton/refmint/blob/v0.1.0-beta.2/examples/workflows/sample-invoice.pdf) shows `INV-2026-000042`, the short alias `D1042` and the 21-character printed reference `INVOICE_1PQP2-UYWU-CW` for the same record. In the diff --git a/docs/beta-contract.md b/docs/beta-contract.md index a81167b..d1f9e2d 100644 --- a/docs/beta-contract.md +++ b/docs/beta-contract.md @@ -45,6 +45,13 @@ new prerelease, explicit migration notes and regression evidence; never replace an existing version. Internal modules, example UI and benchmark timings are outside this contract. A stable 1.0 support/deprecation policy is a separate decision. +## Preparing the stable API + +The [1.0 API contract](stable-contract.md) and [alpha.3 migration guide](migration-alpha3-to-1.md) +carry these existing guarantees forward. The installed-archive check now compares +alpha.3, beta.2 and the unpublished candidate, without changing package version or +wire v1. Publication and consumer-native acceptance remain separate steps. + ## What beta does not prove The [existing workflow comparison](workflows.md) is synthetic. The diff --git a/docs/format-v1.md b/docs/format-v1.md index cd2b10b..cade0ff 100644 --- a/docs/format-v1.md +++ b/docs/format-v1.md @@ -58,7 +58,7 @@ After a valid checksum: check namespace; require two header symbols; check versi ## Compatibility commitment -The v1 layout, canonical encoding and accepted presentation forms defined here are frozen. Changes to that wire contract require a new version and reader; they must not change v1 output. Package APIs may continue to evolve during alpha. Explicit versioning does not authorize removal of v1 readers from deployed applications. +The v1 layout, canonical encoding and accepted presentation forms defined here are frozen. Changes to that wire contract require a new version and reader; they must not change v1 output. The [API contract for 1.0 preparation](stable-contract.md) is separate from this wire contract. Package version 1.0 does not change wire v1. Explicit versioning does not authorize removal of v1 readers from deployed applications. The alpha.1 random-reference format remains available through `createReferences`, with its generation, parsing and error-result shapes preserved. Its HRP grammar prohibits underscores. The new HRP requires one, so the formats are structurally disjoint even when the namespace and data lengths happen to match. Each API rejects the other's format. Do not fall back from a failed v1 parse to guessing a legacy ID or another version. diff --git a/docs/migration-alpha3-to-1.md b/docs/migration-alpha3-to-1.md new file mode 100644 index 0000000..897accd --- /dev/null +++ b/docs/migration-alpha3-to-1.md @@ -0,0 +1,78 @@ +# Preparing an alpha.3 → 1.0 upgrade + +There is no published 1.0 in this change. This guide describes the prepared +[API contract](stable-contract.md) and the checks required before upgrading. +Keep the consumer's current exact pin until the stable archive and integration +change have been reviewed. Installing a prerelease does not satisfy a stable-1.0 +requirement, and npm dist-tags must not be used as compatibility evidence. + +## Preferred path: keep v1 and the same API + +The alpha.3 and beta.2 APIs are unchanged in this preparation. Callers continue +to use the same imports, factory configuration and result checks: + +```ts +import { createReferenceCodec } from 'refmint/codec'; + +const tickets = createReferenceCodec({ prefix: 'btk', kind: 'uint64', version: 1 }); +const code = tickets.encode(42n); // btk_1pqp2cy59jf +const printed = tickets.format(code); // btk_1pqp2-cy59-jf +const result = tickets.decode(printed); +if (result.ok) { + const existingId: bigint = result.value; + // Apply the application's ID bounds and authorization before lookup. +} +``` + +For Billetsys, retain `btk` / uint64 / wire v1 and positive signed Java Long IDs. +Use exact decimal strings for JSON values beyond Number's safe-integer range; +converting an already-rounded Number to bigint cannot recover the original ID. +Keep the normal ticket routes, server permission checks and default-off feature +flag. The example and frozen application vectors are exercised by the installed +package compatibility matrix; they do not exercise a live Billetsys server. + +1. Inventory previously issued codes and their namespace/kind/version or random + prefix/length configuration. Preserve the old package and lockfile. Unknown + deployment history is not evidence that no codes were issued. +2. Check `pnpm test:compatibility` on the exact candidate archive. Both directions + matter: old codes must resolve in the candidate, and newly generated v1 codes + must resolve in alpha.3 for mixed deployments and rollback. Preserve all old + vectors, including boundary IDs and codes copied from synthetic documents. +3. After a stable version is published and accepted by the consumer's maintainers, + update the exact dependency and lockfile in a separate integration PR. No + intermediate beta.2 deployment is required. Keep API usage and format settings. +4. Run the application's existing tests and real permission/lookup checks, test + missing/forbidden/wrong-record cases, and exercise old and new frontends together. + In Billetsys retain the disabled/enabled bundle tests and lazy-loading behavior. +5. Test rollback of the package/bundle. With unchanged v1, new codes remain readable + by the previous application. Keep ordinary identifier search available. + +When those gates pass and format/configuration stay unchanged, there is **no +database migration, mapping table, backfill, ID replacement or code reissue**. +Printed/emailed codes keep their identity. Random-reference users must retain +their existing mapping, uniqueness constraints and namespace length. + +## If an incompatible change becomes necessary + +An API-only change needs explicit call-site migration or a compatible adapter; +it need not change stored identifiers. Prefer preserving the alpha.3 calls. + +A necessary new wire format must get its own version and specification. Deploy +readers for both formats while still writing v1, then enable the new writer only +after consumer approval. Retain v1 readers for the lifetime of already-issued +references. A failed checksum or unknown version must not trigger fallback guessing. +After issuing v2, rollback must keep the dual reader: a v1-only binary cannot read +v2. Switching writers back to v1 does not undo codes already shared with users. + +If a prior bug admitted ambiguous or invalid codes, isolate the affected case, +add a regression and agree a repair path. Never silently choose another record +or rewrite historical vectors. No such format change is required by this PR. + +## Remaining release and integration decisions + +Stable publication, release-channel checks, support/deprecation ownership and +consumer-native acceptance remain [release gates](stability.md). The library owner +reviews and publishes the package; consumer maintainers choose the pin, target +branch, timing, rollout and whether to enable the feature. There is no calendar +release promise here. The [pilot limitations](manual-entry-pilot.md), PDF demo and +security boundaries continue to apply; compatibility is not a speed claim. diff --git a/docs/stability.md b/docs/stability.md index 1125844..b0a16b9 100644 --- a/docs/stability.md +++ b/docs/stability.md @@ -11,6 +11,14 @@ version and reader. This is not a promise that the API, error diagnostics, supported environments or support policy will never change. Applications must retain the readers needed for references they have already issued. +## API and compatibility preparation + +The [1.0 API contract](stable-contract.md) fixes the surface being prepared. +`pnpm test:package` now compares its exact archive with integrity-pinned alpha.3 +and beta.2 in both directions, including TypeScript consumers and historical +Billetsys codes. See [upgrade and rollback checks](migration-alpha3-to-1.md). +This work does not publish 1.0 or complete the remaining gates below. + ## Criteria before a stable release - Review and freeze the public API and error behavior; document any migration from diff --git a/docs/stable-contract.md b/docs/stable-contract.md new file mode 100644 index 0000000..ff6242c --- /dev/null +++ b/docs/stable-contract.md @@ -0,0 +1,159 @@ +# Public API contract for 1.0 preparation + +This contract fixes the public surface being prepared for RefMint 1.0. The +repository still builds an unpublished candidate with the current beta version; +this document does not publish 1.0, move npm tags, or authorize a consumer upgrade. +Changes under preparation must preserve this contract and the historical-release +checks. Release and support readiness remain separate [gates](stability.md). + +## Imports and environments + +The public ESM entry points are `refmint` and `refmint/codec`. The root exports +`createReferences`, `createReferenceCodec`, `planReferences` and +`collisionProbability`. `refmint/codec` exports `createReferenceCodec` and its +codec types; it does not require random generation or a Web Crypto source. +Internal `dist/*` paths are not public imports. There is no CommonJS entry point. +The runtime has no package dependencies and declares `sideEffects: false`. + +The current Node floor is 22. CI checks Node 22/24/26 on Linux and Node 24 on +Windows/macOS, plus Chromium, Firefox and WebKit. These jobs do not claim coverage +of every OS/Node combination or every historical browser. Browsers need native +BigInt and the standard ES2022 APIs used by the package; random generation also +needs secure `crypto.getRandomValues`. There is no insecure randomness fallback. +Installed TypeScript consumers are checked with 5.9.3 using strict NodeNext; +older compiler versions are not promised without their own checks. + +## Factories and values + +| API | Stable inputs and behavior | +| --- | --- | +| `createReferences(options)` | Prefix plus a fixed `randomLength`, a complete collision budget, or the default 26-symbol payload. Returns `prefix`, `plan`, `generate`, `parse`, `parseCanonical`, `diagnose`, `format` | +| `planReferences(options)` | Same options and immutable plan, without requiring randomness. Chooses the shortest supported payload satisfying the conservative bound | +| `collisionProbability(length, count)` | Validated length/count and the documented birthday approximation; no uniqueness guarantee | +| `createReferenceCodec(options)` | `prefix`, `kind: 'uint64' \| 'uuid'`, optional `version: 1`. Returns `prefix`, `kind`, `version`, `encode`, `decode`, `decodeCanonical`, `diagnose`, `format` | + +Factories validate and snapshot options once; later mutation does not reconfigure +them. Factory objects and random-reference plans are immutable. Unknown options, +incomplete budgets, unsupported versions and invalid bounds are rejected. + +For uint64, `encode` accepts **bigint only**, from 0 through 2^64−1. It never +rounds a Number or coerces decimal text. UUID input/output is lowercase hyphenated +hexadecimal preserving all 128 bits, including nil and all-ones values. It does +not validate UUID version/variant semantics. A consumer can impose a narrower +range: Billetsys accepts only positive Java Long IDs through 2^63−1. + +The exported types remain available from the same entry points: + +- Random API: `Reference`, `ReferenceOptions`, `ReferencePlan`, `References`, + `ParseError`, `ParseResult`, `ParseDiagnostic`, `ValidationDetails` at the root. +- Codec API: `ReferenceKind`, `ReferenceValue`, `EncodedReference`, + `ReferenceCodecOptions`, `ReferenceCodec`, `DecodeError`, `DecodeResult`, + `DecodeDiagnostic` at both entry points. + +Brands prevent accidental mixing of namespaces, random/existing-ID references, +and codec kinds. They are compile-time checks, not credentials. A caller upgrading +must still validate untrusted input; casting a string does not validate it. + +## Results, errors and diagnostics + +`parse`/`parseCanonical` return `{ ok: true, id, normalized }` or +`{ ok: false, error }`. Codec decoding adds `value` on success: bigint for uint64, +string for UUID. `diagnose` has the same successful result and adds `details` on +failure. Ordinary parse/decode results do not acquire a mandatory details object. + +The random error codes are `type`, `too_long`, `format`, `case`, `character`, +`checksum`, `prefix`, `length`, `non_canonical`. The codec also has `version`, +`kind` and `overflow`. The order in [SPEC.md](../SPEC.md#parsing-and-error-precedence) +and [format v1](format-v1.md#validation-order) is contractual: e.g. a damaged +prefix fails checksum before it can be identified as a different namespace. + +Human-input boundaries accept only the documented ASCII surrounding whitespace, +uniform uppercase and exact groups of four. Canonical methods require the exact +lowercase, ungrouped stored code. The raw limit remains 128 UTF-16 code units. +Unicode lookalikes and malformed grouping are rejected, never repaired. +Diagnostics use zero-based UTF-16 offsets in the original input, including its +whitespace/grouping; a non-BMP character uses its first code-unit offset. Length +units remain `input_code_units`, `data_symbols` or `payload_symbols`. A checksum +failure supplies no guessed character position or replacement identifier. + +Malformed human input returns an error result, without invoking object coercion +hooks. Encoding and configuration errors use these exception classes, so callers +can distinguish a wrong value type from a value outside the supported range: + +| Invalid operation | Exception | +| --- | --- | +| Non-object options, unknown option keys, or combining fixed length with a collision budget | `TypeError` | +| Invalid/missing prefix or codec kind; unsupported explicitly supplied version | `RangeError` | +| Invalid length, count or probability; incomplete or unattainable collision budget | `RangeError` | +| uint64 input other than bigint; noncanonical or non-string UUID input | `TypeError` | +| uint64 bigint below zero or above 2^64−1 | `RangeError` | +| `format` given an invalid, foreign or noncanonical code | `TypeError` | +| Random generation without secure Web Crypto | `Error` | + +If multiple configuration fields are invalid, validation may fail at an earlier +field; these categories describe an invalid field with the other options valid. +Exceptions raised by a caller's own option getter or host crypto provider are +not suppressed. Human-readable exception prose and ordering of optional +information are not machine protocols. Adding optional diagnostics must preserve +existing field types and meanings. + +## Identifier compatibility is independent of package version + +The [v1 wire contract](format-v1.md) remains unchanged. `refmint@1.0.0` does not +mean wire version 2. Canonical encoding, grouping, normalization, recovered values +and namespace separation must preserve existing valid codes. Unknown versions or +kinds are errors, never a reason to guess another format. + +The legacy random format remains supported with its original prefix and payload +length. Persist that configuration; do not recalculate an existing namespace's +length from a revised collision budget and invalidate old codes. A different +format needs an explicit new reader/writer and an application lifecycle plan. +Old readers must remain available wherever previously issued references are +still expected to work, even across a future package-major upgrade. + +## Change policy + +For the prepared 1.x contract, removals or incompatible changes to signatures, +brands, result fields, error behavior, accepted values, defaults or minimum +platform requirements require a new package major and migration notes. Changes +to exported unions (including error codes) need special review: adding a case can +break an exhaustive TypeScript consumer. They are not automatically safe minors. +Compatible additions use a minor; fixes use a patch only after the compatibility +gates pass. No release may silently reinterpret a v1 code as a different ID. + +A necessary security correction needs a documented impact analysis, regression +and migration or repair path; never conceal a compatibility failure by replacing +old fixtures. Follow [SECURITY.md](../SECURITY.md) for disclosure. This contract +does not promise an independent audit, calendar support period or response-time +SLA. Those release/maintenance decisions remain explicit gates before stable +publication. Neither a stable API nor its checksum proves authorization, record +existence, or a practical manual-entry advantage. + +## Executable checks + +`pnpm test:compatibility` packs the checkout, verifies the SHA-512-pinned npm +alpha.3 and beta.2 archives, and installs all three outside the checkout. It checks +all nine writer/reader pairs, 20 existing-ID and three legacy frozen vectors, +five historical synthetic Billetsys codes, negative cases, entry points and the +same strict TypeScript consumer against each package. +Every accepted presentation is also checked against the canonical reader: +uppercase, surrounding ASCII whitespace and grouping must be rejected there, +while human-input readers return the canonical ID and correct `normalized` flag. +Exception checks assert the documented classes, not just that something throws. + +`pnpm test:package` also runs this matrix on **its exact archive** on every core +CI platform. It retains its offline-install, inventory, documentation and runtime +hash checks. The matrix fetches the two pinned archives; a network failure is a +failed check, not a skipped compatibility result. Installation uses a fresh +offline store and ignores lifecycle scripts. Temporary archives/consumers are +removed on success or failure. For an already packed candidate: + +```sh +pnpm test:compatibility /absolute/path/to/refmint-candidate.tgz +``` + +The candidate's version must match the checkout; it is identified by its archive +hash even while its manifest still says beta.2. Historical digests and vectors +must not be regenerated to accommodate changes. The matrix supplements, rather +than replaces, independent Bech32m, boundary, browser, PDF and application tests. +See the [alpha.3 migration guide](migration-alpha3-to-1.md). diff --git a/package.json b/package.json index d786813..f22890d 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "test": "node --test tests/*.test.mjs", "test:types": "tsc -p tests/tsconfig.json", "test:package": "node scripts/check-package.mjs", + "test:compatibility": "node scripts/check-compatibility.mjs", "test:demo": "node --test tests/demo/*.test.mjs", "check": "pnpm build && pnpm test && pnpm test:types", "example": "node examples/ticket-lookup.mjs", diff --git a/scripts/check-compatibility.mjs b/scripts/check-compatibility.mjs new file mode 100644 index 0000000..db9160f --- /dev/null +++ b/scripts/check-compatibility.mjs @@ -0,0 +1,107 @@ +// Compare published releases with the exact archive under review, not checkout imports. +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, mkdir, readFile, readdir, writeFile, copyFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const manager = process.env.npm_execpath; +assert.ok(manager, 'Run with pnpm test:compatibility'); +assert.ok(process.argv.length <= 3, 'Usage: pnpm test:compatibility [candidate.tgz]'); +const temp = await mkdtemp(join(tmpdir(), 'refmint-compatibility-')); +function run(script, args, cwd) { + const result = spawnSync(process.execPath, [script, ...args], { + cwd, encoding: 'utf8', env: process.env, timeout: 120000 + }); + assert.equal(result.status, 0, `${script} failed:\n${result.stdout}\n${result.stderr}\n${result.error ?? ''}`); + return result.stdout; +} +try { + const source = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')); + const pins = JSON.parse(await readFile(join(root, 'tests/fixtures/historical-releases.json'), 'utf8')); + const frozen = await readFile(join(root, 'tests/fixtures/formats-v1.json')); + assert.equal(createHash('sha256').update(frozen).digest('hex'), pins.formatsV1Sha256, + 'The historical format vectors must not change'); + let candidate = process.argv[2] && resolve(process.argv[2]); + if (!candidate) { + const packs = join(temp, 'packs'); + await mkdir(packs); + run(manager, ['pack', '--pack-destination', packs], root); + const archives = (await readdir(packs)).filter(name => name.endsWith('.tgz')); + assert.equal(archives.length, 1); + candidate = join(packs, archives[0]); + } + const dependencies = { refmint: 'file:' + candidate.replaceAll('\\', '/') }; + const archives = [{ alias: 'refmint', version: source.version, + sha512: createHash('sha512').update(await readFile(candidate)).digest('base64') }]; + assert.deepEqual(pins.releases.map(x => [x.alias, x.version]), + [['refmint-alpha3', '0.1.0-alpha.3'], ['refmint-beta2', '0.1.0-beta.2']]); + for (const release of pins.releases) { + assert.equal(release.tarball, `https://registry.npmjs.org/refmint/-/refmint-${release.version}.tgz`); + const response = await fetch(release.tarball, { redirect: 'error', signal: AbortSignal.timeout(30000) }); + assert.ok(response.ok, `${release.version}: HTTP ${response.status}`); + const chunks = []; + let size = 0; + for await (const chunk of response.body) { + size += chunk.length; + assert.ok(size <= 1024 * 1024, 'Historical archive exceeds the one MiB bound'); + chunks.push(chunk); + } + const bytes = Buffer.concat(chunks); + const digest = createHash('sha512').update(bytes).digest('base64'); + assert.equal('sha512-' + digest, release.integrity, `${release.version}: archive integrity mismatch`); + const file = join(temp, `${release.alias}.tgz`); + await writeFile(file, bytes); + dependencies[release.alias] = 'file:' + file.replaceAll('\\', '/'); + archives.push({ alias: release.alias, version: release.version, sha512: digest }); + } + const consumer = join(temp, 'consumer'); + await mkdir(consumer); + await writeFile(join(consumer, 'package.json'), JSON.stringify({ + name: 'refmint-compatibility-consumer', private: true, type: 'module', + packageManager: source.packageManager, dependencies + })); + // The only network inputs are the two pinned, verified archives above. + // A fresh offline store also rejects an accidental runtime dependency. + run(manager, ['install', '--offline', '--ignore-scripts', '--no-frozen-lockfile', '--store-dir', join(temp, 'store')], consumer); + for (const archive of archives) { + const manifest = JSON.parse(await readFile(join(consumer, 'node_modules', archive.alias, 'package.json'), 'utf8')); + assert.equal(manifest.name, 'refmint'); + assert.equal(manifest.version, archive.version, `${archive.alias}: wrong installed version`); + for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies']) { + assert.deepEqual(manifest[field] ?? {}, {}, `${archive.alias}: unexpected ${field}`); + } + assert.equal(manifest.type, 'module'); + assert.equal(manifest.engines.node, '>=22'); + assert.equal(manifest.sideEffects, false); + assert.deepEqual(manifest.exports, { + '.': { types: './dist/index.d.ts', import: './dist/index.js' }, + './codec': { types: './dist/codec.d.ts', import: './dist/codec.js' } + }); + } + await writeFile(join(consumer, 'formats-v1.json'), frozen); + for (const name of ['billetsys-v1.json', 'compatibility-invalid-v1.json']) { + await copyFile(join(root, 'tests/fixtures', name), join(consumer, name)); + } + await copyFile(join(root, 'tests/compatibility/runtime.mjs'), join(consumer, 'runtime.mjs')); + const types = await readFile(join(root, 'tests/compatibility/types.mts'), 'utf8'); + const typeFiles = []; + for (const { alias } of archives) { + const name = `${alias}.mts`; + await writeFile(join(consumer, name), types.replaceAll("'refmint", "'" + alias)); + typeFiles.push(name); + } + // Compile the same consumer against each published declaration surface and the candidate. + run(join(root, 'node_modules/typescript/bin/tsc'), [ + '--noEmit', '--strict', '--exactOptionalPropertyTypes', '--noUncheckedIndexedAccess', + '--target', 'ES2022', '--module', 'NodeNext', '--moduleResolution', 'NodeNext', ...typeFiles + ], consumer); + console.log(run(join(consumer, 'runtime.mjs'), [], consumer)); + console.log('PASS: installed alpha.3, beta.2 and candidate API/types; all 9 writer/reader pairs.'); + console.log('Verified archives: ' + JSON.stringify(archives)); +} finally { + await rm(temp, { recursive: true, force: true }); +} diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index e90f9b4..a5fe276 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -15,6 +15,7 @@ let stale; function run(script, args, cwd) { const result = spawnSync(process.execPath, [script, ...args], { cwd, encoding: 'utf8', env: process.env, timeout: 120000 }); if (result.status !== 0) throw new Error(`${script} failed:\n${result.stdout}\n${result.stderr}\n${result.error ?? ''}`); + return result.stdout; } try { const source = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')); @@ -131,6 +132,8 @@ const decoded = numbers.decode(n); if (decoded.ok) { const original: bigint = decoded.value; void original; } `); run(join(root, 'node_modules/typescript/bin/tsc'), ['--noEmit', '--strict', '--target', 'ES2022', '--module', 'NodeNext', '--moduleResolution', 'NodeNext', 'types.mts'], consumer); + // Reuse this exact archive for the historical-release matrix on every core CI platform. + console.log(run(join(root, 'scripts/check-compatibility.mjs'), [join(packs, archives[0])], root)); console.log(`PASS: ${source.name}@${source.version} archive installs offline; ESM, types, documentation and licenses are intact.`); } finally { if (stale) await rm(stale, { recursive: true, force: true }); diff --git a/tests/compatibility/runtime.mjs b/tests/compatibility/runtime.mjs new file mode 100644 index 0000000..f4c2243 --- /dev/null +++ b/tests/compatibility/runtime.mjs @@ -0,0 +1,174 @@ +// This file is copied outside the checkout and imports only installed archives. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFile } from 'node:fs/promises'; +import * as alpha from 'refmint-alpha3'; +import * as beta from 'refmint-beta2'; +import * as candidate from 'refmint'; +import { createReferenceCodec as alphaCodec } from 'refmint-alpha3/codec'; +import { createReferenceCodec as betaCodec } from 'refmint-beta2/codec'; +import { createReferenceCodec as candidateCodec } from 'refmint/codec'; + +const releases = [ + { name: 'alpha.3', path: 'refmint-alpha3', api: alpha, codec: alphaCodec }, + { name: 'beta.2', path: 'refmint-beta2', api: beta, codec: betaCodec }, + { name: 'candidate', path: 'refmint', api: candidate, codec: candidateCodec } +]; +const frozen = JSON.parse(await readFile(new URL('./formats-v1.json', import.meta.url))); +const tickets = JSON.parse(await readFile(new URL('./billetsys-v1.json', import.meta.url))); +const invalid = JSON.parse(await readFile(new URL('./compatibility-invalid-v1.json', import.meta.url))); +assert.equal(frozen.codec.length, 20); +assert.equal(frozen.legacy.length, 3); +assert.equal(tickets.codec.length, 5); +const vectors = [...frozen.codec, ...tickets.codec]; +const valueOf = v => v.kind === 'uint64' ? BigInt(v.value) : v.value; +const grouped = id => { + const start = id.lastIndexOf('1') + 1; + return id.slice(0, start) + id.slice(start).match(/.{1,4}/g).join('-'); +}; +const presentations = id => [ + id, id.toUpperCase(), grouped(id), grouped(id).toUpperCase(), + ' ' + id, id + ' ', '\t' + id + '\r\n', ' \t' + grouped(id).toUpperCase() + '\r\n' +]; + +for (const { name, path, api, codec } of releases) { + test(`${name}: public entry points and factory contracts`, async () => { + assert.deepEqual(Object.keys(api).sort(), ['collisionProbability', 'createReferenceCodec', 'createReferences', 'planReferences']); + assert.equal(codec, api.createReferenceCodec); + for (const internal of ['dist/index.js', 'dist/codec.js', 'dist/bech32m.js', 'dist/presentation.js']) { + await assert.rejects(import(`${path}/${internal}`), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); + } + const options = { prefix: 'btk', kind: 'uint64', version: 1 }; + const c = codec(options); + options.prefix = 'changed'; + assert.equal(c.prefix, 'btk'); + assert.equal(c.kind, 'uint64'); + assert.equal(c.version, 1); + assert.ok(Object.isFrozen(c)); + assert.deepEqual(Object.keys(c).sort(), ['decode', 'decodeCanonical', 'diagnose', 'encode', 'format', 'kind', 'prefix', 'version']); + assert.throws(() => c.format(c.encode(42n).toUpperCase()), TypeError); + const uuid = codec({ prefix: 'btk', kind: 'uuid' }); + assert.throws(() => uuid.encode('550E8400-e29b-41d4-a716-446655440000'), TypeError); + assert.throws(() => uuid.format(c.encode(42n)), TypeError); + assert.equal(c.format(c.encode(42n)), 'btk_1pqp2-cy59-jf'); + }); + + test(`${name}: documented exception classes remain distinguishable`, t => { + const c = codec({ prefix: 'btk', kind: 'uint64' }); + for (const bad of [42, '42', null, undefined, {}, NaN]) assert.throws(() => c.encode(bad), TypeError); + for (const bad of [-1n, 1n << 64n]) assert.throws(() => c.encode(bad), RangeError); + for (const [options, ErrorClass] of [ + [null, TypeError], [undefined, TypeError], [{}, RangeError], + [{ prefix: 'btk', kind: 'uint64', extra: true }, TypeError], + [{ prefix: 'BTK', kind: 'uint64' }, RangeError], + [{ prefix: 'btk', kind: 'number' }, RangeError], + [{ prefix: 'btk', kind: 'uint64', version: 2 }, RangeError] + ]) assert.throws(() => codec(options), ErrorClass); + for (const [options, ErrorClass] of [ + [null, TypeError], [undefined, TypeError], [{}, RangeError], + [{ prefix: 'ticket', unexpected: 1 }, TypeError], + [{ prefix: 'ticket', randomLength: 14, expectedCount: 100, maxCollisionProbability: 1e-9 }, TypeError], + [{ prefix: 'Ticket' }, RangeError], [{ prefix: 'ticket', randomLength: 7 }, RangeError], + [{ prefix: 'ticket', expectedCount: 1 }, RangeError] + ]) { + assert.throws(() => api.createReferences(options), ErrorClass); + assert.throws(() => api.planReferences(options), ErrorClass); + } + assert.throws(() => api.collisionProbability(7, 100), RangeError); + assert.throws(() => api.collisionProbability(14, 0), RangeError); + t.mock.getter(globalThis, 'crypto', () => undefined); + assert.throws(() => api.createReferences({ prefix: 'ticket' }).generate(), Error); + assert.equal(c.decode(c.encode(42n)).value, 42n); // Existing-ID codecs do not need an RNG. + }); + + test(`${name}: input rejection, error precedence and raw diagnostic positions`, () => { + const c = codec({ prefix: 'btk', kind: 'uint64' }); + const id = 'btk_1pqp2cy59jf'; + const cases = [ + [null, 'type'], [42, 'type'], [{ toString() { throw new Error('must not coerce'); } }, 'type'], + ['x'.repeat(128), 'format'], ['x'.repeat(129), 'too_long'], ['K'.repeat(129), 'too_long'], + ['K' + id, 'character'], ['\ud800' + id, 'character'], ['\u00a0' + id, 'character'], + ['B' + id.slice(1), 'case'], ['42', 'format'], ['btk_1p-qp2cy59jf', 'format'], + [id + '!', 'character'], [id.slice(0, -1) + 'q', 'checksum'], + ['xyz' + id.slice(3), 'checksum'] + ]; + for (const [input, error] of cases) { + assert.deepEqual(c.decode(input), { ok: false, error }); + assert.equal(c.diagnose(input).error, error); + } + assert.deepEqual(c.diagnose(' 💥' + id), { ok: false, error: 'character', details: { positions: [2] } }); + assert.deepEqual(c.diagnose(id.slice(0, -1) + 'q'), { ok: false, error: 'checksum', details: {} }); + assert.deepEqual(c.diagnose('x'.repeat(129)), { + ok: false, error: 'too_long', details: { expected: 128, actual: 129, unit: 'input_code_units' } + }); + for (const v of invalid.cases) { + const reader = codec({ prefix: v.prefix, kind: v.kind }); + assert.deepEqual(reader.decode(v.reference), { ok: false, error: v.error }, v.description); + // The stage and details must agree with the published alpha, including precedence. + assert.deepEqual(reader.diagnose(v.reference), alphaCodec({ prefix: v.prefix, kind: v.kind }).diagnose(v.reference)); + } + assert.equal(c.decode(codec({ prefix: 'other', kind: 'uint64' }).encode(42n)).error, 'prefix'); + assert.equal(c.decode(codec({ prefix: 'btk', kind: 'uuid' }).encode('00000000-0000-0000-0000-00000000002a')).error, 'kind'); + }); + + test(`${name}: legacy API, immutable plans and error results`, () => { + const config = { prefix: 'ticket', randomLength: 14 }; + const r = api.createReferences(config); + config.randomLength = 8; + assert.equal(r.plan.randomLength, 14); + assert.ok(Object.isFrozen(r)); + assert.ok(Object.isFrozen(r.plan)); + assert.deepEqual(api.planReferences({ prefix: 'ticket' }), { + randomLength: 26, randomBits: 130, checksumLength: 6, canonicalLength: 39, + expectedCount: null, maxCollisionProbability: null, collisionUpperBound: null, collisionProbability: null + }); + const budget = api.planReferences({ prefix: 'ticket', expectedCount: 1000000, maxCollisionProbability: 1e-9 }); + assert.equal(budget.randomLength, 14); + assert.ok(budget.collisionUpperBound <= 1e-9); + assert.ok(api.collisionProbability(14, 1000000) <= budget.collisionUpperBound); + for (const input of [null, {}, 42, []]) assert.deepEqual(r.parse(input), { ok: false, error: 'type' }); + assert.throws(() => r.format('ticket1invalid'), TypeError); + for (const v of frozen.legacy) { + const reader = api.createReferences({ prefix: v.prefix, randomLength: v.randomLength }); + assert.deepEqual(reader.parseCanonical(v.reference), { ok: true, id: v.reference, normalized: false }); + assert.deepEqual(reader.parse(grouped(v.reference).toUpperCase()), { ok: true, id: v.reference, normalized: true }); + assert.equal(reader.parseCanonical(grouped(v.reference)).error, 'non_canonical'); + for (const kind of ['uint64', 'uuid']) assert.equal(codec({ prefix: v.prefix, kind }).decode(v.reference).ok, false); + } + }); +} + +for (const writer of releases) for (const reader of releases) { + test(`${writer.name} writes → ${reader.name} reads: all frozen and application v1 codes`, () => { + for (const v of vectors) { + const options = { prefix: v.prefix, kind: v.kind, version: 1 }; + const w = writer.codec(options), r = reader.codec(options), value = valueOf(v); + const id = w.encode(value); + assert.equal(id, v.reference); + assert.equal(w.format(id), grouped(v.reference)); + for (const input of presentations(id)) { + const success = { ok: true, id, value, normalized: input !== id }; + assert.deepEqual(r.decode(input), success); + assert.deepEqual(r.diagnose(input), success); + assert.deepEqual(r.decodeCanonical(input), input === id ? success : { ok: false, error: 'non_canonical' }); + if (input !== id) assert.throws(() => r.format(input), TypeError); + assert.equal(r.encode(r.decode(input).value), id); + } + assert.equal(reader.api.createReferences({ prefix: v.prefix }).parse(id).ok, false); + } + for (const v of frozen.legacy) { + const options = { prefix: v.prefix, randomLength: v.randomLength }; + const w = writer.api.createReferences(options), r = reader.api.createReferences(options); + for (const id of [w.generate(), v.reference]) { + assert.equal(w.format(id), grouped(id)); + for (const input of presentations(id)) { + const success = { ok: true, id, normalized: input !== id }; + assert.deepEqual(r.parse(input), success); + assert.deepEqual(r.diagnose(input), success); + assert.deepEqual(r.parseCanonical(input), input === id ? success : { ok: false, error: 'non_canonical' }); + if (input !== id) assert.throws(() => r.format(input), TypeError); + } + } + } + }); +} diff --git a/tests/compatibility/types.mts b/tests/compatibility/types.mts new file mode 100644 index 0000000..d3dea65 --- /dev/null +++ b/tests/compatibility/types.mts @@ -0,0 +1,81 @@ +// The same consumer is compiled against each historical package and the candidate. +import { + createReferences, createReferenceCodec, planReferences, collisionProbability, + type Reference, type ReferenceOptions, type ReferencePlan, type References, + type ParseError, type ParseResult, type ParseDiagnostic, type ValidationDetails, + type ReferenceKind, type ReferenceValue, type EncodedReference, + type ReferenceCodecOptions, type ReferenceCodec, type DecodeError, + type DecodeResult, type DecodeDiagnostic +} from 'refmint'; +import { createReferenceCodec as standalone, type EncodedReference as StandaloneReference } from 'refmint/codec'; + +const options: ReferenceOptions<'ticket'> = { prefix: 'ticket', randomLength: 14 }; +const refs: References<'ticket'> = createReferences(options); +const random: Reference<'ticket'> = refs.generate(); +const plan: ReferencePlan = planReferences(options); +const probability: number = collisionProbability(plan.randomLength, 1000); +const randomResult: ParseResult<'ticket'> = refs.parse('input'); +const canonical: ParseResult<'ticket'> = refs.parseCanonical(random); +const diagnosis: ParseDiagnostic<'ticket'> = refs.diagnose('input'); +const codecOptions: ReferenceCodecOptions<'btk', 'uint64'> = { prefix: 'btk', kind: 'uint64', version: 1 }; +const codec: ReferenceCodec<'btk', 'uint64'> = createReferenceCodec(codecOptions); +const kind: ReferenceKind = codec.kind; +const value: ReferenceValue<'uint64'> = 9007199254740993n; +const encoded: EncodedReference<'btk', 'uint64'> = codec.encode(value); +const subpath: StandaloneReference<'btk', 'uint64'> = standalone(codecOptions).encode(42n); +codec.format(subpath); +const result: DecodeResult<'btk', 'uint64'> = codec.decode(encoded); +const strict: DecodeResult<'btk', 'uint64'> = codec.decodeCanonical(encoded); +const detail: DecodeDiagnostic<'btk', 'uint64'> = codec.diagnose('input'); +if (result.ok) { + const decoded: bigint = result.value; + const normalized: boolean = result.normalized; + codec.format(result.id); + void [decoded, normalized]; +} else { + const error: DecodeError = result.error; + void error; +} +if (!diagnosis.ok) { + const error: ParseError = diagnosis.error; + const details: ValidationDetails = diagnosis.details; + const position: number | undefined = details.positions?.[0]; + const unit: 'input_code_units' | 'data_symbols' | 'payload_symbols' | undefined = details.unit; + void [error, position, unit]; +} +const uuids = createReferenceCodec({ prefix: 'btk', kind: 'uuid' }); +const uuid: ReferenceValue<'uuid'> = '550e8400-e29b-41d4-a716-446655440000'; +const uuidResult = uuids.decode(uuids.encode(uuid)); +if (uuidResult.ok) { const original: string = uuidResult.value; void original; } +// @ts-expect-error uint64 never accepts an imprecise JS number. +codec.encode(42); +// @ts-expect-error UUID and uint64 brands are distinct. +uuids.format(encoded); +// @ts-expect-error Record namespaces cannot be interchanged. +createReferenceCodec({ prefix: 'other', kind: 'uint64' }).format(encoded); +// @ts-expect-error Unvalidated text is not a canonical reference. +codec.format('btk_1pqp2cy59jf'); +// @ts-expect-error Random references cannot be treated as existing-ID references. +codec.format(random); +// @ts-expect-error The version remains wire v1, independently of package version. +createReferenceCodec({ prefix: 'btk', kind: 'uint64', version: 2 }); +// @ts-expect-error Factories do not change configuration after construction. +codec.prefix = 'other'; +// @ts-expect-error A partial collision budget is invalid. +createReferences({ prefix: 'ticket', expectedCount: 1000 }); +// @ts-expect-error Budget and fixed length cannot both be supplied. +createReferences({ prefix: 'ticket', randomLength: 14, expectedCount: 1000, maxCollisionProbability: 1e-9 }); +// @ts-expect-error Random-reference brands retain the namespace. +createReferences({ prefix: 'other', randomLength: 14 }).format(random); +void [probability, randomResult, canonical, kind, strict, detail]; + +// Keep machine-readable discriminants usable by exhaustive consumers. +type Equal = (() => T extends A ? 1 : 2) extends + (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; +type ParseCodes = Expect>; +type DecodeCodes = Expect>; +type Kinds = Expect>; +type NumericValue = Expect, bigint>>; +type UuidValue = Expect, string>>; diff --git a/tests/fixtures/billetsys-v1.json b/tests/fixtures/billetsys-v1.json new file mode 100644 index 0000000..f83be78 --- /dev/null +++ b/tests/fixtures/billetsys-v1.json @@ -0,0 +1,36 @@ +{ + "description": "Frozen synthetic ticket IDs encoded once by the integrity-pinned npm alpha.3 archive; checked independently with bech32@2.0.0. Never regenerate to accept a changed v1 encoding.", + "source": "refmint@0.1.0-alpha.3; see historical-releases.json", + "codec": [ + { + "prefix": "btk", + "kind": "uint64", + "value": "1", + "reference": "btk_1pqp3nn09k" + }, + { + "prefix": "btk", + "kind": "uint64", + "value": "42", + "reference": "btk_1pqp2cy59jf" + }, + { + "prefix": "btk", + "kind": "uint64", + "value": "9007199254740991", + "reference": "btk_1pq8llllllllllwsnyua" + }, + { + "prefix": "btk", + "kind": "uint64", + "value": "9007199254740993", + "reference": "btk_1pqgqqqqqqqqqpyhukta" + }, + { + "prefix": "btk", + "kind": "uint64", + "value": "9223372036854775807", + "reference": "btk_1pq8llllllllllllcfp0mh" + } + ] +} diff --git a/tests/fixtures/compatibility-invalid-v1.json b/tests/fixtures/compatibility-invalid-v1.json new file mode 100644 index 0000000..ef482ef --- /dev/null +++ b/tests/fixtures/compatibility-invalid-v1.json @@ -0,0 +1,75 @@ +{ + "description": "Malformed v1 payloads with valid checksums generated independently by bech32@2.0.0; alpha.3 rejection confirmed. Not valid application identifiers.", + "cases": [ + { + "description": "missing headers", + "prefix": "btk", + "kind": "uint64", + "reference": "btk_1c7es4d", + "error": "length" + }, + { + "description": "missing kind", + "prefix": "btk", + "kind": "uint64", + "reference": "btk_1pxt2k6y", + "error": "length" + }, + { + "description": "unknown version", + "prefix": "btk", + "kind": "uint64", + "reference": "btk_1zqph4gl8h", + "error": "version" + }, + { + "description": "unknown kind", + "prefix": "btk", + "kind": "uint64", + "reference": "btk_1pfpasl6yf", + "error": "kind" + }, + { + "description": "empty payload", + "prefix": "btk", + "kind": "uint64", + "reference": "btk_1pqe5hae0", + "error": "length" + }, + { + "description": "too many symbols", + "prefix": "btk", + "kind": "uint64", + "reference": "btk_1pqpppppppppppppps5jmtc", + "error": "length" + }, + { + "description": "uint64 overflow", + "prefix": "btk", + "kind": "uint64", + "reference": "btk_1pqsqqqqqqqqqqqqd5y586", + "error": "overflow" + }, + { + "description": "nonminimal integer", + "prefix": "btk", + "kind": "uint64", + "reference": "btk_1pqqp8729fd", + "error": "non_canonical" + }, + { + "description": "UUID high bits set", + "prefix": "btk", + "kind": "uuid", + "reference": "btk_1ppgqqqqqqqqqqqqqqqqqqqqqqqqqrc6m4x", + "error": "overflow" + }, + { + "description": "short UUID", + "prefix": "btk", + "kind": "uuid", + "reference": "btk_1ppqqqqqqqqqqqqqqqqqqqqqqqqqu3wzd5", + "error": "length" + } + ] +} diff --git a/tests/fixtures/historical-releases.json b/tests/fixtures/historical-releases.json new file mode 100644 index 0000000..057fd4b --- /dev/null +++ b/tests/fixtures/historical-releases.json @@ -0,0 +1,18 @@ +{ + "description": "Published npm archives, pinned independently of mutable dist-tags. Do not replace old digests or regenerate frozen identifiers to accept an incompatible candidate.", + "formatsV1Sha256": "2fcf8a2ae8a9932b2060c5fc355fe8debfa0d47c2c0c763b9c3b89f8e7fe4a68", + "releases": [ + { + "alias": "refmint-alpha3", + "version": "0.1.0-alpha.3", + "tarball": "https://registry.npmjs.org/refmint/-/refmint-0.1.0-alpha.3.tgz", + "integrity": "sha512-PPmQ026pZkFzgt2eTt4L+ppsbcpXxeqonOKWoSVoZZKO9dVEm+jUwHZjJteRmfC3+G80fp9EhcQzpfSTaSExjQ==" + }, + { + "alias": "refmint-beta2", + "version": "0.1.0-beta.2", + "tarball": "https://registry.npmjs.org/refmint/-/refmint-0.1.0-beta.2.tgz", + "integrity": "sha512-1+pJjyXr2bjw0B3F3xdShUKOsO1RXMbAunCYDxE3+WbsjX1fnJ48MbkTHcat594Ps8gIAUgH4SdxxEtaOZc36Q==" + } + ] +}