diff --git a/.claude/skills/builder-piece/SKILL.md b/.claude/skills/builder-piece/SKILL.md new file mode 100644 index 00000000..f188190b --- /dev/null +++ b/.claude/skills/builder-piece/SKILL.md @@ -0,0 +1,70 @@ +--- +name: builder-piece +description: The steps for one System Builder piece in CITY_NET, from branch to the report with a compare link. Use when starting, finishing or pushing any piece of builder work, or any small PR into feature/system-builder. +--- + +# One builder piece + +Builder work goes in small pieces, one branch and one PR each, into `feature/system-builder`. +The person who owns the repo merges; never push to `main` or `dev`. + +## 1. Start + +- The previous piece must be merged first. Then branch from the updated origin: + `git fetch origin && git checkout -b sb/ origin/feature/system-builder`. +- A new page, window or mechanic gets a mockup first (`docs/mockups/`, listed in its README), and is + built only once it's approved. + +## 2. Build + +- Rules go in pure modules (backend `systemBuilder/`, frontend `src/sheets/`), and components only + draw them. +- Use theme variables only, and portal floating UI into `themeRoot()` (see CLAUDE.md). +- A new or changed hint goes in `frontend/src/sheets/builderHelp.ts`. + +## 3. Test + +- Add tests for what was built: server tests for rules and routes, component tests for pages. +- Run the tests of every file that imports a changed module (grep for its name), plus + `npx tsc -b` and `npx eslint ` in `frontend/`. Check exit codes. +- If a message changed, grep the tests for its old words. +- Run the **mutation check** (the `mutation-check` skill) on the new logic. Every break should be + caught; for each one that survives, add or tighten a test, or remove the code it shows isn't + needed. +- **Browser journeys:** a new page, tab or panel joins `e2e/tests/builder-clicks.spec.ts`. A new + thing the GM makes gets its own short journey. Run the new journey and its neighbours locally, + never the whole suite (CI does that). + +## 4. Upload paths + +Check that every way in and out of a system carries what the piece adds, and add a round-trip test +(download, then import back unchanged): +- the CHOICES importer (`backend/systemBuilder/choicesImport.js`) and its DOWNLOAD EXAMPLE and + DOWNLOAD THIS LIST (`frontend/src/sheets/choicesImport.ts`); +- the sheet importer and its downloadable PDF form; +- the system file and its .zip, exported and installed (`backend/systemBuilder/citysys.js`). + +Say in the report which paths were checked, even when none needed a change. + +## 5. Docs, in the same PR + +- `CHANGELOG.md`, under `[Unreleased]`: `### Added`, `### Changed`, `### Fixed`, or + `### Under the hood` for work players can't see. Write the entry to a file, then + `node scripts/changelog-add.js "### Added" entry.md`. +- `README.md` Project Structure: a line for every new file, and an updated line for a file that + changed what it does. +- `e2e/README.md`: a row for every new journey. `e2e/COVERAGE.md`: a row for every option added or + changed. +- At the end of a phase, also update the builder's tour, HELP topics and hints, and extend + `e2e/tests/builder-journey.spec.ts`. + +## 6. Push and report + +- Commit only the files of the piece (`git add `, never `git add -A`). No AI attribution. +- Push the branch, then report to the user: + - the compare link: `https://github.com/over2take/CITY_NET/compare/feature/system-builder...sb/`; + - what changed, in plain words; + - what was checked, with real numbers; + - which upload paths were checked; + - then **TO TRY** (steps, and whether the backend needs a restart) or **NOTHING TO TRY**. +- Wait for "merged" before the next piece. diff --git a/.claude/skills/mutation-check/SKILL.md b/.claude/skills/mutation-check/SKILL.md new file mode 100644 index 00000000..cd3cb4b4 --- /dev/null +++ b/.claude/skills/mutation-check/SKILL.md @@ -0,0 +1,67 @@ +--- +name: mutation-check +description: Check that new tests really guard new code in CITY_NET by breaking the code on purpose and confirming the tests fail. Use after writing tests for a builder piece or a fix, before pushing, or when asked to mutation-check. +--- + +# Mutation check + +A test suite that passes proves little until it has failed for the right reason. Break the new +code on purpose, one small change at a time, and confirm a test fails each time. The runner is +`scripts/mutation-check.js`. + +## 1. Pick the breaks + +Pick 5 to 30 for a piece, aimed at what the piece added or changed. Each one is a single exact +text swap: +- a condition flipped or loosened: `>=` to `>`, `a && b` to `a`, `kind === 'x'` to `true`; +- a constant changed: a limit, a default, a sample value; +- a branch or a line removed (replaced with `''`), a guard dropped; +- a returned field left out, a message's words changed; +- on the frontend: an `aria-*` attribute removed, a condition that shows a control set to `true`. + +Leave out breaks that change nothing a person could notice. A surviving break like that only shows +the code could be simpler. + +## 2. Write the plan + +Write it to the scratchpad, not the repo: + +```json +{ + "tests": { + "server": { "cwd": "backend", "cmd": "npx vitest run --maxWorkers=2 --bail=1 __tests__/system_builder_x.test.js" }, + "front": { "cwd": "frontend", "cmd": "npx vitest run --maxWorkers=2 --bail=1 src/sheets/__tests__/x.test.ts" } + }, + "files": [ + { "file": "backend/systemBuilder/x.js", "tests": "server", "mutants": [["if (a && b)", "if (a)"]] } + ] +} +``` + +- Give each file the narrowest tests that cover it. `--bail=1` stops at the first failure, so a + caught break doesn't wait out every other test. +- Each "from" must appear exactly once in its file. Write line breaks as `\n`; the runner handles + CRLF. + +## 3. Run it + +```bash +node scripts/mutation-check.js +``` + +The runner checks that the tests pass before anything is broken, saves every file first, and puts +each one back after each break. It prints `N of M caught` and exits 1 if anything survived, was +missing, or didn't come back as it was. + +**If a run is stopped or killed:** run `node scripts/mutation-check.js --restore`, then +`git diff` the files, before anything else, and before committing. Never chain a commit after a +mutation run except with `&&`. + +## 4. Act on what survived + +- **A missing test:** add or tighten one so it fails on that break, then run the plan again. +- **Code that isn't needed:** if no test could catch the break because the code makes no + difference, remove the code. +- **A break that can't be caught by a test:** say why in the report, and don't leave it unexplained. + +Report the result as **N of M caught**, and name any test you added because of a survivor. diff --git a/.gitignore b/.gitignore index 5d3c3a47..b466437a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,9 @@ backend/nodemon.json .idea/ .DS_Store Thumbs.db -.claude/ +# Claude Code: local settings stay private; the repo's skills are shared. +.claude/* +!.claude/skills/ .agents/ # Tunnel Configs and Certificates diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..fa74ef97 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,74 @@ +# CITY_NET: notes for AI coding agents + +For any AI coding tool working on this repo (Claude Code reads it through `CLAUDE.md`), and for the +people using one. + +A shared city map for tabletop games, with tokens, character sheets, shops, a bank and dice, run +by a GM for their players. Since Phase 1 a **System Builder** lets a GM make any game's rules: +words, parts, stats and formulas, rolls, choices, rests, conditions. + +The builder's plan and its mockups live in `docs/`, which is gitignored and exists only on the +main developer's machine. If you don't have it, don't go looking for it: ask the owner, and work +from the code, `CHANGELOG.md` and the README. + +- `backend/`: Node + Express + Socket.IO, SQLite. The builder's rules are `backend/systemBuilder/`. +- `frontend/`: React + TypeScript + Vite. Pure logic sits in `frontend/src/sheets/`, and the + components only draw it. +- `e2e/`: Playwright browser journeys against the real backend and frontend (`e2e/README.md`). + +## Commands + +| What | Where | Command | +|---|---|---| +| Backend tests | `backend/` | `npx vitest run [files]`. Vitest, never `npx jest`. | +| Frontend tests | `frontend/` | `npx vitest run [files] --maxWorkers=2` | +| Frontend typecheck | `frontend/` | `npx tsc -b`. Plain `tsc --noEmit` checks nothing here. | +| Frontend lint | `frontend/` | `npx eslint ` | +| Browser journeys | `e2e/` | `npx playwright test tests/ --workers=1 --retries=0` | +| Mutation check | root | `node scripts/mutation-check.js ` (see `.claude/skills/mutation-check/SKILL.md`) | +| Changelog entry | root | `node scripts/changelog-add.js "### Added" ` | + +Check a test run by its exit code, not its summary line: Vitest can print every test green and +still exit 1 on an error outside a test. The person you're working for is usually on the same +machine, so go easy on it: `--maxWorkers=2`, one headless browser, a small viewport, and stop +servers when done. Their dev server and live database (`backend/city.db`) are theirs: never use them +for tests. The journeys start their own servers on ports 5055 and 5174. + +## House rules + +- **Git:** never push to `main` or `dev`. Builder work goes in small pieces, one branch each, PR'd + into `feature/system-builder`. Wait for a piece to be merged before starting the next one, + with no stacked branches. No AI co-author trailers or "generated with" footers. +- **Every piece** follows `.claude/skills/builder-piece/SKILL.md`: tests, a mutation check, the + upload paths, the docs, then a compare link and what to try. +- **US spelling** everywhere (color, armor), as the code's own names are. +- **Themes:** all 7 must work. Only theme variables, never a hard-coded color: `--green`, `--cyan`, + `--black`, `--dark-green`, `--danger`, `--warning`, `--grid-section`, `--grid-cell`, + `--panel-bg`. Portal floating UI into `themeRoot()` (`frontend/src/utils/themeRoot.ts`), never + `document.body`, or it falls outside the theme. +- **Windows:** use `DraggableWindow`, named like a program (`SHOP.EXE · VIC'S ARMS`), and + `TerminalWindow` for a window with several parts. Prefer folders in an existing window over new + windows. Keep rules in tested pure modules. +- **Line endings:** the source is CRLF. A script that edits files reads them as LF and writes CRLF + back. On Windows, don't pass quoted code to `node -e` from PowerShell: write a script file. +- **Lint:** react-refresh allows no hooks or other exports beside an exported component, so hooks go + in `frontend/src/hooks/`. +- **New libraries:** ask first, and add each to the README's Tech stack table in the same PR. +- **Frontend tests and backend code:** a frontend test may load a backend module only if that + module needs no third-party package (CI's frontend job has none). + `frontend/src/__tests__/crossBoundaryImports.test.ts` checks it. +- **Changing a message:** grep the tests for the old words before pushing. Tests pin messages word + for word. + +## Browser journeys + +- **On CI they run in four parts at once**, split test by test, each part on a fresh database. So + every test stands alone: never lean on what an earlier test made. +- CI is several times slower than a desktop, so wait for what you expect (`expect`, `expect.poll`), + never for a fixed time. A builder draft saves itself 5 s after the last edit: wait for + `save-status` to say `· SAVED` before reading it through the API. +- Look up a status within its panel, never across the whole page: each formula box has a + `role="status"` words line. +- A new builder page, tab or panel joins `tests/builder-clicks.spec.ts` in the same PR. A new thing + the GM makes gets its own short journey. Each option a GM can set is proven at the table once, + tracked in `e2e/COVERAGE.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 23c40e50..508c2e07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -693,6 +693,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **Notes and skills for AI coding tools in the repo.** `AGENTS.md` holds the project's commands and + house rules for any AI coding tool that reads it (Cursor, Copilot, Codex and others), and `CLAUDE.md` + loads it for Claude Code, so there is one copy. `.claude/skills/` holds two shared skills, `builder-piece` (the steps for each builder piece) + and `mutation-check` (breaking new code on purpose to see the tests catch it). Two helpers moved + into `scripts/`: `mutation-check.js`, which runs the tests first, puts every file back and can + undo a killed run with `--restore`, and `changelog-add.js`. The rest of `.claude/` (local + settings) stays out of the repo. - **The browser journeys run in four parts at once on CI.** Each pull request's journeys were run one after another on one machine, about 27 minutes. They now run on four machines at once, each with its own servers and database and a share of the tests, about 12 minutes. The check is still called diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..14f3e810 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,4 @@ +The project's notes for AI coding agents are in AGENTS.md, shared with other tools. The skills it +names are in .claude/skills/. + +@AGENTS.md diff --git a/README.md b/README.md index 06b07701..909432b6 100644 --- a/README.md +++ b/README.md @@ -1294,6 +1294,14 @@ CITY_NET/ ├── Dockerfile.backend ├── Dockerfile.frontend ├── .github/workflows/ # CI Tests on PRs and main; Release to Docker Hub on green main; Dev Build to Docker Hub on dispatch or a push to dev. Includes Updater on Real Docker (docker-test/updater-journey.mjs), and Nginx Proxy Behaviour, which runs the real nginx against the repository config and a stub upstream — a 40MB body through, the caller's address forwarded, the socket upgrading — because every other test mounts a router directly and never sees the proxy. And Browser Journeys, the e2e/ Playwright tests in a real browser +├── .claude/skills/ # Skills for Claude Code, shared with the repo (the rest of .claude/ is local and ignored) +│ ├── builder-piece/SKILL.md # The steps for one builder piece: branch, tests, mutation check, upload paths, docs, then a compare link and what to try +│ └── mutation-check/SKILL.md # Breaking new code on purpose to see the tests catch it: picking the breaks, writing the plan, acting on what survives +├── AGENTS.md # Notes for any AI coding tool (Cursor, Copilot, Codex, Claude Code...): what the project is, its commands, and the house rules (git, spelling, themes, windows, line endings, CI journeys); docs/ is only on the main developer's machine +├── CLAUDE.md # Claude Code's way in: it loads AGENTS.md (@AGENTS.md), so there is one copy of the rules +├── scripts/ # Helpers for working on the repo, run from its root +│ ├── mutation-check.js # Runs a mutation plan: tests pass first, each break run against its tests and put back, a backup so --restore undoes a killed run, exit 1 on a survivor +│ └── changelog-add.js # Adds an entry from a file under a heading of CHANGELOG.md's [Unreleased], keeping its line endings ├── docker-compose.yml # Image tags read ${IMAGE_TAG:-latest}, so the release channel is a setting rather than an edit ├── nginx.conf # Proxies /api and the socket to the backend. Forwards X-Forwarded-For, without which every request reaches the app from this container's address and anything counting per caller counts the whole table as one; and allows a body at least as large as the biggest upload the app accepts, or a large map is refused by the proxy before the app ever sees it. A backend test asserts the second └── .env.example diff --git a/scripts/changelog-add.js b/scripts/changelog-add.js new file mode 100644 index 00000000..d918f05e --- /dev/null +++ b/scripts/changelog-add.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +// Adds an entry to CHANGELOG.md under a heading of its [Unreleased] section: the first heading of that +// name before the first released version. The entry is a file (written with an editor, so it has no +// stray BOM or shell quoting), its own lines kept; the changelog keeps its line endings. +// +// node scripts/changelog-add.js "### Added" entry.md + +const fs = require('fs'); +const path = require('path'); + +const [heading, entryFile] = process.argv.slice(2); +if (!heading || !entryFile) { + console.error('Usage: node scripts/changelog-add.js "### Added" '); + process.exit(2); +} +const F = path.resolve(__dirname, '..', 'CHANGELOG.md'); +let t = fs.readFileSync(F, 'utf8'); +const crlf = t.includes('\r\n'); +if (crlf) t = t.replace(/\r\n/g, '\n'); +const entry = fs.readFileSync(entryFile, 'utf8').replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\n*$/, '\n'); +const start = t.indexOf('## [Unreleased]'); +const end = t.indexOf('\n## [', start + 1); +const head = `${heading}\n\n`; +const at = t.indexOf(head, start); +if (start < 0 || at < 0 || (end > 0 && at > end)) { + console.error(`No "${heading}" under [Unreleased] in CHANGELOG.md`); + process.exit(1); +} +t = t.slice(0, at + head.length) + entry + t.slice(at + head.length); +fs.writeFileSync(F, crlf ? t.replace(/\n/g, '\r\n') : t); +console.log(`Added under ${heading}.`); diff --git a/scripts/mutation-check.js b/scripts/mutation-check.js new file mode 100644 index 00000000..1f5be5f9 --- /dev/null +++ b/scripts/mutation-check.js @@ -0,0 +1,102 @@ +#!/usr/bin/env node +// A mutation check: breaks the code on purpose, one small change at a time, and runs the tests that +// guard it. A break the tests catch (they fail) is "caught"; one they miss "survived", and points at +// a missing test or code that isn't needed. See .claude/skills/mutation-check/SKILL.md. +// +// node scripts/mutation-check.js plan.json run the plan +// node scripts/mutation-check.js --restore put files back after a run was killed +// +// plan.json, paths from the repo root: +// { +// "tests": { "server": { "cwd": "backend", "cmd": "npx vitest run --maxWorkers=2 --bail=1 __tests__/x.test.js" } }, +// "files": [ +// { "file": "backend/systemBuilder/x.js", "tests": "server", +// "mutants": [["const most = 20;", "const most = 2;"], ["if (a && b)", "if (a)"]] } +// ] +// } +// +// Each "from" must appear exactly once in its file; one that doesn't is reported MISSING. Files keep +// their line endings (CRLF here): "from" and "to" are written with \n. Every file's text is saved +// before anything changes, so --restore can put it back even after the run is killed (a killed +// process skips its own clean-up). Exits 1 when a break survived, was missing, or a file didn't +// come back as it was. + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execSync } = require('child_process'); + +const ROOT = path.resolve(__dirname, '..'); +const BACKUP = path.join(os.tmpdir(), 'citynet-mutation-backup.json'); +const at = (p) => path.resolve(ROOT, p); + +const restore = () => { + if (!fs.existsSync(BACKUP)) return false; + const saved = JSON.parse(fs.readFileSync(BACKUP, 'utf8')); + for (const [file, text] of Object.entries(saved)) fs.writeFileSync(file, text); + fs.unlinkSync(BACKUP); + return true; +}; + +if (process.argv[2] === '--restore') { + console.log(restore() ? 'Restored every file from the last run.' : 'Nothing to restore.'); + process.exit(0); +} +if (fs.existsSync(BACKUP)) { + console.error(`A previous run was killed before it put its files back. Run: node scripts/mutation-check.js --restore\n(${BACKUP})`); + process.exit(2); +} +if (!process.argv[2]) { + console.error('Usage: node scripts/mutation-check.js | --restore'); + process.exit(2); +} + +const plan = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); +const run = (name) => { + const t = plan.tests[name]; + if (!t) throw new Error(`No tests named ${name}`); + try { execSync(t.cmd, { cwd: at(t.cwd), stdio: 'pipe', timeout: 300_000 }); return true; } catch { return false; } +}; + +// The tests must pass on the code as it is, or every break would look caught. +for (const name of new Set(plan.files.map((f) => f.tests))) { + if (!run(name)) { + console.error(`The "${name}" tests fail before anything is broken: fix them first.\n ${plan.tests[name].cmd}`); + process.exit(2); + } +} + +const originals = Object.fromEntries(plan.files.map((f) => [at(f.file), fs.readFileSync(at(f.file), 'utf8')])); +fs.writeFileSync(BACKUP, JSON.stringify(originals)); +const stop = () => { restore(); console.error('\nStopped: every file put back.'); process.exit(130); }; +process.on('SIGINT', stop); +process.on('SIGTERM', stop); + +const results = []; +try { + for (const f of plan.files) { + const file = at(f.file); + const original = originals[file]; + const crlf = original.includes('\r\n'); + const text = original.split('\r\n').join('\n'); + for (const [from, to] of f.mutants) { + const label = from.split('\n')[0].trim().slice(0, 90); + if (text.split(from).length !== 2) { results.push(['MISSING', f.file, label]); continue; } + const broken = text.replace(from, () => to); + fs.writeFileSync(file, crlf ? broken.split('\n').join('\r\n') : broken); + const caught = !run(f.tests); + fs.writeFileSync(file, original); + results.push([caught ? 'caught' : 'SURVIVED', f.file, label]); + console.log(`${caught ? 'caught ' : 'SURVIVED'} ${path.basename(f.file)} ${label}`); + } + } +} finally { + restore(); +} + +const changed = Object.entries(originals).filter(([file, text]) => fs.readFileSync(file, 'utf8') !== text).map(([file]) => file); +const caught = results.filter((r) => r[0] === 'caught').length; +console.log(`\n${caught} of ${results.length} caught.`); +for (const r of results.filter((x) => x[0] !== 'caught')) console.log(`${r[0]}: ${r[1]}: ${r[2]}`); +if (changed.length) console.error(`NOT PUT BACK: ${changed.join(', ')}`); +process.exit(caught === results.length && !changed.length ? 0 : 1);