Skip to content

types(tools): the last 39 scripts are TypeScript, and the rules found real work - #532

Merged
oddharsh merged 3 commits into
mainfrom
types/tools-rest-ts
Aug 24, 2026
Merged

oddharsh merged 3 commits into
mainfrom
types/tools-rest-ts

Conversation

@oddharsh

Copy link
Copy Markdown
Owner

Final slice. Every non-test file in tools/ is now .ts — 39 scripts renamed, specifiers rewritten across 82 files including package.json's scripts, the workflows, and the photo shell scripts. Zero .mjs left outside the test suite.

The JSDoc trap struck twice more, both undoing today's work

annotation what it un-fixed
check-infra's DnsResolved/DnsUnreachable typedef the one that took that file 25 errors → 0 in #500. Went inert, took all 25 back
contract-shared's testGlobals cast quietly re-broke every fetch and cache double in the suite at once

Fourth and fifth instance. Renaming a JSDoc-annotated file is a disarm, not a conversion.

What the type-aware rules found

no-misused-spread on apply-infra's cf(). init: RequestInit allows headers to be a Headers instance or an array of pairs, and { ...init.headers } silently yields index keys or nothing for either. That's the same spread-on-the-wrong-shape class that broke webmention ids in gotcha 34 — sitting in the one script allowed to mutate Cloudflare.

Seven bare .sort() calls, now explicit. They're string sorts and the default was right, but the comparator is deliberately byte-stable rather than localeCompare, which is locale-dependent and would make a drift checker's output depend on the machine running it.

Ten template expressions interpolating unknown, each fixed by naming the shape it came from rather than stringifying at the seam.

Four contract tests failed and every one was doing its job

The dev-stage path, the check-*.mjs wrapper scan, my own package-manager scan (whose floor fired: "expected the tools directory, got 0 files"), and the manifest projection whose generated banner names its generator. Updated, not loosened.

353/353 under bun, 352 under node, build/routes/photos green, six type programs clean, tools baseline still 0, lint clean with all ten rules now covering every script in tools/.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 24, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
aadhar-sh ed96394 Aug 24 2026, 01:44 AM

… real work

Final slice. Every non-test file in tools/ is now .ts: 39 scripts renamed,
specifiers rewritten across 82 files including package.json's scripts, the
workflows and the photo shell scripts. Zero .mjs left outside the test suite.

THE JSDOC TRAP STRUCK TWICE MORE, in both cases undoing work from earlier
today. check-infra's DnsResolved/DnsUnreachable typedef — the one that took
that file from 25 errors to 0 in #500 — went inert and took all 25 back.
contract-shared's `testGlobals` cast went inert too, which quietly re-broke
every fetch and cache double in the suite at once. Both are real syntax now.
This is the fourth and fifth instance; renaming a JSDoc-annotated file is a
DISARM, not a conversion.

WHAT THE TYPE-AWARE RULES FOUND once they could finally see these files:

  - no-misused-spread on apply-infra's `cf()`. `init: RequestInit` allows
    headers to be a Headers instance or an array of pairs, and `{ ...init.headers }`
    silently yields index keys or nothing for either. That is the same
    spread-on-the-wrong-shape class that broke webmention ids in gotcha 34, and
    it was sitting in the one script allowed to mutate Cloudflare.
  - seven bare `.sort()` calls, now explicit. They are string sorts and the
    default was right, but the comparator is deliberately byte-stable rather
    than localeCompare, which is locale-dependent and would make the output of
    a drift checker depend on the machine running it.
  - ten template expressions interpolating unknown, each fixed by naming the
    shape it came from rather than by stringifying at the seam.

Four contract tests failed and every one was doing its job: the dev-stage path,
the check-*.mjs wrapper scan, my own package-manager scan (whose FLOOR fired,
"expected the tools directory, got 0 files"), and the manifest projection whose
generated banner names its generator. Updated, not loosened.

