clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/fix-sync-enrollment-startup
lofi / tools / type_closure_lint.ts
type_closure_lint.ts 8.0K
1/**2 * Entry type closure lint. For every JSR entrypoint in the deno.json export3 * map, every locally-declared type referenced in an exported symbol's public4 * signature must itself be exported from that same entrypoint. A consumer who5 * imports `RuntimeDiagnostics` from `.` must be able to name the types inside6 * it (e.g. `SharedFieldAlert`) from `.` as well, not hunt through submodules.7 *8 * `deno doc --lint` only flags module-private references; a type exported from9 * its declaring submodule but not re-exported from the entry passes that check10 * while still leaving a hole in the entry's surface. This lint closes it.11 *12 * References that resolve outside `package/` (DOM lib, TypeScript built-ins,13 * npm dependencies) and generic type parameters are ignored.14 *15 * @module16 */1718type DocLocation = { filename: string; line: number };1920type TypeRefResolution =21 | { kind: "typeParam" }22 | { kind: "local" }23 | { kind: "import"; specifier: string; name: string };2425type DocDeclaration = {26 location: DocLocation;27 declarationKind: string;28 kind: string;29};3031type DocSymbol = { name: string; declarations: DocDeclaration[] };3233type DocOutput = { nodes: Record<string, { symbols?: DocSymbol[] }> };3435/** A type name referenced somewhere inside one exported symbol's signature. */36type TypeReference = {37 /** Exported symbol whose signature contains the reference. */38 symbol: string;39 /** Referenced type name as written (root identifier). */40 name: string;41 /** Module that contains the referencing declaration. */42 declFile: string;43 resolution: TypeRefResolution | undefined;44 /**45 * References without resolution metadata (`extends` clauses, `typeof`46 * queries) only count when the name is declared in `declFile` itself;47 * otherwise they are ambient or imported-external and are skipped.48 */49 requireLocalDecl: boolean;50};5152/**53 * Suppressions a human has judged to be false positives. Keys are54 * `"<entry> <type-name>"`; values explain why the entry is exempt.55 */56const allowlist: ReadonlyMap<string, string> = new Map([]);5758const root = `${Deno.cwd()}/`;59const rootUrl = new URL(`file://${root}`);60const packagePrefix = new URL("package/", rootUrl).href;6162async function runDoc(args: string[]): Promise<DocOutput> {63 const command = new Deno.Command(Deno.execPath(), {64 args: ["doc", "--json", ...args],65 stdout: "piped",66 stderr: "piped",67 });68 const result = await command.output();69 if (!result.success) {70 console.error(new TextDecoder().decode(result.stderr));71 console.error(`deno doc --json failed for: ${args.join(" ")}`);72 Deno.exit(1);73 }74 return JSON.parse(new TextDecoder().decode(result.stdout));75}7677/**78 * Walks a documentation node collecting referenced type names: `typeRef`79 * nodes (unions, tuples, mapped, conditional, indexed-access, and function80 * types all bottom out here), `typeof` queries, and string-typed class81 * `extends` clauses. Skips `private` class members and JSDoc subtrees, which82 * are not part of the public signature.83 */84function collectReferences(85 value: unknown,86 symbol: string,87 declFile: string,88 out: TypeReference[],89): void {90 if (Array.isArray(value)) {91 for (const item of value) collectReferences(item, symbol, declFile, out);92 return;93 }94 if (value === null || typeof value !== "object") return;95 const node = value as Record<string, unknown>;96 if (node.accessibility === "private") return;97 if (node.kind === "typeRef") {98 const ref = node.value as { typeName?: string; resolution?: TypeRefResolution } | undefined;99 if (ref?.typeName) {100 out.push({101 symbol,102 name: ref.typeName.split(".")[0],103 declFile,104 resolution: ref.resolution,105 requireLocalDecl: false,106 });107 }108 } else if (node.kind === "typeQuery" && typeof node.value === "string") {109 out.push({110 symbol,111 name: node.value.split(".")[0],112 declFile,113 resolution: undefined,114 requireLocalDecl: true,115 });116 }117 if (typeof node.extends === "string") {118 // Class defs carry `extends` as a bare superclass name (interface119 // `extends` is an array of types and recurses normally).120 out.push({121 symbol,122 name: node.extends,123 declFile,124 resolution: undefined,125 requireLocalDecl: true,126 });127 }128 for (const [key, child] of Object.entries(node)) {129 if (key === "jsDoc" || key === "location") continue;130 collectReferences(child, symbol, declFile, out);131 }132}133134/** Lazily built per-module map of declared symbol name → declaration line. */135const declarationCache = new Map<string, Promise<Map<string, number>>>();136137function declarationsIn(fileUrl: string): Promise<Map<string, number>> {138 let cached = declarationCache.get(fileUrl);139 if (!cached) {140 cached = runDoc(["--private", fileUrl]).then((doc) => {141 const map = new Map<string, number>();142 for (const symbol of doc.nodes[fileUrl]?.symbols ?? []) {143 const line = symbol.declarations[0]?.location.line;144 if (line !== undefined && !map.has(symbol.name)) map.set(symbol.name, line);145 }146 return map;147 });148 declarationCache.set(fileUrl, cached);149 }150 return cached;151}152153/**154 * Resolves a reference to the module expected to declare it, or `null` when155 * the reference is a generic type parameter or resolves outside `package/`.156 * `names` lists identifiers that satisfy the closure when exported from the157 * entry (the local alias plus, for re-imports, the original name).158 */159function resolveTarget(ref: TypeReference): { file: string; names: string[] } | null {160 const { resolution } = ref;161 if (resolution?.kind === "typeParam") return null;162 if (resolution?.kind === "local" || resolution === undefined) {163 if (!ref.declFile.startsWith(packagePrefix)) return null;164 if (resolution === undefined && !ref.requireLocalDecl) return null; // ambient (DOM/built-in)165 return { file: ref.declFile, names: [ref.name] };166 }167 if (!resolution.specifier.startsWith(".")) return null; // bare specifier: npm/jsr dependency168 const target = new URL(resolution.specifier, ref.declFile).href;169 if (!target.startsWith(packagePrefix)) return null;170 return { file: target, names: [ref.name, resolution.name] };171}172173const denoConfig = JSON.parse(await Deno.readTextFile(new URL("deno.json", rootUrl)));174const entries = Object.entries(denoConfig.exports as Record<string, string>)175 .map(([jsrName, path]) => ({ jsrName, url: new URL(path, rootUrl).href }));176177const doc = await runDoc(entries.map((entry) => entry.url));178const violations: string[] = [];179let emptyEntries = 0;180181for (const entry of entries) {182 const symbols = doc.nodes[entry.url]?.symbols ?? [];183 if (symbols.length === 0) {184 emptyEntries += 1;185 continue;186 }187 const exported = new Set(symbols.map((symbol) => symbol.name));188 const references: TypeReference[] = [];189 for (const symbol of symbols) {190 for (const declaration of symbol.declarations) {191 collectReferences(declaration, symbol.name, declaration.location.filename, references);192 }193 }194 const seen = new Set<string>();195 for (const ref of references) {196 if (exported.has(ref.name)) continue;197 if (allowlist.has(`${entry.jsrName} ${ref.name}`)) continue;198 const target = resolveTarget(ref);199 if (target === null || target.names.some((name) => exported.has(name))) continue;200 const key = `${ref.symbol} ${ref.name}`;201 if (seen.has(key)) continue;202 seen.add(key);203 const declared = await declarationsIn(target.file);204 const line = declared.get(target.names.at(-1) ?? ref.name);205 if (line === undefined && ref.requireLocalDecl) continue; // ambient or imported-external206 const path = target.file.slice(rootUrl.href.length);207 violations.push(208 `${entry.jsrName} ${ref.symbol} → ${ref.name} (declared at ${path}:${line ?? "?"})`,209 );210 }211}212213if (violations.length > 0) {214 console.error(violations.sort().join("\n"));215 console.error(`Entry type closure failed with ${violations.length} unexported type(s).`);216 Deno.exit(1);217}218219console.log(220 `Entry type closure holds for ${entries.length} JSR entrypoints` +221 (emptyEntries > 0 ? ` (${emptyEntries} export no symbols).` : "."),222);