From 6791bb7c2f2b98c9ee7195c98f5f5fa90c66651b Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Fri, 21 Aug 2026 20:09:39 +0000 Subject: [PATCH] scripts: add check type discoverability script --- scripts/check-type-discoverability.mjs | 312 ++++++++++++++++++++ scripts/check-type-discoverability.test.mjs | 173 +++++++++++ 2 files changed, 485 insertions(+) create mode 100644 scripts/check-type-discoverability.mjs create mode 100644 scripts/check-type-discoverability.test.mjs diff --git a/scripts/check-type-discoverability.mjs b/scripts/check-type-discoverability.mjs new file mode 100644 index 000000000..8d6dbcbab --- /dev/null +++ b/scripts/check-type-discoverability.mjs @@ -0,0 +1,312 @@ +import { existsSync, statSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +function isPathWithin(directory, file) { + const relativePath = relative(directory, file); + return relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath); +} + +function formatConfigDiagnostics(diagnostics) { + return ts.formatDiagnostics(diagnostics, { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => process.cwd(), + getNewLine: () => "\n", + }); +} + +function getContractSymbol(program, checker, type) { + const symbol = type.aliasSymbol ?? type.getSymbol(); + if (!symbol || symbol.name.startsWith("__")) return undefined; + + const declarations = symbol.getDeclarations() ?? []; + const contractDeclarations = declarations.filter( + (declaration) => + ts.isInterfaceDeclaration(declaration) || ts.isClassDeclaration(declaration) || ts.isTypeAliasDeclaration(declaration), + ); + if (contractDeclarations.length === 0 || checker.getPropertiesOfType(type).length === 0) return undefined; + if (contractDeclarations.every((declaration) => program.isSourceFileDefaultLibrary(declaration.getSourceFile()))) { + return undefined; + } + return symbol; +} + +function createContractCollector(program, checker) { + const cache = new Map(); + + return (rootType) => { + const cached = cache.get(rootType); + if (cached) return cached; + + const contracts = []; + const seenTypes = new Set(); + const seenContracts = new Set(); + + function visit(type) { + if (!type || seenTypes.has(type)) return; + seenTypes.add(type); + + const symbol = getContractSymbol(program, checker, type); + if (symbol && !seenContracts.has(symbol)) { + seenContracts.add(symbol); + contracts.push({ symbol, type }); + } + + if (type.isUnionOrIntersection()) { + for (const member of type.types) visit(member); + } + + for (const typeArgument of type.aliasTypeArguments ?? []) visit(typeArgument); + if (type.flags & ts.TypeFlags.Object && type.objectFlags & ts.ObjectFlags.Reference) { + for (const typeArgument of checker.getTypeArguments(type)) visit(typeArgument); + } + + if (symbol) { + const declaration = symbol.getDeclarations()?.[0]; + if (declaration && program.isSourceFileDefaultLibrary(declaration.getSourceFile())) return; + } + + if (type.flags & ts.TypeFlags.Object) { + for (const property of checker.getPropertiesOfType(type)) { + visit(checker.getTypeOfPropertyOfType(type, property.name)); + } + + if (type.objectFlags & (ts.ObjectFlags.Class | ts.ObjectFlags.Interface)) { + for (const baseType of checker.getBaseTypes(type)) visit(baseType); + } + } + } + + visit(rootType); + cache.set(rootType, contracts); + return contracts; + }; +} + +function createTypeContainmentChecker(checker) { + return (rootType, containedType) => { + const seenTypes = new Set(); + const containedProperties = checker.getPropertiesOfType(containedType); + + function visit(type, depth) { + if (type === containedType) return true; + if (!type || depth > 6 || seenTypes.has(type)) return false; + seenTypes.add(type); + + if ( + type.flags & ts.TypeFlags.Object && + containedType.flags & ts.TypeFlags.Object && + type.objectFlags & ts.ObjectFlags.Anonymous + ) { + const properties = new Map(checker.getPropertiesOfType(type).map((property) => [property.name, property])); + if ( + containedProperties.length > 0 && + containedProperties.every((containedProperty) => { + const property = properties.get(containedProperty.name); + if (!property) return false; + const declarations = new Set(property.getDeclarations() ?? []); + return (containedProperty.getDeclarations() ?? []).some((declaration) => declarations.has(declaration)); + }) + ) { + return true; + } + } + + if (type.isUnionOrIntersection() && type.types.some((member) => visit(member, depth + 1))) return true; + if ((type.aliasTypeArguments ?? []).some((typeArgument) => visit(typeArgument, depth + 1))) return true; + if ( + type.flags & ts.TypeFlags.Object && + type.objectFlags & ts.ObjectFlags.Reference && + checker.getTypeArguments(type).some((typeArgument) => visit(typeArgument, depth + 1)) + ) { + return true; + } + if (!(type.flags & ts.TypeFlags.Object)) return false; + + for (const signature of [ + ...checker.getSignaturesOfType(type, ts.SignatureKind.Call), + ...checker.getSignaturesOfType(type, ts.SignatureKind.Construct), + ]) { + if (visit(checker.getReturnTypeOfSignature(signature), depth + 1)) return true; + } + if (!(type.objectFlags & ts.ObjectFlags.Anonymous)) return false; + for (const property of checker.getPropertiesOfType(type)) { + const declaration = property.valueDeclaration ?? property.getDeclarations()?.[0]; + if (!declaration || declaration.getSourceFile().isDeclarationFile) continue; + if (visit(checker.getTypeOfPropertyOfType(type, property.name), depth + 1)) return true; + } + return false; + } + + return visit(rootType, 0); + }; +} + +export function analyzeTypeDiscoverability(projectPath = "tsconfig.json", selectedFiles = []) { + const resolvedProjectPath = resolve(projectPath); + const configFile = ts.readConfigFile(resolvedProjectPath, ts.sys.readFile); + if (configFile.error) throw new Error(formatConfigDiagnostics([configFile.error])); + + const parsedConfig = ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + dirname(resolvedProjectPath), + undefined, + resolvedProjectPath, + ); + if (parsedConfig.errors.length > 0) throw new Error(formatConfigDiagnostics(parsedConfig.errors)); + + const program = ts.createProgram(parsedConfig.fileNames, parsedConfig.options); + const checker = program.getTypeChecker(); + const collectContracts = createContractCollector(program, checker); + const containsType = createTypeContainmentChecker(checker); + const rootFiles = new Set(program.getRootFileNames().map((file) => resolve(file))); + const selectedPaths = selectedFiles.map((file) => { + const path = resolve(file); + if (!existsSync(path)) throw new Error(`Selected path does not exist: ${path}`); + return { path, isDirectory: statSync(path).isDirectory() }; + }); + for (const selection of selectedPaths) { + const matchingFiles = [...rootFiles].filter((file) => + selection.isDirectory ? isPathWithin(selection.path, file) : file === selection.path, + ); + if (matchingFiles.length === 0) { + throw new Error(`Selected path contains no files from the TypeScript project: ${selection.path}`); + } + } + const issues = []; + + for (const sourceFile of program.getSourceFiles()) { + const resolvedFileName = resolve(sourceFile.fileName); + if (sourceFile.isDeclarationFile || !rootFiles.has(resolvedFileName)) continue; + if ( + selectedPaths.length > 0 && + !selectedPaths.some((selection) => + selection.isDirectory + ? isPathWithin(selection.path, resolvedFileName) + : resolvedFileName === selection.path, + ) + ) { + continue; + } + + function visit(node) { + if (ts.isObjectLiteralExpression(node)) { + const actualType = checker.getTypeAtLocation(node); + const directContext = checker.getContextualType(node); + const directContractSymbols = new Set( + directContext ? collectContracts(directContext).map((contract) => contract.symbol) : [], + ); + + let ancestor = node.parent; + let issue; + while (ancestor && !ts.isSourceFile(ancestor)) { + if (ts.isExpression(ancestor)) { + if (!containsType(checker.getTypeAtLocation(ancestor), actualType)) break; + const contextualType = checker.getContextualType(ancestor); + if (contextualType) { + const contextualContracts = collectContracts(contextualType); + const hasDirectAssociation = contextualContracts.some( + (contract) => + directContractSymbols.has(contract.symbol) && checker.isTypeAssignableTo(actualType, contract.type), + ); + if (hasDirectAssociation) break; + + for (const contract of contextualContracts) { + if (!checker.isTypeAssignableTo(actualType, contract.type)) continue; + issue = contract; + break; + } + } + } + if (issue) break; + ancestor = ancestor.parent; + } + + if (issue) { + const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + issues.push({ + fileName: sourceFile.fileName, + line: position.line + 1, + character: position.character + 1, + target: checker.symbolToString(issue.symbol, node, ts.SymbolFlags.Type), + }); + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + } + + return issues; +} + +function printUsage() { + console.log(`Usage: node scripts/check-type-discoverability.mjs [options] [files...] + +Options: + -p, --project TypeScript configuration (default: tsconfig.json) + -h, --help Show this help + +When files or directories are provided, only matching project files are checked. Directories are recursive. +The check follows values through enclosing expressions, but not through separate variable or function declarations.`); +} + +const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + let projectPath = "tsconfig.json"; + const selectedFiles = []; + let showHelp = false; + + for (let index = 2; index < process.argv.length; index++) { + const argument = process.argv[index]; + if (argument === "--help" || argument === "-h") { + showHelp = true; + continue; + } + if (argument === "--project" || argument === "-p") { + const value = process.argv[++index]; + if (!value) { + console.error(`${argument} requires a path`); + process.exit(2); + } + projectPath = value; + continue; + } + if (argument.startsWith("-")) { + console.error(`Unknown option: ${argument}`); + process.exit(2); + } + selectedFiles.push(argument); + } + + if (showHelp) { + printUsage(); + } else if (!existsSync(projectPath)) { + console.error(`TypeScript configuration not found: ${projectPath}`); + process.exitCode = 2; + } else { + try { + const issues = analyzeTypeDiscoverability(projectPath, selectedFiles); + if (issues.length > 0) { + console.error("Object literals without a discoverable named type association:"); + for (const issue of issues) { + const fileName = relative(process.cwd(), issue.fileName) || issue.fileName; + console.error( + ` ${fileName}:${issue.line}:${issue.character}: structurally consumed as ${issue.target}; add a direct type annotation, generic argument, or satisfies expression`, + ); + } + console.error(`Found ${issues.length} object literal${issues.length === 1 ? "" : "s"}.`); + process.exitCode = 1; + } else { + console.log("No object literals without a discoverable named type association found."); + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 2; + } + } +} diff --git a/scripts/check-type-discoverability.test.mjs b/scripts/check-type-discoverability.test.mjs new file mode 100644 index 000000000..07bad6079 --- /dev/null +++ b/scripts/check-type-discoverability.test.mjs @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { analyzeTypeDiscoverability } from "./check-type-discoverability.mjs"; + +async function createProject(files) { + const directory = await mkdtemp(join(tmpdir(), "pi-type-discoverability-")); + await writeFile( + join(directory, "tsconfig.json"), + `${JSON.stringify( + { + compilerOptions: { + strict: true, + target: "ES2022", + module: "NodeNext", + moduleResolution: "NodeNext", + types: [], + noEmit: true, + }, + include: ["**/*.ts"], + }, + null, + "\t", + )}\n`, + ); + for (const [fileName, source] of Object.entries(files)) { + const path = join(directory, fileName); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, source); + } + return directory; +} + +test("finds structural implementations that lose their named type through generic inference", async () => { + const directory = await createProject({ + "example.ts": `interface ClientTuiServer { +\tattachSession(sessionId: number): string; +} + +class ExperimentalClientTui { +\tconstructor(options: { servers: ClientTuiServer[] }) {} +} + +const servers = [1, 2, 3]; +new ExperimentalClientTui({ +\tservers: servers.map(() => ({ +\t\tattachSession: () => "inferred", +\t})), +}); +new ExperimentalClientTui({ +\tservers: servers.map(() => ({ +\t\tattachSession: () => "generic", +\t})), +}); +new ExperimentalClientTui({ +\tservers: servers.map((): ClientTuiServer => ({ +\t\tattachSession: () => "return type", +\t})), +}); +new ExperimentalClientTui({ +\tservers: servers.map(() => ({ +\t\tattachSession: () => "satisfies", +\t} satisfies ClientTuiServer)), +}); + +function identity(value: T): T { +\treturn value; +} +function consume(server: ClientTuiServer): void {} +consume(identity({ +\tattachSession: () => "not map-specific", +})); + +const unrelated = { +\tattachSession: () => "no named destination", +}; +const direct: ClientTuiServer = { +\tattachSession: () => "direct", +}; + +interface Product { +\tname: string; +} +function makeProduct(options: { name: string }): Product { +\treturn { name: options.name }; +} +function consumeProduct(product: Product): void {} +consumeProduct(makeProduct({ name: "factory input does not become the product" })); +`, + }); + + try { + const issues = analyzeTypeDiscoverability(join(directory, "tsconfig.json")); + assert.deepEqual( + issues.map(({ line, target }) => ({ line, target })), + [ + { line: 11, target: "ClientTuiServer" }, + { line: 35, target: "ClientTuiServer" }, + ], + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("accepts a directly typed member of a broader named union", async () => { + const directory = await createProject({ + "example.ts": `interface UserMessage { role: "user"; content: string } +interface AssistantMessage { role: "assistant"; content: string } +type AgentMessage = UserMessage | AssistantMessage; +function identity(value: T): T { return value; } +function consume(message: AgentMessage): void {} +consume(identity({ +\trole: "user", +\tcontent: "hello", +} satisfies UserMessage)); +`, + }); + + try { + const issues = analyzeTypeDiscoverability(join(directory, "tsconfig.json")); + assert.deepEqual(issues, []); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("checks named type aliases and classes without configuration", async () => { + const directory = await createProject({ + "example.ts": `type NamedAlias = { run(): void }; +class NamedClass { label = ""; } +function identity(value: T): T { return value; } +function consumeAlias(value: NamedAlias): void {} +function consumeClass(value: NamedClass): void {} +consumeAlias(identity({ run() {} })); +consumeClass(identity({ label: "class" })); +`, + }); + + try { + const issues = analyzeTypeDiscoverability(join(directory, "tsconfig.json")); + assert.deepEqual( + issues.map(({ target }) => target), + ["NamedAlias", "NamedClass"], + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("checks explicitly selected project files and directories", async () => { + const source = `interface NamedValue { run(): void } +function identity(value: T): T { return value; } +function consume(value: NamedValue): void {} +consume(identity({ run() {} })); +`; + const directory = await createProject({ "first.ts": source, "nested/second.ts": source }); + + try { + const fileIssues = analyzeTypeDiscoverability(join(directory, "tsconfig.json"), [join(directory, "first.ts")]); + assert.equal(fileIssues.length, 1); + assert.equal(fileIssues[0].fileName, join(directory, "first.ts")); + + const directoryIssues = analyzeTypeDiscoverability(join(directory, "tsconfig.json"), [join(directory, "nested")]); + assert.equal(directoryIssues.length, 1); + assert.equal(directoryIssues[0].fileName, join(directory, "nested/second.ts")); + assert.equal(directoryIssues[0].target, "NamedValue"); + } finally { + await rm(directory, { recursive: true, force: true }); + } +});