353/353 under bun, 352 under node, build/routes/photos green, six type programs
clean, tools baseline still 0, lint clean with the ten rules now covering every
script in tools/.
@oddharsh
oddharsh force-pushed the types/tools-rest-ts branch from 26923f7 to b00c184 Compare August 24, 2026 01:37
CI caught what the root tree could not. lens-reader's package.json runs its own
typecheck through `node ../tools/check-test-types.mjs`, and lwe-ask carries the
same shape — neither is reachable from the root sweep, so both still named .mjs
and the Reader lens step died on MODULE_NOT_FOUND.

This is the sub-project boundary CLAUDE.md already documents from the other
direction: a green local suite proves nothing about CI when the two have
different dependency sets. Here it is different SCRIPTS, in a manifest the root
never reads.

Also corrected the stale tool names in nine files of prose: wrangler configs,
.gitignore, the manifest note and several Worker comments all pointed at .mjs
files that no longer exist.
The wire-size job copies perf-snapshot into .perf-measure and runs it with
node. My sweep updated the SOURCE path to .ts and left the DESTINATION as
.mjs, and node type-strips by EXTENSION — so the copy was TypeScript wearing
a .mjs name and failed to parse.

The job caught it at its own stash proof rather than downstream, which is that
step existing for exactly this: it runs the script with no arguments and
requires exit 2, so anything else means the copy is short a file or cannot
parse. Reproduced locally the same way before and after.

Also corrected one comment naming perf-budget.mjs.
@github-actions

Copy link
Copy Markdown
Contributor

Wire-size diff vs merge base

e2284df → e7ddedc

Brotli q11, the quality the edge serves. Every number here is deterministic:
an unchanged file produces no row. Advisory — this check fails on nothing.

Client assets

Total Brotli: 77.74 KiB → 77.73 KiB (-0.00 KiB, -0.0%) across 22 files, 3 changed.

file base head Δ
lens.js 22.53 KiB 22.52 KiB -0.00 KiB (-0.0%)
lens-boot.js 0.48 KiB 0.49 KiB +0.00 KiB (+0.2%)
lens-browser.js 4.09 KiB 4.09 KiB -0.00 KiB (-0.0%)

Pages

Total Brotli: 531.29 KiB → 531.29 KiB (+0.00 KiB, +0.0%) across 50 files, 1 changed.

No single file moved by 0.13 KiB or more.

Plus 1 file under the 0.13 KiB floor, net +0.00 KiB — usually content-hash churn in /a/ references, counted in the total above.

Worker bundle

No change. 237.86 KiB gzip.

Dictionary deltas (pd/)

146 → 146 deltas, 492.24 KiB → 492.24 KiB (-0.00 KiB).

@oddharsh
oddharsh merged commit 7fddfd6 into main Aug 24, 2026
6 checks passed
@oddharsh
oddharsh deleted the types/tools-rest-ts branch August 24, 2026 01:46
oddharsh added a commit that referenced this pull request Aug 24, 2026
Second tier, and the seventh program to declare the flag. #530's ratchet test
asserts every program setting strictNullChecks appears in its declared list;
this adds tools/ to it.

RATCHETED RATHER THAN CLEAN, which is the difference from tier 1. tools/ carried
395 diagnostics under strictNullChecks; 101 are fixed here and the rest are
seeded into config/ts-tools-baseline.json, where check-tool-types.mjs has
held a per-file count since it was written. That ratchet fails on a new file, on
a file that got worse, and on a file that got BETTER without the baseline being
updated, so the number is monotone. The alternative was leaving the flag off
until all 395 were done, which protects nothing in the meantime.

THE SEEDED NUMBER IS 600 RATHER THAN THE 294 THIS BRANCH FIRST MEASURED, and
the gap is #532 rather than a regression here. That PR converted 39 more tools
scripts from .mjs to .ts while this was in flight, and JS-mode leniency withdraws
with the extension, so the same code answers for more under the flag. The ratchet
is what surfaced it: rebasing tripped both its arms, one file at a time.

