Public: anyone can read this; the crew can change it.
forked from trevato/cs
Take updates from trevato/cs #286
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/275-take-updates-from-trevato-cs → main 2 commits fork
6 files
changed +225
−39
.gitignore +4
| @@ -4,3 +4,7 @@ static/ | ||
| 4 | 4 | *.db |
| 5 | 5 | *.db-wal |
| 6 | 6 | *.db-shm |
| 7 | static/app.*.js | |
| 8 | .checkdata/ | |
| 9 | .scout-tmp/ | |
| 10 | cookies*.txt |
README.md +7 −3
| @@ -28,7 +28,7 @@ bun run scripts/surface.ts --write 0.2.0 # record today's door as | ||
| 28 | 28 | bun run scripts/cost.ts # what each group behind the door adds to a bundle |
| 29 | 29 | bun run scripts/cost.ts --guide # the same, as the lines GUIDE.md carries |
| 30 | 30 | bun test # the check every pull runs |
| 31 | bun run scripts/mutate-result.ts # break each rule in src/result.ts, see a test go red | |
| 31 | bun run scripts/mutate-result.ts # break each rule in src/result.ts and src/gate.ts, see a test go red | |
| 32 | 32 | FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface |
| 33 | 33 | |
| 34 | 34 | |
| @@ -1057,9 +1057,13 @@ never checked itself. | ||
| 1057 | 1057 | through `src/mapname.ts`; a body that names no map at all is an older client |
| 1058 | 1058 | and played `de_forge`) — with every one of those rules pinned in |
| 1059 | 1059 | `src/result.test.ts`; |
| 1060 | `bun run scripts/mutate-result.ts` breaks each line of it in turn and names | |
| 1060 | `bun run scripts/mutate-result.ts` breaks each line of that gate in turn — | |
| 1061 | `src/result.ts`, and the counted number and printable name in `src/gate.ts` | |
| 1062 | that every posted score goes through — and names | |
| 1061 | 1063 | the test that catches it, so a rule nothing tests is visible rather than |
| 1062 | assumed. `src/matchid.ts` is the one place that says what a match id is — the shape | |
| 1064 | assumed. A match's numbers only ever count up: posts arrive as it is played | |
| 1065 | and a failed one is retried, so `saveMatch` raises each number rather than | |
| 1066 | overwriting it, and a post that arrives late cannot undo a finished match. `src/matchid.ts` is the one place that says what a match id is — the shape | |
| 1063 | 1067 | above, the `<match>--<nonce>` key one playing of a match is stored under, and |
| 1064 | 1068 | the match read back out of it — and the parser, the `?match=` query and the |
| 1065 | 1069 | page's field all ask it rather than each holding a regular expression; |
scripts/mutate-result.ts +47 −30
| @@ -1,40 +1,48 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * A test that stays green when the rule it tests is deleted is not a test. |
| 3 | * This runs `src/result.test.ts` against a copy of `src/result.ts` with one | |
| 4 | * rule broken at a time and reports which test caught it. Every mutant must | |
| 5 | * die; a survivor is a rule nothing pins. | |
| 3 | * This runs `src/result.test.ts` against a copy of the gate a match comes in | |
| 4 | * through, with one rule broken at a time, and reports which test caught it. | |
| 5 | * Every mutant must die; a survivor is a rule nothing pins. | |
| 6 | 6 | * |
| 7 | 7 | * bun run scripts/mutate-result.ts |
| 8 | 8 | * |
| 9 | * The gate is two files, not one: `src/result.ts` says what a match is, and | |
| 10 | * `src/gate.ts` holds the two primitives every posted score is checked with — | |
| 11 | * a counted number and a printable name — because the range posts scores too. | |
| 12 | * A mutant names the file it breaks, defaulting to `src/result.ts`; both are | |
| 13 | * put back afterwards whatever happens. | |
| 14 | * | |
| 9 | 15 | * It is not part of `bun test`: it spawns one test run per mutant, which takes |
| 10 | * seconds rather than milliseconds. Run it when you change `src/result.ts`. | |
| 16 | * seconds rather than milliseconds. Run it when you change either file. | |
| 11 | 17 | */ |
| 12 | 18 | |
| 13 | 19 | import { copyFileSync, unlinkSync, writeFileSync } from "node:fs"; |
| 14 | 20 | |
| 15 | const SOURCE = "src/result.ts"; | |
| 16 | const BACKUP = "src/result.ts.original"; | |
| 21 | const RESULT = "src/result.ts"; | |
| 22 | const GATE = "src/gate.ts"; | |
| 23 | const backupOf = (file: string): string => `${file}.original`; | |
| 17 | 24 | |
| 18 | 25 | /** |
| 19 | * Each mutant: a name, the text to find, and what to put in its place. | |
| 20 | * `redundant` marks a line no test can kill because another rule already | |
| 21 | * refuses everything it would — defence in depth rather than behaviour. Those | |
| 22 | * are expected to survive, and a mutant that stops matching its note (dies | |
| 23 | * when it should survive, or the reverse) fails this run. | |
| 26 | * Each mutant: a name, the text to find, and what to put in its place — in | |
| 27 | * `file`, or in `src/result.ts` when it does not say. `redundant` marks a line | |
| 28 | * no test can kill because another rule already refuses everything it would — | |
| 29 | * defence in depth rather than behaviour. Those are expected to survive, and a | |
| 30 | * mutant that stops matching its note (dies when it should survive, or the | |
| 31 | * reverse) fails this run. | |
| 24 | 32 | */ |
| 25 | const MUTANTS: { rule: string; find: string; replace: string; redundant?: string }[] = [ | |
| 33 | const MUTANTS: { rule: string; find: string; replace: string; file?: string; redundant?: string }[] = [ | |
| 26 | 34 | { rule: "a body must be an object", find: `if (typeof value !== "object" || value === null) return null;`, replace: "" }, |
| 27 | 35 | // The id's own shape lives in `src/matchid.ts` now; what this file can still |
| 28 | 36 | // break is asking it at all. |
| 29 | 37 | { rule: "matchId goes through the one door", find: "if (!isMatchId(matchId)) return null;", replace: "" }, |
| 30 | 38 | { rule: "a map that is not a map is refused", find: "const map = body.map === undefined ? DEFAULT_MAP : playedOn(body.map);", replace: "const map = playedOn(body.map) ?? DEFAULT_MAP;" }, |
| 31 | 39 | { rule: "a refused map refuses the result", find: "if (map === null) return null;", replace: "" }, |
| 32 | { rule: "a number must be a number", find: `if (typeof value !== "number" || !Number.isFinite(value)) return null;`, replace: "" }, | |
| 33 | { rule: "a number must be finite", find: "!Number.isFinite(value)", replace: "false" }, | |
| 34 | { rule: "a float is floored", find: "const n = Math.floor(value);", replace: "const n = value;" }, | |
| 35 | { rule: "negative zero is refused", find: "if (Object.is(n, -0)) return null;", replace: "" }, | |
| 36 | { rule: "a negative is refused", find: "n < 0 || n > max", replace: "n > max" }, | |
| 37 | { rule: "a number over its ceiling is refused", find: "n < 0 || n > max", replace: "n < 0" }, | |
| 40 | { rule: "a number must be a number", find: `if (typeof value !== "number" || !Number.isFinite(value)) return null;`, replace: "", file: GATE }, | |
| 41 | { rule: "a number must be finite", find: "!Number.isFinite(value)", replace: "false", file: GATE }, | |
| 42 | { rule: "a float is floored", find: "const n = Math.floor(value);", replace: "const n = value;", file: GATE }, | |
| 43 | { rule: "negative zero is refused", find: "if (Object.is(n, -0)) return null;", replace: "", file: GATE }, | |
| 44 | { rule: "a negative is refused", find: "n < 0 || n > max", replace: "n > max", file: GATE }, | |
| 45 | { rule: "a number over its ceiling is refused", find: "n < 0 || n > max", replace: "n < 0", file: GATE }, | |
| 38 | 46 | { rule: "the rounds ceiling", find: "const rounds = integer(body.rounds, MAX_ROUNDS);", replace: "const rounds = integer(body.rounds, Infinity);" }, |
| 39 | 47 | { |
| 40 | 48 | rule: "the score ceilings", find: "const scoreMine = integer(body.scoreMine, MAX_ROUNDS);", |
| @@ -65,27 +73,31 @@ const MUTANTS: { rule: string; find: string; replace: string; redundant?: string | ||
| 65 | 73 | { rule: "kills are bounded by the enemies there were", find: "if (kills > rounds * KILLS_PER_ROUND) return null;", replace: "" }, |
| 66 | 74 | { rule: "the clock is bounded by the rounds", find: "if (seconds > (rounds + 1) * SECONDS_PER_ROUND) return null;", replace: "" }, |
| 67 | 75 | { rule: "finished is exactly true", find: "finished: body.finished === true,", replace: "finished: !!body.finished," }, |
| 68 | { rule: "a name must be a string", find: `if (typeof value !== "string") return "anon";`, replace: `if (typeof value !== "string") return String(value);` }, | |
| 69 | { rule: "control characters become spaces", find: `.replace(/[\\u0000-\\u001f\\u007f]/g, " ")`, replace: "" }, | |
| 70 | { rule: "a name is trimmed", find: ".trim().slice(0, 24)", replace: ".slice(0, 24)" }, | |
| 71 | { rule: "a name is cut to 24", find: ".slice(0, 24)", replace: "" }, | |
| 72 | { rule: "an empty name is anon", find: `return stripped.length === 0 ? "anon" : stripped;`, replace: "return stripped;" }, | |
| 76 | { rule: "a name must be a string", find: `if (typeof value !== "string") return "anon";`, replace: `if (typeof value !== "string") return String(value);`, file: GATE }, | |
| 77 | { rule: "control characters become spaces", find: `.replace(/[\\u0000-\\u001f\\u007f]/g, " ")`, replace: "", file: GATE }, | |
| 78 | { rule: "a name is trimmed", find: ".trim().slice(0, 24)", replace: ".slice(0, 24)", file: GATE }, | |
| 79 | { rule: "a name is cut to 24", find: ".slice(0, 24)", replace: "", file: GATE }, | |
| 80 | { rule: "an empty name is anon", find: `return stripped.length === 0 ? "anon" : stripped;`, replace: "return stripped;", file: GATE }, | |
| 73 | 81 | { rule: "a win is finished", find: "result.finished && result.scoreMine > result.scoreTheirs", replace: "result.scoreMine > result.scoreTheirs" }, |
| 74 | 82 | { rule: "a draw is not a win", find: "result.scoreMine > result.scoreTheirs", replace: "result.scoreMine >= result.scoreTheirs" }, |
| 75 | 83 | ]; |
| 76 | 84 | |
| 77 | const original = await Bun.file(SOURCE).text(); | |
| 78 | copyFileSync(SOURCE, BACKUP); | |
| 85 | const originals = new Map<string, string>(); | |
| 86 | for (const file of [RESULT, GATE]) { | |
| 87 | originals.set(file, await Bun.file(file).text()); | |
| 88 | copyFileSync(file, backupOf(file)); | |
| 89 | } | |
| 79 | 90 | let wrong = 0; |
| 80 | 91 | |
| 81 | 92 | try { |
| 82 | for (const { rule, find, replace, redundant } of MUTANTS) { | |
| 93 | for (const { rule, find, replace, file = RESULT, redundant } of MUTANTS) { | |
| 94 | const original = originals.get(file) as string; | |
| 83 | 95 | if (!original.includes(find)) { |
| 84 | console.log(`? ${rule}: no longer in ${SOURCE} — the mutant needs rewriting`); | |
| 96 | console.log(`? ${rule}: no longer in ${file} — the mutant needs rewriting`); | |
| 85 | 97 | wrong++; |
| 86 | 98 | continue; |
| 87 | 99 | } |
| 88 | writeFileSync(SOURCE, original.replace(find, replace)); | |
| 100 | writeFileSync(file, original.replace(find, replace)); | |
| 89 | 101 | const run = Bun.spawnSync(["bun", "test", "src/result.test.ts"], { stderr: "pipe", stdout: "pipe" }); |
| 90 | 102 | const log = run.stderr.toString() + run.stdout.toString(); |
| 91 | 103 | const killers = [...log.matchAll(/\(fail\) (.*?)(?: \[[\d.]+m?s\])?$/gm)].map((m) => m[1]?.trim()); |
| @@ -98,10 +110,15 @@ try { | ||
| 98 | 110 | : ` UNEXPECTED: nothing catches this line being deleted`); |
| 99 | 111 | wrong++; |
| 100 | 112 | } |
| 113 | // Put this mutant's file back before the next one is broken, so only ever | |
| 114 | // one rule is missing at a time. | |
| 115 | writeFileSync(file, original); | |
| 101 | 116 | } |
| 102 | 117 | } finally { |
| 103 | copyFileSync(BACKUP, SOURCE); | |
| 104 | unlinkSync(BACKUP); | |
| 118 | for (const file of [RESULT, GATE]) { | |
| 119 | copyFileSync(backupOf(file), file); | |
| 120 | unlinkSync(backupOf(file)); | |
| 121 | } | |
| 105 | 122 | } |
| 106 | 123 | |
| 107 | 124 | const expected = MUTANTS.filter((m) => m.redundant !== undefined).length; |
src/db.test.ts +49 −2
| @@ -232,8 +232,10 @@ test("which day a result counts for is read out of its id, not taken from the br | ||
| 232 | 232 | // that day has begun here, and then it counts for the day it names. |
| 233 | 233 | expect(saveMatch(db, player("u-1", "kestrel"), result({ matchId: "20260922" }), now)).toBe(true); |
| 234 | 234 | expect(dayBoard(db, "2026-09-22")).toEqual([]); |
| 235 | expect(saveMatch(db, player("u-1", "kestrel"), result({ matchId: "20260922", kills: 3 }), now + 86_400_000)).toBe(true); | |
| 236 | expect(dayBoard(db, "2026-09-22").map((r) => r.kills)).toEqual([3]); | |
| 235 | // The second post's kills are above the first's because a match's numbers | |
| 236 | // only ever count up — what is under test here is the day, not the kills. | |
| 237 | expect(saveMatch(db, player("u-1", "kestrel"), result({ matchId: "20260922", kills: 13 }), now + 86_400_000)).toBe(true); | |
| 238 | expect(dayBoard(db, "2026-09-22").map((r) => r.kills)).toEqual([13]); | |
| 237 | 239 | // And the row it went on is the same row: a re-post, not a second match. |
| 238 | 240 | expect(recentMatches(db).filter((m) => m.id === "20260922").length).toBe(1); |
| 239 | 241 | expect(recentMatches(db).filter((m) => m.id === "20260921")[0]?.day).toBe("2026-09-21"); |
| @@ -266,3 +268,48 @@ test("a database written before the match of the day existed opens, and files th | ||
| 266 | 268 | expect(saveMatch(db, player("u-1", "kestrel"), result({ matchId: "20260921" }), now)).toBe(true); |
| 267 | 269 | expect(dayBoard(db, "2026-09-21").map((r) => r.name)).toEqual(["kestrel"]); |
| 268 | 270 | }); |
| 271 | ||
| 272 | test("a post that arrives late cannot put a match's numbers back down", () => { | |
| 273 | // The game posts as it plays and `src/client/pending.ts` retries a post that | |
| 274 | // did not land, so the last post to reach the database is not always the | |
| 275 | // latest one the game made. Round 5's post arriving after round 12's must | |
| 276 | // not undo seven rounds. | |
| 277 | const db = openDatabase(dir()); | |
| 278 | const id = crypto.randomUUID(); | |
| 279 | const late = result({ | |
| 280 | matchId: id, rounds: 5, scoreMine: 3, scoreTheirs: 2, | |
| 281 | kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: false, | |
| 282 | }); | |
| 283 | const end = result({ | |
| 284 | matchId: id, rounds: 12, scoreMine: 7, scoreTheirs: 5, | |
| 285 | kills: 21, deaths: 9, headshots: 6, damage: 2900, seconds: 980, finished: true, | |
| 286 | }); | |
| 287 | expect(saveMatch(db, guest("kestrel"), end)).toBe(true); | |
| 288 | expect(saveMatch(db, guest("kestrel"), late)).toBe(true); | |
| 289 | const row = recentMatches(db)[0]!; | |
| 290 | expect(row.rounds).toBe(12); | |
| 291 | expect(row.kills).toBe(21); | |
| 292 | expect(row.deaths).toBe(9); | |
| 293 | expect(row.headshots).toBe(6); | |
| 294 | expect(row.damage).toBe(2900); | |
| 295 | expect(row.seconds).toBe(980); | |
| 296 | // A match that got to the end stays finished, and the score stays the pair | |
| 297 | // the post that knew the most rounds sent. | |
| 298 | expect(row.finished).toBe(1); | |
| 299 | expect([row.score_mine, row.score_theirs]).toEqual([7, 5]); | |
| 300 | expect(liveness(row, Date.now())).toBe("finished"); | |
| 301 | }); | |
| 302 | ||
| 303 | test("a post that knows more still moves every number up", () => { | |
| 304 | // The other direction, which is the common one: the match is written as it | |
| 305 | // is played, and each post raises what it raises. | |
| 306 | const db = openDatabase(dir()); | |
| 307 | const id = crypto.randomUUID(); | |
| 308 | saveMatch(db, guest("kestrel"), result({ matchId: id, rounds: 5, scoreMine: 3, scoreTheirs: 2, kills: 9, finished: false })); | |
| 309 | saveMatch(db, guest("kestrel"), result({ matchId: id, rounds: 6, scoreMine: 3, scoreTheirs: 3, kills: 12, finished: false })); | |
| 310 | const row = recentMatches(db)[0]!; | |
| 311 | expect(row.rounds).toBe(6); | |
| 312 | expect(row.kills).toBe(12); | |
| 313 | expect([row.score_mine, row.score_theirs]).toEqual([3, 3]); | |
| 314 | expect(liveness(row, Date.now())).toBe("live"); | |
| 315 | }); |
src/db.ts +18 −4
| @@ -200,6 +200,14 @@ export function liveness(row: { finished: number; updated_at: string }, now = Da | ||
| 200 | 200 | * Whether it counts for a day is not asked of the browser: it is read out of |
| 201 | 201 | * the id by `dayFiled` in `src/daily.ts` against this server's clock, which is |
| 202 | 202 | * why `now` is here at all — a test moves it rather than the calendar. |
| 203 | * | |
| 204 | * A match's numbers never go backwards. Posts for one match arrive as it is | |
| 205 | * played, and `src/client/pending.ts` retries one that did not land — so the | |
| 206 | * last post to reach this function is not always the latest one the game made, | |
| 207 | * and an overwrite would let a retried early post undo a finished match. Every | |
| 208 | * number a match keeps only counts up (a round played, a kill, a death, a | |
| 209 | * headshot, damage dealt, a second on the clock), so a late arrival may raise | |
| 210 | * one and never lower it, and a match that got to the end stays finished. | |
| 203 | 211 | */ |
| 204 | 212 | export function saveMatch(db: Database, player: Player, result: Result, now = Date.now()): boolean { |
| 205 | 213 | const existing = db |
| @@ -213,10 +221,16 @@ export function saveMatch(db: Database, player: Player, result: Result, now = Da | ||
| 213 | 221 | `INSERT INTO match (id, session, user_id, name, map, day, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, seconds) |
| 214 | 222 | VALUES (?1, ?2, ?13, ?3, ?14, ?15, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) |
| 215 | 223 | ON CONFLICT(id) DO UPDATE SET |
| 216 | name = excluded.name, map = excluded.map, day = excluded.day, kills = excluded.kills, deaths = excluded.deaths, | |
| 217 | headshots = excluded.headshots, damage = excluded.damage, rounds = excluded.rounds, | |
| 218 | score_mine = excluded.score_mine, score_theirs = excluded.score_theirs, | |
| 219 | finished = excluded.finished, seconds = excluded.seconds, | |
| 224 | name = excluded.name, map = excluded.map, day = excluded.day, | |
| 225 | kills = MAX(kills, excluded.kills), deaths = MAX(deaths, excluded.deaths), | |
| 226 | headshots = MAX(headshots, excluded.headshots), damage = MAX(damage, excluded.damage), | |
| 227 | rounds = MAX(rounds, excluded.rounds), | |
| 228 | -- The score is a pair, not two independent numbers, so it is taken whole | |
| 229 | -- from whichever post knows the most rounds rather than maxed column by | |
| 230 | -- column, which could pair one post's score with another's round count. | |
| 231 | score_mine = CASE WHEN excluded.rounds >= rounds THEN excluded.score_mine ELSE score_mine END, | |
| 232 | score_theirs = CASE WHEN excluded.rounds >= rounds THEN excluded.score_theirs ELSE score_theirs END, | |
| 233 | finished = MAX(finished, excluded.finished), seconds = MAX(seconds, excluded.seconds), | |
| 220 | 234 | updated_at = datetime('now')`, |
| 221 | 235 | ).run( |
| 222 | 236 | result.matchId, player.session, player.name, result.kills, result.deaths, result.headshots, |
src/tree.test.ts added +100
| @@ -0,0 +1,100 @@ | ||
| 1 | /** | |
| 2 | * `.scout-tmp/` held a live session cookie and four rendered pages once | |
| 3 | * (issue #98): scratch a builder meant to throw away, merged by mistake. | |
| 4 | * `.gitignore` keeps it from happening again; this walks the tree the way | |
| 5 | * a clean checkout would look — skipping `.git` and `node_modules`, since | |
| 6 | * `git ls-files` isn't available to a test — and fails the same way CI | |
| 7 | * would if either shape came back: a Netscape cookie jar, or a directory | |
| 8 | * named for one of the scratch patterns `.gitignore` already lists. | |
| 9 | * | |
| 10 | * "Ignored" and "committed" are different questions. A file that matches | |
| 11 | * one of `.gitignore`'s own patterns (`data/app.db`, `static/app.*.js`) | |
| 12 | * is proof the ignore is doing its job, not a violation — the app writes | |
| 13 | * those on every run and every build, so flagging them makes the guard | |
| 14 | * red on any machine that has used the app at all (issue #129). A | |
| 15 | * directory named for one of `.gitignore`'s scratch entries is different: | |
| 16 | * nothing normal creates `.scout-tmp/` or `.checkdata/`, so seeing one | |
| 17 | * still means someone put it there on purpose, ignored or not — the | |
| 18 | * ignore stops it from being committed, not from existing or from being | |
| 19 | * force-added, so this still has to look. A cookie jar is checked | |
| 20 | * regardless of either answer: ignoring a path doesn't undo a commit | |
| 21 | * that already happened, so a cookie jar is a credential wherever it sits. | |
| 22 | * | |
| 23 | * Not every ignored directory is scratch, though, and on this repository | |
| 24 | * most of them aren't: `bun install` writes `node_modules/`, | |
| 25 | * `scripts/build.ts` writes the bundles into `static/` and | |
| 26 | * `scripts/pack.ts` writes the engine package into `dist/`, all of them on | |
| 27 | * purpose, all of them ignored because they are built rather than kept. So | |
| 28 | * the scratch names are the ignored directories *minus* the ones this | |
| 29 | * repository's own tools make — `MADE_ON_PURPOSE` below — which leaves the | |
| 30 | * ones nothing normal creates, and keeps the rule the parent wrote: add a | |
| 31 | * scratch directory to `.gitignore` and this guard starts watching for it | |
| 32 | * without being told twice. | |
| 33 | */ | |
| 34 | ||
| 35 | import { expect, test } from "bun:test"; | |
| 36 | import { closeSync, openSync, readdirSync, readFileSync, readSync } from "node:fs"; | |
| 37 | import { join } from "node:path"; | |
| 38 | ||
| 39 | const root = join(import.meta.dir, ".."); | |
| 40 | const SKIP_DIRS = new Set([".git", "node_modules"]); | |
| 41 | const COOKIE_JAR_HEADER = "# Netscape HTTP Cookie File"; | |
| 42 | ||
| 43 | const gitignore = readFileSync(join(root, ".gitignore"), "utf8"); | |
| 44 | const lines = gitignore | |
| 45 | .split("\n") | |
| 46 | .map((line) => line.trim()) | |
| 47 | .filter((line) => line.length > 0 && !line.startsWith("#")); | |
| 48 | ||
| 49 | /** Ignored directories a tool in this repository writes on purpose. */ | |
| 50 | const MADE_ON_PURPOSE = new Set(["node_modules", "dist", "static", "data"]); | |
| 51 | ||
| 52 | const scratchDirNames = new Set( | |
| 53 | lines | |
| 54 | .filter((line) => line.endsWith("/")) | |
| 55 | .map((line) => line.slice(0, -1)) | |
| 56 | .filter((name) => !MADE_ON_PURPOSE.has(name)), | |
| 57 | ); | |
| 58 | ||
| 59 | /** Just enough of the file to know its first line, not the whole thing. */ | |
| 60 | const firstLine = (path: string): string => { | |
| 61 | const fd = openSync(path, "r"); | |
| 62 | try { | |
| 63 | const buffer = Buffer.alloc(256); | |
| 64 | const read = readSync(fd, buffer, 0, buffer.length, 0); | |
| 65 | return buffer.toString("utf8", 0, read).split("\n")[0] ?? ""; | |
| 66 | } finally { | |
| 67 | closeSync(fd); | |
| 68 | } | |
| 69 | }; | |
| 70 | ||
| 71 | const violations: string[] = []; | |
| 72 | ||
| 73 | const walk = (dir: string): void => { | |
| 74 | for (const entry of readdirSync(dir, { withFileTypes: true })) { | |
| 75 | if (SKIP_DIRS.has(entry.name)) continue; | |
| 76 | const path = join(dir, entry.name); | |
| 77 | const relative = path.slice(root.length + 1); | |
| 78 | if (entry.isDirectory()) { | |
| 79 | if (scratchDirNames.has(entry.name)) violations.push(`${relative}/ is a scratch directory .gitignore already refuses`); | |
| 80 | walk(path); | |
| 81 | } else if (entry.isFile()) { | |
| 82 | // A file's name is not checked against .gitignore's own scratch | |
| 83 | // patterns (`*.db`, `static/app.*.js`): matching one of those means | |
| 84 | // the ignore is working, and running the app or building the bundle | |
| 85 | // is exactly what produces them, on every machine, every time — the | |
| 86 | // opposite of a violation. Its content still is: a cookie jar's | |
| 87 | // header doesn't stop being a credential because its filename | |
| 88 | // happens to be ignored. | |
| 89 | if (firstLine(path).trim() === COOKIE_JAR_HEADER) { | |
| 90 | violations.push(`${relative} is a Netscape cookie jar`); | |
| 91 | } | |
| 92 | } | |
| 93 | } | |
| 94 | }; | |
| 95 | ||
| 96 | test("the repository holds no cookie jar and no scratch directory", () => { | |
| 97 | violations.length = 0; | |
| 98 | walk(root); | |
| 99 | expect(violations).toEqual([]); | |
| 100 | }); |
new above
offline