Skip to content

[DWS] Refresh toolchain, move to TypeScript 6, raise minimum Node.js to 22 - #16

Merged
nickwinder merged 1 commit into
mainfrom
nick/dws/dependency-refresh
Aug 11, 2026
Merged

[DWS] Refresh toolchain, move to TypeScript 6, raise minimum Node.js to 22#16
nickwinder merged 1 commit into
mainfrom
nick/dws/dependency-refresh

Conversation

@nickwinder

Copy link
Copy Markdown
Collaborator

Stacked on #15. Base is nick/dws/security-axios-form-data, so the diff here is toolchain-only. Merge #15 first; this will retarget to main automatically.

Why

Two problems, both invisible until something breaks:

  1. The supported runtime matrix was entirely end-of-life. engines.node claimed >=18.0.0 and CI tested 18.x/20.x. Node 18 went EOL in April 2025 and Node 20 in April 2026 — the project was testing against, and promising support for, runtimes that receive no security patches.
  2. gitleaks-action@v2 stops working on 2026-09-16. It runs on the Node 20 Actions runtime, which GitHub removes from hosted runners on that date. Secret scanning would have started failing with no code change on our side.

The dependency staleness (16 packages) is the routine part; those two are the reasons this shouldn't sit.

What changed

  • Minimum Node.js is now 22engines.node >=22.0.0, CI matrices 22.x/24.x. Breaking, but needs no consumer code change.
  • TypeScript 5.9.36.0.3 — see the section below; this was the substantive part.
  • 16 dev dependencies updated, including ESLint 9.39.210.8.1, globals 16 → 17, @types/node 24 → 26, typescript-eslint 8.53.08.66.0, jest 30.2.030.4.2.
  • Codecov removed — coverage still runs in CI, so the jest.config.mjs thresholds still gate the build; it is just no longer uploaded to a third-party service.
  • GitHub Actionscheckout/setup-node/upload-artifact → v7, github-script → v9, gitleaks-action v2 → v3 (the time-sensitive one).
  • npm run typecheck now covers the test suite, which nothing typechecked before outside of ts-jest at test time.

The main thing to review: TypeScript 6

TypeScript 7 is not reachable — ts-jest peers on typescript <7 and typescript-eslint on <6.1.0. But 6.0 is the supported bridge toward it, and staying on 5.9 meant sitting on compiler options that 7.0 removes outright. Four things had to be solved, and each is a place to check my reasoning:

  • moduleResolution: node (node10) → bundler. Deprecated in 6, removed in 7. bundler describes how this package is actually built — tsup/esbuild bundles src/, imports are extensionless. node16/nodenext would have required adding a .js extension to every relative import. I deliberately did not reach for ignoreDeprecations, which only defers the failure into TS 7.
  • TypeScript 6 dropped the implicit pull-in of every node_modules/@types package, so src/__tests__/tsconfig.json names jest and node explicitly. This one is worth understanding: without it, type-aware linting doesn't fail loudly — it degrades into ~3,500 no-unsafe-* "type could not be resolved" errors, because jest's globals silently become any.
  • openapi-typescript declares a typescript: ^5.x peer, which makes npm ci fail with ERESOLVE on TypeScript 6 — that would have broken every CI job. A scoped overrides entry relaxes that one peer. This is evidence-backed rather than hopeful: the tool regenerates both files under src/generated/ byte-for-byte identically on 6.0.3, and CI's generated-types-drift job re-checks that on every run.
  • tsconfig.test.jsonsrc/__tests__/tsconfig.json, and eslint.config.mjs moves from the legacy parserOptions.project array to projectService. The old file existed only to hand type-aware linting a project containing the tests; the project service discovers a config adjacent to the files it covers without being told where.

