Merge pull request 'Hold the shipped source to ECMAScript alone, and run the suite on Deno and Bun' (#39) from runs-anywhere into main
CI / gate (push) Successful in 7s

This commit was merged in pull request #39.
This commit is contained in:
2026-09-01 17:18:07 +02:00
8 changed files with 98 additions and 35 deletions
+20 -2
View File
@@ -60,6 +60,15 @@ they never reach a consumer.
## 6. The package contract ## 6. The package contract
- Runs on any ES2022 engine, not only Node — a browser as readily as a server. The shipped source
is ECMAScript and nothing else: no host import, no host global, no DOM. `tsconfig.build.json` is
that gate, typechecking the shipped files alone, so `node:fs`, `process` and an ES2024 method are
compile errors here rather than a consumer's crash there. The standard is the line, never an
engine list: one implementing it in part — Hermes is the live doubt, on §10's property escapes
and on lookbehind — is out of scope rather than a bug. Node's test runner, the corpus reads and
the build are the repo's own,
never the library's, and `engines.node` states the floor the shipped JavaScript needs — `>=18`
never the higher one those repo-only tools want.
- ESM only — no CommonJS build, no dual-package hazard. - ESM only — no CommonJS build, no dual-package hazard.
- One entrypoint: built JavaScript, `.d.ts` beside it. Do not add a TypeScript-source entrypoint — - One entrypoint: built JavaScript, `.d.ts` beside it. Do not add a TypeScript-source entrypoint —
Node refuses to type-strip under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), Node refuses to type-strip under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`),
@@ -104,8 +113,9 @@ descends, so a document reports its first error in document order.
differs from npm → publish and tag `vX.Y.Z`. No bump, no deploy; the bump is each shipping PR's differs from npm → publish and tag `vX.Y.Z`. No bump, no deploy; the bump is each shipping PR's
deliberate semver judgment. deliberate semver judgment.
- Renovate watches devDependencies, Docker pins and action tags; automerges everything on green CI. - Renovate watches devDependencies, Docker pins and action tags; automerges everything on green CI.
- Docker images pin the full patch version (`node:24.19.0-alpine3.24`, never `node:24`); actions - Docker images pin the full patch version (`node:24.19.0-alpine3.24`, never `node:24`), as
pin semver tags. specific as the publisher tags: `oven/bun:1.4.0-alpine` pins Bun's patch and leaves the base
floating because Bun publishes nothing narrower. Actions pin semver tags.
## 10. Tests first, in Docker ## 10. Tests first, in Docker
@@ -113,6 +123,14 @@ Test for the behaviour wanted first, then implement until green. `node --test`,
Node, tsc and npm never run on the host — only via the pinned images (§9). Tests are independent, Node, tsc and npm never run on the host — only via the pinned images (§9). Tests are independent,
coverage does not decline, containers are torn down after a run. coverage does not decline, containers are torn down after a run.
The gate runs that same suite under Deno and Bun as well as Node, the three images pinned alike,
and neither extra leg is Node's proof twice. Deno refuses an extensionless or directory specifier,
so it holds the module graph to the fully-spelled form a browser can load; Bun runs
JavaScriptCore, the one engine of the three that is not V8, where the Unicode property escapes
emphasis matching leans on can disagree. Both refuse a run matching no test, so Node's is the only
vacuous-green guard, and a test may reach only for what all three `node:` shims carry — the price
of proving those engines over the corpus rather than over a smoke import.
The floors live in the `test` script, so `npm test` and the gate are one path: 100% of lines and The floors live in the `test` script, so `npm test` and the gate are one path: 100% of lines and
functions, and a branch floor that only ever moves upward. It sits below 100 because the guards functions, and a branch floor that only ever moves upward. It sits below 100 because the guards
`noUncheckedIndexedAccess` and ADF's optional keys force — `?? []`, `?? {}`, `?.`, an index `noUncheckedIndexedAccess` and ADF's optional keys force — `?? []`, `?? {}`, `?.`, an index
+2 -1
View File
@@ -64,4 +64,5 @@ Personas, never named consumers (AGENTS.md §7):
## The package ## The package
ESM only, no runtime dependencies, public npmjs. Built JavaScript with `.d.ts` beside it. ESM only, no runtime dependencies, public npmjs. Built JavaScript with `.d.ts` beside it.
Contract: `AGENTS.md` §56. Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs under Node,
Deno and Bun. Contract: `AGENTS.md` §56.
+14 -5
View File
@@ -2,13 +2,19 @@
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
image=node:24.19.0-alpine3.24 bun_image=oven/bun:1.4.0-alpine
in_node() { docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/app" -w /app "$image" "$@"; } deno_image=denoland/deno:2.9.6
node_image=node:24.19.0-alpine3.24
in_image() {
local image=$1 entrypoint=$2
shift 2
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@"
}
in_node npm ci in_image "$node_image" npm ci
in_node npm run typecheck in_image "$node_image" npm run typecheck
if ! test_output=$(in_node npm test 2>&1); then if ! test_output=$(in_image "$node_image" npm test 2>&1); then
printf '%s\n' "$test_output" printf '%s\n' "$test_output"
exit 1 exit 1
fi fi
@@ -17,3 +23,6 @@ if printf '%s' "$test_output" | grep -q ' tests 0'; then
echo 'the gate ran zero tests — failing instead of a vacuous green' echo 'the gate ran zero tests — failing instead of a vacuous green'
exit 1 exit 1
fi fi
in_image "$deno_image" deno test --allow-read --no-check src/
in_image "$bun_image" bun test src/
+2 -2
View File
@@ -10,11 +10,11 @@
}, },
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=24" "node": ">=18"
}, },
"scripts": { "scripts": {
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=97 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"", "test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=97 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "24.13.3", "@types/node": "24.13.3",
+18
View File
@@ -2,6 +2,15 @@
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"automerge": true, "automerge": true,
"customManagers": [ "customManagers": [
{
"customType": "regex",
"datasourceTemplate": "docker",
"depNameTemplate": "denoland/deno",
"description": "Pin the Deno image ci.sh runs",
"managerFilePatterns": ["ci.sh"],
"matchStrings": ["denoland/deno:(?<currentValue>[0-9][^\\s\"']*)"],
"versioningTemplate": "docker"
},
{ {
"customType": "regex", "customType": "regex",
"datasourceTemplate": "docker", "datasourceTemplate": "docker",
@@ -11,6 +20,15 @@
"matchStrings": ["node:(?<currentValue>[0-9][^\\s\"']*)"], "matchStrings": ["node:(?<currentValue>[0-9][^\\s\"']*)"],
"versioningTemplate": "docker" "versioningTemplate": "docker"
}, },
{
"customType": "regex",
"datasourceTemplate": "docker",
"depNameTemplate": "oven/bun",
"description": "Pin the Bun image ci.sh runs",
"managerFilePatterns": ["ci.sh"],
"matchStrings": ["oven/bun:(?<currentValue>[0-9][^\\s\"']*)"],
"versioningTemplate": "docker"
},
{ {
"customType": "regex", "customType": "regex",
"datasourceTemplate": "docker", "datasourceTemplate": "docker",
+10 -3
View File
@@ -139,10 +139,17 @@ numbering is the order the work was planned in, not the order it ships.
additions are read here as one list before that freeze — nine sessions mint them additions are read here as one list before that freeze — nine sessions mint them
independently, and one cause wearing two codes is breaking to undo after `0.1.0`. `0.1.0` independently, and one cause wearing two codes is breaking to undo after `0.1.0`. `0.1.0`
is the markdown round-trip: both markdown directions, the types, `isAdfDocument`. The build is the markdown round-trip: both markdown directions, the types, `isAdfDocument`. The build
lands here: a build tsconfig emitting JS and `.d.ts` to `dist/` (the dev config's lands here: `tsconfig.build.json` gains emit of JS and `.d.ts` to `dist/` (its own
`allowImportingTsExtensions` forces `noEmit`, so the build config needs `allowImportingTsExtensions` forces `noEmit`, so `rewriteRelativeImportExtensions` lands
`rewriteRelativeImportExtensions`), plus `exports`/`files` in `package.json`. The beside it), plus `exports`/`files` in `package.json`. The
maintainer's bump PR also removes `private: true`, the guard against any earlier publish. maintainer's bump PR also removes `private: true`, the guard against any earlier publish.
§6's browser half is first checkable here, on the emitted `dist/index.js` a browser can
load — the compile gate names no host API, and a real page converting the corpus is the
other half. Headless Firefox is that page, settling both at once: the browser proof, and the
only SpiderMonkey there is, `ci.sh`'s three legs being two V8s and a JavaScriptCore that is
not Safari's. `engines.node` gets its one-line proof here
too — `import('./dist/index.js')` under a pinned Node 18 image, which cannot run the
suite that type stripping wants 22+ for, but proves exactly what the field claims.
**Settled** (the maintainer, 2026-09-01): the round-trip proved over the checked-in corpus **Settled** (the maintainer, 2026-09-01): the round-trip proved over the checked-in corpus
is what `0.1.0` ships on, and the open-ended proof work follows it rather than gating it — is what `0.1.0` ships on, and the open-ended proof work follows it rather than gating it —
3k's spec suite and 4's generators and maintainer-supplied payloads are `0.2.0`, 4b's retry 3k's spec suite and 4's generators and maintainer-supplied payloads are `0.2.0`, 4b's retry
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"types": [],
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"isolatedModules": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"strict": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"exclude": ["src/**/*.test.ts"],
"include": ["src"]
}
+3 -22
View File
@@ -1,28 +1,9 @@
{ {
"extends": "./tsconfig.build.json",
"compilerOptions": { "compilerOptions": {
"lib": ["ESNext"], "lib": ["ESNext"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ESNext", "target": "ESNext",
"types": ["node"], "types": ["node"]
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"isolatedModules": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"strict": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
}, },
"include": ["src"] "exclude": []
} }