test(05-03): add failing guidance-envelope types-only gates (GE1-GE7) (RED)

- 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)
This commit is contained in:
araharah-executor
2026-10-24 05:08:52 +08:00
parent 2ee5e4e38b
commit 0b8671e9ca
+188
View File
@@ -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 <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)`,
);
}
});