Files
ng-eventually/packages/sdk/test/vocabulary.test.ts
T
Sylvain Duchesne c5878c6126 test: le contrôle de contrat disait oui à des noms absents
`isNamespaceMember` cherchait le nom dans **tout le texte** des modules de namespace,
commentaires compris. Un nom mentionné n'importe où comptait donc comme exporté.

Mesuré : `documentInboxAddress`, `escapeLiteral`, `getCaps`, `addLink`, `setCurrentUser`
passaient tous — et **`linkTo`** aussi, la fonction supprimée parce qu'elle brisait la
règle d'accès, dont l'absence est expliquée dans un commentaire de `surface/placement.ts`.
Le commentaire qui dit pourquoi elle n'est pas là suffisait à la faire passer pour
publiée.

C'est très probablement ainsi que la dérive du contrat a traversé cinq sections sans
rien faire rougir. Un contrôle qui dit oui à un nom absent est pire que pas de contrôle :
il se lit comme une vérification.

Les exports sont désormais extraits **après retrait des commentaires**, déclarations et
ré-exports nommés, par un seul `moduleExports()` que les trois contrôles partagent.
Vérifié : les six fantômes ci-dessus sont maintenant contrôlés, et les vrais membres
(`post`, `share`, `createEntityDoc`, `docCreate`) toujours reconnus.

Ajouté au passage un contrôle qui manquait dans l'autre sens : chaque groupe de namespace
de l'annexe doit correspondre **exactement** aux exports réels de son module. Un membre
retiré de `inbox:` ne faisait rougir personne.

Trouvé par une revue adverse, reproduit puis corrigé ici. 181 tests, 0 échec.
2026-08-07 13:32:49 +02:00

308 lines
15 KiB
TypeScript

