docs: chaque symbole dit d'où il vient

98 annotations posées à côté des déclarations, et un test qui les exige sur la
surface publiée. Elles portent trois choses : le niveau qui répond, la référence
amont, et la catégorie parmi les cinq.

La cinquième est celle qui manquait : declared-not-wired, quand la cible DÉFINIT
la forme et ne la câble pas. Neuf symboles en relèvent, dont readLinks — que
j'avais classé « notre invention » en raisonnant depuis l'absence, alors que
c'est le meilleur alignement disponible.

Les références citent un SYMBOLE, jamais une ligne : trois citations du document
avaient déjà pourri. Cinq corrections au passage, toutes vérifiées à la source —
un chemin ORM qui n'existe pas, deux plages de lignes fausses, et surtout
docs.* et subscribeDoc étiquetés PASSTHROUGH alors qu'ils sont alignés : nos
noms, plus un argument jamais transmis. La sémantique survit à la migration,
les sites d'appel non, et la nuance disparaissait sous une étiquette trop
flatteuse.

Le test échoue à l'annotation retirée, à la catégorie mal orthographiée, et à
une invention qui prétendrait citer une référence — vérifié en cassant les
trois. Il a aussi attrapé un défaut en lui-même : le gabarit de format placé
dans index.ts se faisait analyser comme une annotation.

La classification couvre l'interne qui prétend ressembler à la cible — tout
emulated-verifier — et exclut ce qui ne le prétend pas. La faute d'origine
portait sur une fonction non exportée ; n'être pas publié n'a protégé personne.

