docs(04): pattern map — analogs + conventions for every phase-4 surface
This commit is contained in:
@@ -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<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
|
||||||
Reference in New Issue
Block a user