Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
Two games, one engine, no sharing — and a test that says so in both directions #92
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/68-two-games-one-engine-no-sharing-and-a-te → main 1 commit fork
8 files
changed +298
−136
README.md +24 −8
| @@ -168,7 +168,7 @@ subtends — and the panel says which of the two you are looking at. | ||
| 168 | 168 | module that is not behind the door, and any change to that list of names that |
| 169 | 169 | is not also a change to the list written out in the test. One exception, and |
| 170 | 170 | it is written down there too: a `.test.ts` may import another `.test.ts`, which |
| 171 | is how `src/range/independence.test.ts` reuses the source-reading helpers | |
| 171 | is how `src/independence.test.ts` reuses the source-reading helpers | |
| 172 | 172 | rather than keeping a second copy of them. |
| 173 | 173 | - `src/engine/GUIDE.md` — how to build a game on it: what a game supplies (an |
| 174 | 174 | entry, a host, a bindings table, a tick rate, its own recipes and its own |
| @@ -268,11 +268,9 @@ subtends — and the panel says which of the two you are looking at. | ||
| 268 | 268 | is imported by the entry, so the bundle has not grown by a byte; `level.test.ts` |
| 269 | 269 | proves the shell has no seam a bullet can leave through and `paint.test.ts` |
| 270 | 270 | pins the atlas byte for byte. No movement and no WebGL yet — those need the |
| 271 | physics and the renderer, and #13 builds the finale on this. `independence.test.ts` reads every source in | |
| 272 | here and fails on an import that leaves those three places, reusing the | |
| 273 | `sources`/`specifiersOf` helpers from `src/engine/boundary.test.ts` rather than a | |
| 274 | second copy of the rule — a test may read another test's helpers, which is the | |
| 275 | one path past the door, because a `.test.ts` is in no bundle; `binding.test.ts`, | |
| 271 | physics and the renderer, and #13 builds the finale on this. | |
| 272 | `src/independence.test.ts` reads every source in here and fails on an import | |
| 273 | that leaves those three places; `binding.test.ts`, | |
| 276 | 274 | `targets.test.ts` and `readout.test.ts` pin the table, the intent, the game and |
| 277 | 275 | the drawing headlessly — a lane with its randomness dialled flat puts a plate |
| 278 | 276 | where arithmetic knows, so a ray down the barrel hits, a ray 5° wide of it |
| @@ -295,10 +293,28 @@ subtends — and the panel says which of the two you are looking at. | ||
| 295 | 293 | `pin.test.ts` — so a renderer, an atlas or a movement tuning that changes the |
| 296 | 294 | game says so, naming the second of the run where it changed. `src/pages/range.ts` is its page: its own title, prose and overlay |
| 297 | 295 | through the same shell, with no database, no endpoint and no cookie behind it. |
| 298 | - `src/rl/` — training, which the game never imports: `arena` is the environment | |
| 296 | - `src/rl/` — training, which no game module imports: `arena` is the environment | |
| 299 | 297 | (a real duel on de_forge with the round and the economy taken out) and `ppo` is |
| 300 | 298 | the algorithm, with its backward pass checked against finite differences. |
| 301 | `scripts/train.ts` runs them and writes `src/client/weights.ts`. | |
| 299 | `scripts/train.ts` runs them and writes `src/client/weights.ts`. It is the | |
| 300 | *shooter's* training and `src/independence.test.ts` counts it as the shooter: | |
| 301 | it reads `src/client/` module by module because that game is the environment, | |
| 302 | and for the same reason it may not read `src/range/`. | |
| 303 | - `src/independence.test.ts` — **two games, one engine, no sharing, and this is | |
| 304 | the test that says so**, in both directions: a game's sources may not import | |
| 305 | another game's — the shooter reaching into the range fails here by name | |
| 306 | (`src/client/world.ts imports "../range/targets.ts"; a game may not import | |
| 307 | another game`) exactly as the range reaching into the shooter does — and the | |
| 308 | range is held to the stricter rule it exists for on top of that: a module | |
| 309 | under `src/range/` may import the engine's door, `src/pages/shell.ts` and its | |
| 310 | own siblings, and nothing else. Both rules read the sources with the | |
| 311 | `outsideSources`/`sources`/`specifiersOf` helpers from | |
| 312 | `src/engine/boundary.test.ts` rather than a second copy of them — a test may | |
| 313 | read another test's helpers, which is the one path past the door, because a | |
| 314 | `.test.ts` is in no bundle. Who owns which directory comes from the registry | |
| 315 | (`dirname(entry)`), so a third game is covered by the entry that adds it. | |
| 316 | `src/server.ts` and `src/pages/` belong to no game and are held to neither | |
| 317 | rule: the app is allowed to know about every game on it. | |
| 302 | 318 | - `src/client/replay.ts` — the golden digest: a seeded round and a seeded duel |
| 303 | 319 | in the arena, each stepped through one fixed script of inputs and hashed tick |
| 304 | 320 | by tick (positions, velocities, view angles, health, armour, duck and |
src/client/stage.test-helper.ts +1 −1
| @@ -15,7 +15,7 @@ | ||
| 15 | 15 | * |
| 16 | 16 | * It ships in no bundle: only `main.test.ts` imports it. `src/range/dom.ts` is |
| 17 | 17 | * the same idea for the other game — it cannot import this one, because |
| 18 | * `independence.test.ts` forbids the range from reaching into `src/client/`, | |
| 18 | * `src/independence.test.ts` forbids either game from reaching into the other, | |
| 19 | 19 | * but a third game copies whichever is nearer and adds its own ids. |
| 20 | 20 | */ |
| 21 | 21 |
src/engine/boundary.test.ts +6 −6
| @@ -93,10 +93,10 @@ export function outsideSources(dirs = OUTSIDE): string[] { | ||
| 93 | 93 | /** |
| 94 | 94 | * Why this file outside the engine may not import this specifier, or null if it |
| 95 | 95 | * may. Only one path into the engine is a path: `index.ts`, the door. The one |
| 96 | * exception is a test reading another test's helpers — `src/range/` checks its | |
| 97 | * own boundary with the walk and the regular expression in this file rather | |
| 98 | * than a second copy of them — because the door is about what ships, and a | |
| 99 | * `.test.ts` is in no bundle. | |
| 96 | * exception is a test reading another test's helpers — `src/independence.test.ts` | |
| 97 | * holds both games apart with the walk and the regular expression in this file | |
| 98 | * rather than a second copy of them — because the door is about what ships, and | |
| 99 | * a `.test.ts` is in no bundle. | |
| 100 | 100 | */ |
| 101 | 101 | export function reachesPastTheDoor(file: string, specifier: string): string | null { |
| 102 | 102 | if (!specifier.startsWith(".")) return null; |
| @@ -190,9 +190,9 @@ test("the door rule is about the door and nothing else", () => { | ||
| 190 | 190 | expect(reachesPastTheDoor("src/client/world.ts", "bun:test")).toBeNull(); |
| 191 | 191 | expect(reachesPastTheDoor("scripts/build.ts", "../src/engine/trace.ts")).toMatch(/door/); |
| 192 | 192 | // A test may read another test's helpers; a module may not, test or not. |
| 193 | expect(reachesPastTheDoor("src/range/independence.test.ts", "../engine/boundary.test.ts")).toBeNull(); | |
| 193 | expect(reachesPastTheDoor("src/independence.test.ts", "./engine/boundary.test.ts")).toBeNull(); | |
| 194 | 194 | expect(reachesPastTheDoor("src/range/main.ts", "../engine/boundary.test.ts")).toMatch(/door/); |
| 195 | expect(reachesPastTheDoor("src/range/independence.test.ts", "../engine/math.ts")).toMatch(/door/); | |
| 195 | expect(reachesPastTheDoor("src/independence.test.ts", "./engine/math.ts")).toMatch(/door/); | |
| 196 | 196 | }); |
| 197 | 197 | |
| 198 | 198 | test("every module in the engine is behind the door, and the door holds nothing else", () => { |
src/engine/guide.test.ts +1 −1
| @@ -37,7 +37,7 @@ | ||
| 37 | 37 | * The list of exports is `SURFACE` from `boundary.test.ts`, imported rather |
| 38 | 38 | * than parsed a second time: one copy of the door's surface in this repository. |
| 39 | 39 | * Importing it registers that file's tests here again, which costs a |
| 40 | * millisecond and is what `purity.test.ts` and `src/range/independence.test.ts` | |
| 40 | * millisecond and is what `purity.test.ts` and `src/independence.test.ts` | |
| 41 | 41 | * already pay for the same reason. |
| 42 | 42 | */ |
| 43 | 43 |
src/independence.test.ts added +264
| @@ -0,0 +1,264 @@ | ||
| 1 | /** | |
| 2 | * Two games, one engine, no sharing — and this is the test that says so, both | |
| 3 | * ways round. It holds two rules, and they are different sizes on purpose. | |
| 4 | * | |
| 5 | * The first is symmetric and is the direction's whole claim: **a game's sources | |
| 6 | * may not import another game's**. The shooter reaching into the range is as | |
| 7 | * much a lie as the range reaching into the shooter, and until this file existed | |
| 8 | * only the second half was read. It is checked from the repository root over | |
| 9 | * every source outside the engine, so it covers both games with one walk and | |
| 10 | * covers the third the day a third entry is added to `src/games.ts`. | |
| 11 | * | |
| 12 | * The second is the range's alone and is stricter: a module under `src/range/` | |
| 13 | * may import the engine, `src/pages/shell.ts` and its own siblings, and nothing | |
| 14 | * else — no `src/rl/`, no `src/result.ts`, no package, because there is nothing | |
| 15 | * to install. The shooter cannot be held to that and should not be: it is the | |
| 16 | * app's own game and it legitimately shares the server's `src/result.ts` with | |
| 17 | * the server. The range is the proof that the engine suffices, so it pays the | |
| 18 | * higher price, and the pair of rules lives in one file because they are one | |
| 19 | * question — who may import whom, once you are outside the engine. | |
| 20 | * | |
| 21 | * What is deliberately *not* refused: `src/server.ts` imports both games' pages | |
| 22 | * and `src/pages/` is the chrome both are served inside. Neither belongs to a | |
| 23 | * game, so neither is held to a rule about game sources; the app is allowed to | |
| 24 | * know about every game on it, which is what an app is. | |
| 25 | * | |
| 26 | * Both rules read the sources with the same three helpers `src/engine/ | |
| 27 | * boundary.test.ts` uses, imported rather than copied: one walk and one regular | |
| 28 | * expression for imports in this repository, not three that can drift apart. | |
| 29 | * The price is that the engine's boundary tests are registered again here — they | |
| 30 | * run twice and cost a millisecond, which is cheaper than a second copy of a | |
| 31 | * rule. | |
| 32 | */ | |
| 33 | ||
| 34 | import { expect, test } from "bun:test"; | |
| 35 | import { readFileSync } from "node:fs"; | |
| 36 | import { dirname, join, relative, resolve } from "node:path"; | |
| 37 | import { outsideSources, sources, specifiersOf } from "./engine/boundary.test.ts"; | |
| 38 | import { GAMES } from "./games.ts"; | |
| 39 | ||
| 40 | const SRC = import.meta.dir; | |
| 41 | const ROOT = resolve(SRC, ".."); | |
| 42 | const RANGE = join(SRC, "range"); | |
| 43 | const ENGINE = join(SRC, "engine"); | |
| 44 | const SHELL = join(SRC, "pages", "shell.ts"); | |
| 45 | const BUILD = resolve(ROOT, "scripts", "build.ts"); | |
| 46 | ||
| 47 | /** | |
| 48 | * The directories that are a game's without being the game: today `src/rl/` is | |
| 49 | * the shooter's and nothing is the range's. The training is the arena — a | |
| 50 | * de_forge duel with the round and the economy taken out — and PPO over it, and | |
| 51 | * it imports `src/client/` module by module because the environment *is* that | |
| 52 | * game. So it counts as the shooter here: it may read `src/client/`, and by the | |
| 53 | * same token it may not read `src/range/`, because the range is not its game | |
| 54 | * and a training run reaching for the other game's plates is exactly the | |
| 55 | * sharing this file exists to refuse. | |
| 56 | */ | |
| 57 | const ALSO: Record<string, readonly string[]> = { app: ["src/rl"] }; | |
| 58 | ||
| 59 | /** | |
| 60 | * The game a source belongs to, or null if it belongs to none. A game's own | |
| 61 | * directory is its registry entry with the file name taken off, so a third game | |
| 62 | * is covered by the entry that adds it and by nothing else. | |
| 63 | */ | |
| 64 | export function gameOf(file: string): string | null { | |
| 65 | for (const game of GAMES) | |
| 66 | for (const dir of [dirname(game.entry), ...(ALSO[game.name] ?? [])]) | |
| 67 | if (file === dir || file.startsWith(`${dir}/`)) return game.name; | |
| 68 | return null; | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * Why this file may not import this specifier, or null if it may. Both are | |
| 73 | * written the way `outsideSources` hands them out — a path from the repository | |
| 74 | * root — and so is the message, because the point of a failure here is that it | |
| 75 | * names the offender and the line to delete. | |
| 76 | * | |
| 77 | * A specifier that is not a path is another rule's business: the range's is | |
| 78 | * below, and the engine's is `leavesEngine`. | |
| 79 | */ | |
| 80 | export function importsAnotherGame(file: string, specifier: string): string | null { | |
| 81 | const mine = gameOf(file); | |
| 82 | if (mine === null || !specifier.startsWith(".")) return null; | |
| 83 | const theirs = gameOf(relative(ROOT, resolve(ROOT, dirname(file), specifier))); | |
| 84 | if (theirs === null || theirs === mine) return null; | |
| 85 | return `${file} imports "${specifier}"; a game may not import another game`; | |
| 86 | } | |
| 87 | ||
| 88 | test("both games have the sources this test is about, and the app has none", () => { | |
| 89 | const files = outsideSources(); | |
| 90 | const of = (game: string): string[] => files.filter((f) => gameOf(f) === game); | |
| 91 | expect(of("app")).toContain("src/client/world.ts"); | |
| 92 | expect(of("app")).toContain("src/rl/arena.ts"); | |
| 93 | expect(of("range")).toContain("src/range/targets.ts"); | |
| 94 | expect(of("range")).toContain("src/range/main.ts"); | |
| 95 | // The app is not a game: it is what the games are served by. | |
| 96 | expect(gameOf("src/server.ts")).toBeNull(); | |
| 97 | expect(gameOf("src/pages/shell.ts")).toBeNull(); | |
| 98 | expect(gameOf("src/pages/range.ts")).toBeNull(); | |
| 99 | expect(gameOf("src/games.ts")).toBeNull(); | |
| 100 | expect(gameOf("scripts/build.ts")).toBeNull(); | |
| 101 | }); | |
| 102 | ||
| 103 | test("no game's sources import another game's, in either direction", () => { | |
| 104 | const offences: string[] = []; | |
| 105 | for (const file of outsideSources()) { | |
| 106 | const source = readFileSync(join(ROOT, file), "utf8"); | |
| 107 | for (const specifier of specifiersOf(source)) { | |
| 108 | const why = importsAnotherGame(file, specifier); | |
| 109 | if (why !== null) offences.push(why); | |
| 110 | } | |
| 111 | } | |
| 112 | expect(offences).toEqual([]); | |
| 113 | }); | |
| 114 | ||
| 115 | test("the rule is refused both ways round, and names who did it", () => { | |
| 116 | expect(importsAnotherGame("src/client/world.ts", "../range/targets.ts")).toBe( | |
| 117 | `src/client/world.ts imports "../range/targets.ts"; a game may not import another game`, | |
| 118 | ); | |
| 119 | expect(importsAnotherGame("src/range/main.ts", "../client/world.ts")).toBe( | |
| 120 | `src/range/main.ts imports "../client/world.ts"; a game may not import another game`, | |
| 121 | ); | |
| 122 | expect(importsAnotherGame("src/client/scene.test.ts", "../range/level.ts")).toMatch(/another game/); | |
| 123 | expect(importsAnotherGame("src/range/readout.ts", "../client/hud.ts")).toMatch(/another game/); | |
| 124 | // The training is the shooter's, so the range is another game to it too. | |
| 125 | expect(importsAnotherGame("src/rl/arena.ts", "../range/targets.ts")).toBe( | |
| 126 | `src/rl/arena.ts imports "../range/targets.ts"; a game may not import another game`, | |
| 127 | ); | |
| 128 | expect(importsAnotherGame("src/range/pin.ts", "../rl/arena.ts")).toMatch(/another game/); | |
| 129 | }); | |
| 130 | ||
| 131 | // A title that ends on the keyword, with the closing quote right behind it, | |
| 132 | // reads as an import to the helper above — a false positive this test would | |
| 133 | // then hold against itself. So no title here ends on one. | |
| 134 | test("the engine's door, the shell, a sibling and the app are what a game may reach for", () => { | |
| 135 | expect(importsAnotherGame("src/client/world.ts", "../engine/index.ts")).toBeNull(); | |
| 136 | expect(importsAnotherGame("src/range/main.ts", "../engine/index.ts")).toBeNull(); | |
| 137 | expect(importsAnotherGame("src/client/main.ts", "../pages/shell.ts")).toBeNull(); | |
| 138 | expect(importsAnotherGame("src/range/main.ts", "../pages/shell.ts")).toBeNull(); | |
| 139 | expect(importsAnotherGame("src/client/world.ts", "./physics.ts")).toBeNull(); | |
| 140 | expect(importsAnotherGame("src/range/main.ts", "./binding.ts")).toBeNull(); | |
| 141 | expect(importsAnotherGame("src/client/main.test.ts", "../result.ts")).toBeNull(); | |
| 142 | expect(importsAnotherGame("src/client/world.ts", "gl-matrix")).toBeNull(); | |
| 143 | // The training and the shooter are one game between them, both ways. | |
| 144 | expect(importsAnotherGame("src/rl/arena.ts", "../client/world.ts")).toBeNull(); | |
| 145 | expect(importsAnotherGame("src/client/replay.test.ts", "../rl/arena.ts")).toBeNull(); | |
| 146 | // The app is served both games and is held to neither rule. | |
| 147 | expect(importsAnotherGame("src/server.ts", "./pages/range.ts")).toBeNull(); | |
| 148 | expect(importsAnotherGame("src/pages/games.ts", "../games.ts")).toBeNull(); | |
| 149 | expect(importsAnotherGame("scripts/build.ts", "../src/range/main.ts")).toBeNull(); | |
| 150 | }); | |
| 151 | ||
| 152 | test("a shooter file that reached into the range is caught by reading it", () => { | |
| 153 | // The keyword is spelled at run time so this fixture is not itself an import | |
| 154 | // the test above would read out of this file and hold against it. | |
| 155 | const F = "fr" + "om"; | |
| 156 | const source = [ | |
| 157 | `import { createWorld } ${F} "./world.ts";`, | |
| 158 | `import { Camera } ${F} "../engine/index.ts";`, | |
| 159 | `import { popUp } ${F} "../range/targets.ts";`, | |
| 160 | ].join("\n"); | |
| 161 | const offences = specifiersOf(source) | |
| 162 | .map((specifier) => importsAnotherGame("src/client/scene.ts", specifier)) | |
| 163 | .filter((why) => why !== null); | |
| 164 | expect(offences).toEqual([ | |
| 165 | `src/client/scene.ts imports "../range/targets.ts"; a game may not import another game`, | |
| 166 | ]); | |
| 167 | }); | |
| 168 | ||
| 169 | /** | |
| 170 | * The range's own rule, which is stricter than the one above and is why the | |
| 171 | * range exists: a module under `src/range/` may import the engine, the page | |
| 172 | * shell and its own siblings, and nothing else. Not `src/client/` — a shortcut | |
| 173 | * import from the shooter would make the whole direction untrue, and it is one | |
| 174 | * line nobody would notice in review — not `src/rl/`, and no package, because | |
| 175 | * there is nothing to install. | |
| 176 | * | |
| 177 | * Which module of the engine is a second question, and `boundary.test.ts` | |
| 178 | * answers it: the way in is `src/engine/index.ts`, the door, and these files go | |
| 179 | * through it. The rule below is the wider one — where an import may point — so | |
| 180 | * it still passes a deeper path; nothing in the range writes one. | |
| 181 | */ | |
| 182 | ||
| 183 | /** `sources` names what it finds relative to the engine; these want their own names. */ | |
| 184 | const rangeSources = (): string[] => sources(RANGE).map((p) => relative(RANGE, resolve(ENGINE, p))); | |
| 185 | ||
| 186 | /** Why this file under `src/range/` may not import this specifier, or null if it may. */ | |
| 187 | export function leavesRange(file: string, specifier: string): string | null { | |
| 188 | const isTest = file.endsWith(".test.ts"); | |
| 189 | if (!specifier.startsWith(".")) { | |
| 190 | if (isTest && (specifier === "bun:test" || specifier.startsWith("node:"))) return null; | |
| 191 | return `"${specifier}" is not a path: the range installs nothing`; | |
| 192 | } | |
| 193 | const target = resolve(RANGE, join(file, ".."), specifier); | |
| 194 | const sibling = relative(RANGE, target); | |
| 195 | if (sibling !== "" && !sibling.startsWith("..")) return null; | |
| 196 | const fromSrc = relative(ENGINE, target); | |
| 197 | if (fromSrc !== "" && !fromSrc.startsWith("..")) return null; | |
| 198 | if (target === SHELL) return null; | |
| 199 | // A test may ask the build script where the bundles are and what one is | |
| 200 | // called — `bundle.test.ts` boots the committed artifact — because a | |
| 201 | // `.test.ts` ships in no bundle. A module that ships may not. | |
| 202 | if (isTest && target === BUILD) return null; | |
| 203 | return `"${specifier}" resolves outside src/range/, src/engine/ and src/pages/shell.ts`; | |
| 204 | } | |
| 205 | ||
| 206 | test("the range has the sources this test is about", () => { | |
| 207 | const files = rangeSources(); | |
| 208 | expect(files).toContain("main.ts"); | |
| 209 | expect(files).toContain("binding.ts"); | |
| 210 | expect(files).toContain("targets.test.ts"); | |
| 211 | }); | |
| 212 | ||
| 213 | test("no range module imports the shooter, the training, or a package", () => { | |
| 214 | const offences: string[] = []; | |
| 215 | for (const file of rangeSources()) { | |
| 216 | const source = readFileSync(join(RANGE, file), "utf8"); | |
| 217 | for (const specifier of specifiersOf(source)) { | |
| 218 | const why = leavesRange(file, specifier); | |
| 219 | if (why !== null) offences.push(`${file}: ${why}`); | |
| 220 | } | |
| 221 | } | |
| 222 | expect(offences).toEqual([]); | |
| 223 | }); | |
| 224 | ||
| 225 | test("the rule catches the import it exists to catch", () => { | |
| 226 | // The shortcut that would make the second game a copy of the first. | |
| 227 | expect(leavesRange("main.ts", "../client/world.ts")).toMatch(/outside/); | |
| 228 | expect(leavesRange("main.ts", "../client/binding.ts")).toMatch(/outside/); | |
| 229 | expect(leavesRange("main.ts", "../rl/arena.ts")).toMatch(/outside/); | |
| 230 | expect(leavesRange("main.ts", "../db.ts")).toMatch(/outside/); | |
| 231 | expect(leavesRange("main.ts", "../pages/game.ts")).toMatch(/outside/); | |
| 232 | expect(leavesRange("main.ts", "gl-matrix")).toMatch(/installs nothing/); | |
| 233 | expect(leavesRange("main.ts", "node:fs")).toMatch(/installs nothing/); | |
| 234 | expect(leavesRange("main.ts", "bun:test")).toMatch(/installs nothing/); | |
| 235 | }); | |
| 236 | ||
| 237 | test("the engine, the shell and a sibling are what the range may reach for", () => { | |
| 238 | expect(leavesRange("main.ts", "../engine/loop.ts")).toBeNull(); | |
| 239 | expect(leavesRange("main.ts", "../engine/math.ts")).toBeNull(); | |
| 240 | expect(leavesRange("main.ts", "./binding.ts")).toBeNull(); | |
| 241 | expect(leavesRange("sub/deep.ts", "../readout.ts")).toBeNull(); | |
| 242 | expect(leavesRange("sub/deep.ts", "../../engine/trace.ts")).toBeNull(); | |
| 243 | expect(leavesRange("main.ts", "../pages/shell.ts")).toBeNull(); | |
| 244 | expect(leavesRange("binding.test.ts", "bun:test")).toBeNull(); | |
| 245 | expect(leavesRange("bundle.test.ts", "node:path")).toBeNull(); | |
| 246 | expect(leavesRange("bundle.test.ts", "../../scripts/build.ts")).toBeNull(); | |
| 247 | // A test file's licence is its own: a module that ships may not reach for node, | |
| 248 | // nor for the script that bundles it. | |
| 249 | expect(leavesRange("main.ts", "node:path")).toMatch(/installs nothing/); | |
| 250 | expect(leavesRange("main.ts", "../../scripts/build.ts")).toMatch(/outside/); | |
| 251 | }); | |
| 252 | ||
| 253 | test("a range file that reached into the shooter is caught by reading it", () => { | |
| 254 | const F = "fr" + "om"; | |
| 255 | const source = [ | |
| 256 | `import { createLoop } ${F} "../engine/loop.ts";`, | |
| 257 | `import { RANGE_KEYS } ${F} "./binding.ts";`, | |
| 258 | `import { createWorld } ${F} "../client/world.ts";`, | |
| 259 | ].join("\n"); | |
| 260 | const offences = specifiersOf(source) | |
| 261 | .map((specifier) => leavesRange("main.ts", specifier)) | |
| 262 | .filter((why) => why !== null); | |
| 263 | expect(offences).toEqual([`"../client/world.ts" resolves outside src/range/, src/engine/ and src/pages/shell.ts`]); | |
| 264 | }); |
src/range/independence.test.ts deleted −118
| @@ -1,118 +0,0 @@ | ||
| 1 | /** | |
| 2 | * The rule that makes the range a proof rather than a second copy: a module | |
| 3 | * under `src/range/` may import the engine, the page shell and its own | |
| 4 | * siblings, and nothing else. Not `src/client/` — a shortcut import from the | |
| 5 | * shooter would make the whole direction untrue, and it is one line nobody | |
| 6 | * would notice in review — not `src/rl/`, and no package, because there is | |
| 7 | * nothing to install. | |
| 8 | * | |
| 9 | * Which module of the engine is a second question, and `boundary.test.ts` | |
| 10 | * answers it: the way in is `src/engine/index.ts`, the door, and these files go | |
| 11 | * through it. The rule below is the wider one — where an import may point — so | |
| 12 | * it still passes a deeper path; nothing in here writes one. | |
| 13 | * | |
| 14 | * It reads the sources with the same two helpers `src/engine/boundary.test.ts` | |
| 15 | * uses, imported rather than copied: one regular expression for imports in this | |
| 16 | * repository, not two that can drift apart. The price is that the engine's four | |
| 17 | * boundary tests are registered again here — they run twice and cost a | |
| 18 | * millisecond, which is cheaper than a second copy of the rule. | |
| 19 | */ | |
| 20 | ||
| 21 | import { expect, test } from "bun:test"; | |
| 22 | import { readFileSync } from "node:fs"; | |
| 23 | import { join, relative, resolve } from "node:path"; | |
| 24 | import { sources, specifiersOf } from "../engine/boundary.test.ts"; | |
| 25 | ||
| 26 | const RANGE = import.meta.dir; | |
| 27 | const SRC = resolve(RANGE, ".."); | |
| 28 | const ENGINE = join(SRC, "engine"); | |
| 29 | const SHELL = join(SRC, "pages", "shell.ts"); | |
| 30 | const BUILD = resolve(SRC, "..", "scripts", "build.ts"); | |
| 31 | ||
| 32 | /** `sources` names what it finds relative to the engine; these want their own names. */ | |
| 33 | const rangeSources = (): string[] => sources(RANGE).map((p) => relative(RANGE, resolve(ENGINE, p))); | |
| 34 | ||
| 35 | /** Why this file under `src/range/` may not import this specifier, or null if it may. */ | |
| 36 | export function leavesRange(file: string, specifier: string): string | null { | |
| 37 | const isTest = file.endsWith(".test.ts"); | |
| 38 | if (!specifier.startsWith(".")) { | |
| 39 | if (isTest && (specifier === "bun:test" || specifier.startsWith("node:"))) return null; | |
| 40 | return `"${specifier}" is not a path: the range installs nothing`; | |
| 41 | } | |
| 42 | const target = resolve(RANGE, join(file, ".."), specifier); | |
| 43 | const sibling = relative(RANGE, target); | |
| 44 | if (sibling !== "" && !sibling.startsWith("..")) return null; | |
| 45 | const fromSrc = relative(ENGINE, target); | |
| 46 | if (fromSrc !== "" && !fromSrc.startsWith("..")) return null; | |
| 47 | if (target === SHELL) return null; | |
| 48 | // A test may ask the build script where the bundles are and what one is | |
| 49 | // called — `bundle.test.ts` boots the committed artifact — because a | |
| 50 | // `.test.ts` ships in no bundle. A module that ships may not. | |
| 51 | if (isTest && target === BUILD) return null; | |
| 52 | return `"${specifier}" resolves outside src/range/, src/engine/ and src/pages/shell.ts`; | |
| 53 | } | |
| 54 | ||
| 55 | test("the range has the sources this test is about", () => { | |
| 56 | const files = rangeSources(); | |
| 57 | expect(files).toContain("main.ts"); | |
| 58 | expect(files).toContain("binding.ts"); | |
| 59 | expect(files).toContain("independence.test.ts"); | |
| 60 | }); | |
| 61 | ||
| 62 | test("no range module imports the shooter, the training, or a package", () => { | |
| 63 | const offences: string[] = []; | |
| 64 | for (const file of rangeSources()) { | |
| 65 | const source = readFileSync(join(RANGE, file), "utf8"); | |
| 66 | for (const specifier of specifiersOf(source)) { | |
| 67 | const why = leavesRange(file, specifier); | |
| 68 | if (why !== null) offences.push(`${file}: ${why}`); | |
| 69 | } | |
| 70 | } | |
| 71 | expect(offences).toEqual([]); | |
| 72 | }); | |
| 73 | ||
| 74 | test("the rule catches the import it exists to catch", () => { | |
| 75 | // The shortcut that would make the second game a copy of the first. | |
| 76 | expect(leavesRange("main.ts", "../client/world.ts")).toMatch(/outside/); | |
| 77 | expect(leavesRange("main.ts", "../client/binding.ts")).toMatch(/outside/); | |
| 78 | expect(leavesRange("main.ts", "../rl/arena.ts")).toMatch(/outside/); | |
| 79 | expect(leavesRange("main.ts", "../db.ts")).toMatch(/outside/); | |
| 80 | expect(leavesRange("main.ts", "../pages/game.ts")).toMatch(/outside/); | |
| 81 | expect(leavesRange("main.ts", "gl-matrix")).toMatch(/installs nothing/); | |
| 82 | expect(leavesRange("main.ts", "node:fs")).toMatch(/installs nothing/); | |
| 83 | expect(leavesRange("main.ts", "bun:test")).toMatch(/installs nothing/); | |
| 84 | }); | |
| 85 | ||
| 86 | // A title that ends on the keyword, with the closing quote right behind it, | |
| 87 | // reads as an import to the helper above — a false positive this test would | |
| 88 | // then hold against itself. So no title here ends on one. | |
| 89 | test("the engine, the shell and a sibling are what it may reach for", () => { | |
| 90 | expect(leavesRange("main.ts", "../engine/loop.ts")).toBeNull(); | |
| 91 | expect(leavesRange("main.ts", "../engine/math.ts")).toBeNull(); | |
| 92 | expect(leavesRange("main.ts", "./binding.ts")).toBeNull(); | |
| 93 | expect(leavesRange("sub/deep.ts", "../readout.ts")).toBeNull(); | |
| 94 | expect(leavesRange("sub/deep.ts", "../../engine/trace.ts")).toBeNull(); | |
| 95 | expect(leavesRange("main.ts", "../pages/shell.ts")).toBeNull(); | |
| 96 | expect(leavesRange("binding.test.ts", "bun:test")).toBeNull(); | |
| 97 | expect(leavesRange("independence.test.ts", "node:path")).toBeNull(); | |
| 98 | expect(leavesRange("bundle.test.ts", "../../scripts/build.ts")).toBeNull(); | |
| 99 | // A test file's licence is its own: a module that ships may not reach for node, | |
| 100 | // nor for the script that bundles it. | |
| 101 | expect(leavesRange("main.ts", "node:path")).toMatch(/installs nothing/); | |
| 102 | expect(leavesRange("main.ts", "../../scripts/build.ts")).toMatch(/outside/); | |
| 103 | }); | |
| 104 | ||
| 105 | test("a file that reached into the shooter is caught by reading it", () => { | |
| 106 | // The keyword is spelled at run time so this fixture is not itself an import | |
| 107 | // the test above would read out of this file and hold against it. | |
| 108 | const F = "fr" + "om"; | |
| 109 | const source = [ | |
| 110 | `import { createLoop } ${F} "../engine/loop.ts";`, | |
| 111 | `import { RANGE_KEYS } ${F} "./binding.ts";`, | |
| 112 | `import { createWorld } ${F} "../client/world.ts";`, | |
| 113 | ].join("\n"); | |
| 114 | const offences = specifiersOf(source) | |
| 115 | .map((specifier) => leavesRange("main.ts", specifier)) | |
| 116 | .filter((why) => why !== null); | |
| 117 | expect(offences).toEqual([`"../client/world.ts" resolves outside src/range/, src/engine/ and src/pages/shell.ts`]); | |
| 118 | }); |
src/range/main.ts +1 −1
| @@ -20,7 +20,7 @@ | ||
| 20 | 20 | * spot, which is the physics' to change, not the renderer's. |
| 21 | 21 | * |
| 22 | 22 | * Nothing outside `src/engine/` and this directory is imported, and |
| 23 | * `independence.test.ts` reads these files to make sure it stays that way. | |
| 23 | * `src/independence.test.ts` reads these files to make sure it stays that way. | |
| 24 | 24 | */ |
| 25 | 25 | |
| 26 | 26 | import { createRangeAudio } from "./audio.ts"; |
src/range/scene.ts +1 −1
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | * The range's frame, as data: the room, the plates standing in it and the |
| 3 | 3 | * tracer of the last shot, in the engine's `Scene` and nothing else. It is the |
| 4 | 4 | * second game's answer to `src/client/scene.ts`, written without reading it — |
| 5 | * nothing under `src/client/` is imported here, and `independence.test.ts` | |
| 5 | * nothing under `src/client/` is imported here, and `src/independence.test.ts` | |
| 6 | 6 | * fails the day one is. |
| 7 | 7 | * |
| 8 | 8 | * What goes in is a `Run` from `targets.ts`, where the view is pointing, and |
new above
offline