Quatre symboles ont résisté et sont annotés avec leur catégorie dominante, la
seconde nommée dans la note plutôt que lissée.
This commit is contained in:
Sylvain Duchesne
2026-08-16 22:53:50 +02:00
parent 6138d831da
commit 43aadbeb45
26 changed files with 353 additions and 8 deletions
+202
View File
@@ -0,0 +1,202 @@
/**
* Every published symbol must SAY what it is aligned on — in a machine-checkable line
* beside the code, not in a document that drifts away from it.
*
* ── Why this is a test, and why the line lives in the source ───────────────
* The library's governing rule is that nothing may diverge from the target. Nothing in
* the code recorded *what each symbol is aligned on*, and it cost twice in one week:
* `docs/api-contract.md` claimed a "1:1 passthrough" that was false in two ways, and a
* maintainer classified `branch-registers.readLinks` as this library's own invention when
* it implements `AddLinkV0` — a type NextGraph **defines** (`engine/repo/src/types.rs`)
* and constructs nowhere. Reading "nothing constructs this upstream" and concluding "so
* it is ours" is inference from an ABSENT IMPLEMENTATION, which the project's own rule
* forbids: a definition is a fact, its being unwired is an absence, and the absence says
* nothing about the target.
*
* So the provenance is recorded per symbol, next to it, and this test pins it.
*
* ── The format ────────────────────────────────────────────────────────────
*
* // @provenance <name> kind=<kind> level=<1|2|3|none> ref=<path:symbol|none> — <note>
*
* - **name** — the PUBLISHED name, qualified for a namespace member (`inbox.post`,
* `storeRegistry.createEntityDoc`). Internal symbols carry their own name. It is in
* the line so this file can map an annotation to a symbol without a TypeScript parser.
* - **level** — which layer of the target answers, numbered from the bottom like the
* stack: `1` the engine (`engine/`), `2` the wasm binding / `@ng-org/web`
* (`sdk/js/lib-wasm`, `sdk/js/web`), `3` the JS ORM (`sdk/js/orm`). `none` only when
* nothing answers.
* - **ref** — `path:symbol` into `nextgraph-rs` (a SYMBOL, never a line number: line
* numbers rot and three citations in `docs/api-contract.md` already had). `none` only
* for `invention`.
* - **kind** — from a closed set:
*
* | kind | meaning |
* |----------------------|----------------------------------------------------------------|
* | `passthrough` | upstream's own symbol, same name and shape |
* | `aligned` | our name or ergonomics, upstream's semantics and model |
* | `declared-not-wired` | upstream DEFINES the type or shape; nothing constructs it yet |
* | `invention` | nothing at any level answers — a deliberate, documented bet |
* | `divergent` | upstream does this, and we do it differently |
*
* ── What this test deliberately does NOT check ────────────────────────────
* That the `ref` path exists on disk. `nextgraph-rs` is a sibling checkout that may be
* absent on another machine, and a test that goes red for that reason teaches nothing —
* it just trains people to skip it. The claim is verified by a human reading the source;
* this file pins that a claim was MADE, is well-formed, and covers the whole surface.
*/
import { test, expect } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
const SRC = path.join(import.meta.dir, "..", "src");
const KINDS = new Set(["passthrough", "aligned", "declared-not-wired", "invention", "divergent"]);
const LEVELS = new Set(["1", "2", "3", "none"]);
/** `path/to/file.ext:Symbol` — a path and a symbol, never a line number. */
const REF_SHAPE = /^[A-Za-z0-9_.@/-]+\.[a-z]+:[A-Za-z0-9_]+$/;
interface Provenance {
name: string;
kind: string;
level: string;
ref: string;
file: string;
}
/**
* The names a module really EXPORTS — declarations and named re-exports, comments
* stripped first so a name that only appears in prose (an explained removal, a
* `{@link}`) is not mistaken for a live export. Same rule as `vocabulary.test.ts`,
* for the same reason: a gate that says yes to a name that is not there reads as
* verified while proving nothing.
*/
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, "")
.replace(/^\s*\/\/.*$/gm, "");
for (const m of text.matchAll(
/^export (?:declare )?(?:async )?(?:const|function|class|interface|type) (\w+)/gm,
)) {
out.add(m[1]!);
}
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 symbol the entry publishes, as a consumer names it: bare for a direct export,
* `ns.member` for a namespace member. The namespace NAMES themselves are groupings, not
* symbols — a consumer never holds `docs`, it calls `docs.docCreate` — so they carry no
* provenance of their own and are excluded.
*/
function publishedSymbols(): string[] {
const ns = namespaces();
const out = new Set<string>();
for (const name of moduleExports(path.join(SRC, "index.ts"))) {
if (!ns.has(name)) out.add(name);
}
for (const [name, members] of ns) for (const m of members) out.add(`${name}.${m}`);
return [...out].sort();
}
/** Every `@provenance` line in the source tree, parsed. */
function annotations(): Provenance[] {
const out: Provenance[] = [];
const walk = (dir: string): void => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.name.endsWith(".ts")) {
for (const line of fs.readFileSync(full, "utf8").split("\n")) {
// Anchored to a REAL annotation line — a comment that IS the annotation, not
// one that quotes the format. `src/index.ts` documents the shape in its header
// (a `// @provenance <name> kind=<kind> …` template nested inside a `//` block);
// an unanchored match read that template as a symbol called `<name>` with a kind
// called `<kind>`, and the well-formedness check went red on the documentation
// of its own format. A `<…>` name is skipped for the same reason, belt and
// braces: a placeholder is never a symbol.
const m = line.match(
/^\s*\/\/ @provenance\s+(\S+)\s+kind=(\S+)\s+level=(\S+)\s+ref=(\S+)/,
);
if (m && !m[1]!.startsWith("<")) {
out.push({
name: m[1]!,
kind: m[2]!,
level: m[3]!,
ref: m[4]!,
file: path.relative(SRC, full),
});
}
}
}
}
};
walk(SRC);
return out;
}
test("every published symbol carries a provenance annotation", () => {
const annotated = new Set(annotations().map((a) => a.name));
const missing = publishedSymbols().filter((s) => !annotated.has(s));
// A failure is a question, not a formality: on what does this symbol align? Read the
// nextgraph-rs source and say so — including "nothing answers", which is `invention`.
expect(missing).toEqual([]);
});
test("no symbol is annotated twice", () => {
const seen = new Map<string, string[]>();
for (const a of annotations()) seen.set(a.name, [...(seen.get(a.name) ?? []), a.file]);
const duplicated = Object.fromEntries([...seen].filter(([, files]) => files.length > 1));
// Two annotations for one name is two answers to one question, and nothing says which
// is current — the drift this file exists to prevent, reintroduced inside it.
expect(duplicated).toEqual({});
});
test("every provenance annotation is well-formed", () => {
const malformed: string[] = [];
for (const a of annotations()) {
const where = `${a.file} :: ${a.name}`;
if (!KINDS.has(a.kind)) malformed.push(`${where} — kind '${a.kind}' is not one of ${[...KINDS].join(" | ")}`);
if (!LEVELS.has(a.level)) malformed.push(`${where} — level '${a.level}' is not 1 | 2 | 3 | none`);
if (a.kind === "invention") {
// Nothing answers, at any level. A ref here would name a counterpart the kind
// denies exists — the two halves have to agree or the label means nothing.
if (a.ref !== "none") malformed.push(`${where} — kind=invention must carry ref=none, not '${a.ref}'`);
if (a.level !== "none") malformed.push(`${where} — kind=invention must carry level=none, not '${a.level}'`);
} else {
// Every other kind CLAIMS a counterpart, so it must cite one.
if (a.ref === "none") malformed.push(`${where} — kind=${a.kind} claims a counterpart, so ref may not be 'none'`);
else if (!REF_SHAPE.test(a.ref)) malformed.push(`${where} — ref '${a.ref}' is not 'path:symbol' (a symbol, never a line number)`);
if (a.level === "none") malformed.push(`${where} — kind=${a.kind} claims a counterpart, so level must be 1, 2 or 3`);
}
}
expect(malformed).toEqual([]);
});
test("the published surface is annotated at every kind that claims a counterpart", () => {
// Not a quota — a smoke check that the closed set is actually being USED. If every
// published symbol ever lands on one kind, the vocabulary has stopped discriminating
// and the annotations have become decoration.
const published = new Set(publishedSymbols());
const kinds = new Set(annotations().filter((a) => published.has(a.name)).map((a) => a.kind));
expect(kinds.size).toBeGreaterThan(1);
});