- GE1 TTL constant === 7200 (~2h, D-06) - GE2 field freeze: envelope 4 fields / snapshot 6 fields (SessionClaim law) - GE3 inline snapshot: typed inline, zero snapshotRef tokens in bot/src (D-07) - GE4 types-only: zero behavior tokens comment-stripped (T-05-08) - GE5-GE7 negative-space guards: one kv namespace, no Env field, no consumer (green by construction — redden only on drift; 05-01 C4/C5/C6 precedent)
189 lines
8.0 KiB
JavaScript
189 lines
8.0 KiB
JavaScript
#!/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 <name> { ... }` — 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)`,
|
||
);
|
||
}
|
||
});
|