Files
arah2/.planning/phases/04-destination-search-kl-gazetteer/04-PATTERNS.md
T

30 KiB
Raw Blame History

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):

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):

 * 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):

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):

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:

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):

/** 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):

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):

{
  "name": "@araharah/theme",
  "type": "module",

Dependency declaration (web/package.json:13-16):

  "dependencies": {
    "maplibre-gl": "^6.15.0",
    "@araharah/theme": "*"
  },

Cross-workspace JSON import — the exact gazetteer pattern (web/src/map/map.ts:20):

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):

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):

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):

  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):

  /** 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):

      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):

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):

 * 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):

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

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