Design notes worth a look

  • ESLint 10 removes --ext, so the lint scripts drop --ext .ts. The flag was already redundant under flat config — I confirmed eslint src and eslint src --ext .ts resolve the same 31 files before changing anything. The failure mode here is a lint that silently matches nothing and exits 0, so the file count is asserted explicitly in Verification below.
  • Nine type assertions removed. The newer typescript-eslint correctly flags them as unnecessary; one !config || !config.type guard collapses to !config?.type. All semantics-preserving.
  • Prettier 3.9 reformats three test files. format:check was already failing on maininputs.test.ts had eight lines over the 100-column limit — so this brings the tree to clean rather than merely preserving it.
  • No new major needed. 3.0.0 is prepared in-repo but unpublished (npm latest is 2.1.0), so the breaking Node bump folds into it. docs/MIGRATION.md gains it as item 5.
  • @types/node 26 sits above the Node 22 floor. I checked this rather than assuming: the emitted .d.ts references only Buffer, Uint8Array, File and NodeJS.ReadableStream, and a consumer pinned to @types/node@22 typechecks against it cleanly (evidence below).

Verification

All commands run on the branch head, on TypeScript 6.0.3.

Definition of done (AGENTS.md):

$ npx tsc --version
Version 6.0.3

$ npm run typecheck    # now: src AND tests — exit 0
$ npm run lint         # exit 0, 0 problems
$ npm run format:check
All matched files use Prettier code style!

$ npm run test:unit
Test Suites: 11 passed, 11 total
Tests:       315 passed, 315 total

Lint really is linting — guards the ESLint 10 silent-zero-files trap. 31 files, identical to the pre-upgrade baseline:

$ npx eslint src -f json | node -e "...console.log(JSON.parse(s).length)"
31

Generated types did not drift — the exact check CI runs:

$ npm run generate:types -- --check        # exit 0
$ npm run generate:types:extract -- --check # exit 0
$ git diff --stat src/generated/            # (no output — byte-identical)

npm ci resolves from the regenerated lockfile. This is the check that caught the openapi-typescript peer conflict — without the scoped override it fails with ERESOLVE, which would have broken every CI job:

$ cd $(mktemp -d) && cp .../package.json .../package-lock.json . && npm ci
# exit 0
$ node -e "console.log(require('./node_modules/typescript/package.json').version)"
6.0.3

Build and artifacts:

$ npm run build
ESM dist/index.js   114.05 KB  ⚡️ Build success
CJS dist/index.cjs  114.91 KB  ⚡️ Build success
# dist/index.js, dist/index.cjs, dist/index.d.ts all present

Downstream consumer typecheck — a throwaway project pinned to @types/node@22 + typescript@5.9.3, importing NutrientClient, FileInput, AccountUsage and ProductName from the emitted dist/index.d.ts, compiles clean under strict. Confirms the @types/node 26 bump does not leak into the public surface.

Security posture:

$ npm audit --omit=dev
found 0 vulnerabilities

One dev-only low remains: esbuild (via tsup) arbitrary file read when running its dev server on Windows. It affects esbuild serve, which this repo never invokes. Clearing it needs an overrides edit that was deliberately left out of scope; flagging rather than silently absorbing it.

Integration tests were not run — they require a live API key.

@nickwinder
nickwinder marked this pull request as ready for review August 9, 2026 21:42
@nickwinder
nickwinder requested a review from HungKNguyen August 9, 2026 21:42

@HungKNguyen HungKNguyen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM a followup either in this PR or another PR, we should follow the PSPDFKit org rule and use SHA-pin the GitHub Actions instead of version numbers (context)

Base automatically changed from nick/dws/security-axios-form-data to main August 10, 2026 21:39
@nickwinder
nickwinder force-pushed the nick/dws/dependency-refresh branch from 00f999d to 07a0b36 Compare August 10, 2026 21:39
… to 22

Node.js 18 reached end-of-life in April 2025 and Node.js 20 in April 2026.
CI was still testing both and engines.node still claimed >=18, so the
supported matrix consisted entirely of runtimes that no longer receive
security patches. engines.node moves to >=22.0.0 and CI now runs 22.x and
24.x. This is breaking, but needs no code change from consumers: there is no
API difference.

