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.
This commit is contained in:
Sylvain Duchesne
2026-08-07 13:32:49 +02:00
parent 0455a408b6
commit c5878c6126
+66 -31
View File
@@ -91,37 +91,71 @@ function words(name: string): string[] {
const SRC = path.join(import.meta.dir, "..", "src"); 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<string> {
const out = new Set<string>(); const out = new Set<string>();
for (const entry of ["index.ts"]) { if (!fs.existsSync(file)) return out;
const text = fs.readFileSync(path.join(SRC, entry), "utf8"); const text = fs
// `export * as ns from "…"` .readFileSync(file, "utf8")
for (const m of text.matchAll(/export \* as (\w+) from/g)) out.add(m[1]!); .replace(/\/\*[\s\S]*?\*\//g, "") // block and JSDoc comments
// `export { a, b as c }` / `export type { … }`, single- and multi-line .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 m of text.matchAll(/export (?:type )?\{([^}]*)\}/g)) {
for (const raw of m[1]!.split(",")) { for (const raw of m[1]!.split(",")) {
const name = raw.trim().replace(/^type /, "").split(/\s+as\s+/).pop()?.trim(); const name = raw.trim().replace(/^type /, "").split(/\s+as\s+/).pop()?.trim();
if (name) out.add(name); if (name) out.add(name);
} }
} }
// `export const x` / `export function x` / `export interface x` return out;
for (const m of text.matchAll(/export (?:declare )?(?:const|function|class|interface|type) (\w+)/g)) {
out.add(m[1]!);
} }
/** 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. // `export * from "./x"` — re-exports every name that module declares.
for (const m of text.matchAll(/export \* from "\.\/([^"]+)"/g)) { for (const m of text.matchAll(/export \* from "\.\/([^"]+)"/g)) {
const file = path.join(SRC, m[1]! + ".ts"); for (const n of moduleExports(path.join(SRC, m[1]! + ".ts"))) out.add(n);
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]; 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", () => { test("every published name is built from the target's vocabulary, or carries an emulation marker", () => {
const offenders: string[] = []; const offenders: string[] = [];
for (const name of publishedNames()) { for (const name of publishedNames()) {
@@ -237,6 +271,21 @@ function contractInventory(): Record<string, string[]> {
return out; 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", () => { 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 // 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 // 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: [] }); 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;
}