IT ALSO MADE THREE OF MY OWN FIXES INERT, which is the trap this migration keeps
hitting and the fifth time it has fired. deploy-promote had its bail helper
typed as never, and two nullable holders typed, all three in JSDoc. Correct
while the file was
.mjs and ignored the moment it became .ts: that file went 0 back to 37. All three
are real annotations now, plus four accumulators the conversion newly exposed,
and it is clean again.

Worth a follow-up rather than widening this: 16 JSDoc type annotations across 5
tools .ts files on main are inert for exactly the same reason.

Three files cleared outright:

  tools/lib/html-to-md.ts        45 → 0
  talks/…/build-deck.mjs         30 → 0
  tools/deploy-promote.mjs       26 → 0

THE .mjs AND .ts RULES ARE EXACT INVERSES, and getting this backwards costs a
compile. A .ts file ignores JSDoc types; a .mjs file rejects a TypeScript
annotation outright with TS8010. tools/ is 88 .mjs files, so nearly everything
in this tier is JSDoc, and the three .ts files under tools/lib take annotations.
Worth knowing before the next tier, since the same directory holds both.

TYPING `die` AS `never` CLEARED A CLUSTER, and it is the more general lesson.
deploy-promote.mjs uses `die()` as control flow throughout ("bail unless X"), and
without `@returns {never}` TypeScript keeps the impossible branch alive and
reports the value the caller just refused as still nullable. One annotation took
that file from 11 remaining to 9, and the same shape is waiting in every other
script with a bail helper.

html-to-md.ts gained real types rather than annotations of convenience: `Token`
as a discriminated union whose raw-text arm carries `raw` and `owner` together
(they are one fact, and pairing them is what lets the tree builder drop a
<script> body without proving the field is there), and `TextNode | ElementNode`
with the stack declared as elements only, which is the invariant that makes
`top().children` total.

VERIFIED, because this converter publishes every Markdown twin. Built the twins
with this branch's converter and with origin/main's, hashing all 58: identical
at 568b28e024de910a957c6bc382e0a3e241764fd91e068297b68a1b1ede0ecbc9. And
`deploy:promote --dry-run` runs end to end, resolves a target, and prints its
freshness guard, which is the release path exercised rather than assumed.

One behavioural addition, in deploy-promote: the sampling loop's result is read
after the loop, so a zero SAMPLE_ATTEMPTS would have ramped to the next step
having verified nothing. That now dies by name. It is a guard on a constant
rather than on the network, and it is the one outcome the script exists to
prevent.

typecheck (10 programs), lint, 354 contract tests, build, and the site bundle at
237.34 KiB gzip.
oddharsh added a commit that referenced this pull request Aug 24, 2026
Second tier, and the seventh program to declare the flag. #530's ratchet test
asserts every program setting strictNullChecks appears in its declared list;
this adds tools/ to it.

RATCHETED RATHER THAN CLEAN, which is the difference from tier 1. tools/ carried
395 diagnostics under strictNullChecks; 101 are fixed here and the rest are
seeded into config/ts-tools-baseline.json, where check-tool-types.mjs has
held a per-file count since it was written. That ratchet fails on a new file, on
a file that got worse, and on a file that got BETTER without the baseline being
updated, so the number is monotone. The alternative was leaving the flag off
until all 395 were done, which protects nothing in the meantime.

THE SEEDED NUMBER IS 600 RATHER THAN THE 294 THIS BRANCH FIRST MEASURED, and
the gap is #532 rather than a regression here. That PR converted 39 more tools
scripts from .mjs to .ts while this was in flight, and JS-mode leniency withdraws
with the extension, so the same code answers for more under the flag. The ratchet
is what surfaced it: rebasing tripped both its arms, one file at a time.

IT ALSO MADE THREE OF MY OWN FIXES INERT, which is the trap this migration keeps
hitting and the fifth time it has fired. deploy-promote had its bail helper
typed as never, and two nullable holders typed, all three in JSDoc. Correct
while the file was
.mjs and ignored the moment it became .ts: that file went 0 back to 37. All three
are real annotations now, plus four accumulators the conversion newly exposed,
and it is clean again.

