diff --git a/.changeset/first-release.md b/.changeset/first-release.md new file mode 100644 index 0000000..48d7a61 --- /dev/null +++ b/.changeset/first-release.md @@ -0,0 +1,11 @@ +--- +"@cosyte/dates": minor +--- + +First release of `@cosyte/dates`: validate, render and convert the date parts the `@cosyte/*` parsers return, without adding precision a value did not state or guessing a time zone. + +What you can depend on from this release: `validateParts`, `isValidParts` and `assertValidParts`, which report every fault in a value and name the rule each one broke; `precisionOf`; `toISO`, which renders ISO 8601 at the value's own precision; `toTemporal`, which returns the narrowest Temporal type that carries everything the value states; `toZonedDateTime` and `toInstant`, which need a time of day and a zone and never fall back to the host zone or to UTC; the re-exported `Temporal`; the shape of `DateParts`; the issue codes; and the four error classes, `DatePartsError`, `MissingZoneError`, `PrecisionError` and `AmbiguousLocalTimeError`. + +What the version number promises: while it is below 1.0.0, a breaking change to any of the above ships in a new minor version, never a patch, and its entry here says what broke and what to do instead. A fix that changes nothing else a consumer relies on ships as a patch. + +Not covered yet: parsing vendor date text (the parsers do that), two-digit years (never interpreted), arithmetic, comparison or formatting helpers (convert with `toTemporal()` and use Temporal for those), locale-aware output, years outside 1000 to 9999, calendars other than the proleptic Gregorian, and leap seconds. diff --git a/.github/CI.md b/.github/CI.md index 37e2c12..176ee22 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -14,7 +14,7 @@ no gate: it blocks the very pull requests that would fix it. | workflow | trigger | what it is | |---|---|---| -| `.github/workflows/ci.yml` | push to `main`, pull request to `main` | The gate. One job, `verify`: install from the frozen lockfile, build, the typed-exports check (`pnpm run attw`), typecheck, lint, format check, the test suite, the suite again under a second host time zone, and the em-dash scan. | +| `.github/workflows/ci.yml` | push to `main`, pull request to `main` | The gate. One job, `verify`: install from the frozen lockfile, build, the typed-exports check (`pnpm run attw`), the runnable examples (`pnpm run examples`), typecheck, lint, format check, the test suite, the suite again under a second host time zone, and the em-dash scan. | | `.github/workflows/no-emdash.yml` | push to `main`, pull request to `main` (including `edited`) | Repo-local gate. Scans every tracked file, plus the pull request's own title, body and commit messages, for U+2014. | | `.github/workflows/scorecard.yml` | push to `main`, weekly cron | Thin caller of `cosyte/.github/.github/workflows/scorecard.yml@main`. Supply-chain analysis, SARIF into the Security tab. | | `.github/workflows/codeql.yml` | push to `main`, pull request to `main`, weekly cron | Thin caller of the org CodeQL reusable at a published reference. | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d5c062..5ecd2be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,12 @@ jobs: - name: Types resolve (attw) run: pnpm run attw + # The programs under examples/ import the package by its name, which resolves through the + # package's own `exports` to the build above, and each one checks its own output. They run + # as a step of this job rather than a job of their own, to keep the single status above. + - name: Examples + run: pnpm run examples + - name: Typecheck run: pnpm run typecheck diff --git a/README.md b/README.md index 8aba33a..76b70e5 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,9 @@ nobody has their date library chosen for them. **0.1.0. The public API is settled and safe to depend on.** The exported functions, the shape of `DateParts`, the issue codes and the error classes are -what this package intends to keep, and a breaking change to any of them will -come with a major version. +what this package intends to keep. While the version is below 1.0.0, a breaking +change to any of them ships in a new minor version, never a patch, and the +changelog says what broke and what to do instead. Not covered at 0.1.0, and named so the boundary is not a surprise: @@ -111,6 +112,10 @@ That output is identical under every host `TZ`, and `test/readme.test.ts` runs this exact block against the built package and compares it character for character. +More runnable programs, one for each job this package does, are in +[`examples/`](https://github.com/cosyte/dates/tree/main/examples), and the full +guide is at [docs.cosyte.com/dates](https://docs.cosyte.com/dates). + ## PHI and safety A date of birth is PHI, and a wrong conversion is the quiet kind of defect: it @@ -188,8 +193,8 @@ identifier or a numeric offset is required). Issues and pull requests are welcome at [github.com/cosyte/dates](https://github.com/cosyte/dates). A contribution has to clear the same gate CI runs on every push: `pnpm run build`, `pnpm run -typecheck`, `pnpm run lint`, `pnpm run test`, and -`bash scripts/check-no-emdash.sh`. New behaviour needs a test, and anything +attw`, `pnpm run typecheck`, `pnpm run lint`, `pnpm run test`, `pnpm run +examples`, and `bash scripts/check-no-emdash.sh`. New behaviour needs a test, and anything touching conversion needs one that pins the exact output rather than a shape. ## License diff --git a/eslint.config.js b/eslint.config.js index c7c7555..de3a54a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,16 @@ import tseslint from 'typescript-eslint'; export default tseslint.config( { - ignores: ['dist/**', 'node_modules/**', 'coverage/**', '.tmp/**'], + // `.cosyte-release-tooling/` is a checkout of cosyte/.github that the shared release workflow + // makes inside this working tree before it runs `pnpm lint`. It is not this repository's code. + ignores: [ + 'dist/**', + 'node_modules/**', + 'coverage/**', + '.tmp/**', + 'dist-artifacts/**', + '.cosyte-release-tooling/**', + ], }, js.configs.recommended, ...tseslint.configs.recommended, diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..b429879 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,22 @@ +# Examples + +Three small programs, one for each job the [README](../README.md) describes. Each imports +`@cosyte/dates` by its package name, which Node resolves through this package's own `exports` map to +the built `dist/`, prints what it did, checks its own output, and exits non-zero on a mismatch. Every +value in them is synthetic. + +```bash +pnpm install +pnpm build +pnpm examples # run all three +pnpm tsx examples/validate-parts.ts # or one +``` + +| Example | What it shows | Calls | +| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------- | +| [`validate-parts.ts`](./validate-parts.ts) | Every fault in a value reported at once; a date that does not exist refused, never rolled over. | `validateParts`, `isValidParts`, `assertValidParts` | +| [`render-at-precision.ts`](./render-at-precision.ts) | A year stays a year: ISO 8601 and Temporal types at the precision the value arrived with. | `toISO`, `precisionOf`, `toTemporal` | +| [`instants-and-zones.ts`](./instants-and-zones.ts) | An instant only when the zone is known; a daylight-saving repeat refused until the offset is stated. | `toInstant`, `toZonedDateTime` | + +`pnpm examples` runs `scripts/run-examples.ts`, which fails if any example exits non-zero or stops +before printing its final `: ok` line. CI runs it on every pull request, after the build. diff --git a/examples/instants-and-zones.ts b/examples/instants-and-zones.ts new file mode 100644 index 0000000..10c09be --- /dev/null +++ b/examples/instants-and-zones.ts @@ -0,0 +1,61 @@ +/** + * Turn a timestamp into an absolute instant only when its zone is known. A value that states its + * offset keeps it; a value that does not needs the caller to say which zone applies; and a local + * time a daylight-saving transition skips or repeats is refused rather than resolved by a guess. + * + * Run from the repository root after `pnpm build`: + * + * pnpm tsx examples/instants-and-zones.ts + * + * Every value here is synthetic. The output does not depend on the host time zone. The example + * checks its own output and exits non-zero on a mismatch. + */ + +import assert from 'node:assert/strict'; + +import { + AmbiguousLocalTimeError, + MissingZoneError, + toInstant, + toZonedDateTime, +} from '@cosyte/dates'; + +// The sender stated +05:30, so that offset is used as sent. +const collected = { year: 1988, month: 5, day: 7, hour: 13, minute: 45, offsetMinutes: 330 }; +const instant = toInstant(collected).toString(); +console.log('stated offset:', instant); +assert.equal(instant, '1988-05-07T08:15:00Z'); + +// No offset on the wire: nothing guesses one, not the host zone and not UTC. +const local = { year: 2026, month: 3, day: 9, hour: 8, minute: 5 }; +try { + toInstant(local); + assert.fail('toInstant guessed a zone for a value with no offset'); +} catch (error) { + assert.ok(error instanceof MissingZoneError); + console.log('no zone given:', error.name); +} + +// The caller knows the sender's zone and says so. +const inNewYork = toInstant(local, { timeZone: 'America/New_York' }).toString(); +const zoned = toZonedDateTime(local, { timeZone: 'America/New_York' }).toString(); +console.log('in America/New_York:', inNewYork, '|', zoned); +assert.equal(inNewYork, '2026-03-09T12:05:00Z'); +assert.equal(zoned, '2026-03-09T08:05:00-04:00[America/New_York]'); + +// 01:30 happens twice on 1 November 2026 in New York. Refused, not picked. +const repeated = { year: 2026, month: 11, day: 1, hour: 1, minute: 30 }; +try { + toInstant(repeated, { timeZone: 'America/New_York' }); + assert.fail('toInstant picked one side of a daylight-saving transition'); +} catch (error) { + assert.ok(error instanceof AmbiguousLocalTimeError); + console.log('repeated local time:', error.name); +} + +// Saying which of the two offsets was meant settles it. +const resolved = toInstant(repeated, { offsetMinutes: -240 }).toString(); +console.log('with offset -04:00:', resolved); +assert.equal(resolved, '2026-11-01T05:30:00Z'); + +console.log('instants-and-zones: ok'); diff --git a/examples/render-at-precision.ts b/examples/render-at-precision.ts new file mode 100644 index 0000000..e2b06f8 --- /dev/null +++ b/examples/render-at-precision.ts @@ -0,0 +1,79 @@ +/** + * Render and convert a value at the precision it arrived with. A year stays a year: nothing adds a + * month, a day or a midnight the value did not state. + * + * Run from the repository root after `pnpm build`: + * + * pnpm tsx examples/render-at-precision.ts + * + * Every value here is synthetic. The example checks its own output and exits non-zero on a + * mismatch. + */ + +import assert from 'node:assert/strict'; + +import { PrecisionError, Temporal, precisionOf, toISO, toTemporal } from '@cosyte/dates'; +import type { DateParts } from '@cosyte/dates'; + +const rows: ReadonlyArray<{ value: DateParts; iso: string; precision: string }> = [ + { value: { year: 1988 }, iso: '1988', precision: 'year' }, + { value: { year: 1988, month: 5 }, iso: '1988-05', precision: 'month' }, + { value: { year: 1988, month: 5, day: 7 }, iso: '1988-05-07', precision: 'day' }, + { + value: { year: 1988, month: 5, day: 7, hour: 13, minute: 45 }, + iso: '1988-05-07T13:45', + precision: 'minute', + }, + { + value: { + year: 1988, + month: 5, + day: 7, + hour: 13, + minute: 45, + second: 6, + fraction: 0.25, + offsetMinutes: 330, + }, + iso: '1988-05-07T13:45:06.25+05:30', + precision: 'fraction', + }, +]; + +for (const row of rows) { + const iso = toISO(row.value); + const precision = precisionOf(row.value); + console.log(iso.padEnd(30), precision); + assert.equal(iso, row.iso); + assert.equal(precision, row.precision); +} + +// toTemporal returns the narrowest Temporal type that carries everything the value states. +const month = toTemporal({ year: 1988, month: 5 }); +const day = toTemporal({ year: 1988, month: 5, day: 7 }); +const local = toTemporal({ year: 1988, month: 5, day: 7, hour: 13, minute: 45 }); +const zoned = toTemporal({ + year: 1988, + month: 5, + day: 7, + hour: 13, + minute: 45, + offsetMinutes: 330, +}); +console.log([month, day, local, zoned].map((value) => value.constructor.name).join(' ')); +assert.ok(month instanceof Temporal.PlainYearMonth); +assert.ok(day instanceof Temporal.PlainDate); +assert.ok(local instanceof Temporal.PlainDateTime); +assert.ok(zoned instanceof Temporal.ZonedDateTime); + +// Temporal has no year-only type, so a year is refused rather than given an invented month. +try { + toTemporal({ year: 1988 }); + assert.fail('toTemporal invented a month for a year-only value'); +} catch (error) { + assert.ok(error instanceof PrecisionError); + console.log('year-only toTemporal refused:', error.name, `(precision ${error.precision})`); + assert.equal(error.precision, 'year'); +} + +console.log('render-at-precision: ok'); diff --git a/examples/validate-parts.ts b/examples/validate-parts.ts new file mode 100644 index 0000000..bfc9bcb --- /dev/null +++ b/examples/validate-parts.ts @@ -0,0 +1,44 @@ +/** + * Validate a date-parts value before trusting it. Every fault is reported, each naming the + * component and the rule it broke, and a value that passes comes back with its precision. + * + * Run from the repository root after `pnpm build`: + * + * pnpm tsx examples/validate-parts.ts + * + * Every value here is synthetic. The example checks its own output and exits non-zero on a + * mismatch. + */ + +import assert from 'node:assert/strict'; + +import { assertValidParts, DatePartsError, isValidParts, validateParts } from '@cosyte/dates'; + +// A leap day that exists: valid, at day precision. +const admitted = validateParts({ year: 2024, month: 2, day: 29 }); +assert.ok(admitted.valid); +console.log('2024-02-29 valid:', admitted.valid, '| precision:', admitted.precision); +assert.equal(admitted.precision, 'day'); + +// Three faults in one value, all reported rather than only the first. +const broken = validateParts({ year: 88, month: 13, day: 40 }); +assert.ok(!broken.valid); +const faults = broken.issues.map((issue) => `${issue.component}:${issue.code}`); +console.log('faults:', faults.join(', ')); +assert.deepEqual(faults, ['year:year-not-four-digits', 'month:out-of-range', 'day:out-of-range']); + +// A day that does not exist is refused, never rolled over into March. +const input: unknown = { year: 2025, month: 2, day: 29 }; +console.log('2025-02-29 valid:', isValidParts(input)); +assert.equal(isValidParts(input), false); + +try { + assertValidParts(input); + assert.fail('assertValidParts accepted a date that does not exist'); +} catch (error) { + assert.ok(error instanceof DatePartsError); + console.log('refused:', error.issues[0]?.code); + assert.equal(error.issues[0]?.code, 'no-such-calendar-date'); +} + +console.log('validate-parts: ok'); diff --git a/package.json b/package.json index c59773a..698ba1b 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "@cosyte/dates", - "version": "0.1.0", + "version": "0.0.0", "description": "Date and time utilities for the @cosyte/* healthcare parsers: precision-preserving conversion and validation that never guesses a timezone.", "license": "MIT", "author": "Cosyte", "type": "module", "sideEffects": false, - "homepage": "https://github.com/cosyte/dates#readme", + "homepage": "https://docs.cosyte.com/dates", "repository": { "type": "git", "url": "git+https://github.com/cosyte/dates.git" @@ -19,10 +19,13 @@ "datetime", "healthcare", "hl7", + "fhir", "temporal", + "iso-8601", "precision", "timezone", - "validation" + "validation", + "typescript" ], "exports": { ".": { @@ -51,10 +54,11 @@ "changeset": "changeset", "typecheck": "tsc --noEmit", "lint": "eslint . --max-warnings=0", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"scripts/**/*.mjs\" \"docs-content/**/*.{md,json}\" \"*.{ts,json,md,yml}\"", - "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\" \"scripts/**/*.mjs\" \"docs-content/**/*.{md,json}\" \"*.{ts,json,md,yml}\"", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"scripts/**/*.{ts,mjs}\" \"examples/**/*.{ts,md}\" \"docs-content/**/*.{md,json}\" \"*.{ts,json,md,yml}\"", + "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\" \"scripts/**/*.{ts,mjs}\" \"examples/**/*.{ts,md}\" \"docs-content/**/*.{md,json}\" \"*.{ts,json,md,yml}\"", "test": "vitest run", "test:tz": "TZ=UTC vitest run && TZ=Pacific/Kiritimati vitest run", + "examples": "tsx scripts/run-examples.ts", "clean": "rm -rf dist coverage .tmp dist-artifacts", "attw": "node scripts/attw.mjs --profile esm-only", "pack:docs": "bash scripts/build-docs-artifacts.sh", @@ -76,6 +80,7 @@ "eslint": "10.9.1", "prettier": "3.9.6", "tsup": "8.5.1", + "tsx": "4.21.0", "typescript": "5.9.3", "typescript-eslint": "8.68.0", "vitest": "4.1.11" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6d6662..3572064 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,10 @@ importers: version: 3.9.6 tsup: specifier: 8.5.1 - version: 8.5.1(postcss@8.5.26)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.26)(tsx@4.21.0)(typescript@5.9.3) + tsx: + specifier: 4.21.0 + version: 4.21.0 typescript: specifier: 5.9.3 version: 5.9.3 @@ -44,7 +47,7 @@ importers: version: 8.68.0(eslint@10.9.1)(typescript@5.9.3) vitest: specifier: 4.1.11 - version: 4.1.11(@types/node@22.20.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)) + version: 4.1.11(@types/node@22.20.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(tsx@4.21.0)) packages: @@ -1076,6 +1079,9 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1481,6 +1487,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -1648,6 +1657,11 @@ packages: typescript: optional: true + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2416,13 +2430,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7))': + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.2(@types/node@22.20.1)(esbuild@0.27.7) + vite: 8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(tsx@4.21.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -2776,6 +2790,10 @@ snapshots: get-caller-file@2.0.5: {} + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -3057,11 +3075,12 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 - postcss-load-config@6.0.1(postcss@8.5.26): + postcss-load-config@6.0.1(postcss@8.5.26)(tsx@4.21.0): dependencies: lilconfig: 3.1.3 optionalDependencies: postcss: 8.5.26 + tsx: 4.21.0 postcss@8.5.26: dependencies: @@ -3094,6 +3113,8 @@ snapshots: resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + reusify@1.1.0: {} rolldown@1.2.6: @@ -3263,7 +3284,7 @@ snapshots: ts-interface-checker@0.1.13: {} - tsup@8.5.1(postcss@8.5.26)(typescript@5.9.3): + tsup@8.5.1(postcss@8.5.26)(tsx@4.21.0)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -3274,7 +3295,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.26) + postcss-load-config: 6.0.1(postcss@8.5.26)(tsx@4.21.0) resolve-from: 5.0.0 rollup: 4.63.1 source-map: 0.7.6 @@ -3291,6 +3312,13 @@ snapshots: - tsx - yaml + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.14.3 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -3324,7 +3352,7 @@ snapshots: validate-npm-package-name@5.0.1: {} - vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7): + vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(tsx@4.21.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -3335,11 +3363,12 @@ snapshots: '@types/node': 22.20.1 esbuild: 0.27.7 fsevents: 2.3.3 + tsx: 4.21.0 - vitest@4.1.11(@types/node@22.20.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)): + vitest@4.1.11(@types/node@22.20.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(tsx@4.21.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)) + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(tsx@4.21.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -3356,7 +3385,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@22.20.1)(esbuild@0.27.7) + vite: 8.2.2(@types/node@22.20.1)(esbuild@0.27.7)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 diff --git a/scripts/run-examples.ts b/scripts/run-examples.ts new file mode 100644 index 0000000..908a92f --- /dev/null +++ b/scripts/run-examples.ts @@ -0,0 +1,60 @@ +/** + * Runs every example under `examples/*.ts` (depth 1) against the BUILT package and fails if any of + * them fails. Each example checks its own output and exits non-zero on a mismatch; this runner also + * requires the `: ok` line each one prints last, so an example that stopped early without an + * error still fails. An empty `examples/` fails too: a runner that ran nothing proves nothing. + * + * The examples import `@cosyte/dates` by name, which Node resolves through this package's own + * `exports` map to `dist/`, so build first: + * + * pnpm build && pnpm examples + * + * File names are passed to `spawnSync` as arguments, never through a shell. + */ + +import { spawnSync } from 'node:child_process'; +import { readdirSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const examplesDir = path.join(root, 'examples'); +const tsx = path.join(root, 'node_modules', '.bin', 'tsx'); + +const examples = readdirSync(examplesDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.ts')) + .map((entry) => entry.name) + .sort(); + +if (examples.length === 0) { + console.error('FAIL no examples found under examples/, so nothing was run'); + process.exit(1); +} + +let failed = 0; +for (const file of examples) { + const marker = `${path.basename(file, '.ts')}: ok`; + const result = spawnSync(tsx, [path.join('examples', file)], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout = result.stdout ?? ''; + const markerSeen = stdout.split('\n').includes(marker); + + if (result.error !== undefined || result.status !== 0 || !markerSeen) { + failed += 1; + console.error(`FAIL ${file}`); + console.error( + ` exit status: ${String(result.status)}${result.error ? ` (${result.error.message})` : ''}`, + ); + console.error(` final line "${marker}" printed: ${String(markerSeen)}`); + if (result.stderr) console.error(result.stderr.trimEnd().replace(/^/gm, ' | ')); + continue; + } + console.log(`OK ${file}`); +} + +console.log(`${String(examples.length - failed)} of ${String(examples.length)} examples passed`); +process.exit(failed === 0 ? 0 : 1); diff --git a/test/package-contract.test.ts b/test/package-contract.test.ts index 17f2683..37ff855 100644 --- a/test/package-contract.test.ts +++ b/test/package-contract.test.ts @@ -25,14 +25,25 @@ const manifest = JSON.parse( // Criterion: the manifest declares the package identity. describe('package identity', () => { - it('declares the settled name, version, licence, module format and engine floor', () => { + it('declares the settled name, licence, module format and engine floor', () => { expect(manifest.name).toBe('@cosyte/dates'); - expect(manifest.version).toBe('0.1.0'); expect(manifest.license).toBe('MIT'); expect(manifest.type).toBe('module'); expect(manifest.engines?.['node']).toBe('>=22.0.0'); }); + // The version is written by the release (`changeset version`), never by hand, so it is held to + // the changelog that same release writes rather than to a literal. A literal here fails the + // "Version Packages" pull request that ships the next version. `0.0.0` is the one version with no + // changelog section, because nothing is ever released at it. + it('declares a plain release version that CHANGELOG.md records', () => { + expect(manifest.version).toMatch(/^\d+\.\d+\.\d+$/); + if (manifest.version !== '0.0.0') { + const changelog = readFileSync(path.join(packageRoot, 'CHANGELOG.md'), 'utf8'); + expect(changelog.split('\n')).toContain(`## ${manifest.version ?? ''}`); + } + }); + it('carries a full MIT licence text naming the copyright owner', () => { const licence = readFileSync(path.join(packageRoot, 'LICENSE'), 'utf8'); expect(licence).toContain('MIT License'); diff --git a/tsconfig.json b/tsconfig.json index ee0937d..451ca14 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,10 @@ "forceConsistentCasingInFileNames": true, "declaration": true, "skipLibCheck": true, - "noEmit": true + "noEmit": true, + "paths": { + "@cosyte/dates": ["./src/index.ts"] + } }, - "include": ["src", "test", "tsup.config.ts", "vitest.config.ts"] + "include": ["src", "test", "examples", "scripts/**/*.ts", "tsup.config.ts", "vitest.config.ts"] }