diff --git a/packages/sdk/test/vocabulary.test.ts b/packages/sdk/test/vocabulary.test.ts index 0758b41..f962f6b 100644 --- a/packages/sdk/test/vocabulary.test.ts +++ b/packages/sdk/test/vocabulary.test.ts @@ -91,37 +91,71 @@ function words(name: string): string[] { const SRC = path.join(import.meta.dir, "..", "src"); -/** Every identifier the entry point publishes, read from its `export` statements. */ -function publishedNames(): string[] { +/** + * The names a module really EXPORTS — declarations and named re-exports, comments + * stripped first. + * + * ── Why the comments have to go, and why it is not a detail ──────────────── + * This used to be a word-search over the module's whole text. A name mentioned + * ANYWHERE — including in a comment explaining why it was removed — counted as + * exported. Measured on 2026-08-07: `documentInboxAddress`, `escapeLiteral`, + * `getCaps`, `addLink`, `setCurrentUser` all passed, and so did **`linkTo`** — the + * function deleted for breaking the access rule, whose absence is documented in a + * comment inside `surface/placement.ts`. So the two checks below could wave through + * a phantom, which is how the contract drifted through five sections unseen. + * + * A gate that says yes to a name that is not there is worse than no gate: it reads + * as verified. + */ +function moduleExports(file: string): Set { const out = new Set(); - for (const entry of ["index.ts"]) { - const text = fs.readFileSync(path.join(SRC, entry), "utf8"); - // `export * as ns from "…"` - for (const m of text.matchAll(/export \* as (\w+) from/g)) out.add(m[1]!); - // `export { a, b as c }` / `export type { … }`, single- and multi-line - for (const m of text.matchAll(/export (?:type )?\{([^}]*)\}/g)) { - for (const raw of m[1]!.split(",")) { - const name = raw.trim().replace(/^type /, "").split(/\s+as\s+/).pop()?.trim(); - if (name) out.add(name); - } - } - // `export const x` / `export function x` / `export interface x` - for (const m of text.matchAll(/export (?:declare )?(?:const|function|class|interface|type) (\w+)/g)) { - out.add(m[1]!); - } - // `export * from "./x"` — re-exports every name that module declares. - for (const m of text.matchAll(/export \* from "\.\/([^"]+)"/g)) { - const file = path.join(SRC, m[1]! + ".ts"); - if (!fs.existsSync(file)) continue; - const t = fs.readFileSync(file, "utf8"); - for (const mm of t.matchAll(/^export (?:declare )?(?:const|function|class|interface|type) (\w+)/gm)) { - out.add(mm[1]!); - } + if (!fs.existsSync(file)) return out; + const text = fs + .readFileSync(file, "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") // block and JSDoc comments + .replace(/^\s*\/\/.*$/gm, ""); // line comments + // `export function x` / `export const x` / `export interface x` / … + for (const m of text.matchAll(/^export (?:declare )?(?:async )?(?:const|function|class|interface|type) (\w+)/gm)) { + out.add(m[1]!); + } + // `export { a, b as c }` and `export { … } from "…"`, single- and multi-line. + for (const m of text.matchAll(/export (?:type )?\{([^}]*)\}/g)) { + for (const raw of m[1]!.split(",")) { + const name = raw.trim().replace(/^type /, "").split(/\s+as\s+/).pop()?.trim(); + if (name) out.add(name); } } + return out; +} + +/** namespace name → the names it really carries (`export * as ns from "./x"`). */ +function namespaces(): Map> { + const text = fs.readFileSync(path.join(SRC, "index.ts"), "utf8"); + const out = new Map>(); + for (const m of text.matchAll(/export \* as (\w+) from "\.\/([^"]+)"/g)) { + out.set(m[1]!, moduleExports(path.join(SRC, m[2]! + ".ts"))); + } + return out; +} + +/** Every identifier the entry point publishes, read from its `export` statements. */ +function publishedNames(): string[] { + const text = fs.readFileSync(path.join(SRC, "index.ts"), "utf8"); + const out = moduleExports(path.join(SRC, "index.ts")); + for (const m of text.matchAll(/export \* as (\w+) from/g)) out.add(m[1]!); + // `export * from "./x"` — re-exports every name that module declares. + for (const m of text.matchAll(/export \* from "\.\/([^"]+)"/g)) { + for (const n of moduleExports(path.join(SRC, m[1]! + ".ts"))) out.add(n); + } return [...out]; } +/** Names that live inside a re-exported namespace rather than on the entry itself. */ +function isNamespaceMember(name: string): boolean { + for (const members of namespaces().values()) if (members.has(name)) return true; + return false; +} + test("every published name is built from the target's vocabulary, or carries an emulation marker", () => { const offenders: string[] = []; for (const name of publishedNames()) { @@ -237,6 +271,21 @@ function contractInventory(): Record { return out; } +test("the appendix's namespace groups list exactly what each namespace carries", () => { + // The check above compares the appendix against the ENTRY's own names, so a namespace + // member missing from its group slipped through: `inbox:` could lose an entry and + // nothing noticed. Here each group is compared to its module's real exports, both ways. + const inventory = contractInventory(); + const drift: Record = {}; + for (const [ns, members] of namespaces()) { + const listed = new Set(inventory[ns] ?? []); + const missing = [...members].filter((n) => !listed.has(n)).sort(); + const extra = [...listed].filter((n) => !members.has(n)).sort(); + if (missing.length || extra.length) drift[ns] = { missing, extra }; + } + expect(drift).toEqual({}); +}); + test("the api-contract appendix lists exactly what the entry exports", () => { // The appendix is the instrument a reader diffs against when the surface moves. It // went stale once — still naming `storeRegistry`'s shim internals after the entry had @@ -256,17 +305,3 @@ test("the api-contract appendix lists exactly what the entry exports", () => { expect({ missing, extra }).toEqual({ missing: [], extra: [] }); }); -/** Names that live inside a re-exported namespace rather than on the entry itself. */ -function isNamespaceMember(name: string): boolean { - const src = path.join(import.meta.dir, "..", "src"); - for (const entry of ["index.ts"]) { - const text = fs.readFileSync(path.join(src, entry), "utf8"); - for (const m of text.matchAll(/export \* as \w+ from "\.\/([^"]+)"/g)) { - const file = path.join(src, m[1]! + ".ts"); - if (fs.existsSync(file) && new RegExp(`\\b${name}\\b`).test(fs.readFileSync(file, "utf8"))) { - return true; - } - } - } - return false; -}