Files
arah2/docs/DEVELOPMENT.md

640 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- generated-by: gsd-doc-writer -->
# arah² — Development Guide
Contributor-facing reference for working on arah² day to day: local setup, the
per-workspace dev loop, the build commands, the code style the project actually
enforces, branch and commit conventions, and the project-specific gates you must
not break.
For *using* the app, see the [README](../README.md). For *how the system is put
together*, see [ARCHITECTURE.md](./ARCHITECTURE.md). For *what to configure*
(secrets, `wrangler.jsonc`, env vars), see [CONFIGURATION.md](./CONFIGURATION.md).
For the road-graph fork contract, see [ROAD-GRAPH.md](./ROAD-GRAPH.md). For the
free-tier posture, see [BUDGETS.md](./BUDGETS.md).
---
## Local setup
### Prerequisites
| Requirement | Notes |
| --- | --- |
| Node.js + npm | No `engines` field and no `.nvmrc`/`.node-version` exist in the repo. The README says to use a current Node.js LTS. <!-- VERIFY: the actual minimum Node.js version — nothing in the repository pins it; confirm with maintainers. --> The `@araharah/search` and `@araharah/routing` sources are consumed by plain Node (the corpus runners rely on Node's type-stripping for relative `.ts` imports — the source headers call out Node 24). |
| npm with workspace support | npm 7+ required for `workspaces`. The repo uses npm exclusively — `package-lock.json` is committed and there is no `pnpm-lock.yaml` or `yarn.lock`. |
| A Cloudflare account | `wrangler` authenticates on first use via an interactive browser login. Needed for `bot:dev`, `bot:deploy`, KV access, and the R2 bucket (road graph). |
| A Telegram bot token | From @BotFather. Required by `bot:dev` and by every script in `bot/scripts/`. |
| Python 3 | Only for `theme`'s `gallery` / `basemap:preview`, which serve on port `8123` via `python3 -m http.server 8123`. |
| Network access | Fonts, tiles, the Play CDN in the mockup reference pages, and the Overpass fetch inside `build-road-graph.mjs` are all fetched over the network; the capture harnesses need it. |
### Clone and install
```bash
git clone http://gitmirah:3000/c-fu/arah2.git
cd arah2
npm install
```
`npm install` at the root installs all five workspaces (`theme`, `bot`, `web`,
`search`, `routing`) at once — npm hoists shared deps into the root
`node_modules/`. There is no per-workspace `npm install` step.
### Configure local secrets
There is no committed `.env`. The Worker reads secrets from `bot/.dev.vars`
(gitignored via `bot/.gitignore`):
```bash
cp bot/.dev.vars.example bot/.dev.vars
# then edit bot/.dev.vars and fill in BOT_TOKEN, WEBHOOK_SECRET, WEBHOOK_URL, MINI_APP_URL
```
`MINI_APP_URL` is the public Mini App origin (not a secret) the `/cari` venue
card's OPEN deep link is built from; it must match the URL registered in
@BotFather. Full details, including the `process.env` vs `.dev.vars` precedence
rule, are in [CONFIGURATION.md](./CONFIGURATION.md#environment-variables).
### Fork provisioning (before first run against real infra)
A fresh fork needs its own road graph and its own bindings before `bot:dev` /
`bot:deploy` route for real:
1. **Create the R2 bucket** (road-graph host — public configuration, never a
secret): `npx wrangler r2 bucket create araharah-graphs` (or your own name —
it must agree with `bot/wrangler.jsonc`'s `ROAD_GRAPH` `bucket_name`).
2. **Build the graph**: `node routing/scripts/build-road-graph.mjs` — fetches one
Overpass extract and encodes `routing/data/kl-roadgraph.v1.bin` (build
artifact, gitignored). `--check <file>` validates it offline. **Do not commit
the binary.**
3. **Upload it**: `node routing/scripts/upload-road-graph.mjs` (1 MB floor / 80 MB
ceiling pre-flight).
4. **Point the webhook**: `npm run bot:deploy` runs `wrangler deploy` +
`scripts/set-webhook.mjs` automatically; or set it standalone.
Steps 1–3 are spelled out fully in [ROAD-GRAPH.md](./ROAD-GRAPH.md) — that doc is
the fork contract. The search gazetteer needs no provisioning: its dataset
(`search/src/data/kl-gazetteer.json`) is committed.
### Pre-build before first run
Both `npm run dev` and `npm run deploy` run the build first, but building by hand
first gives you faster feedback:
```bash
npm run build # theme -> web (this is the deployable artifact)
```
---
## The dev loop
```mermaid
graph TD
classDef edit fill:#EB8008,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
classDef check fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
classDef gate fill:#ED3F1C,stroke:#1A1A1A,stroke-width:2px,color:#FFFFFF
EDIT["Edit source<br/>bot/src, web/src, theme/src,<br/>search/src, routing/src"]:::edit
TC["npm run bot:typecheck<br/>npm run web:typecheck<br/>npm run search:typecheck<br/>npm run routing:typecheck"]:::check
T1["npm run bot:test"]:::check
T2["npm run web:test"]:::check
T3["npm run search:test"]:::check
T4["npm run routing:test"]:::check
AU["npm run audit<br/>off-token CSS audit"]:::check
BUILD["npm run build<br/>theme then web"]:::check
DEV1["npm run bot:dev<br/>wrangler dev :8787"]:::edit
DEV2["npm run web:dev<br/>vite :5173"]:::edit
HARN["npm run web:build:harness<br/>+ npm run web:capture"]:::check
COMMIT["git commit<br/>type(phase): subject"]:::gate
EDIT --> TC
EDIT --> T1
EDIT --> T2
EDIT --> T3
EDIT --> T4
EDIT --> AU
TC --> BUILD
T1 --> BUILD
T2 --> BUILD
T3 --> BUILD
T4 --> BUILD
AU --> BUILD
BUILD --> DEV1
BUILD --> DEV2
BUILD --> HARN
DEV1 --> COMMIT
DEV2 --> COMMIT
HARN --> COMMIT
COMMIT --> EDIT
```
### Bot workspace (`@araharah/bot`)
```bash
npm run bot:dev # wrangler dev --port 8787 --local
```
Runs the Worker on <http://localhost:8787>. `--local` keeps everything on
Miniflare: KV is emulated under `bot/.wrangler/` (gitignored) and nothing touches
the production namespace. Secrets come from `bot/.dev.vars`.
The Worker serves both the API and the Mini App static assets, so after changing
anything under `web/` you must rebuild for `wrangler dev` to serve the new
bundle:
```bash
npm run build && npm run bot:dev
```
The cron trigger is exercisable locally under `wrangler dev`:
```bash
curl "http://localhost:8787/cdn-cgi/local/scheduled"
```
### Web workspace (`@araharah/web`)
```bash
npm run web:dev # vite dev server on http://localhost:5173
```
Vite is configured with `port: 5173` and `strictPort: true`, so it will fail
rather than silently pick 5174.
Important: **there is no Worker behind the Vite dev server.** `main.ts` calls
relative paths (`/api/session`, `/api/location`), which 404 against plain Vite.
For interactive work against a real API, either run `bot:dev` and open
<http://localhost:8787> (the Worker serves the built bundle), or use the harness
below. In `import.meta.env.DEV`, `main.ts` falls back to the mock Telegram
WebApp so the shell still renders.
### Harness (Mock-Telegram) workflow
The Mini App's UI can be driven without Telegram at all. The harness build swaps
in `web/src/mock/telegram-mock.ts` plus `/api/*` fetch stubs:
```bash
npm run web:build:harness # vite build --mode harness -> web/dist-harness/
npm run web:capture # Playwright screenshots of every boot/live/denied state
```
`npm run web:capture` serves `web/dist-harness` (never `web/dist` — the harness
block is dead-code-eliminated from the production build) and writes
`web/screenshots/*.png` (gitignored). It waits on the `RAMS-BASEMAP-READY` and
`ARAHARAH-LIVE` console signals and runs the GEO-03 runtime storage probe in-page
before declaring success.
Harness URL parameters, documented in `web/index.html`:
| Parameter | Values | Effect |
| --- | --- | --- |
| `?mock=` | `android`, `ios`, `tdesktop`, `macos`, `web` | Which Telegram client surface to simulate |
| `?scheme=` | `light`, `dark` | Telegram color scheme |
| `?pin=` | `1` | Drive the chat-pin relay link (`?pin=1` stubs `/api/location`) |
| `?locale=` | `en`, `ms` | Force a locale |
| `?sessiondelay=` | milliseconds | Delay the session handshake |
| `?fixdelay=` | milliseconds | Delay the location fix |
### Search workspace (`@araharah/search`)
```bash
npm run search:typecheck # tsc --noEmit
npm run search:test # node test/runner.mjs — unit suites + corpus assertions
npm run search:corpus # the golden-corpus bake-off (search/scripts/gazetteer-corpus.mjs)
```
`search:corpus` measures the pinned golden corpus against the SAME shared search
module the bot and the Mini App import — exit 0 only when top-3 hit rate clears
the corpus threshold AND every negative is refused (zero leaks). `corpus.test.mjs`
asserts the same measurements in-process, so a corpus regression fails the suite
too. Two further maintainer scripts regenerate the dataset when needed:
`search/scripts/overpass-topup.mjs` (fetch more POIs) and
`search/scripts/json-to-sqlite.mjs` — the committed dataset has an ODbL notice
inside its envelope.
### Routing workspace (`@araharah/routing`)
```bash
npm run routing:typecheck # tsc --noEmit
npm run routing:test # node test/runner.mjs — codec, snap, A*, seams
npm run routing:corpus # the offline tolerance gate (routing/scripts/route-corpus.mjs)
npm run routing:fixtures # regenerate fixtures from the OSRM demo server (maintainer tool)
npm run glyphs -w routing # render the turn-card PNGs into web/public/glyphs/
```
`routing:corpus` is fully offline (zero network in the measurement path): it
measures the committed fixture dataset against the same module the webview
imports. `routing:fixtures` is the only step that touches the OSRM demo server —
the committed fixtures were generated there, and the committed dataset is what
ships. The graph binary track lives under `routing/data/` (see *Fork
provisioning* above and [ROAD-GRAPH.md](./ROAD-GRAPH.md)); `build-road-graph.mjs`
and `upload-road-graph.mjs` are maintainer tools, and
`scripts/measure-route-cpu.mjs` feeds the budget audit's route-CPU gate.
### Theme workspace (`@araharah/theme`)
```bash
npm run build -w theme # src/theme.css -> dist/rams.css (minified)
npm run sheet # build, then open theme/src/sheet.html
npm run gallery # build + capture, then serve gallery.html + basemap on :8123
npm run audit # off-token audit of the rendered CSS
```
`npm run audit` and `npm run capture` are the two pieces of design-system
evidence tooling. `capture` renders `sheet.html` and the four read-only mockups
via Playwright and writes `theme/screenshots/` (gitignored). `gallery` blocks on
`python3 -m http.server 8123` serving the theme workspace, so stop it with
`Ctrl-C` when done.
Note that `npm run gallery` ends with a **blocking** `basemap:preview` server —
it will appear to hang. That is expected.
### Registering bot commands
The Telegram `/` menu is persistent server state, not something the Worker pushes.
Register it explicitly after adding or renaming a command:
```bash
npm run bot:commands # node scripts/register-commands.mjs
```
`bot/scripts/register-commands.mjs` reads `BOT_TOKEN` (from `process.env`, else
from `bot/.dev.vars`), derives each description from the `helpMenu` lines in
`bot/src/i18n/strings.ts`, and makes six `setMyCommands` calls (default en/ms, all_private_chats en/ms, and the two stale group-scope clears) — one for
the default scope (English) and one for `language_code: "ms"` (Malay). Because
the descriptions are *derived* from the same strings `/help` replies with, the
menu and the reply cannot drift.
If you add a command, you must add it to the `menuFor()` command array **and** to
the corresponding `helpMenu` lines — the script throws
`helpMenu for "<locale>" is missing its /<command> line (D-03 copy drift)` if the
two disagree. The gate `CS5 D-03: register-commands derives both scopes from
helpMenu` in `bot/test/commands.test.mjs` enforces this.
Only re-run this when the command *list or its descriptions* change. Repointing
the webhook is a separate script (`set-webhook.mjs`), run automatically by
`npm run deploy`. `scripts/set-menu-button.mjs` sets the BotFather menu-button
deep link (separate server state, rerun after origin changes).
---
## Build and check commands
### Root scripts (`package.json`)
| Command | Description |
| --- | --- |
| `npm run build` | Build `theme`, then `web`. Produces the deployable artifact. |
| `npm run dev` | `build`, then `bot:dev` — the one-command local app loop. |
| `npm run deploy` | `build`, then `bot:deploy` (`wrangler deploy` + `set-webhook.mjs`). |
| `npm run privacy-audit` | The consolidated privacy audit — five static gates + `--live` probes (`node scripts/privacy-audit.mjs`). |
| `npm run budget-audit` | The consolidated free-tier budget audit — six measurement gates + the comparison table (`node scripts/budget-audit.mjs`). |
| `npm run bot:test` | `npm test -w bot` — the bot's hand-rolled suite. |
| `npm run bot:typecheck` | `tsc --noEmit` in `bot`. |
| `npm run bot:dev` | `npm run dev -w bot` — `wrangler dev --port 8787 --local`. |
| `npm run bot:deploy` | `wrangler deploy && node scripts/set-webhook.mjs`. |
| `npm run bot:commands` | Register the Telegram command menu (EN + MS). |
| `npm run web:test` | `npm test -w web` — the Mini App suite. |
| `npm run web:typecheck` | `tsc --noEmit` in `web`. |
| `npm run web:dev` | `npm run dev -w web` — Vite dev server on 5173. |
| `npm run web:build` | Alias of `build` (theme then web). |
| `npm run web:build:harness` | Harness-mode build into `web/dist-harness/`. |
| `npm run web:capture` | Playwright screenshots of the Mini App states. |
| `npm run search:test` | `npm test -w search` — the gazetteer suite (incl. corpus assertions). |
| `npm run search:typecheck` | `tsc --noEmit` in `search`. |
| `npm run search:corpus` | The golden-corpus search bake-off. |
| `npm run routing:test` | `npm test -w routing` — the routing suite (codec, snap, A\*, seams). |
| `npm run routing:typecheck` | `tsc --noEmit` in `routing`. |
| `npm run routing:corpus` | The offline route-tolerance gate. |
| `npm run routing:fixtures` | Regenerate committed fixtures from the OSRM demo server (maintainer tool). |
| `npm run sheet` | Passthrough to the theme workspace. |
| `npm run gallery` | Passthrough to the theme workspace. |
| `npm run audit` | Passthrough to the theme workspace — off-token CSS audit. |
| `npm run capture` | Passthrough to the theme workspace — token-sheet screenshots. |
### Workspace-local scripts
| Command | Where | Description |
| --- | --- | --- |
| `npm test` | `bot`, `web`, `search`, `routing` | `node test/runner.mjs` — zero-dependency, auto-discovers `*.test.mjs`. |
| `npm run typecheck` | `bot`, `web`, `search`, `routing` | `tsc --noEmit`, `strict` + `noUncheckedIndexedAccess`. |
| `npm run dev` | `bot` | `wrangler dev --port 8787 --local`. |
| `npm run deploy` | `bot` | `wrangler deploy && node scripts/set-webhook.mjs`. |
| `npm run commands` | `bot` | `node scripts/register-commands.mjs`. |
| `npm run build` | `web` | `vite build` → `web/dist`. |
| `npm run build:harness` | `web` | `vite build --mode harness` → `web/dist-harness`. |
| `npm run capture` | `web` | `node scripts/capture.mjs`. |
| `npm run corpus` | `search` | `node scripts/gazetteer-corpus.mjs`. |
| `npm run corpus` | `routing` | `node scripts/route-corpus.mjs`. |
| `npm run fixtures` | `routing` | `node scripts/fetch-route-fixtures.mjs` (network — OSRM demo; maintainer tool). |
| `npm run glyphs` | `routing` | `node scripts/build-glyphs.mjs` — render `web/public/glyphs/*.png`. |
| `npm run build` | `theme` | `tailwindcss -i ./src/theme.css -o ./dist/rams.css --minify`. |
| `npm run send` | `theme` | Copy self-hosted `woff2` fonts into `dist/fonts/`. |
| `npm run basemap` | `theme` | `node basemap/build.mjs` — derive `basemap/liberty-rams.json` from tokens. |
| `npm run basemap:preview` | `theme` | `python3 -m http.server 8123` (blocking). |
| `npm run audit` | `theme` | `node ./scripts/audit.mjs ./dist/rams.css`. |
| `npm run capture` | `theme` | `node ./scripts/screenshot.mjs`. |
| `npm run test` | `theme` | `node ./scripts/audit.test.mjs`. |
Maintainer scripts **without** an npm alias (run with `node <path>`): the
road-graph track `routing/scripts/build-road-graph.mjs` and
`routing/scripts/upload-road-graph.mjs`, the dataset tools
`search/scripts/overpass-topup.mjs` and `search/scripts/json-to-sqlite.mjs`, and
the CPU ladder `routing/scripts/measure-route-cpu.mjs` (also invoked by
`budget-audit`'s route-CPU gate).
### Before you open a change
Run the four typechecks and the four test suites. They are fast and they are the
project's actual quality gates:
```bash
npm run bot:typecheck
npm run web:typecheck
npm run search:typecheck
npm run routing:typecheck
npm run bot:test
npm run web:test
npm run search:test
npm run routing:test
npm run audit # only if you touched theme/ or any rendered CSS
```
`theme` has its own suite (`npm test -w theme` → `node ./scripts/audit.test.mjs`).
Note it has **no root passthrough script** — you must use the `-w theme` form to
run it, unlike the other four workspaces.
As a release gate, run the two consolidated audits (they print evidence lines per
gate; exit 2 means NOT RUN and is never a pass):
```bash
npm run privacy-audit # add --live when credentials + a build exist
npm run budget-audit
```
---
## Code style
**There is no linter and no formatter configured in this repository.** No
ESLint, no Prettier, no Biome, no `.editorconfig`, no `lint` or `format` script
in any of the five `package.json` files, and no commit hooks (no Husky, no
`.git/hooks` wiring). If you add one, it becomes the project's standard
retroactively — but today style is a review-time convention, not an automated
check. What *is* mechanically enforced is TypeScript strictness and the gate
suites described below.
### TypeScript
All four TS workspaces (`bot`, `web`, `search`, `routing`) are strict beyond the
default. The settings that actually change how you write code:
| Setting | `bot` | `web` | `search` / `routing` | Why it matters |
| --- | --- | --- | --- | --- |
| `strict` | yes | yes | yes | No implicit `any`, strict null checks. |
| `noUncheckedIndexedAccess` | yes | yes | yes | Indexing an array/record yields `T \| undefined` — you must narrow. This is the single most common source of compile friction in this repo. |
| `verbatimModuleSyntax` | yes | yes | yes | Type-only imports must use `import type`. |
| `allowImportingTsExtensions` | yes | yes | yes | Import with explicit `.ts` extensions is allowed and is the house style. |
| `noEmit` | yes | yes | yes | `typecheck` is `tsc --noEmit`; bundling is wrangler's or Vite's job. |
| `target` / `lib` | ES2023 / ES2023 | ES2023 / ES2023 + DOM | ES2023 / ES2023 | No downleveling. The data planes are DOM-free. |
| `types` | `@cloudflare/workers-types` | `vite/client` | none | Determines which globals exist. `bot` has no DOM; `web` does; the data planes are V8-native. |
| `resolveJsonModule` | no | yes | yes | `web` imports the basemap JSON style; the data planes load their committed datasets via static JSON imports. |
`moduleResolution` is `"bundler"` in all four — deliberately not `node16`, because
the runtimes are bundler-hosted (wrangler / Vite) or Node type-stripping (the
corpus runners), not raw Node + CommonJS.
### House conventions observable in the source
- **Explicit `.ts` extensions on relative imports.** e.g. `import { strings } from
"../src/i18n/strings.ts"`, `from "./harness.mjs"`. Data-plane consumers import the
subpath with extension too (`@araharah/search/src/index.ts`) — a bare package
specifier resolves to nothing (no `exports`/`main` field).
- **`.mjs` for scripts and tests, `.ts` for source.** Everything in
`bot/scripts/`, `bot/test/`, `web/test/`, `web/scripts/`, `search/scripts/`,
`search/test/`, `routing/scripts/`, `routing/test/`, `theme/scripts/`, and
root `scripts/` is `.mjs` and runs under plain Node.
- **`node:` prefixed builtins** — `import { readFileSync } from "node:fs"`, never
bare `"fs"`.
- **Heavy header comments.** Every script and several source modules open with a
block comment stating what it is, which decision IDs it satisfies, and its
known constraints. Match that when adding a file.
- **Named constants in SCREAMING_SNAKE for tunables** — `SESSION_TTL_SECONDS = 900`,
`PIN_TTL_SECONDS = 300`, `GUIDANCE_SESSION_TTL_SECONDS = 7200`,
`CARD_CADENCE_S = 240`. The privacy gate greps for this exact shape (see below).
- **Zero inline user-facing copy.** All strings live in `bot/src/i18n/strings.ts`
and `web/src/i18n/strings.ts`, keyed by locale, with `en` and `ms` carrying
identical key-sets. Gate: `COPY5 no shell module inlines copy`.
- **Structural interfaces for injection.** `KvLike`, `TelegramWebAppLike`,
`acquireFix(deps, …)`, `createGuidanceWatch(geo, …)`, `HttpRoutingSourceOptions`
— dependencies are passed in so tests can supply fakes without touching
Workers types or `window`.
- **No runtime deps in the data planes.** `@araharah/search` and
`@araharah/routing` are V8-native and dependency-free; `routing/scripts/` may
use dev tools (`sharp` for glyph rendering) but the library code never does.
---
## Project conventions (the gates)
These are the rules the codebase enforces on itself. Each is a real test that
reads source or build artifacts — treat them as non-negotiable, because a change
that breaks one is a regression even if the behavior looks fine.
| Gate | Suite | Rule |
| --- | --- | --- |
| `PG1` | `bot/test/privacy.test.mjs` | Every `.put(` line in `kv/sessions.ts`, `kv/location-pin.ts`, `kv/guidance-sessions.ts`, and `webhook.ts` must carry `expirationTtl` **on the same line**, and the value must be a named `SCREAMING_SNAKE` constant — never an inline literal. No TTL-free write path can ship. The same law is asserted repo-wide by the `kv-ttl` gate in the consolidated privacy audit. |
| `PG2` | `bot/test/privacy.test.mjs` | The KV modules export nothing persistence-shaped: no `saveProfile`, `storeProfile`, `saveLocation`, `storeLocation`, `saveHistory`, `storeHistory`, `putHistory`, `setLocation`. |
| `GEO3` | `web/test/no-storage.test.mjs` | After stripping comments, no `web/src` file may reference `localStorage`, `sessionStorage`, `indexedDB`, `caches`, `CacheStorage`, `document.cookie`, `WebApp.DeviceStorage`, `WebApp.SecureStorage`, or `WebApp.CloudStorage`. Also no `serviceWorker.register`, `navigator.serviceWorker`, or `workbox`. The `storage-api` gate generalizes this repo-wide. |
| `WK5` | `bot/test/webhook.test.mjs` | An empty `WEBHOOK_SECRET` must fail the webhook **closed** with a constant 500, without running the bot or touching KV. |
| `CS4` | `bot/test/commands.test.mjs` | All replies go out with link previews disabled and nothing else attached; the command tree has no reply-menu or inline-button surface — except the two documented exceptions (the location reply keyboard and the `/cari` venue card). |
| `CS6` | `bot/test/commands.test.mjs` | The command stack registers exactly `/start`, `/mula`, `/help`, `/privacy`, `/privasi`, `/cari`, `/search`. |
| `COPY1` | `web/test/copy-gate.test.mjs` | `en` and `ms` carry identical key-sets (locale parity). |
| `COPY5` | `web/test/copy-gate.test.mjs` | No module under `web/src/ui/*.ts` or `web/src/main.ts` inlines copy strings. |
| `BA1`–`BA6` | `web/test/build-artifact.test.mjs` | The production bundle references no third-party CDN, self-hosts its `woff2` fonts, contains a hashed MapLibre worker chunk, carries the inlined Rams style JSON, and contains **no** Mock-Telegram installer or runner-page fetch stub in any emitted file. |
| `R12` | `web/test/guidance-replay.test.mjs` | The guidance state machine is pure and replay-verifiable: every behavior is decided in `guidance.ts`, and its structural case gates that the module contains no console, no document, no maplibre, no localStorage. |
| route-privacy | `bot/test/route-privacy.test.mjs` | The `/api/route` handler persists nothing and logs nothing beyond categories — the origin coordinate never touches storage or a log line. |
| audit exit codes | `theme/scripts/audit.mjs` | `0` clean, `1` off-token violations, `2` token-sheet drift. No silent passes. |
### The consolidated audits (release gates)
Two root tools carry the same laws beyond the suites — both are maintainer-time
repo tools (nothing in `bot/src`, `web/src`, `routing/src`, or `search/src`
imports them), both follow the same contract: one evidence line per gate, exit
`0` clean / `1` violations / `2` NOT RUN (environment drift — a skipped probe is
NEVER mistaken for a pass), category-only output (never a coordinate, id, token,
or payload). Both honor a `PRIVACY_AUDIT_ROOT` override and are cwd-independent,
and both double as the fork onboarding check.
**`npm run privacy-audit`** (`scripts/privacy-audit.mjs`, gates in
`scripts/lib/source-gates.mjs` + `scripts/lib/bundle-gates.mjs`):
| Gate | Rule |
| --- | --- |
| `storage-api` | The memory-only law, repo-wide. |
| `kv-ttl` | The PG1 same-line TTL law, repo-wide. |
| `log-vocabulary` | The D-14 category-only log law. |
| `runtime-hosts` | The POSITIVE fetch-host allowlist — no fetch outside it. |
| `bundle-telemetry` | The production `web/dist` scan — no analytics/beacon/telemetry. |
| `kv-expiry` / `asset-headers` (`--live`) | Live KV expiry metadata + cache headers, read verbatim. |
**`npm run budget-audit`** (`scripts/budget-audit.mjs`, gates in
`scripts/lib/budget-gates.mjs`):
| Gate | Rule |
| --- | --- |
| `route-cpu` | Re-runs the A\* CPU ladder (child process) — inside its budget or a documented, decided escalation. |
| `bundle-budget` | Route-payload gzip + whole-app bundle gzip ceilings. |
| `kv-writes` | Write classes + the cron's ZERO-writes law. |
| `subrequests` | The per-request-class subrequest inventory. |
| `golden-corpus` | Re-runs `routing:corpus` + `search:corpus`. |
| `cache-headers` (`--live`) | Observed cache headers, verbatim. |
The human record for both lives in `docs/BUDGETS.md` and
`docs/PRIVACY-AUDIT.md`.
The same privacy promise is asserted from both sides: the **bot** must never
write without a TTL, and the **web** client must have no storage surface at all.
If you add a persistence mechanism, these suites fail — which is the point.
### The three invariants worth internalizing
**Access control.** The `chat_id` for `/api/location`, `/api/location-fallback`,
and `/api/guidance` is *always* resolved from the session claim, never from
client input; locale comes from `claim.language_code`; the fallback request body
is never read and a guidance body's `chat_id` is structurally dropped. `/api/route`
uses the claim only as an identity gate and persists nothing about the caller.
If you add a route, derive identity the same way.
**Honest degradation.** Every failure is a labeled state, never a silent one and
never a fabricated value. A `null` heading hides the marker cone instead of
guessing; `getPin` range-validates so a corrupt record cannot fly the camera to
null island; the chat-pin poll is bounded at 60 s so `AWAITING PIN` cannot persist
forever; every source timeout resolves `null` so the chain always advances;
`bootMap()` returning `null` renders the shell with `data-map="absent"`; every
`/api/route` miss class answers `200 null` (the honest router-pending card); a
null road graph degrades a cron card to the street-less bare verb but still
sends. Copy along this pattern: if you cannot do the thing, say so in a state,
don't fake it.
**The backend ladders are import-site code.** Search and routing backends are
selected by constructing them at the import site — no env read, no config, no
registry. If you add a backend rung (e.g. engine B), it slots beneath the
existing seam and the ladder documentation updates in
`ARCHITECTURE.md`/`docs/SEARCH-QUALITY.md` — never a runtime switch.
### Design-token workflow
`theme/src/tokens.css` is the **single source of truth** for every color, spacing,
typography, and radius value in the project. It declares them in a Tailwind v4
`@theme static` block (55 `--color-*` tokens plus spacing and typography);
`static` forces every token into the compiled CSS so the package is consumable as
plain custom properties by anything, including the MapLibre basemap derivation.
Rules:
1. **Never hardcode a value that belongs to a token.** Not a hex, not a px
padding, not a font stack. `npm run audit` will flag it in rendered CSS.
2. **Never restate token values in documentation.** `DESIGN.md` and other docs
describe principles and point at the token package.
3. **Never edit `stitch_minimalist_route_navigator/`.** Those four mockups are
read-only reference; `theme/src/theme.css` `@source`-scans them to compile
their utility vocabulary, and `theme/scripts/screenshot.mjs` falls back to
their committed `screen.png` on capture failure.
4. **Adding a token** means editing `tokens.css`, rebuilding (`npm run build -w
theme`), and re-running `npm run audit`. If it is a cartographic color, also
consider whether `theme/basemap/build.mjs` should derive it.
5. **`theme/src/theme.css`** is the entry stylesheet: it `@import`s tailwindcss,
`tokens.css`, and `components.css`, declares seven self-hosted `@font-face`
rules from `theme/fonts/` (no Google Fonts CDN), scopes class generation
with explicit `@source` lines (the four mockups are the allowed utility
sources; `@source not "../scripts"` and `@source not "../basemap"` exclude
prose), and defines the `--shell-bg` / `--shell-on-surface` theme slots.
6. **The theme-slot machine.** Light/dark is a slot switch, not a color import:
`web/src/telegram/theme.ts` reads Telegram's signal (or the `header_bg_color`
luminance fallback) and its `applyTheme`'s ONLY statement is the
`data-tg-theme` attribute set — CSS custom properties re-slot between the two
Rams token pairs and the MapLibre style is never re-set. `web/test/theme.test.mjs`
greps that invariant; don't fight it.
7. **`theme/basemap/build.mjs`** forks OpenFreeMap `liberty` and derives every
cartographic color from `tokens.css` via `desaturate(...)`, with an accent
guard that fails the build if `#EB8008`, `#ED3F1C`, or `#736B1E` leak into any
layer. `building-3d` is stripped (no faux 3D).
---
## Branch and commit conventions
### Branches
The repo's only active branch is `main` (there is a stray `master` but it is not
the active branch; the remote is `origin/main`), and there is **no remote branch
convention documented** — no `CONTRIBUTING.md`, no PR template, no CI config (no
`.github/`, no `.gitea/`). In practice, work lands on `main` via atomic commits
rather than long-lived feature branches. If you prefer branches, `feat/`,
`fix/`, and `docs/` prefixes match the commit vocabulary below.
### Commit messages
The convention is **conventional-commit style with a phase-scoped type**:
`type(phase): subject`. The type is one of `feat`, `fix`, `docs`, `chore`, `test`.
The scope in parentheses is a phase identifier, sometimes with a sub-task
(`03-04`). Examples straight from `git log`:
```
feat(03-03): status strip, state cards, recenter control, boot choreography — map-first, D-09 staged states
fix(03-04): load telegram-web-app.js in head — production WebApp was never defined
docs(03-04): log debug round 5 — diagnostic probe armed
test(02-01): hand-rolled harness + RED initData vector suite (D-12, TDD RED)
chore(03-01): lockfile resolution for the web workspace
fix(02-02): ... (when the scope names the phase area rather than a number)
```
The subject is lowercase after the colon, imperative, and — the house habit —
carries an em-dash clause explaining the *why* or the observable failure it fixes
("production WebApp was never defined (no initData…)"). Reference decision IDs
(`D-09`, `WR-03`, `PRIV-02`) are appended in parentheses when relevant.
Keep commits atomic: the log shows one concern per commit, frequently a
`fix(...)` immediately after the `docs(...)` log entry that motivated it.
---
## Pull requests
**There is no PR process configured** — no `.github/PULL_REQUEST_TEMPLATE.md`, no
CONTRIBUTING file, no CI, no required reviewers. Given the private single-branch
setup, changes are typically committed straight to `main`. If you do open a PR,
match what the project actually relies on:
- **Run the gates** and paste the output: all four typechecks, all four test
suites, `npm run audit` if any rendered CSS or theme changed, and — for anything
privacy- or budget-relevant — `npm run privacy-audit` / `npm run budget-audit`.
- **Explain the observable before/after**, following the commit-subject habit
(what was broken, what it does now).
- **Call out gate-relevant surface**: if you touched a KV write, `web/src`
persistence, the command surface, the i18n strings, or the guidance envelope,
say which gates you re-verified (`PG1`, `GEO3`, `CS6`, `COPY1`, `R12`…).
- **Include screenshots** for any Mini App change — `npm run web:build:harness
&& npm run web:capture` produces the current states.
- **Do not commit build output.** `theme/dist`, `web/dist`, `web/dist-harness`,
`routing/data/*.bin`, and the `screenshots/` directories are gitignored by
design; they are regenerated at build time.
---
## Committing generated artifacts
`dist/` and the screenshot directories are **never committed**. This is a
deliberate rule, called out in `.gitignore`: the web build consumes
`theme/dist/rams.css` at build time without vendoring, so a committed
`theme/dist` would go stale. Always rebuild (`npm run build`) rather than
expecting a checked-in artifact.
`routing/data/kl-roadgraph.v1.bin` is likewise a build artifact (`routing/data/.gitignore`
ignores `*.bin` — it is ODbL-derived and per-fork), and `web/dist-harness/`
exists only for capture runs.
`.planning/` is git-TRACKED (the ignore line is commented out; planning artifacts are part of the repo) (local-only planning docs), as is `bot/.dev.vars`
and `bot/.wrangler/`.