From 842cd0e5f9fb486296658fcac180c87fb53f53c9 Mon Sep 17 00:00:00 2001 From: w0dm4n Date: Mon, 12 Oct 2026 14:07:43 +0200 Subject: [PATCH] test: unblock the Dev Index after the catalogue carded server exports/print Every Dev Index run since 2026-10-01 (and CI on main) failed two tests in tests/validator-013.test.ts. Neither is a validator or index regression: - open77-app b7240927 (2026-09-30) published real server `_G` cards for `exports` and `print` in content/api/api.json. The guide-only fallback in src/index/conventions.ts is designed to go dormant once a card exists, and it did; the tests still asserted the fallback's answer: `guideOnlyGlobals(index, "server")` deep-equal ["exports", "print"], and the server card header `exports(name: string, fn: function)`, now `exports(name: string (export name, 1-64 characters), fn: function)`. - validator-013.test.ts and index.test.ts read `index/` unconditionally, so the workflow's `OPEN77_INDEX_DIR=index-build npm test` smoke step never saw the new index. The 09-30 run passed against the old snapshot, then committed an index the tests had never run against. Changes: - both test files honour OPEN77_INDEX_DIR, like rp-animations.test.ts, so the Dev Index tests the index it is about to publish; - the conventions test asserts that each guide-only global is active exactly while the index has no card for it, and that both server globals come back from the guides on a copy of the index without those cards; - "answers server:exports and server:print" now asserts intent on any index: a server answer (card or guides, never both, never "missing"), client + server on the bare name, both listed under _G; - a new test keeps the original strict fallback assertions (guide signature, guide pointers, the guide section inlined, the footnote) on the index with the cards removed; - comments in conventions.ts and server.ts say the fallback is dormant on catalogues from 2026-09-30 on. database.access enforcement and the other 0.1.3 tests are unchanged. Co-Authored-By: Claude Opus 5.5 --- src/index/conventions.ts | 4 +- src/server.ts | 5 ++- tests/index.test.ts | 4 +- tests/validator-013.test.ts | 87 ++++++++++++++++++++++++++++++++----- 4 files changed, 84 insertions(+), 16 deletions(-) diff --git a/src/index/conventions.ts b/src/index/conventions.ts index cf829b0..1b7fa67 100644 --- a/src/index/conventions.ts +++ b/src/index/conventions.ts @@ -13,7 +13,9 @@ * params)` is the same native as `Open77.database.query`, called without a * callback from a managed coroutine (the guide's "Await form" column); * - a global documented by a guide table row that the generator did not - * turn into a card: the server `exports(name, fn)` and `print(...)`. + * turn into a card: the server `exports(name, fn)` and `print(...)` + * (carded since the 2026-09-30 catalogue, so dormant on any index built + * from it; kept for indexes that predate it). * * Each table entry names the guide section that documents it, and every * entry is applied only when the index it is used with still carries the diff --git a/src/server.ts b/src/server.ts index 16d4aad..5f1ecb5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -166,8 +166,9 @@ export function createMcpServer(context: ServerContext): McpServer { const { bare, side } = splitRoute(name); if (side && runtime && side !== runtime) return text(`${name} names the ${side} side but runtime=${runtime} was passed; drop one of them.`); const cards = findCards(name, runtime); - // `exports` and `print` on the server are documented by guide table - // rows and have no card yet: a real answer, not "missing". + // `exports` and `print` on the server were documented by guide table + // rows only until the 2026-09-30 catalogue carded them; an index + // without those cards still gets a real answer, not "missing". const fromGuides = guideOnlyGlobals(index, runtime ?? side).filter((g) => g.name === bare && !cards.some((c) => c.runtime === g.runtime)); if (!cards.length && !fromGuides.length) { const near = search.search(name, { kinds: ["card"], limit: 5 }).map((h) => h.title); diff --git a/tests/index.test.ts b/tests/index.test.ts index d16de3c..7bbc551 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -13,7 +13,9 @@ import { detectWorkspace } from "../src/workspace/detect.js"; import { scaffold } from "../src/workspace/scaffold.js"; import { parseManifest, validateResource } from "../src/workspace/validate.js"; -const INDEX_DIR = path.resolve("index"); +// The embedded snapshot by default; the Dev Index workflow points this at the +// index it just built, so a new snapshot is tested before it is committed. +const INDEX_DIR = path.resolve(process.env["OPEN77_INDEX_DIR"] ?? "index"); async function context(build?: string): Promise { const index = await loadIndex(INDEX_DIR); diff --git a/tests/validator-013.test.ts b/tests/validator-013.test.ts index 60c8b50..a8caabe 100644 --- a/tests/validator-013.test.ts +++ b/tests/validator-013.test.ts @@ -6,6 +6,12 @@ * - `server:exports` / `server:print` answered "missing" although the * server-exports guide uses both; * - a command name another loaded resource already registers went unnoticed. + * + * The guide-only answer for the server `exports` / `print` is a fallback: the + * catalogue published on 2026-09-30 (open77-app b7240927) carries real server + * cards for both, and on such an index the fallback is dormant. The tests + * below assert the answer on whatever index they run against, and exercise the + * fallback itself on a copy of that index with the two cards removed. */ import assert from "node:assert/strict"; @@ -15,11 +21,16 @@ import path from "node:path"; import { test } from "node:test"; import { GUIDE_ONLY_GLOBALS, NAMESPACE_ALIASES, guideOnlyGlobals, resolveNamespaceAlias, stripAwaitForm } from "../src/index/conventions.js"; import { loadIndex } from "../src/index/loader.js"; +import type { DevIndex } from "../src/index/types.js"; import type { ServerContext } from "../src/server.js"; import { detectWorkspace, findResourceDir } from "../src/workspace/detect.js"; import { commandCollisions, registeredCommands, validateResource } from "../src/workspace/validate.js"; -const INDEX_DIR = path.resolve("index"); +// The Dev Index workflow runs the suite against the index it just built +// (OPEN77_INDEX_DIR=index-build) before it commits that index as the embedded +// snapshot; reading `index` unconditionally let a snapshot this file had never +// seen land on main (2026-09-30) and fail every run after it. +const INDEX_DIR = path.resolve(process.env["OPEN77_INDEX_DIR"] ?? "index"); async function context(build?: string): Promise { const index = await loadIndex(INDEX_DIR); @@ -31,6 +42,24 @@ async function context(build?: string): Promise { }; } +/** Whether the index carries a real `_G` card for a guide-only global. */ +function carded(index: DevIndex, runtime: "client" | "server", name: string): boolean { + return index.cards.some((c) => c.namespace === "_G" && c.runtime === runtime && c.name === name); +} + +/** The same index without the cards the guide-only table stands in for: an index from before the catalogue carded them. */ +function withoutGuideOnlyCards(index: DevIndex): DevIndex { + const covered = new Set(GUIDE_ONLY_GLOBALS.map((g) => `${g.runtime}:${g.name}`)); + return { ...index, cards: index.cards.filter((c) => !(c.namespace === "_G" && covered.has(`${c.runtime}:${c.name}`))) }; +} + +function sectionOf(index: DevIndex, ref: string): string { + const [slug, anchor] = ref.split("#"); + const section = index.guides.find((g) => g.slug === slug)?.sections.find((s) => s.anchor === anchor); + assert.ok(section, `${ref} is a section of the index`); + return section.text; +} + async function mcpClient(ctx: ServerContext) { const { Client } = await import("@modelcontextprotocol/sdk/client/index.js"); const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js"); @@ -67,13 +96,7 @@ const BANK_SERVER = [ test("the conventions table is backed by the index it is used with", async () => { const ctx = await context(); - const bySlug = new Map(ctx.index.guides.map((g) => [g.slug, g])); - const sectionText = (ref: string) => { - const [slug, anchor] = ref.split("#"); - const section = bySlug.get(slug!)?.sections.find((s) => s.anchor === anchor); - assert.ok(section, `${ref} is a section of the embedded index`); - return section.text; - }; + const sectionText = (ref: string) => sectionOf(ctx.index, ref); for (const alias of NAMESPACE_ALIASES) { const text = sectionText(alias.guide); assert.ok(text.includes(`\`${alias.alias}\``), `${alias.guide} names ${alias.alias}`); @@ -84,9 +107,15 @@ test("the conventions table is backed by the index it is used with", async () => assert.ok(global.guides.length, `${global.name} points at a guide`); assert.ok(sectionText(global.guides[0]!).includes(global.name), `${global.guides[0]} mentions ${global.name}`); } - // Dormant as soon as a card exists: the client `exports` has one. + // Dormant as soon as a card exists: the client `exports` has one, and so do + // the server `exports` and `print` on a catalogue from 2026-09-30 or later. assert.ok(!guideOnlyGlobals(ctx.index, "client").some((g) => g.name === "exports")); - assert.deepEqual(guideOnlyGlobals(ctx.index, "server").map((g) => g.name).sort(), ["exports", "print"]); + for (const global of GUIDE_ONLY_GLOBALS) { + const active = guideOnlyGlobals(ctx.index, global.runtime).some((g) => g.name === global.name); + assert.equal(active, !carded(ctx.index, global.runtime, global.name), `${global.runtime} ${global.name} is answered from the guides only while no card exists`); + } + // Without those cards, both server globals come back from the guides. + assert.deepEqual(guideOnlyGlobals(withoutGuideOnlyCards(ctx.index), "server").map((g) => g.name).sort(), ["exports", "print"]); const namespaces = new Set(ctx.index.cards.map((c) => c.namespace)); assert.equal(resolveNamespaceAlias("MySQL.query.await", namespaces), "Open77.database.query.await"); @@ -156,14 +185,48 @@ test("open77_api takes client: as the mirror of server:", async () => { } }); -test("open77_api answers server:exports and server:print from the guides", async () => { +test("open77_api answers server:exports and server:print, never as missing", async () => { + // Content-agnostic: a real card or the guide-backed fallback, whichever the + // index carries. The card's parameter types are the website's to word + // (`name: string (export name, 1-64 characters)` since 2026-09-30). const ctx = await context(); const { ask, close } = await mcpClient(ctx); + try { + const exportsCard = await ask("open77_api", { name: "server:exports" }); + assert.match(exportsCard, /^# server exports\(name: string\b[^\n]*\bfunction\)/); + assert.doesNotMatch(exportsCard, /No native named/); + assert.equal(exportsCard.match(/^# server exports\(/gm)?.length, 1, "one server answer: a card, or the guides when there is none, never both"); + const printCard = await ask("open77_api", { name: "server:print" }); + assert.match(printCard, /^# server print\(/); + assert.equal(printCard.match(/^# server print\(/gm)?.length, 1); + // The bare name renders the client card and the server answer together. + const bare = await ask("open77_api", { name: "exports" }); + assert.match(bare, /^# client exports\(/); + assert.match(bare, /\n# server exports\(name: string\b/); + assert.doesNotMatch(await ask("open77_api", { name: "exports", runtime: "client" }), /\n# server exports/); + // The listing says the two exist, as cards or in the guide-only footnote. + const listing = await ask("open77_namespace", { namespace: "_G", runtime: "server" }); + assert.match(listing, /server exports\(name: string\b/); + assert.match(listing, /server print\(/); + const pending = GUIDE_ONLY_GLOBALS.filter((g) => g.runtime === "server" && !carded(ctx.index, g.runtime, g.name)); + if (pending.length) assert.match(listing, /documented in the guides only/); + else assert.doesNotMatch(listing, /documented in the guides only/, "no footnote once every server global has a card"); + assert.doesNotMatch(await ask("open77_namespace", { namespace: "_G", runtime: "client" }), /documented in the guides only/); + } finally { + await close(); + } +}); + +test("without a server card, exports and print are answered from the guides", async () => { + const ctx = await context(); + const index = withoutGuideOnlyCards(ctx.index); + const { ask, close } = await mcpClient({ ...ctx, index }); try { const exportsCard = await ask("open77_api", { name: "server:exports" }); assert.match(exportsCard, /^# server exports\(name: string, fn: function\)/); + assert.match(exportsCard, /no card on this index/); assert.match(exportsCard, /server-exports#publish-a-service/); - assert.match(exportsCard, /exports\('add', function\(playerId, points\)/, "the guide's worked example is inlined"); + assert.ok(exportsCard.includes(sectionOf(index, "server-exports#publish-a-service")), "the guide's worked example is inlined"); const printCard = await ask("open77_api", { name: "server:print" }); assert.match(printCard, /^# server print\(/); assert.match(printCard, /server-api#logging/);