Public: anyone can read this; the crew can change it.

The check typechecks: `bun test` runs TypeScript, it does not verify it #46

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/drift:vesper/22-the-check-typechecks-bun-test-runs-types → main 1 commit fork

  • Merged. vesper/22-the-check-typechecks-bun-test-runs-types was merged into main as df130d5.

Review

Sign in to review this change.
11 files changed +203 −16
AGENTS.md +5 −3
@@ -9,8 +9,10 @@ preview at its own address, deployed from your branch as you push.
99 - Work on the branch you were given. You cannot move the default branch;
1010 a person merges your pull once it is reviewed.
1111 - Keep state under `FORGE_APP_DATA`. Never write elsewhere at runtime.
12 - Run `bun test` before you push. It is the check the pull shows, and every
13 behaviour you add gets a test that would fail without it.
12 - Run `bun run test` before you push — `tsc --noEmit`, then `bun test`, the
13 one command the forge runs and the check the pull shows. Every behaviour
14 you add gets a test that would fail without it, and the types have to hold
15 under `strict`: fix the code, never the config.
1416 - `src/tty.ts` is the app's terminal. The exact command that reaches it is
1517 `FORGE_APP_SSH` at runtime; print that verbatim wherever the app tells
1618 people how to connect. What a person can do on the page they can do here,
@@ -82,6 +84,6 @@ To add a concept:
8284 - A phone is a first-class client: the page works at 360px wide with no
8385 horizontal scroll, every control is at least 44px on a side, and the
8486 keyboard reaches everything a mouse does.
85 - Finish: the app boots, `/` answers, `bun test` is green, the README says
87 - Finish: the app boots, `/` answers, `bun run test` is green, the README says
8688 how to use it and what is real versus simulated, `forge.json` says how
8789 to run it.
README.md +30 −1
@@ -9,11 +9,39 @@ makes "production data" and "preview data" two different things.
99
1010 ```sh
1111 PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here
12 bun test # the check every pull runs
12 bun install # the one dev dependency
13 bun run test # the check every pull runs
14 bun run typecheck # just the types
15 bun test # just the tests
1316 FORGE_APP_DATA=./data bun run src/tty.ts # the terminal
1417 FORGE_APP_DATA=./data bun run src/db.ts --check # schema version, integrity
1518 ```
1619
20 ## The check
21
22 `bun run test` is one command with two halves — `tsc --noEmit`, then `bun
23 test` — and it is exactly the string `forge.json` hands the forge on every
24 push; `src/check.test.ts` fails if the two ever drift apart. The two halves
25 are not the same check: Bun *strips* types to run a file, so without the first
26 half a wrong type deploys green. It begins with `bun install
27 --frozen-lockfile` so that a bare checkout has a typechecker to run at all;
28 where `node_modules` is already there that step is two milliseconds and no
29 network.
30
31 `tsconfig.json` is `strict`, and `noUncheckedIndexedAccess` with it, because
32 most of this app is arithmetic over buffers where an index out of range is
33 the bug worth catching. Nothing in it is turned down to make the tree pass,
34 there is no `@ts-expect-error` anywhere in `src/`, and `src/check.test.ts`
35 holds the strict flags on so that a red check cannot be made green by
36 editing the config.
37
38 TypeScript is the only tool this repository depends on, and it earns its
39 place the way a compiler does: nothing else can tell you the types you wrote
40 are the types you have, and none of it ships — a dev dependency, `noEmit`,
41 no runtime import, the app still starts with `bun run src/server.ts` and
42 nothing else. `@types/bun` rides along so the check knows what Bun hands a
43 program.
44
1745 ## What is real, and what is not yet
1846
1947 The page is the arena and nothing else: a square the sea will be drawn in,
@@ -71,6 +99,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
7199 | `src/env.ts` | what the forge told this process |
72100 | `static/app.css`, `static/live.js` | the look, and the regions that stay current |
73101 | `static/icon.svg` | the icon: a wake, a mote and the bloom it carries |
102 | `tsconfig.json` | the half of the check that reads the types |
74103
75104 ## The link you send a friend
76105
bun.lock added +64
@@ -0,0 +1,64 @@
1 {
2 "lockfileVersion": 1,
3 "configVersion": 1,
4 "workspaces": {
5 "": {
6 "name": "drift",
7 "devDependencies": {
8 "@types/bun": "^1.4.2",
9 "typescript": "^7.0.2",
10 },
11 },
12 },
13 "packages": {
14 "@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
15
16 "@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA=="],
17
18 "@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
19
20 "@typescript/typescript-darwin-arm64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
21
22 "@typescript/typescript-darwin-x64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
23
24 "@typescript/typescript-freebsd-arm64": ["@typescript/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
25
26 "@typescript/typescript-freebsd-x64": ["@typescript/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
27
28 "@typescript/typescript-linux-arm": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
29
30 "@typescript/typescript-linux-arm64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
31
32 "@typescript/typescript-linux-loong64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
33
34 "@typescript/typescript-linux-mips64el": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
35
36 "@typescript/typescript-linux-ppc64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
37
38 "@typescript/typescript-linux-riscv64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
39
40 "@typescript/typescript-linux-s390x": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
41
42 "@typescript/typescript-linux-x64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
43
44 "@typescript/typescript-netbsd-arm64": ["@typescript/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
45
46 "@typescript/typescript-netbsd-x64": ["@typescript/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
47
48 "@typescript/typescript-openbsd-arm64": ["@typescript/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
49
50 "@typescript/typescript-openbsd-x64": ["@typescript/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
51
52 "@typescript/typescript-sunos-x64": ["@typescript/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
53
54 "@typescript/typescript-win32-arm64": ["@typescript/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
55
56 "@typescript/typescript-win32-x64": ["@typescript/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
57
58 "bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
59
60 "typescript": ["[email protected]", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
61
62 "undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
63 }
64 }
forge.json +1 −1
@@ -1,7 +1,7 @@
11 {
22 "app": {
33 "run": "bun run src/server.ts",
4 "test": "bun test",
4 "test": "bun install --frozen-lockfile && bunx tsc --noEmit && bun test",
55 "tty": "bun run src/tty.ts"
66 }
77 }
package.json +6 −1
@@ -5,6 +5,11 @@
55 "type": "module",
66 "scripts": {
77 "start": "bun run src/server.ts",
8 "test": "bun test"
8 "typecheck": "bunx tsc --noEmit",
9 "test": "bun install --frozen-lockfile && bunx tsc --noEmit && bun test"
10 },
11 "devDependencies": {
12 "@types/bun": "^1.4.2",
13 "typescript": "^7.0.2"
914 }
1015 }
src/card.ts +5 −4
@@ -67,9 +67,9 @@ function glow(
6767 // Smooth to zero at the edge, squared so the core stays hot.
6868 const fall = (1 - d2 / r2) ** 2 * strength;
6969 const at = (py * p.w + px) * 3;
70 p.px[at] += colour[0] * fall;
71 p.px[at + 1] += colour[1] * fall;
72 p.px[at + 2] += colour[2] * fall;
70 p.px[at] = p.px[at]! + colour[0] * fall;
71 p.px[at + 1] = p.px[at + 1]! + colour[1] * fall;
72 p.px[at + 2] = p.px[at + 2]! + colour[2] * fall;
7373 }
7474 }
7575 }
@@ -207,7 +207,8 @@ function rim(
207207 WAKE[2] + (GATE[2] - WAKE[2]) * g,
208208 ];
209209 const at = (py * p.w + px) * 3;
210 for (let c = 0; c < 3; c++) p.px[at + c] += colour[c]! * strength * 0.75;
210 for (let c = 0; c < 3; c++)
211 p.px[at + c] = p.px[at + c]! + colour[c]! * strength * 0.75;
211212 }
212213 }
213214 }
src/check.test.ts added +55
@@ -0,0 +1,55 @@
1 /**
2 * The check is one command, and a person runs the one the forge runs.
3 * Bun strips types rather than reading them, so `bun test` alone is a green
4 * light on code that does not typecheck: the command has to carry both, in
5 * `forge.json` and in `package.json`, and they have to stay the same string.
6 */
7
8 import { expect, test } from "bun:test";
9 import { readFileSync } from "node:fs";
10 import { join } from "node:path";
11
12 const root = join(import.meta.dir, "..");
13 const read = (name: string): unknown =>
14 JSON.parse(readFileSync(join(root, name), "utf8"));
15
16 const forge = read("forge.json") as { app?: { test?: string } };
17 const pkg = read("package.json") as {
18 scripts?: Record<string, string>;
19 devDependencies?: Record<string, string>;
20 };
21
22 test("the forge's check and `bun run test` are the same command", () => {
23 expect(forge.app?.test).toBeString();
24 expect(pkg.scripts?.test).toBe(forge.app!.test!);
25 });
26
27 test("the check reads the types before it runs them", () => {
28 // Both halves, in that order: a type error costs nothing to find and
29 // there is no point running a suite over code that does not compile.
30 expect(forge.app?.test).toContain("tsc --noEmit");
31 expect(forge.app?.test).toMatch(/tsc --noEmit.*&&.*bun test/);
32 expect(pkg.devDependencies?.typescript).toBeString();
33 });
34
35 test("tsconfig is strict, and stays strict", () => {
36 const tsconfig = read("tsconfig.json") as {
37 compilerOptions?: Record<string, unknown>;
38 };
39 const options = tsconfig.compilerOptions ?? {};
40 expect(options["strict"]).toBe(true);
41 expect(options["noEmit"]).toBe(true);
42 // Every escape hatch that would make the check look like a check while
43 // letting the types rot. None of them belongs in this file.
44 for (const loosened of [
45 "noImplicitAny",
46 "strictNullChecks",
47 "strictFunctionTypes",
48 "strictBindCallApply",
49 "strictPropertyInitialization",
50 "useUnknownInCatchVariables",
51 "alwaysStrict",
52 ])
53 expect(options[loosened]).not.toBe(false);
54 expect(options["ignoreDeprecations"]).toBeUndefined();
55 });
src/http.ts +5 −2
@@ -32,7 +32,10 @@ export const CSP = [
3232 "base-uri 'none'",
3333 "form-action 'self'",
3434 ].join("; ");
35 export const SECURITY: Record<string, string> = {
35 // Written with `satisfies` rather than a `Record<string, string>` annotation
36 // so that a caller naming one of these headers is checked against the list
37 // that is actually sent, rather than against any string at all.
38 export const SECURITY = {
3639 "content-security-policy": CSP,
3740 "x-content-type-options": "nosniff",
3841 "referrer-policy": "strict-origin-when-cross-origin",
@@ -40,7 +43,7 @@ export const SECURITY: Record<string, string> = {
4043 ...(publicOrigin.startsWith("https:")
4144 ? { "strict-transport-security": "max-age=31536000" }
4245 : {}),
43 };
46 } satisfies Record<string, string>;
4447
4548 export const escape = (s: string): string =>
4649 s.replace(
src/live.test.ts +5 −2
@@ -28,8 +28,11 @@ function open(
2828 shared,
2929 },
3030 );
31 let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
32 let pending: Promise<ReadableStreamReadResult<Uint8Array>> | undefined;
31 // Only `read` is used here, and saying so structurally keeps the test out
32 // of the argument between the web and Bun declarations of a stream reader.
33 type Chunk = { done: boolean; value?: Uint8Array | undefined };
34 let reader: { read(): Promise<Chunk> } | undefined;
35 let pending: Promise<Chunk> | undefined;
3336 const drain = async (quiet = 25): Promise<string> => {
3437 reader ??= response.body!.getReader();
3538 let buffer = "";
src/protocol.test.ts +3 −2
@@ -12,6 +12,7 @@ import {
1212 parseSteer,
1313 POSITION_SCALE,
1414 PROTOCOL_VERSION,
15 type ProtocolFailure,
1516 ROSTER_MAX,
1617 SEQ_MAX,
1718 STEER_MAX_BYTES,
@@ -151,7 +152,7 @@ test("a frame of the wrong shape is the caller's bug, and throws", () => {
151152 test("a foreign, truncated or wrong-version payload is a failure, not a throw", () => {
152153 const good = encodeFrame(sampleFrame(3));
153154
154 const cases: [unknown, string][] = [
155 const cases: [unknown, ProtocolFailure][] = [
155156 ["a frame, honest", "not_bytes"],
156157 [null, "not_bytes"],
157158 [{ tick: 1 }, "not_bytes"],
@@ -214,7 +215,7 @@ test("a steer arrives as text, bytes or an object and lands in the unit disc", (
214215 });
215216
216217 test("every steer that is not one is refused by name", () => {
217 const cases: [unknown, string][] = [
218 const cases: [unknown, ProtocolFailure][] = [
218219 ["left a bit", "not_json"],
219220 ["", "not_json"],
220221 ['"a string"', "not_an_object"],
tsconfig.json added +24
@@ -0,0 +1,24 @@
1 {
2 "compilerOptions": {
3 "target": "ESNext",
4 "module": "Preserve",
5 "moduleResolution": "bundler",
6 "lib": ["ESNext"],
7 "types": ["bun"],
8
9 "allowImportingTsExtensions": true,
10 "verbatimModuleSyntax": true,
11 "noEmit": true,
12 "skipLibCheck": true,
13
14 "strict": true,
15 "noUncheckedIndexedAccess": true,
16 "noImplicitOverride": true,
17 "noFallthroughCasesInSwitch": true,
18 "noUnusedLocals": true,
19 "noUnusedParameters": true,
20 "exactOptionalPropertyTypes": true,
21 "forceConsistentCasingInFileNames": true
22 },
23 "include": ["src/**/*.ts"]
24 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs