Files
ng-eventually/packages/client/test/vocabulary.test.ts
T
Sylvain Duchesne 66a40fbb89 feat(access-gate): le parcours de connexion passe dans le polyfill
Chaque étape de cet écran n'existe que parce qu'un wallet héberge plusieurs
identités. Une app qui l'implémente écrit du code qu'elle devra supprimer, et
pire, du code qui enseigne à ses auteurs un modèle que NextGraph n'a pas :
« je nomme mon identité ». Le premier consommateur en avait ~300 lignes — un
gate, un écran, un module wallet, un contexte d'identité, trois features. C'est
le travail de la bibliothèque, pas le sien.

`ensureIdentity()` : un appel, avant que l'app s'affiche. Il ne prend PAS
d'identifiant — nommer une identité est l'étape qui disparaîtra, donc elle ne
doit pas figurer dans la signature. Le jour où le wallet fournit l'identité,
l'appel se résout sans rien afficher et le code de l'appelant ne bouge pas.

L'écran est en DOM natif, sous shadow root : c'est une barrière technique
montrée avant qu'une application s'affiche, comme une demande de mot de passe
sur une bêta fermée. La lier à un framework obligerait chaque consommateur à
adopter ce framework pour un écran voué à disparaître.

L'ordre de résolution de l'identité est pinné par des tests, parce que s'y
tromper échoue en SILENCE : le parcours traverse deux partitions localStorage
distinctes — la page et l'iframe du broker — et seul l'URL franchit la
frontière. Si le stockage l'emportait, l'iframe lirait une identité vide,
provisionnerait un second utilisateur virtuel, et l'utilisateur reviendrait dans
un espace vide sans la moindre erreur.

Les identifiants du wallet partagé (fichier, mot de passe) passent par
`configure()` : ce sont des données de déploiement, et cet appel est déjà celui
qui devient inerte à la migration. Au passage, l'ancien champ `sharedWallet:
{ name, secret }` — inutilisé nulle part — est remplacé.

168 tests unitaires, typecheck vert.
2026-08-05 17:06:10 +02:00

226 lines
11 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",
// 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");
/** Every identifier the two entry points publish, read from the `export` statements. */
function publishedNames(): string[] {
const out = new Set<string>();
for (const entry of ["index.ts", "polyfill.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]!);
}
}
}
return [...out];
}
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/polyfill");
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 ------------------------
/**
* 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 api-contract appendix lists exactly what the entries export", () => {
// 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: [] });
});
/** 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", "polyfill.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;
}