diff --git a/.planning/phases/04-destination-search-kl-gazetteer/04-PATTERNS.md b/.planning/phases/04-destination-search-kl-gazetteer/04-PATTERNS.md new file mode 100644 index 0000000..42dddd8 --- /dev/null +++ b/.planning/phases/04-destination-search-kl-gazetteer/04-PATTERNS.md @@ -0,0 +1,411 @@ +# Phase 4: Destination Search + KL Gazetteer - Pattern Map + +**Mapped:** 2026-10-23 +**Files analyzed:** 30 new/modified surfaces (grouped into 18 rows below) +**Analogs found:** 16 / 18 rows matched (2 rows are genuinely new: repo-level LICENSE + ODbL data notice) + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|-------------------|------|-----------|----------------|---------------| +| `bot/src/bot/commands/cari.ts` | controller | request-response | `bot/src/bot/commands/start.ts` | exact | +| `bot/src/bot/handlers/search-alternate.ts` | controller | event-driven (callback_query) | `bot/src/bot/handlers/location-fallback.ts` | exact | +| `bot/src/bot/bot.ts` (mod) | controller wiring | request-response | itself (registration list) | — | +| `bot/src/search/source.ts` (SearchSource + GazetteerSource) | service | request-response (in-memory query) | `bot/src/kv/location-pin.ts` (typed module + defensive validation) | role-match | +| `bot/src/i18n/strings.ts` (ext: search copy) | config/i18n | transform | itself + `web/src/i18n/strings.ts` | exact | +| `bot/src/config.ts` (ext: possible `MINI_APP_URL`) | config | — | itself (typed `Env`) | — | +| `shared/` workspace pkg (`package.json`, `src/index.ts`) | package config | — | `theme/package.json` (`@araharah/theme`) | exact | +| `shared/src/types.ts` | model (types) | — | `bot/src/kv/location-pin.ts` interface block | role-match | +| `shared/src/rules.ts` (JUPEM table) | config/data | transform | (data — no code analog; RESEARCH §"Malay normalizer" is the source) | none (data) | +| `shared/src/normalize.ts` | utility | transform (pure) | `bot/src/security/initdata.ts` shape (pure parse fn, typed in/out) | partial | +| `shared/src/distance.ts` (Damerau OSA) | utility | transform (pure) | none — new algorithm; RESEARCH code example is the spec | none | +| `shared/src/score.ts` (tiered scorer) | utility | transform (pure) | none — new; RESEARCH Pattern 3 is the spec | none | +| `shared/data/kl-gazetteer.json` | data | — | `theme/basemap/liberty-rams.json` (committed JSON consumed cross-workspace) | role-match | +| `shared/data/golden-corpus.json` | data (test fixture) | — | `bot/test/fixtures/` (new dir) convention | none (data) | +| `web/src/search/destination.ts` | service | request-response (id→entry) | `web/src/session/store.ts` (module-scope, memory-only) | role-match | +| `web/src/main.ts` (mod: dest preload) | controller (boot) | event-driven boot | itself (staged boot; Phase 3) | — | +| `web/src/telegram/webapp.ts` (ext: home-screen) | middleware/adapter | — | itself (`TelegramWebAppLike` optional members) | — | +| `web/src/pwa/homescreen.ts` (new guard, placement at planner's discretion) | hook | event-driven | `web/src/telegram/backbutton.ts` wire pattern | role-match | +| `web/src/mock/telegram-mock.ts` (ext) | test harness | — | itself (`MockOptions` extension precedent) | — | +| `web/index.html` (mod: `?dest=` profile) | config (harness page) | — | itself (profile block, lines 20–35) | — | +| `web/scripts/capture.mjs` (ext: dest capture) | script | batch | itself (profile-driven shots) | — | +| `bot/scripts/gazetteer-topup.mjs` | script (repo tool) | file-I/O + network | `bot/scripts/register-commands.mjs` + `theme/scripts/audit.mjs` | exact | +| `bot/scripts/gazetteer-corpus.mjs` | script (test runner) | batch | `bot/test/runner.mjs` + `theme/scripts/audit.mjs` (exit-code contract) | exact | +| `bot/scripts/gazetteer-to-sqlite.mjs` | script | file-I/O transform | `bot/scripts/register-commands.mjs` | role-match | +| `bot/test/*.test.mjs` (8 new suites) | test | — | `bot/test/commands.test.mjs` + `bot/test/privacy.test.mjs` (grep gates) | exact | +| `web/test/destination.test.mjs`, `web/test/homescreen.test.mjs` | test | — | `web/test/` suites (same harness shape as bot) | exact | +| `bot/test/commands.test.mjs` (ext), `web/test/no-storage.test.mjs` (ext), `web/test/build-artifact.test.mjs` (ext) | test (gates) | — | themselves | — | +| `root package.json` + `bot/package.json` + `web/package.json` (mod: workspace wiring) | config | — | existing `@araharah/theme` wiring (`web/package.json:15`) | exact | +| `LICENSE` (GPL-3.0) + `shared/data/NOTICE.md` (ODbL) | doc | — | **none** — repo has no LICENSE today (RESEARCH Finding 4) | none | +| `bot/wrangler.jsonc` / `bot/.dev.vars.example` (mod: `MINI_APP_URL` if chosen) | config | — | themselves (`.dev.vars.example:1-5` comment style) | — | + +## Pattern Assignments + +### `bot/src/bot/commands/cari.ts` (controller, request-response) + +**Analog:** `bot/src/bot/commands/start.ts` (command registration + pure builder + strings-only copy) — with `location-fallback.ts` as the *markup-exception documentation* precedent, because `/cari` is the second deliberate exception to Phase 2's D-02 "plain text only" law (it sends `sendVenue` + inline keyboard). + +**File header + register pattern** (`bot/src/bot/commands/start.ts:14-32`): +```typescript +import type { Bot, Context } from "grammy"; +import { pickLocale, strings } from "../../i18n/strings.ts"; +import type { Locale } from "../../i18n/strings.ts"; + +/** Pure builder — the exact text the /start surface replies with. */ +export function startReplyText(locale: Locale): string { + return strings[locale].startIntro; +} + +async function handleStart(ctx: Context): Promise { + const locale = pickLocale(ctx.from?.language_code); + await ctx.reply(startReplyText(locale), { link_preview_options: { is_disabled: true } }); +} + +/** Register the command stack entry: /start and its Malay alias /mula. */ +export function registerStart(bot: Bot): void { + bot.command("start", handleStart); + bot.command("mula", handleStart); +} +``` +Copy for `/cari`: `registerCari(bot, source)` registers `bot.command("cari", ...)` (+ its reserved alias if any), a pure exported builder for testability, all copy from `strings.ts`, query length-bound **before** any processing (RESEARCH §Security Domain V5: ≤128 chars). + +**The documented-exception pattern for the venue card's inline keyboard** (`bot/src/bot/handlers/location-fallback.ts:15-22` header — replicate this documentation style in `cari.ts`): +```typescript + * D-02 structural note: this handler is the ONE deliberate exception + * to the Phase-2 "plain text only" command-surface rule. The GEO-02 + * reply keyboard is required by the platform; it lives in handlers/ + * and is NOT part of commandSources in commands.test.mjs's CS4 gate +``` +⚠️ **Consequence for the planner:** `commands.test.mjs:78-87` (CS4) greps `commandSources` for `/keyboard/i` and must FAIL unless the CS4 file list or the gate is amended when `cari.ts` ships. `commands.test.mjs:150-163` (CS6) asserts the exact registered command set `["help","mula","privasi","privacy","start"]` — adding `/cari` breaks this gate by design; extend the expected list. `commands.test.mjs:141-145` explicitly greps register-commands for `cari|pergi_ke` stubs — that assertion must be updated when the command goes live. + +**Bot API call pattern via `bot.api` (not `bot.chat`)** (`bot/src/bot/handlers/location-fallback.ts:36-44`): +```typescript +export async function requestLocationFallback(bot: Bot, chatId: number, locale: Locale): Promise { + await bot.api.sendMessage(chatId, strings[locale].awaitingPin, { + reply_markup: { + keyboard: [[{ text: strings[locale].shareLocationButton, request_location: true }]], + resize_keyboard: true, + one_time_keyboard: true, + }, + }); +} +``` +`sendVenue` follows the same `bot.api.*` / `ctx.replyWithVenue` shape; RESEARCH §"Code Examples" gives the exact grammy `InlineKeyboard().webApp(...).row().text(alt, \`alt:${id}\`)` payload. + +--- + +### `bot/src/bot/handlers/search-alternate.ts` (controller, event-driven) + +**Analog:** `bot/src/bot/handlers/location-fallback.ts` — the register-with-dependency pattern (handler receives its dependency via `createBot`, not `env`). + +**Registration + dependency threading** (`bot/src/bot/handlers/location-fallback.ts:62-72`, wired at `bot/src/bot/bot.ts:35`): +```typescript +export function registerLocationFallback(bot: Bot, kv: LocationPinKv): void { + bot.on("message", async (ctx: Context) => { + const location = ctx.message?.location; + if (!location) return; + const chatId = ctx.chat?.id; + if (chatId === undefined) return; + await recordSharedPin(kv, chatId, location); + const locale = pickLocale(ctx.from?.language_code); + await ctx.reply(strings[locale].pinReceived, { link_preview_options: { is_disabled: true } }); + }); +} +``` +Copy for the alternates handler: `registerSearchAlternate(bot, source)` — `bot.callbackQuery(/^alt:(.+)$/, ...)`, regex-validate `ctx.match[1]` against `/^[a-z0-9-]{1,64}$/` **before** the gazetteer lookup (V5), `await ctx.answerCallbackQuery()` on every path (grammY's 30-second rule), plus the catch-all `bot.on("callback_query:data")` that always answers. Note bot.ts threads `env.chat_sessions` positionally — `registerSearchAlternate(bot, source)` matches this shape; the `SearchSource` instance is constructed in `createBot` from the gazetteer import. + +**Wiring site** (`bot/src/bot/bot.ts:28-37`) — where the new registrations slot in: +```typescript +export function createBot(env: Env, opts: { baseFetch?: typeof fetch } = {}): Bot { + const bot = opts.baseFetch + ? new Bot(env.BOT_TOKEN, { client: { fetch: opts.baseFetch } }) + : new Bot(env.BOT_TOKEN); + registerStart(bot); // /start + /mula (D-04) + registerHelp(bot); // /help + registerPrivacy(bot); // /privacy + /privasi (D-04) + registerLocationFallback(bot, env.chat_sessions); // GEO-02 location reply fallback + return bot; +} +``` + +--- + +### `bot/src/search/source.ts` (service, in-memory query) + `shared/src/types.ts` + +**Analog:** `bot/src/kv/location-pin.ts` — the repo's pattern for a typed data module: narrow exported interface, a named constant, "the entire surface" documentation, and **defensive validation on read** (a corrupt record can never become a coordinate). + +**Typed record + named constant + full-surface discipline** (`bot/src/kv/location-pin.ts:25-32`): +```typescript +/** GEO-03: a shared pin ephemerally relays chat → Mini App (which polls + * GET /api/location); 300 s bounds the exposure, then it is gone. */ +export const PIN_TTL_SECONDS = 300; + +/** The entire persisted pin surface — a 4th property means drift. */ +export interface LocationPin { + latitude: number; + longitude: number; + horizontal_accuracy: number | null; +} +``` +`GazetteerEntry` (id, name, aliases, lat, lng, category, address?) copies this discipline: the interface comment names D-07/D-08 and states that a field beyond the list means schema drift. + +**Defensive read — the pattern the gazetteer loader and `byId()` should mirror** (`bot/src/kv/location-pin.ts:52-73`): +```typescript +export async function getPin(kv: LocationPinKv, chatId: number): Promise { + const raw = await kv.get(`loc:${chatId}`); + if (raw === null) return null; + try { + const parsed: unknown = JSON.parse(raw); + if ( + parsed && + typeof parsed === "object" && + "latitude" in parsed && + ... + Math.abs((parsed as LocationPin).latitude) <= 90 && + Math.abs((parsed as LocationPin).longitude) <= 180 + ) { + return parsed as LocationPin; + } + return null; + } catch { + return null; + } +} +``` +`GazetteerSource.search()` / `byId()` return `null`/`[]` on any miss — never a best-guess entry (D-13's honest-refusal contract). + +**Env contract extension point** (`bot/src/config.ts:4-12`): if the plan adds `MINI_APP_URL` (RESEARCH A6 — public origin, belongs in `wrangler.jsonc` `vars`, not a secret), it extends this same typed `Env` interface with the same per-line comment style. + +--- + +### `shared/` workspace package (NEW — cross-workspace module) + +**Analog:** the `theme` package — the repo's one proven example of a scoped workspace consumed by another workspace, including a **JSON subpath import**, which is exactly the gazetteer's consumption shape. + +**Package identity** (`theme/package.json:1-3`): +```json +{ + "name": "@araharah/theme", + "type": "module", +``` + +**Dependency declaration** (`web/package.json:13-16`): +```json + "dependencies": { + "maplibre-gl": "^6.15.0", + "@araharah/theme": "*" + }, +``` + +**Cross-workspace JSON import — the exact gazetteer pattern** (`web/src/map/map.ts:20`): +```typescript +import ramsStyle from "@araharah/theme/basemap/liberty-rams.json"; +``` +`shared/package.json` copies the `@araharah/` scope + `"type": "module"`; both `bot/package.json` and `web/package.json` gain `"@araharah/search": "*"`; root `package.json:5-9` `workspaces` array gains `"shared"`. Note: `theme` has **no** `exports` map — subpath imports resolve by file path. `shared/` should do the same (no build step, source-imported), OR add an `exports` map if the planner wants path stability — `theme`'s no-exports shape is the precedent. + +**Import-extension convention (load-bearing for the shared module):** bot and web both import TS with explicit `.ts` extensions (`bot/tsconfig.json:10-11` — `verbatimModuleSyntax: true`, `allowImportingTsExtensions: true`; every import in `bot/src/webhook.ts` and `web/src/main.ts` ends in `.ts`). The shared module's own internal imports must use `.ts` extensions too, and `import type` for type-only imports (`verbatimModuleSyntax` enforces it). + +**Node-24 type-stripping is proven in-repo** — `.mjs` scripts already import `.ts` directly (`bot/test/commands.test.mjs:27`, `bot/scripts/register-commands.mjs:25`): +```javascript +const { strings, pickLocale } = await import("../src/i18n/strings.ts"); +``` +So the corpus runner can `await import("@araharah/search/src/index.ts")` under Node 24 (research A5 verified by existing passing suites; still assert it in the plan's first task). + +--- + +### `shared/src/normalize.ts`, `shared/src/score.ts`, `shared/src/distance.ts` (utility, pure transform) + +**No codebase analog** — the repo has no pure-computation module yet; the closest *style* analog is `bot/src/security/initdata.ts` (pure function, typed input/output, zero IO). RESEARCH is the spec: §"Pattern 2" (rule table + inversion at module load), §"Pattern 3" (disjoint tier bands), §"Code Examples" (full OSA implementation + normalizer + glued-word splitter). Repo conventions to carry in: the file-header comment block documenting decision IDs (every bot/web file opens with a `/* name.ts — purpose (D-xx). --- */` banner — see `location-pin.ts:1-21`), and named exported constants over inline literals (the `PIN_TTL_SECONDS` / PG1 "named TTL constant" rule at `bot/test/privacy.test.mjs:41-45` shows literals are gated against). + +--- + +### `web/src/search/destination.ts` + `web/src/main.ts` dest preload (controller, boot) + +**Analog:** `web/src/session/store.ts` (module-scope in-memory state — the destination lives the same way) + the boot sequence in `main.ts`. + +**Memory-only module state — the exact persistence posture for the destination** (`web/src/session/store.ts:18-26`): +```typescript +export const SESSION_TTL_SECONDS = 900; + +let token: string | null = null; +let issuedAtSeconds: number | null = null; + +export function setSession(nextToken: string, issuedAtSec: number = Math.floor(Date.now() / 1000)): void { + token = nextToken; + issuedAtSeconds = issuedAtSec; +} +``` +The destination id/entry is in-memory module scope "alongside `lastFix`, exactly like the existing in-memory-only pattern" (RESEARCH §Security Domain V3). No storage call anywhere — `web/test/no-storage.test.mjs` greps every web/src file with comments stripped. + +**Boot-order discipline** (`web/src/main.ts:55-94`): safe-area → Telegram bindings → map mounts FIRST → chrome → locale from RAW initData (`main.ts:94`: `const locale: Locale = wa ? localeFromInitData(wa.initData) : "en";`). The destination read slots in beside the locale read — read `location.search` + `initData.start_param` once at boot, resolve through the web's gazetteer copy, hold the entry in module scope, null-guard everything (the `map !== null` guard pattern at `main.ts:218-221` is the model for "unknown id → honest unavailable state, never a blank map"). + +**Payload readers** — RESEARCH §"Code Examples" gives the two readers (`readDestinationId()` from `location.search`, `readStartParam(wa)` from initData), both validated `/^[A-Za-z0-9_-]{1,64}$/` before lookup. `initDataUnsafe` is untrusted (Telegram's own docs) — safe here only because the value is re-resolved against the local gazetteer, never used as identity/URL/path. + +--- + +### `web/src/telegram/webapp.ts` extension (home-screen capability, ONBD-03) + +**Analog:** itself — the hand-rolled contract's **optional-member** pattern. New capabilities arrive as optional methods so existing fakes stay valid. + +**The optional-member precedent to copy** (`web/src/telegram/webapp.ts:26-37`): +```typescript + isVersionAtLeast(version: string): boolean; + close(): void; + onEvent?(event: string, cb: () => void): void; + offEvent?(event: string, cb: () => void): void; + BackButton?: { show(): void; hide(): void; onClick(cb: () => void): void; offClick(cb: () => void): void }; + LocationManager?: { + ... + openSettings?(): void; + }; +``` +Extend with `addToHomeScreen?(): void` and `checkHomeScreenStatus?(cb: ...): void` + the `HomeScreenStatus` union — exactly RESEARCH's §"ONBD-03 feasibility" snippet. The capability guard combines method presence + `wa.isVersionAtLeast("8.0")` (the mock already implements `versionAtLeast` — `web/src/mock/telegram-mock.ts:112-122`). Do NOT re-add `@types/telegram-web-app` (deliberately dropped, per the file header at `webapp.ts:4-9`). + +--- + +### `web/src/mock/telegram-mock.ts` extension + `web/index.html` `?dest=` profile (test harness) + +**Analog:** themselves — the `MockOptions` extension pattern and the URLSearchParams profile block. + +**MockOptions extension precedent** (`web/src/mock/telegram-mock.ts:29-43` — every knob is documented with its consumer): +```typescript + /** Telegram language_code carried in the fake initData's user JSON — + * lets main.ts's localeFromInitData exercise its real parsing path + * and lets the runner page switch locales via ?locale=. */ + languageCode?: string; + /** Artificial delivery delay (ms) for LocationManager.init()/getLocation() + * callbacks — the runner page's ?fixdelay= knob. ... */ + deliveryDelayMs?: number; +``` +Add `startParam?: string` (appended into `buildMockInitData` — currently `web/src/mock/telegram-mock.ts:136-140` emits only `user/auth_date/query_id/hash`, the exact gap RESEARCH flags) and `homeScreenStatus?: "unsupported"|"unknown"|"added"|"missed"` so the ONBD-03 branches are testable without a device. + +**Runner-page profile pattern** (`web/index.html:21-35`): +```javascript + const q = new URLSearchParams(location.search); + const profile = q.get("mock") || "android"; + const scheme = q.get("scheme") === "dark" ? "dark" : "light"; + const locale = q.get("locale") === "ms" ? "ms" : "en"; + ... + // Set BEFORE main.ts is imported — this is the marker main.ts's install guard checks. + window.__ARAHARAH_MOCK__ = { profile, scheme, locale }; +``` +The `?dest=` knob follows this: `q.get("dest")` inside the same `if (__ARAHARAH_HARNESS__)` block, plumbed into `installMockWebApp({...})` at `web/index.html:63-70`. The capture harness (`web/scripts/capture.mjs:79-116`) already builds the harness bundle and asserts installer presence/absence both directions — add a dest-profile shot the same way the existing profile shots are declared in its header (`capture.mjs:22-29`). + +--- + +### `bot/scripts/gazetteer-topup.mjs`, `gazetteer-corpus.mjs`, `gazetteer-to-sqlite.mjs` (repo tooling) + +**Analog 1:** `bot/scripts/register-commands.mjs` — bot-side repo script: shebang, decision-ID header, `.dev.vars` reading, exit(1) on failure. + +**Script config-reading pattern** (`bot/scripts/register-commands.mjs:29-54`): +```javascript +function readDevVars() { + const devVarsPath = path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", ".dev.vars"); + let raw = ""; + try { + raw = readFileSync(devVarsPath, "utf8"); + } catch { + return {}; + } + ... +} + +const token = process.env.BOT_TOKEN ?? readDevVars().BOT_TOKEN; +if (!token) { + console.error("BOT_TOKEN missing — copy bot/.dev.vars.example to bot/.dev.vars ..."); + process.exit(1); +} +``` + +**Analog 2:** `theme/scripts/audit.mjs` — the gate-script contract: CLI flags with defaults + **documented exit codes, no silent passes** (`theme/scripts/audit.mjs:11-23`): +```javascript + * Usage: + * node scripts/audit.mjs [--tokens ] [--pages ] [css...] + * Exit codes (no silent passes — T-1-07): + * 0 clean — every checked declaration derives from tokens + * 1 violations — each printed as file:line: declaration + * 2 drift/error — token sheet degraded (see message) +``` +The corpus runner copies this contract verbatim: usage header, exit 0 / exit 1 (top-3 < 90% or any negative leaked), per-case MISSED/LEAKED reporting (RESEARCH §"The corpus runner"). The top-up script gets `--source=overpass|nominatim` flags, reads its Overpass fixture from `bot/test/fixtures/overpass-response.json` in test mode, and must never be imported by `bot/src/` (grep gate — Pitfall 6). Repo scripts import TS via Node-24 type stripping (`register-commands.mjs:25` proves the pattern). + +--- + +### `bot/test/*.test.mjs` (8 new suites) + web test suites + +**Analog 1:** the harness pair — `bot/test/harness.mjs:6-10` (registry) and `bot/test/runner.mjs:15-30` (auto-discovery; a suite that fails to *load* becomes a failing case, so new suites can never crash the run silently). New suites are drop-in files; no config. + +**Analog 2 — source-introspection gate style** (`bot/test/commands.test.mjs:27-32`): +```javascript +const { strings, pickLocale } = await import("../src/i18n/strings.ts"); + +const BOT_SRC = path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", "src", "bot"); +const commandSources = ["bot.ts", "commands/start.ts", ...].map((rel) => + path.join(BOT_SRC, ...rel.split("/")), +); +``` +…with regex assertions over the read source (CS4 at lines 78–87, CS6 registration-set scan at 150–163). New structural gates copy this: the D-15 "no query in any log line" grep gate, the "no overpass URL under bot/src" gate, the corpus-tautology check, the id-set parity check (reads both workspace JSON copies). + +**Analog 3 — the storage grep gate** (`bot/test/privacy.test.mjs:32-49`, PG1): per-line scanning with file:line failure messages, and PG2's forbidden-identifier regex. The D-15 query-leak gate is this exact style applied to log statements. Also note PG1's named-constant rule (line 41–45) — a convention new code must keep. + +--- + +## Shared Patterns + +### Category-only logging (D-15 — binding) +**Source:** `bot/src/webhook.ts:66, 112, 122, 150, 181, 196` +```typescript +console.log("[webhook] rejected: misconfigured"); +console.log(`[initdata] rejected: ${result.reason}`); // D-14: category ONLY — no payload, no ids, no timestamps +console.log("[location-fallback] failed: telegram-send"); +``` +**Apply to:** every log line in `cari.ts`, `search-alternate.ts`, `source.ts`. Shape: `[search] rejected: no-match`. Never the query text, never results, never user ids. Enforced by a new grep gate in the `privacy.test.mjs` style. + +### Strings-module-only copy + en/ms parity gate +**Source:** `bot/src/i18n/strings.ts:21-42` (typed `BotStrings` interface with per-field decision-ID doc comments; `Record` so a missing ms key is a type error) and the CS1 gate (`bot/test/commands.test.mjs:36-41`). Web mirror: `web/src/i18n/strings.ts:16-47`. +**Apply to:** `cari.ts`, `search-alternate.ts`, the web destination-unavailable state, the ONBD-03 affordance. Extend `BotStrings`/`ShellStrings`; extend CS1 expectations; "not in v1 area" copy lives in strings, never inline. + +### `register*(bot, deps)` factory + env-threaded dependencies +**Source:** `bot/src/bot/bot.ts:32-35`, `location-fallback.ts:62` +**Apply to:** `registerCari(bot, source)`, `registerSearchAlternate(bot, source)`. Dependencies are constructed in `createBot` and passed positionally; handlers never import env or singletons. + +### Explicit `.ts` import extensions + `import type` under `verbatimModuleSyntax` +**Source:** `bot/tsconfig.json:10-11`; every import in `bot/src/webhook.ts`, `web/src/main.ts` +**Apply to:** all new TS files in `bot/src`, `web/src`, and inside `shared/src` (internal imports too). + +### Cross-workspace consumption via `@araharah/*` + committed JSON subpath import +**Source:** `web/package.json:15`, `web/src/map/map.ts:20`, `theme/package.json:1-3`, root `package.json:5-9` +**Apply to:** `shared/` package creation and both consumers' package.json edits. Both the Worker (wrangler/esbuild bundles the import) and Vite inline the JSON at build time — no `fs` anywhere under `bot/src`/`web/src` (Workers have no fs; the anti-pattern list is explicit). + +### Memory-only client state, never storage +**Source:** `web/src/session/store.ts:1-17` (header documents the invariant), enforced by `web/test/no-storage.test.mjs` +**Apply to:** the preloaded destination in the webview — module scope, no localStorage/sessionStorage/cookie/IndexedDB/caches. Extend the no-storage gate for the new `web/src/search/` + `web/src/pwa/` files. + +### Source-introspection grep gates for structural claims +**Source:** `bot/test/privacy.test.mjs` (PG1/PG2), `bot/test/commands.test.mjs` (CS4/CS5/CS6) +**Apply to:** D-15 log gate, no-Overpass-in-Worker gate, callback_data ≤64 bytes + `alt:`-only gate, corpus-tautology gate, LICENSE/ODbL-notice presence gate, id-set parity gate. + +### Repo-script contract: header, flags, exit codes, no silent passes +**Source:** `theme/scripts/audit.mjs:11-23`, `bot/scripts/register-commands.mjs:29-54` +**Apply to:** `gazetteer-topup.mjs`, `gazetteer-corpus.mjs`, `gazetteer-to-sqlite.mjs`. Corpus runner exit 1 = D-14 gate failure; wire as `npm run search:corpus` (root script delegating with `-w bot` style used at `package.json:18-28`). + +### Harness profile knobs: URLSearchParams → MockOptions → module global +**Source:** `web/index.html:20-35`, `web/src/mock/telegram-mock.ts:25-43, 136-140` +**Apply to:** `?dest=` (ONBD-02 capture evidence), `startParam` (Carrier B), `homeScreenStatus` (ONBD-03 branches). Everything inside `if (__ARAHARAH_HARNESS__)` so production dead-code-eliminates it (the BA5/T-03-21 discipline documented at `web/index.html:14-19` and asserted in `capture.mjs:99-115`). + +## No Analog Found + +| File | Role | Data Flow | Reason | +|------|------|-----------|--------| +| `LICENSE` (GPL-3.0) + `shared/data/NOTICE.md` (ODbL) | doc | — | Repo has no LICENSE today (RESEARCH Finding 4). Verbatim GPL-3.0 text + OSMF-style attribution; content is prescribed, not patterned. | +| `shared/src/distance.ts`, `shared/src/score.ts`, `shared/src/rules.ts` | utility (pure) | transform | No computation modules exist in the repo. RESEARCH §"Malay normalizer" + §"Pattern 3" + §"Code Examples" carry the implementations verbatim — the planner should lift them as the spec, adding only the repo's file-header/decision-ID conventions. | +| `shared/data/kl-gazetteer.json`, `golden-corpus.json` | data | — | Curated content (D-06/D-08/D-13) — hand-authored, never generated; schema shape comes from RESEARCH Pattern 1. | + +## Metadata + +**Analog search scope:** `bot/src/**`, `bot/test/**`, `bot/scripts/**`, `web/src/**`, `web/scripts/capture.mjs`, `web/index.html`, `web/package.json`, `theme/scripts/**`, `theme/package.json`, root `package.json`, `bot/tsconfig.json`, `bot/wrangler.jsonc`, `bot/.dev.vars.example` +**Files read in full or in part:** 22 +**Key cross-cutting findings for the planner:** +1. `/cari` breaks three existing gates by design — CS4 (no-keyboard grep), CS5 (no `cari` stubs in register-commands), CS6 (exact command set). Plan must amend `bot/test/commands.test.mjs` in the same task that adds the command. +2. The shared-module consumption shape is proven end-to-end by `@araharah/theme`: scoped workspace name, `"*"` dependency, no exports map, committed-JSON subpath import, source-imported TS via Node 24 type stripping (proven by `commands.test.mjs:27` and `register-commands.mjs:25`). +3. The `web_app` inline button on the venue card is the second exception to the D-02 text-only law — `location-fallback.ts` is the documentation precedent (header note + exclusion from CS4's file list). +**Pattern extraction date:** 2026-10-23