Files

431 lines
30 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.
# 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)
> **PATH RECONCILIATION (2026-10-23, plan-phase revision).** This map was written BEFORE the
> phase plans fixed the file layout. The ANALOGS below are all still correct and are cited by
> the plans; only the NAMES of the new files changed. The plans are authoritative. Mapping:
>
> | Pre-planning path in this map | Realized path (per plans 04-01…04-04) |
> |---|---|
> | `shared/` workspace pkg | the `search/` workspace pkg (`@araharah/search`) |
> | `shared/src/types.ts` / `rules.ts` / `normalize.ts` / `distance.ts` / `score.ts` | `search/src/types.ts` / `rules/abbreviations.ts` / `normalize.ts` / `distance.ts` / `score.ts` |
> | `bot/src/search/source.ts` (SearchSource impl) | `search/src/source.ts` (+ `search/src/gazetteer.ts` loader, `search/src/index.ts` public API) |
> | `shared/data/kl-gazetteer.json` | `search/src/data/kl-gazetteer.json` |
> | `shared/data/golden-corpus.json` | `search/test/fixtures/golden-corpus.json` |
> | `shared/data/NOTICE.md` (ODbL) | `LICENSES.md` (repo root) |
> | `bot/scripts/gazetteer-topup.mjs` | `search/scripts/overpass-topup.mjs` |
> | `bot/scripts/gazetteer-corpus.mjs` | `search/scripts/gazetteer-corpus.mjs` |
> | `bot/scripts/gazetteer-to-sqlite.mjs` | `search/scripts/json-to-sqlite.mjs` |
> | `web/src/search/destination.ts` | `web/src/search/resolve-destination.ts` (+ `web/src/search/destination-card.ts`) |
> | `web/src/pwa/homescreen.ts` | `web/src/telegram/homescreen.ts` |
> | `web/test/destination.test.mjs` | `web/test/destination-preload.test.mjs` |
## 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<void> {
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<void> {
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<LocationPin | null> {
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=<id>` 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 <tokens.css>] [--pages <a.html,b.html>] [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<Locale, BotStrings>` 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=<id>` (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