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:
@@ -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
|
||||||
for (const m of text.matchAll(/export (?:type )?\{([^}]*)\}/g)) {
|
// `export function x` / `export const x` / `export interface x` / …
|
||||||
for (const raw of m[1]!.split(",")) {
|
for (const m of text.matchAll(/^export (?:declare )?(?:async )?(?:const|function|class|interface|type) (\w+)/gm)) {
|
||||||
const name = raw.trim().replace(/^type /, "").split(/\s+as\s+/).pop()?.trim();
|
out.add(m[1]!);
|
||||||
if (name) out.add(name);
|
}
|
||||||
}
|
// `export { a, b as c }` and `export { … } from "…"`, single- and multi-line.
|
||||||
}
|
for (const m of text.matchAll(/export (?:type )?\{([^}]*)\}/g)) {
|
||||||
// `export const x` / `export function x` / `export interface x`
|
for (const raw of m[1]!.split(",")) {
|
||||||
for (const m of text.matchAll(/export (?:declare )?(?:const|function|class|interface|type) (\w+)/g)) {
|
const name = raw.trim().replace(/^type /, "").split(/\s+as\s+/).pop()?.trim();
|
||||||
out.add(m[1]!);
|
if (name) out.add(name);
|
||||||
}
|
|
||||||
// `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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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];
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user