Worth a follow-up rather than widening this: 16 JSDoc type annotations across 5
tools .ts files on main are inert for exactly the same reason.

Three files cleared outright:

  tools/lib/html-to-md.ts        45 → 0
  talks/…/build-deck.mjs         30 → 0
  tools/deploy-promote.mjs       26 → 0

THE .mjs AND .ts RULES ARE EXACT INVERSES, and getting this backwards costs a
compile. A .ts file ignores JSDoc types; a .mjs file rejects a TypeScript
annotation outright with TS8010. tools/ is 88 .mjs files, so nearly everything
in this tier is JSDoc, and the three .ts files under tools/lib take annotations.
Worth knowing before the next tier, since the same directory holds both.

TYPING `die` AS `never` CLEARED A CLUSTER, and it is the more general lesson.
deploy-promote.mjs uses `die()` as control flow throughout ("bail unless X"), and
without `@returns {never}` TypeScript keeps the impossible branch alive and
reports the value the caller just refused as still nullable. One annotation took
that file from 11 remaining to 9, and the same shape is waiting in every other
script with a bail helper.

html-to-md.ts gained real types rather than annotations of convenience: `Token`
as a discriminated union whose raw-text arm carries `raw` and `owner` together
(they are one fact, and pairing them is what lets the tree builder drop a
<script> body without proving the field is there), and `TextNode | ElementNode`
with the stack declared as elements only, which is the invariant that makes
`top().children` total.

VERIFIED, because this converter publishes every Markdown twin. Built the twins
with this branch's converter and with origin/main's, hashing all 58: identical
at 568b28e024de910a957c6bc382e0a3e241764fd91e068297b68a1b1ede0ecbc9. And
`deploy:promote --dry-run` runs end to end, resolves a target, and prints its
freshness guard, which is the release path exercised rather than assumed.

One behavioural addition, in deploy-promote: the sampling loop's result is read
after the loop, so a zero SAMPLE_ATTEMPTS would have ramped to the next step
having verified nothing. That now dies by name. It is a guard on a constant
rather than on the network, and it is the one outcome the script exists to
prevent.

