#!/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)`, ); } });