diff --git a/bot/test/guidance-envelope.test.mjs b/bot/test/guidance-envelope.test.mjs new file mode 100644 index 0000000..e909d65 --- /dev/null +++ b/bot/test/guidance-envelope.test.mjs @@ -0,0 +1,188 @@ +#!/usr/bin/env node +/* + * guidance-envelope.test.mjs — GE1–GE7 types-only gates for the frozen + * guidance_sessions contract (D-06/D-07). + * --------------------------------------------------------------------- + * The envelope is a TYPES-ONLY frozen contract this phase (D-06): code + * types + the ~2h TTL constant + the key-scheme doc. NO KV namespace, NO + * service, NO runtime writes — Phase 6 builds the backgrounded turn-card + * service on the frozen shape. These gates pin that boundary MECHANICALLY: + * + * GE1 TTL constant — GUIDANCE_SESSION_TTL_SECONDS === 7200 (the + * ~2h law; every future write carries it) + * GE2 field freeze — GuidanceSessionEnvelope has EXACTLY + * [id, chat_id, route_snapshot, created_at] and + * GuidanceRouteSnapshot has EXACTLY + * [destinationId, destinationName, distanceM, + * durationS, geometry, startedAtSec] (the + * SessionClaim "a 5th property means drift" law) + * GE3 inline snapshot — route_snapshot is TYPED INLINE + * (GuidanceRouteSnapshot, not a reference) and + * the tokens snapshotRef / snapshot_ref appear + * NOWHERE in bot/src (D-07: one key, one TTL) + * GE4 types-only — comments stripped, the module contains zero + * behavior tokens (no KV binding type, no + * put/get/delete, no function export, no import) + * GE5 no new namespace — wrangler.jsonc carries EXACTLY ONE kv + * namespace (chat_sessions) + * GE6 no Env field — config.ts gains no guidance field this phase + * GE7 no premature — no bot/src module imports the contract; only + * consumer the test does (Phase 6 builds the consumer) + * + * RED-shape note: GE1–GE4 fail while the module is absent. GE5–GE7 are + * negative-space boundary guards over files that must NOT change — they + * are green by construction and redden only on drift (the 05-01 C4/C5/C6 + * zero-laws precedent). + * + * Phase 6 note: when the turn-card service legitimately adds the kv + * namespace binding, the Env field, and the consumer, updating GE4–GE7 is + * THAT plan's responsibility — these gates redden until then (T-05-08: + * the contract cannot acquire a runtime shadow this phase). + */ +import { strict as assert } from "node:assert"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "./harness.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); // bot/test +const srcDir = path.join(here, "..", "src"); // bot/src +const MODULE = path.join(srcDir, "kv", "guidance-sessions.ts"); +const CONFIG = path.join(srcDir, "config.ts"); +const WRANGLER = path.join(here, "..", "wrangler.jsonc"); + +/* Comments stripped first — banned-token scans read CODE, not prose + * (routing/test/contract.test.mjs C4 / web no-storage pattern). */ +function stripComments(src) { + return src + .replace(/\/\*[\s\S]*?\*\//g, " ") // block comments + .replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (benign over-stripping) +} + +function tsFilesUnder(dir) { + const out = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...tsFilesUnder(p)); + else if (entry.name.endsWith(".ts")) out.push(p); + } + return out; +} + +/* ————— GE1 — the ~2h TTL constant exists as a documented value ————— */ + +test("GE1 guidance TTL constant — GUIDANCE_SESSION_TTL_SECONDS === 7200 (~2h, D-06)", async () => { + const mod = await import("../src/kv/guidance-sessions.ts"); + assert.equal( + mod.GUIDANCE_SESSION_TTL_SECONDS, + 7200, + "the ~2h TTL must exist as the documented constant awaiting its Phase 6 consumer", + ); +}); + +/* ————— GE2 — the field freeze (SessionClaim law) ————— */ + +/** Field names of `interface { ... }` — lines shaped `^\s{2}\w+:`. */ +function interfaceFields(source, name) { + const decl = source.indexOf(`interface ${name} {`); + assert.notEqual(decl, -1, `interface ${name} not found in the contract module`); + const bodyStart = source.indexOf("{", decl) + 1; + const bodyEnd = source.indexOf("\n}", bodyStart); + assert.notEqual(bodyEnd, -1, `interface ${name} body never closes`); + return source + .slice(bodyStart, bodyEnd) + .split("\n") + .map((line) => line.match(/^\s{2}(\w+):/)) + .filter(Boolean) + .map((m) => m[1]); +} + +test("GE2 field freeze — envelope has EXACTLY [id, chat_id, route_snapshot, created_at]; snapshot EXACTLY its six", () => { + const source = readFileSync(MODULE, "utf8"); + assert.deepEqual( + interfaceFields(source, "GuidanceSessionEnvelope"), + ["id", "chat_id", "route_snapshot", "created_at"], + "the envelope grew/shrank a field — a 5th property means drift (the SessionClaim law)", + ); + assert.deepEqual( + interfaceFields(source, "GuidanceRouteSnapshot"), + ["destinationId", "destinationName", "distanceM", "durationS", "geometry", "startedAtSec"], + "the route snapshot surface drifted (GUID-05/06 scoped fields)", + ); +}); + +/* ————— GE3 — the snapshot is INLINE (D-07: one key, one TTL) ————— */ + +test("GE3 inline snapshot — route_snapshot is typed inline; zero snapshot-ref tokens in bot/src", () => { + const source = readFileSync(MODULE, "utf8"); + assert.ok( + source.includes("route_snapshot: GuidanceRouteSnapshot"), + "route_snapshot must be typed INLINE (GuidanceRouteSnapshot), never a string/number reference", + ); + for (const file of tsFilesUnder(srcDir)) { + assert.doesNotMatch( + readFileSync(file, "utf8"), + /snapshotRef|snapshot_ref/, + `${file} — snapshot-by-reference token found (D-07: one key, one TTL, nothing reconstructable after expiry)`, + ); + } +}); + +/* ————— GE4 — the module ships NO behavior (T-05-08) ————— */ + +test("GE4 types-only — comments stripped, zero behavior tokens in guidance-sessions.ts", () => { + const code = stripComments(readFileSync(MODULE, "utf8")); + const banned = [ + "KvNamespace", + ".put(", + ".get(", + ".delete(", + "export function", + "export async function", + "import ", + ]; + for (const token of banned) { + assert.ok( + !code.includes(token), + `types-only violation: behavior token "${token}" in guidance-sessions.ts (T-05-08: premature persistence path)`, + ); + } +}); + +/* ————— GE5 — exactly one kv namespace (chat_sessions) ————— */ + +test("GE5 no new namespace — wrangler.jsonc carries EXACTLY ONE kv namespace: chat_sessions", () => { + const wrangler = JSON.parse(stripComments(readFileSync(WRANGLER, "utf8"))); + const namespaces = wrangler.kv_namespaces; + assert.equal(Array.isArray(namespaces), true, "kv_namespaces must remain an array"); + assert.equal( + namespaces.length, + 1, + "a second kv namespace appeared — the guidance envelope is TYPES-ONLY this phase (D-06)", + ); + assert.equal(namespaces[0].binding, "chat_sessions", "the single namespace must remain chat_sessions"); +}); + +/* ————— GE6 — the Env contract gains nothing this phase ————— */ + +test("GE6 no Env field — config.ts contains no guidance token", () => { + const config = readFileSync(CONFIG, "utf8"); + assert.ok( + !config.includes("guidance"), + "config.ts gained a guidance field (D-06: Env gains NOTHING this phase — Phase 6's plan owns that change)", + ); +}); + +/* ————— GE7 — no premature consumer ————— */ + +test("GE7 no premature consumer — zero bot/src imports of guidance-sessions (only the test imports it)", () => { + const IMPORT_RE = /\bimport\b[^;\n"']*["'][^"']*guidance-sessions[^"']*["']/; + for (const file of tsFilesUnder(srcDir)) { + const code = stripComments(readFileSync(file, "utf8")); + assert.doesNotMatch( + code, + IMPORT_RE, + `${file} imports the guidance contract (D-06: no consumer until Phase 6 builds the service)`, + ); + } +});