typecheck (10 programs), lint, 354 contract tests, build, and the site bundle at
237.34 KiB gzip.
oddharsh added a commit that referenced this pull request Aug 24, 2026
…onfig (#538)

cf-garage/wrangler.toml becomes cf-garage/cloudflare.config.ts, behind
wrangler 4.124.0's hidden --x-new-config. cf-garage is the trial and the
other three Workers are untouched: it is a demo Worker deployed by hand,
so being wrong costs /garage/cf/* rather than the site.

BUN LEVERAGE IS UNCHANGED, which was the condition. The loader refuses bun
by name, and the refusal turns on the invocation rather than on the tool:

  bun x --no-install wrangler … --x-new-config          loads, deploys
  bun run <script> (same .bin shim)                     loads
  bun ./node_modules/wrangler/bin/wrangler.js …         REFUSED

bun x and bun run resolve node_modules/.bin/wrangler, whose node shebang
hands the process to node, so bun never reaches the loader. Only invoking
wrangler's entry file under bun does, which gotcha 38 already stopped doing
everywhere. bun still installs, runs every script and runs the tests. CI's
step keeps `bun x` and changes one flag.

VERIFIED BYTE-IDENTICAL, measured against the toml at the commit this
branched from: 8.83 KiB / 3.07 KiB gzip and the same four bindings, from
both configs. It reads 8.84 KiB after the rebase because #532 edited
cf-garage/src/index.ts; that is main's number, not this change's. And
`wrangler build --x-cf-build-output` writes the resolved config out so
every field can be read back: the fetch trigger with its zone,
observability plus traces at head_sampling_rate 1, all four bindings, the
DO export.

Three translations worth knowing. accountId sits on a separate `settings`
export, so check-infra.ts now carries a pattern per aux config rather than
one regex over three files in two formats. Bindings live under `env`, which
is what makes the generated types read InferEnv<typeof config.default>
instead of a snapshot (unreached here: `wrangler types` does not take the
flag). And [[migrations]] has no equivalent and needs none, because a DO
class declares its lifecycle as state on its export and wrangler sends the
exports form the moment a config declares one.

[dependencies_instrumentation] could not come across and cost nothing: the
new schema has no field for it, and wrangler reads it as `?.enabled !==
false`, so an absent block behaves exactly like the explicit true.

WHAT A DRY RUN CANNOT CHECK, per the DO note: whether the API accepts the
exports form for a class already carrying migration tag v1. The first real
deploy from that directory is the measurement, and a revert restores the
toml if it refuses.

Deleting the toml also dropped cf-garage out of the quarantine test's own
registry of deployable Workers, which discovers them by config filename.
Its floor caught that; the predicate now knows both filenames.

Green on the rebased base: 354 contract + 353 node tests, typecheck (10
programs, 246 files), oxlint type-aware, tools:check, infra:check
--offline, build.

Co-authored-by: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com>
oddharsh added a commit that referenced this pull request Sep 1, 2026
…left behind (#684)

#532 renamed 39 tools scripts from .mjs to .ts and the prose did not follow.
`build.mjs` was the visible one, 143 times across 69 files. Chasing it turned up
10 more stale paths from the same commit, every one with a .ts counterpart that
exists, so this sweeps all 11.

One of them was a live defect rather than a doc nit: tools/build.ts's own
homepage-bake error told the reader to run `node
tools/photos/build-histogram-index.mjs`, a file that has not existed since #532.

Four mentions are deliberately KEPT as .mjs, since a path rewrite run blind is
how this repository falsifies its own history (the www/ and holding/ note in
CLAUDE.md is the standing warning):

- tools/build.ts's repo-paths comment cites radar-sample.mjs as its example of a
  citation that went stale, and the sentence ends "long after that file became
  .ts". Rewriting it deletes the point.
- src/dict/a-dict/lens-browser.62f8bf05.js is a committed dictionary snapshot.
  It is bytes a browser already holds, so it is history by construction and
  editing it would break the tier it exists to serve.
- design/explore-bac-map.md and design/PORTING.md are July-2026 design passes
  that design/README.md and CLAUDE.md both declare stale on purpose. Making
  their citations resolve would make them read as current.

Why the existing gate never caught this: build.ts's repo-paths invariant scans
the STAGED tree, so docs, tools, configs and workflows are outside it entirely,
and its REPO_PATH regex is anchored on a top-level directory, so a bare
`build.mjs` carries nothing for it to match. The one anchored citation that
survives, `tools/build.mjs`, sits in a history doc it never reads.

Byte impact, measured against a baseline build rather than assumed, because
gotcha 35 prices a moved content hash at ~1400 files:

- all 48 hashed /a/ URLs unchanged. Comments in nav.js, notepad.js and luna.css
  are stripped by the minifiers, exactly as gotcha 35 records.
- 13 staged files changed, 12 of them .src.* readable twins, which are supposed
  to carry comments.
- one genuinely-served file: garage/wire.html, whose `<code>build.mjs</code>` is
  visible page prose about the build. public/llms.txt likewise.
- page-family.<hash8>.dict moves because wire.html's bytes moved. Ordinary for
  any page edit.
- per-page dcz deltas go 54 to 55, and the extra one is garage__wire. build.ts
  skips a p-dict snapshot byte-identical to the current build, so that page had
  no per-page delta while it matched its snapshot and has a useful one now.

config/derivations.json re-records shell/desktop: its input digest covers all of
config/site-manifest.json, and two `note` fields in it named build.mjs.
Regeneration was verified to change no output byte first.

Co-authored-by: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com>
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.

1 participant