39 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 05-routing-route-display | 01 | execute | 1 |
|
true |
|
|
Purpose: the pivot (CONTEXT D-01) replaces Valhalla-behind-a-proxy with a RoutingSource interface whose first backend serves build-time-precomputed fixture routes for 10–20 golden KL pairs. This plan lands the seam, the codec (RESEARCH Finding 1), the geo math, the fixture-backend matching law (200 m origin tolerance, honest-null pending state D-03), the committed-but-empty fixture dataset schema, and the three-environment loader proof — the exact pattern Phase 4's search/ workspace proved.
Output: a buildless workspace (pure .ts + .json), wired into the root package.json workspaces + delegate scripts, with RED→GREEN test suites for the contract, codec, source, and loader.
<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md </execution_context>
@AGENTS.md @.planning/phases/05-routing-route-display/05-CONTEXT.md @.planning/phases/05-routing-route-display/05-RESEARCH.md @.planning/phases/05-routing-route-display/05-PATTERNS.md @search/src/types.ts @search/src/source.ts @search/src/gazetteer.ts @search/package.json @search/tsconfig.json @package.json @bot/src/security/initdata.ts Key contracts the executor needs. `RoutingSource` and the route model are given VERBATIM here — downstream plans (05-02 corpus, 05-04 web surface) import these exact names.From routing/src/types.ts (this plan's output — the contract everything else implements against):
/** A WGS84 point. */
export interface RoutePoint {
lat: number;
lng: number;
}
/** A routing request. The origin is the live fix (memory only — GEO-03/D-07). */
export interface RouteRequest {
origin: RoutePoint;
/** The gazetteer id resolved by the Phase 4 destination resolver. */
destinationId: string;
}
/** Distance/duration summary. Distances in meters, durations in seconds. */
export interface RouteSummary {
distanceM: number;
durationS: number;
}
/**
* A maneuver step — DAY-ONE FIELD (D-07, GUID-05/06 upstream obligation).
* Fixture-world steps are ALWAYS empty arrays (D-10 defers instructions to
* Phase 6): an empty array is honest "not wired"; a fabricated step list
* would not be. Real step geometry arrives with backends #2/#3.
*/
export interface RouteStep {
distanceM?: number;
durationS?: number;
name?: string;
/** polyline6-encoded step geometry (backend #2/#3 populate this). */
geometry?: string;
}
/** Decoded geometry + the snapped-progress support field (D-07). */
export interface RouteGeometry {
points: RoutePoint[];
/** Same length as points; [0] === 0; non-decreasing. Meters along the line. */
cumulativeDistanceM: number[];
}
/** One routable alternative. options[0] of a result is the BEST route. */
export interface RouteOption {
geometry: RouteGeometry;
summary: RouteSummary;
steps: RouteStep[];
}
export interface RouteResult {
destinationId: string;
origin: RoutePoint;
/** 1..3 options, index 0 = best (order preserved from the backend). */
options: RouteOption[];
/** === the RoutingSource name that produced it. */
backend: string;
}
/**
* The pluggable routing seam (D-01). Backends: "fixture" (this phase) →
* "wasm-router" (OSS track, backend #2 — client-side, origin+destination
* never touch a server) → "valhalla" (VPS, backend #3). Backends #2/#3 do
* NOT ship in this phase — the interface exists so they slot in without a
* client rewrite, exactly parallel to SearchSource's ladder (D-03/D-04).
*
* THE NULL CONTRACT IS LOAD-BEARING (D-03): route() resolving to NULL is
* the honest "router pending" state — an unknown destination, or an origin
* outside the fixture corpus's tolerance. Never a blank map, never a fake
* route, never a thrown error for a normal miss.
*
* No logging, no storage, no network (D-05/D-12/D-15 analog): request
* coordinates never leave the caller's frame.
*/
export interface RoutingSource {
readonly name: string;
route(req: RouteRequest): Promise<RouteResult | null>;
}
From routing/src/fixture-source.ts (this plan's output):
/** A pair may serve a request only when the origin sits within this many
* meters (haversine) of the pair's pinned public origin (D-02/D-03). */
export const ORIGIN_TOLERANCE_M = 200;
export function createFixtureRoutingSource(
data?: RouteFixturesFile, // optional injection — tests pass synthetic datasets
): RoutingSource; // { name: "fixture", route(req) }
/** pairId -> pinned origin, or null when unknown (the 05-05 harness knob
* ?fixpair=<pair-id> reads this; public data only). */
export function getPairOrigin(pairId: string): RoutePoint | null;
/** Introspection for the corpus runner (plan 05-02) — lazy, memoized. */
export function getPairs(): readonly FixturePair[];
The fixture JSON schema (both the seed file this plan commits AND the shape routing/test/fixture-source.test.mjs injects):
{
"_license": "ODbL-1.0",
"_attribution": "© OpenStreetMap contributors",
"_notice": "Route geometry © OpenStreetMap contributors, precomputed from the public OSRM demo server (router.project-osrm.org) at build time between fixed public landmark coordinates. ODbL v1.0 — https://opendatacommons.org/licenses/odbl/1-0/",
"_source": "OSRM demo server (router.project-osrm.org) — build-time fetch, never a runtime dependency",
"version": 1,
"originToleranceM": 200,
"pairs": []
}
FixturePair (typed in types.ts):
export interface FixtureRoute {
/** polyline6, stored VERBATIM from the OSRM demo response. */
geometry: string;
distanceM: number;
durationS: number;
}
export interface FixturePair {
/** "<origin-id>-to-<dest-id>", slug charset — also the ?fixpair= knob value. */
pairId: string;
origin: { lat: number; lng: number; label: string };
destinationId: string;
/** Best-first, 1..3 entries (OSRM demo returns best-first; preserved). */
routes: FixtureRoute[];
}
The module header-contract style every new file copies (bot/src/security/initdata.ts):
/*
* <file> — <one-line purpose> (<decision/requirement IDs>).
* ---...
* <the structural laws this module obeys, citing D-xx>
*/
<environment_facts> Verified in this session — do not re-derive, do not spend a task re-probing:
- Node is v24.18.0, npm 11.16.0. Node 24 strips TypeScript types by DEFAULT, so a
.mjstest CANawait import("../src/foo.ts")— the repo's existing pattern (search/test/corpus.test.mjsdoes exactly this). - Static JSON import with the
with { type: "json" }attribute WORKS on this Node (probed in Phase 4;web/test/build-artifact.test.mjsline 47 does exactly this). - The root
package.jsonworkspacesarray is currently["theme","bot","web","search"]— this plan appends"routing". search/tsconfig.jsonis the platform-neutral tsconfig to copy (resolveJsonModule: true, no DOM/Workers types).- The polyline6 codec reference implementation round-trips in this session AND re-encodes a live OSRM demo geometry byte-identically (
encode(decode("ktv_Egu~~EsA…")) === original). Derived vectors are pinned in<polyline_vectors>` below. - A LIVE probe of the OSRM demo server (2026-10-24, KLCC→KL Sentral, public coordinates) returned
code: Ok, 2 routes, 6907.9 m / 663.2 s, polyline6 geometry — the external vector in the codec suite is real data, not invented. </environment_facts>
<polyline_vectors> Hand-derived vectors the codec suite MUST assert (computed with the reference algorithm in this session; the suite's own implementation must reproduce them — RED first):
decodePolyline6("??")→[{ lat: 0, lng: 0 }];encodePolyline6([{ lat: 0, lng: 0 }])→"??"(the zero-delta case)encodePolyline6([{ lat: 3.1578, lng: 101.7117 }])→"oqv_Egt~~`E"(NOTE: contains a backtick — mind the quoting in both the assertion and the plan's grep checks)encodePolyline6([{ lat: 3.1578, lng: 101.7117 }, { lat: 3.1579, lng: 101.7118 }, { lat: 3.158, lng: 101.712 }])→"oqv_Egt~~`EgEgEgEoK"(delta accumulation across 3 points)encodePolyline6([{ lat: -1.5, lng: 2.25 }, { lat: 0.75, lng: -0.125 }])→"~tpzA_pihC_pihCnt}oC"(negative-value zigzag:~inversion branch)- EXTERNAL VECTOR (live OSRM demo geometry, KLCC→KL Sentral, 2026-10-24):
decodePolyline6("ktv_Egu~~EsAlFaDnGeFEmGvEmWbSySjOiTfUiOb@{n@xZ}FhBsGx@cJ?")[0]deep-equals{ lat: 3.157846, lng: 101.711716 }(OSRM snapped the origin onto the road graph — the small offset from 3.1578/101.7117 is expected), andencodePolyline6(decodePolyline6())` === the original string. decodePolyline6("")→[](empty is legal);decodePolyline6("A")THROWS (truncated varint — never a NaN point); any odd-length or dangling-continuation input THROWS.- ROUND-TRIP LAW: for every string above,
encodePolyline6(decodePolyline6(s)) === s. </polyline_vectors>
<matching_law> The fixture backend's contract (fixture-source.ts):
- Slug guard first (mirror
search/src/source.tsENTRY_ID_PATTERN):destinationIdmust match/^[a-z0-9-]{1,64}$/or the request resolves tonullbefore the index is touched. - Lazy index (gazetteer LAW 1): on first
route()call, buildMap<pairId, FixturePair>+ a destinationId → pairs multimap ONCE, memoized in module scope of the factory closure.route-fixtures.jsonis parsed by the STATIC IMPORT at module-eval offixture-source.ts… NO — the static import gives the raw object but the INDEX (Maps, decoded geometry) must be built lazily: module scope holds only the import binding; everyMap/decodePolyline6call happens inside first-use functions. - Match: candidates = pairs whose
destinationIdequals the request's; among them, the FIRST (in file order) whosehaversineM(req.origin, pair.origin) <= ORIGIN_TOLERANCE_M(200) serves. None →null. - Shape:
options= pair.routes mapped best-first: geometry decoded viadecodePolyline6,cumulativeDistanceMviaderiveCumulativeDistanceM(starts at 0, non-decreasing, meters),steps: []ALWAYS (D-10 — a fixture-world step list would be fabricated), summary verbatim from the fixture.backend: "fixture". - Defensive validation (the
bot/src/kv/location-pin.tsprecedent): a malformed pair (non-finite distance/duration, geometry that throws on decode, empty routes array, routes > 3) is SKIPPED at index-build time — it renders as an honestnull, never a throw to the caller, never a NaN coordinate. (Plan 05-02's corpus gate makes malformed pairs unshippable in practice; this is the runtime backstop.) - Single-route results are normal: 1 option → the web surface renders no chip row (UI-SPEC). 2–3 options → index 0 is the best.
getPairOrigin(pairId)returns the pinned origin ornull(slug-guarded) — the harness knob (plan 05-05) and tests read this; it never consults the network. </matching_law>
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Live fix (request origin) → fixture source | The origin is the user's position in memory. It must never reach a log, a storage surface, or a network call — the module's three zero-laws are the mitigation surface. |
| Committed fixture JSON → runtime index | Fixtures are trusted repo content, but a malformed entry (non-finite distance, truncated polyline) would poison the map with NaN or throw in the webview's render path. |
| OSRM-encoded geometry → decoder | Decoded strings are external data at heart (built from a third-party demo server at build time); a decoder that returns partial/NaN points on malformed input converts data rot into a broken map draw. |
| Module scope → Workers/browser startup | Index construction or geometry decoding at module scope costs startup latency in every environment that imports the package (the Workers 1 s limit; the webview first paint). |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-05-01 | Information Disclosure | Request origin coordinates reaching a log line or storage surface (D-12/D-15 analog) | mitigate | The module contains NO console.* and NO storage token anywhere (gate C4 strips comments then greps — the web/test/no-storage.test.mjs comment-stripping pattern), takes no logger parameter, and performs no fetch. The origin lives in the caller's frame and dies with it. |
| T-05-02 | Tampering | Malformed polyline (truncated varint, odd length) decoding to NaN/partial coordinates that poison the map draw | mitigate | decodePolyline6 throws on truncated/dangling input (never returns partial points) — gate P7 asserts decode("A") throws; the fixture backend treats a throwing geometry as a malformed pair and skips it (matching_law #5), so the webview sees null (the pending card), never a NaN LngLat. |
| T-05-03 | Denial of Service | Hostile request shape (100 KB destinationId, wrong types) forcing unbounded work | mitigate | The slug guard (/^[a-z0-9-]{1,64}$/) rejects before the index is touched (gate F4); the index is a prebuilt Map; per-request work is O(matching pairs) with a ≤ 3-route projection — bounded by the committed dataset size, not the request. |
| T-05-04 | Spoofing / Elevation of Privilege | A config/env switch substituting a different backend to bypass the honest-pending contract | mitigate | No env reads, no config reads, no dynamic backend registry — the backend is selected IN CODE at the import site (plan 05-04 constructs createFixtureRoutingSource() directly). Backend #2/#3 are documented ladder entries (type header), not runtime switches. |
| T-05-06 | Repudiation | Fixture geometry shipped without the ODbL attribution (OSM derivative data) | mitigate | The _license/_attribution/_notice header travels INSIDE the JSON (the gazetteer's three-surfaces pattern); gate C6 asserts the seed file carries all three fields and the notice names ODbL v1.0. |
| T-05-07 | Tampering | Fabricated turn instructions passing as real maneuver data (D-10 violation — the app pretending guidance exists) | mitigate | RouteOption.steps is typed but fixtures ALWAYS produce steps: [] (matching_law #4) — gate F5 asserts every served option's steps array is empty; the UI surface (plan 05-04) renders no instruction from it. Day-one fields exist for Phase 6's real consumer (D-07), never for fake data. |
| </threat_model> |
"routing:test": "npm test -w routing",
"routing:typecheck": "npm run typecheck -w routing",
"routing:corpus": "node routing/scripts/route-corpus.mjs"
(routing:corpus points at the script plan 05-02 creates — the delegate lands NOW so the root script surface is stable. Do NOT touch build/deploy/dev — this workspace is buildless, the search/ precedent.)
routing/package.json — copy search/package.json's shape, renamed:
{
"name": "@araharah/routing",
"private": true,
"type": "module",
"scripts": {
"test": "node test/runner.mjs",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}
NO exports/main (the @araharah/theme + @araharah/search precedent — consumers import the explicit subpath with extension). NO runtime dependencies.
routing/tsconfig.json — copy search/tsconfig.json VERBATIM (it already has resolveJsonModule: true, allowImportingTsExtensions, verbatimModuleSyntax, noUncheckedIndexedAccess, strict, no DOM/Workers lib).
routing/test/runner.mjs + routing/test/harness.mjs — copy search/test/runner.mjs and search/test/harness.mjs VERBATIM (zero-dependency harness; no Jest/Vitest/Mocha — the repo law).
routing/src/types.ts — implement the <interfaces> block EXACTLY (RoutePoint, RouteRequest, RouteSummary, RouteStep, RouteGeometry, RouteOption, RouteResult, RoutingSource, FixtureRoute, FixturePair) with the header comment naming the D-01 backend ladder and the D-03 null contract. The day-one fields (D-07) are RouteStep (typed, optional geometry) and RouteGeometry.cumulativeDistanceM — their doc comments must say "GUID-05/06 upstream obligation" verbatim so the type gate (C2) can grep them.
routing/test/contract.test.mjs — RED-first, lazy-import style (let typesPromise = null; function loadTypes() { … import("../src/types.ts") … }). Cases:
C1 interface shape—types.tsexportsRoutingSourcewhoseroutereturns a Promise; assert via a stub object satisfying the interface compiled by Node's type-stripping (structural:{ name: "x", route: async () => null }is assignable by shape — asserttypeof stub.route === "function"andstub.route({origin:{lat:0,lng:0},destinationId:"x"}) instanceof Promise).C2 day-one model fields(D-07/D-11 type gate) — readrouting/src/types.tsAS TEXT and assert it containscumulativeDistanceM,steps: RouteStep[], and the literal stringGUID-05/06 upstream obligation(the field-level contract is pinned by test, not by hope).C3 null contract— the RoutingSource doc comment containsrouter pending(the D-03 semantics are contract documentation, grep-pinned).C4 no-logging/no-storage/no-network— for EVERY file matchingrouting/src/*.ts: strip block+line comments (web/test/no-storage.test.mjsstripCommentspattern) and assert ZERO occurrences ofconsole.,localStorage,sessionStorage,indexedDB,document.cookie,.put(,fetch(,XMLHttpRequest,process.env(gate grows with plan 05-02's script — scripts/ live OUTSIDE src/ and are exempt).C5 zero runtime dependencies—routing/package.jsonparses with NOdependenciesfield (D-05: nothing in the Worker/browser bundle path).C6 ODbL header present—routing/src/data/route-fixtures.jsonparses and carries_license: "ODbL-1.0",_attributioncontainingOpenStreetMap,_noticecontainingODbL, andversion: 1(T-05-06).
Create routing/src/data/route-fixtures.json with exactly the seed schema from the <interfaces> block — pairs: [] (plan 05-02's script populates it; the schema + ODbL header ship NOW so the loader proof has real bytes).
Acceptance: npm run routing:test exits 0 with PASS C1…PASS C6; npm run routing:typecheck exits 0.
cd /home/C-Fu/araharah && npm install --package-lock-only --workspaces 2>&1 | tail -3 ; npm run routing:test 2>&1 | tail -15 ; npm run routing:typecheck 2>&1 | tail -5 ; node -e "const w=require('./package.json').workspaces; if(!w.includes('routing')) throw new Error('routing workspace missing'); console.log('OK')"
<acceptance_criteria>
- node -e "const w=require('./package.json').workspaces; if(!w.includes('routing')) throw new Error(1); console.log('OK')" exits 0
- grep -q '"routing:test": "npm test -w routing"' package.json exits 0 AND grep -q '"routing:corpus": "node routing/scripts/route-corpus.mjs"' package.json exits 0
- test -f routing/package.json && test -f routing/tsconfig.json && test -f routing/test/runner.mjs && test -f routing/test/harness.mjs exits 0
- node -e "const p=require('./routing/package.json'); if(p.name!=='@araharah/routing') throw new Error(1); if(p.dependencies) throw new Error('zero runtime deps'); console.log('OK')" exits 0
- npm run routing:test exits 0 and the output contains PASS C1 through PASS C6
- npm run routing:typecheck exits 0
- grep -q 'export interface RoutingSource' routing/src/types.ts exits 0 AND grep -q 'cumulativeDistanceM' routing/src/types.ts exits 0 AND grep -q 'GUID-05/06 upstream obligation' routing/src/types.ts exits 0
- node -e "const f=require('./routing/src/data/route-fixtures.json'); if(!Array.isArray(f.pairs)||f.pairs.length!==0) throw new Error(1); if(f._license!=='ODbL-1.0') throw new Error(2); console.log('OK')" exits 0
</acceptance_criteria>
The routing/ workspace exists as a buildless fifth npm workspace with the RoutingSource contract (D-01 ladder + D-03 null semantics + D-07 day-one fields), the committed empty fixture schema with ODbL header, and a 6-case contract suite green.
export function decodePolyline6(encoded: string): RoutePoint[];
export function encodePolyline6(points: readonly RoutePoint[]): string;
Implementation laws:
- Pure: ZERO imports, ZERO I/O (the
search/src/distance.tsheader-comment style; cite RESEARCH Finding 1). - Standard algorithm: per coordinate, delta from previous value (
Math.round(value * 1e6) - prev), zigzag (v < 0 ? ~(v << 1) : (v << 1)), 5-bit varint chunks with continuation bit 0x20, chunk + 63 as the output char. Lat then lng per point. - Decode MUST throw on malformed input — a dangling continuation chunk (string ends mid-varint) or odd trailing coordinate: throw
new Error("truncated polyline6 input")(T-05-02: never return partial or NaN points).""decodes to[](legal empty). - Round-trip property: values live on the 1e-6 grid (decode divides integers by 1e6), so
encode(decode(s)) === sfor any valid string — the corpus gate (plan 05-02) leans on this.
routing/test/polyline.test.mjs — RED-first. Cases (vectors from the <polyline_vectors> block — they are computed ground truth, not guesses):
P1 zero vector—decode("??")→[{lat:0,lng:0}]; encode round-trip.P2 KL point vector— encode of[{lat:3.1578,lng:101.7117}]==="oqv_Egt~~`E"and decode inverts exactly.P3 delta accumulation— the 3-point KL vector (encode string from<polyline_vectors>); decode returns the 3 points exactly.P4 negative zigzag— the V4 vector (asserts the~inversion branch).P5 external OSRM vector— decode of the live-probe string's first point deep-equals{lat:3.157846,lng:101.711716};encode(decode(s)) === sfor the full string (external ground truth, RESEARCH Finding 1).P6 empty input—decode("")→[];encode([])→"".P7 malformed refusal—decode("A")throws;decode("g")throws (dangling continuation); neither returns a point (T-05-02).P8 round-trip law— for EVERY vector string in this suite:encode(decode(s)) === s.
Acceptance: npm run routing:test exits 0 with PASS P1…PASS P8 (plus task 1's C-cases still green).
cd /home/C-Fu/araharah && npm run routing:test 2>&1 | tail -15 ; npm run routing:typecheck 2>&1 | tail -5
<acceptance_criteria>
- npm run routing:test exits 0 and the output contains PASS P1 through PASS P8
- npm run routing:typecheck exits 0
- grep -q 'export function decodePolyline6' routing/src/polyline.ts exits 0 AND grep -q 'export function encodePolyline6' routing/src/polyline.ts exits 0
- node -e "import('./routing/src/polyline.ts').then(m=>{ if(JSON.stringify(m.decodePolyline6('??'))!==JSON.stringify([{lat:0,lng:0}])) throw new Error('P1'); if(m.encodePolyline6([{lat:3.1578,lng:101.7117}])!=='oqv_Egt~~\E') throw new Error('P2'); try{m.decodePolyline6('A'); throw new Error('should-throw');}catch(e){} console.log('codec OK')})"exits 0 -grep -v '^\s*//' routing/src/polyline.ts | grep -c 'from "' | head -1` returns 0 (ZERO imports — the distance.ts precedent)
</acceptance_criteria>
The polyline6 codec exists as a pure zero-dependency module proven by 8 named cases including the external live-OSRM vector and the malformed-input refusal law.
export function haversineM(a: RoutePoint, b: RoutePoint): number;
export function deriveCumulativeDistanceM(points: readonly RoutePoint[]): number[];
haversineM: standard great-circle distance, earth radius 6_371_000 m, returns meters (finite for finite inputs).deriveCumulativeDistanceM:[0, d(p0→p1), d(p0→p1)+d(p1→p2), …]— same length as input,[0] === 0, non-decreasing; single point →[0]; empty →[]. This is the snapped-progress support field (D-07): Phase 6's guidance snaps a fix's progress along the line using this array instead of re-deriving it.
routing/src/fixture-source.ts — backend #1 per the <matching_law> block: ORIGIN_TOLERANCE_M = 200, createFixtureRoutingSource(data?), getPairOrigin(pairId), getPairs(). Static import of the seed JSON (import fixturesData from "./data/route-fixtures.json" with { type: "json" }; — the data parameter overrides it for tests). Header comment: name the D-02/D-03/D-12 laws and the 200 m tolerance rationale (GPS jitter around a known landmark, never a fabricated route for an unknown origin).
routing/src/index.ts — named exports ONLY, header documenting the subpath-with-extension import law (import { createFixtureRoutingSource } from "@araharah/routing/src/index.ts"; — a bare @araharah/routing specifier resolves to NOTHING):
export type {
RoutePoint, RouteRequest, RouteSummary, RouteStep,
RouteGeometry, RouteOption, RouteResult, RoutingSource,
FixturePair, FixtureRoute,
} from "./types.ts";
export { decodePolyline6, encodePolyline6 } from "./polyline.ts";
export { haversineM, deriveCumulativeDistanceM } from "./geo.ts";
export {
ORIGIN_TOLERANCE_M,
createFixtureRoutingSource,
getPairOrigin,
getPairs,
} from "./fixture-source.ts";
routing/test/fixture-source.test.mjs — RED-first, injecting SYNTHETIC datasets via createFixtureRoutingSource(data) (never the network, never the committed file). Build one synthetic pair fixture in the test: pairId "kl-tower-to-kl-sentral", origin {lat:3.1528,lng:101.7038,label:"Kuala Lumpur Tower"}, destinationId "kl-sentral", two routes whose geometry is produced by encodePolyline6 over small point paths around KL Sentral, distanceM/durationS sane (e.g. 4_800 / 600). Cases:
F1 empty corpus → null—createFixtureRoutingSource({version:1,originToleranceM:200,pairs:[]})→ everyroute()resolvesnull(the committed seed's state until 05-02 fills it).F2 match serves best-first— request with the pinned origin + destinationId resolves a result withbackend:"fixture", 2 options, option 0 === fixture route order preserved, geometry points decode to the synthetic path, cumulative array starts at 0 and is non-decreasing,stepsdeep-equals[]for every option (T-05-07).F3 tolerance boundary— origin offset ~0.001 deg lat (≈111 m) from the pinned origin → serves; offset ~0.002 deg (≈222 m) →null(the 200 m law, haversine-verified).F4 slug guard— destinationId"not a slug!"and"".padEnd(65,"a")→nullWITHOUT consulting the index (T-05-03).F5 unknown destination → null— a valid slug with no pair →null(the D-03 honest pending; never a nearest-match).F6 malformed pair skipped— a dataset containing a pair withdistanceM: Number.NaNand a pair with an empty routes array servesnull(both skipped) while a well-formed sibling pair still serves — defensive validation, never a throw.F7 lazy index— source-text gate:fixture-source.tswith comments stripped has NOnew Map(and NOdecodePolyline6(call at TOP LEVEL (all inside functions — the gazetteer LAW 1 posture, Workers 1 s startup + webview first paint).F8 getPairOrigin— resolves the synthetic pair's pinned origin by pairId; unknown/garbage pairId →null.
routing/test/loader-env.test.mjs — the three-environment proof (Phase 4's top-mechanical-risk pattern):
L1 bare-Node static JSON import—import fixtures from "../src/data/route-fixtures.json" with { type: "json" }works under Node 24 type-stripping and exposespairs.L2 esbuild bundle marker—node_modules/.bin/esbuild routing/src/index.ts --bundle --format=esm --outfile=/tmp/routing-bundle.mjs(run viaexecFileSyncor spawnSync from the test) exits 0 AND the bundle text contains the seed markerrouter.project-osrm.org(the_sourceheader survives inlining) — Vite uses the same esbuild inlining, so the marker proves the browser path; L1 proves the corpus-runner path.L3 the empty seed serves null— the DEFAULTcreateFixtureRoutingSource()(no injection) resolvesnullfor a well-formed KL request (proves the committed file parses through the whole path).
Acceptance: npm run routing:test exits 0 with PASS F1…PASS F8 + PASS L1…PASS L3; npm run routing:typecheck exits 0.
cd /home/C-Fu/araharah && npm run routing:test 2>&1 | tail -20 ; npm run routing:typecheck 2>&1 | tail -5 ; ls node_modules/.bin/esbuild >/dev/null && echo "esbuild present"
<acceptance_criteria>
- npm run routing:test exits 0 and the output contains PASS F1 through PASS F8 and PASS L1 through PASS L3
- npm run routing:typecheck exits 0
- grep -q 'export function createFixtureRoutingSource' routing/src/fixture-source.ts exits 0 AND grep -q 'ORIGIN_TOLERANCE_M = 200' routing/src/fixture-source.ts exits 0
- grep -q 'route-fixtures.json' routing/src/fixture-source.ts exits 0 (the static import is present — LAW 2)
- grep -q 'export function haversineM' routing/src/geo.ts exits 0 AND grep -q 'export function deriveCumulativeDistanceM' routing/src/geo.ts exits 0
- node -e "import('./routing/src/index.ts').then(m=>{ if(m.ORIGIN_TOLERANCE_M!==200) throw new Error(1); if(typeof m.createFixtureRoutingSource!=='function') throw new Error(2); if(typeof m.getPairOrigin!=='function') throw new Error(3); console.log('index OK')})" exits 0
- node -e "import('./routing/src/geo.ts').then(m=>{const c=m.deriveCumulativeDistanceM([{lat:0,lng:0},{lat:0.001,lng:0}]); if(c.length!==2||c[0]!==0||!(c[1]>100&&c[1]<120)) throw new Error('cumulative math'); console.log('geo OK')})" exits 0
- grep -q 'router.project-osrm.org' routing/src/data/route-fixtures.json exits 0 (the loader marker exists in the committed seed)
</acceptance_criteria>
The fixture RoutingSource backend exists with the 200 m matching law, honest-null pending semantics, defensive pair validation, lazy index, and a proven three-environment loader — the complete backend #1 of the D-01 ladder.
<success_criteria>
- The
@araharah/routingworkspace compiles clean and its 17-case suite passes from the repo root. - The RoutingSource contract, codec, geo math, fixture backend, and public API all exist with the exact names downstream plans import.
- The committed seed fixture file carries the ODbL header and serves honest nulls.
- Zero runtime dependencies, zero network, zero logging, zero storage in
routing/src/. </success_criteria>