/**
* The published names may only use words the TARGET uses, or a marker that says why
* they exist here.
*
* ── Why this is a test and not a rule ─────────────────────────────────────
* The library corrected its vocabulary on 2026-07-30 — upstream a *wallet* is only a
* keyring, and what owns stores is a **user** (a *site*) — by a manual pass over the
* code and docs. `walletInbox` survived that pass and lived on for weeks, and it did
* damage: the name made "one inbox per wallet" sound obvious, hiding that a user
* upstream has **two** (public store repo and protected store repo — the only two
* `AddInboxCap` commits in the engine, `engine/verifier/src/site.rs:128,149`). A
* discipline applied by hand misses one; a test does not.
*
* So this pins the naming half of the design principle (`README.md`): a name either
* belongs to the target's vocabulary — in which case it needs no translation and
* survives migration — or it carries a marker saying WHY it exists only here, which
* also says when it disappears.
*
* ── What it checks, and what it deliberately does not ─────────────────────
* Only the PUBLISHED names, the ones a consumer application types. Internal names are
* held to the same intent but not mechanically: the folder they live in already states
* their fate, and pinning every internal identifier would fight refactoring for little.
*/
import { test, expect } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
/**
* Words the TARGET itself uses, verified in `nextgraph-rs`. A published name built
* from these needs no translation at migration.
*/
const TARGET_WORDS = new Set([
// addressing and objects
"nuri", "doc", "docs", "document", "repo", "store", "stores", "branch", "graph",
"overlay", "cap", "caps", "read", "write", "link", "links", "shape", "shapes",
// actors and containers
"user", "users", "session", "wallet", "inbox", "inboxes", "site", "principal",
// scopes (upstream store types, `StoreRepo::from_type_and_repo`)
"public", "protected", "private", "group", "dialog", "scope",
// acts the target performs
"create", "subscribe", "unsubscribe", "query", "update", "post", "share", "open",
"fetch", "init", "watch", "sparql", "ng", "orm", "type", "types",
// RDF / SPARQL terms the engine's own query paths use
"subject", "base", "schema", "connected", "identity", "identities",
// `publisher` is upstream's word for a pub/sub role on a topic (`as_publisher`,
// `publisher_advert`, 126 occurrences in the engine). Our own "publish a document" is
// banned as ambiguous, but that ban never reaches upstream's term — see the traps
// block in `docs/readcap-and-nuri-model.md`.
"publisher", "topic", "advert",
// the reactive model the ORM exposes (`OrmSubscription`, `DeepSignalSet`)
"observable", "deep", "signal", "set",
]);
/**
* Markers that name WHY something exists only in this library. Each says when it
* disappears, which a bare `fake`/`tmp` would not.
*/
const EMULATION_MARKERS = new Set([
"virtual", "physical", "shim", "emulated", "polyfill",
// `shared` as in "shared wallet" — the single fact every piece of scaffolding in this
// library descends from. A name carrying it says both what it is and when it goes.
"shared",
]);
/** Glue with no domain meaning — never the load-bearing part of a name. */
const NEUTRAL = new Set([
"get", "set", "is", "has", "to", "for", "of", "my", "own", "all", "by", "with",
"current", "reset", "configure", "config", "deps", "id", "ids", "address", "entity",
"list", "resolve", "assert", "escape", "literal", "iri", "record", "registry",
"change", "changed", "state", "value", "data", "info", "count", "the", "a", "an",
"options", "opts", "result", "error", "signal", "filter", "placement", "and", "or",
"make", "use", "on", "off", "from", "into", "at", "in", "out", "up", "down",
// `union` is OURS — the bounded multi-document read — but it names an operation,
// not a domain notion a consumer would have to unlearn. `eventually` is the
// library's own name.
"union", "eventually", "ensure",
// `…Like` is a structural-typing suffix (`NgLike` = "whatever has ng's shape"), not
// a domain word: it describes how the injection is typed, not what the thing is.
"like",
]);
/** `documentInboxAddress` → ["document","inbox","address"] ; `NG` → ["ng"]. */
function words(name: string): string[] {
return name
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
.split(/[\s_]+/)
.map((w) => w.toLowerCase())
.filter(Boolean);
}
const SRC = path.join(import.meta.dir, "..", "src");
/**
* 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<string> {
const out = new Set<string>();
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<string, Set<string>> {
const text = fs.readFileSync(path.join(SRC, "index.ts"), "utf8");
const out = new Map<string, Set<string>>();
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()) {
const ws = words(name);
// A marker anywhere in the name licenses the whole name: it declares the thing
// as ours and says when it goes.
if (ws.some((w) => EMULATION_MARKERS.has(w))) continue;
const unknown = ws.filter((w) => !TARGET_WORDS.has(w) && !NEUTRAL.has(w));
if (unknown.length > 0) offenders.push(`${name}${unknown.join(", ")}`);
}
// A failure here is not "rename to satisfy the test": it is a question. Does the
// target have a word for this? Use it. Does the thing exist only here? Say so with a
// marker. Is the word genuinely neutral glue? Add it to NEUTRAL, deliberately.
expect(offenders).toEqual([]);
});
test("no published name says `wallet` where the target says `user`", () => {
// The specific regression that motivated this file. `wallet` is a legitimate target
// word (a keyring IS a wallet upstream), so the generic check above cannot catch it —
// what is wrong is using it for the thing that owns stores and inboxes.
const wrong = publishedNames().filter((n) =>
/wallet/i.test(n) && /(inbox|store|doc|cap)/i.test(n),
);
expect(wrong).toEqual([]);
});
// --- the invariant the internal contract flagged as a migration risk -------
test("a reserved-namespace key cannot be produced by a consumer's normalizeId", async () => {
// The reserved namespace hosts infrastructure accounts, and its guarantee is that no
// user id lands there. That guarantee is not the library's to make — `normalizeId` is
// injected by the consumer — so a careless one must be refused, not trusted. A
// collision would key a user onto an infrastructure account: reads and writes on
// documents that are not theirs.
const { configureStoreRegistry, resetStoreRegistry } = await import("../src/shared-wallet/bootstrap");
const { ensureAccount, resetRegistryCache } = await import(
"../src/shared-wallet/account-registry"
);
configureStoreRegistry({
getSession: async () => ({ sessionId: "s", privateStoreId: "did:ng:o:p" }),
normalizeId: () => "reserved:index", // pretends to be infrastructure
});
resetRegistryCache();
await expect(ensureAccount("mallory")).rejects.toThrow(/reserved namespace/i);
resetStoreRegistry();
resetRegistryCache();
});
// --- the contract's inventory must match the code ------------------------
/**
* Every `export` the contract SHOWS in a "### Today" block must actually be exported.
*
* ── Why this exists beside the appendix check ─────────────────────────────
* The appendix check pins the NAMES. It cannot see the rulings — the per-subject
* sections where each symbol gets its epistemic label — and on 2026-08-07 three of them
* had drifted without anything going red: § 11 documented `escapeLiteral` / `assertNuri`
* as published (they are not), § 12 listed seven `storeRegistry` functions (there are
* five), § 13 listed `IdentityStore` / `getCurrentUser` (removed two days earlier). A
* reader trusting the sections was reading the surface of a fortnight ago.
*
* A stale ruling is worse than a missing one: it reads as verified. So the sections are
* held to the same standard as the appendix — if a block shows `export function X`, X is
* exported. Anything kept for the record goes in a comment, which this check ignores by
* construction (it only looks at lines beginning with `export`).
*/
test("every `export` shown in a contract '### Today' block is actually exported", () => {
const md = fs.readFileSync(
path.join(import.meta.dir, "..", "..", "..", "docs", "api-contract.md"),
"utf8",
);
const published = new Set(publishedNames());
const stale: string[] = [];
// Sections run from a "### Today" heading to the next heading of any level.
for (const m of md.matchAll(/### Today[^\n]*\n([\s\S]*?)(?=\n#{2,3} )/g)) {
for (const block of m[1]!.matchAll(/```ts\n([\s\S]*?)```/g)) {
for (const line of block[1]!.split("\n")) {
const decl = line.match(
/^export (?:declare )?(?:async )?(?:const|function|class|interface|type) (\w+)/,
);
// Namespace members (`inbox.post`, `storeRegistry.createEntityDoc`) are exported
// from their module, not from the entry — the same allowance the appendix makes.
const name = decl?.[1];
if (name && !published.has(name) && !isNamespaceMember(name)) stale.push(name);
}
}
}
// A failure is a question, not a rename: has the symbol been removed (then say so in a
// comment and rule on why), or has the entry lost something it should still publish?
expect([...new Set(stale)]).toEqual([]);
});
/**
* Read the appendix of `docs/api-contract.md` back into `{ group: names }`.
* The appendix is a generated block; this parses the same shape.
*/
function contractInventory(): Record<string, string[]> {
const md = fs.readFileSync(
path.join(import.meta.dir, "..", "..", "..", "docs", "api-contract.md"),
"utf8",
);
const appendix = md.slice(md.indexOf("## Appendix — full export inventory"));
const out: Record<string, string[]> = {};
for (const block of appendix.matchAll(/```text\n([\s\S]*?)```/g)) {
for (const line of block[1]!.trim().split("\n")) {
const i = line.indexOf(":");
if (i < 0) continue;
const group = line.slice(0, i).trim();
const names = line.slice(i + 1).split(",").map((n) => n.trim()).filter(Boolean);
out[group] = [...(out[group] ?? []), ...names].sort();
}
}
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<string, { missing: string[]; extra: string[] }> = {};
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
// been narrowed to seven functions — and a stale inventory is worse than none: it
// reads as verified. So the code decides, and this test is what makes the document
// follow rather than drift.
const inventory = contractInventory();
const direct = new Set(publishedNames());
// The namespace names themselves are the appendix's group headings, not entries.
for (const group of Object.keys(inventory)) if (group !== "direct") direct.delete(group);
const missing = [...direct].filter(
(n) => !Object.values(inventory).some((names) => names.includes(n)),
);
const extra = Object.values(inventory)
.flat()
.filter((n) => !direct.has(n) && !isNamespaceMember(n));
expect({ missing, extra }).toEqual({ missing: [], extra: [] });
});