Dependency updates. ESLint 9.39.2 -> 10.8.1 (with @eslint/js to 10.0.1),
globals 16 -> 17, @types/node 24 -> 26, typescript-eslint 8.53.0 -> 8.66.0,
jest 30.2.0 -> 30.4.2, ts-jest, prettier, openapi-typescript, tsx, dotenv
and rimraf to their latest in-range releases. ESLint 10 is what required the
Node floor to move first; it needs ^20.19 || ^22.13 || >=24.

TypeScript 5.9.3 -> 6.0.3. TypeScript 7 is not reachable yet -- ts-jest
peers on <7 and typescript-eslint on <6.1.0 -- but 6.0 is the supported
bridge toward it, and staying on 5.9 meant sitting on compiler options that
7.0 removes outright. Three things fell out of it:

  - moduleResolution moves from node (node10) to bundler. TypeScript 6
    deprecates node10 and 7 drops it. bundler describes how this package is
    actually built: tsup/esbuild bundles src/ and imports are extensionless.
    node16/nodenext would have meant rewriting every relative import to
    carry a .js extension.
  - TypeScript 6 no longer implicitly pulls in every node_modules/@types
    package, so the test project names jest and node explicitly. Without
    that, type-aware linting degrades silently into thousands of
    "type could not be resolved" errors rather than failing outright.
  - openapi-typescript declares a typescript ^5.x peer, which makes npm ci
    fail with ERESOLVE on TypeScript 6. A scoped overrides entry relaxes
    that one peer. The tool itself is unaffected: it regenerates both files
    under src/generated/ byte-for-byte identically on 6.0.3, and CI's drift
    job re-checks that on every run.

tsconfig.test.json is replaced by src/__tests__/tsconfig.json. It existed
only to hand type-aware linting a project containing the tests, and
typescript-eslint's project service finds a config adjacent to the files it
covers without being told where to look. eslint.config.mjs moves from the
legacy parserOptions.project array to projectService accordingly.

npm run typecheck now also covers the test suite. Tests were excluded from
the only project tsc --noEmit ran against, so nothing typechecked them
outside of ts-jest at test time.

ESLint 10 removes the --ext flag, so the lint scripts drop `--ext .ts`. The
flag was already redundant under flat config -- `eslint src` and
`eslint src --ext .ts` resolve the same 31 files. Verified after the upgrade
that the count is still 31, because the failure mode here is a lint that
silently matches nothing and exits 0.

The newer typescript-eslint flags nine type assertions as unnecessary. All
nine were genuinely redundant and are removed; one `!config || !config.type`
guard collapses to `!config?.type`. Semantics are unchanged.

Codecov is removed. CI still runs the unit tests with --coverage, so the
thresholds in jest.config.mjs continue to gate the build; coverage is simply
no longer shipped to a third-party service.

Remaining GitHub Actions: checkout, setup-node and upload-artifact to v7,
github-script to v9, gitleaks-action v2 -> v3. The gitleaks bump is
time-sensitive rather than cosmetic -- v2 runs on the Node 20 Actions
runtime, which GitHub removes from hosted runners on 2026-09-16.

Prettier 3.9 reformats three test files. format:check was already failing on
main (inputs.test.ts had eight lines over the 100-column limit), so this
brings the tree to clean rather than merely preserving it.

Changelog and migration entries land under 3.0.0, which is prepared in-repo
but not yet published to npm -- so this does not need a new major.

Verified on TypeScript 6.0.3: typecheck (src + tests), lint (31 files),
format:check, test:unit (315/315), build (esm + cjs + d.ts), generated-types
drift check, npm ci against the regenerated lockfile, and a downstream
typecheck of the emitted .d.ts from a consumer pinned to @types/node 22.
`npm audit --omit=dev` reports 0 vulnerabilities.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nickwinder
nickwinder force-pushed the nick/dws/dependency-refresh branch from 07a0b36 to c0791a4 Compare August 11, 2026 07:44
@nickwinder
nickwinder merged commit 599aee1 into main Aug 11, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants