From 7a2af832f473c91948d779f4d695f311ae6bece5 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 28 Sep 2025 17:53:45 -0700 Subject: [PATCH] sniff sniff --- app/src/sniff.ts | 105 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 app/src/sniff.ts diff --git a/app/src/sniff.ts b/app/src/sniff.ts new file mode 100644 index 0000000..2131bc6 --- /dev/null +++ b/app/src/sniff.ts @@ -0,0 +1,105 @@ +// sniffs the types and params of a file's exports, including const values + +import ts from "typescript" + +export type Signature = { + type: string, + returnType: string | null, + params: Param[] +} + +export type Param = { + name: string, + type: string, + optional: boolean, + rest: boolean, + default: string | null +} + +export class SniffError extends Error { + constructor(message: string) { + super(message) + this.name = "TypeError" + Object.setPrototypeOf(this, SniffError.prototype) + } +} + +export type ExportInfo = + | { kind: "function", name: string, signatures: Signature[] } + | { kind: "value", name: string, type: string, value: string | null } + +let prevProgram: ts.Program | undefined + + +export async function allExports(file: string): Promise { + const program = ts.createProgram([file], { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + noLib: true, + types: [], + skipDefaultLibCheck: true, + skipLibCheck: true + }, undefined, prevProgram) + prevProgram = program + + const checker = program.getTypeChecker() + const sf = program.getSourceFile(file) + if (!sf) throw new SniffError(`File not found: ${file}`) + + const moduleSym = (sf as any).symbol as ts.Symbol | undefined + if (!moduleSym) return [] + + const exportSymbols = checker.getExportsOfModule(moduleSym) + const result: ExportInfo[] = [] + + for (const sym of exportSymbols) { + const decl = sym.valueDeclaration ?? sym.declarations?.[0] ?? sf + const type = checker.getTypeOfSymbolAtLocation(sym, decl) + const sigs = checker.getSignaturesOfType(type, ts.SignatureKind.Call) + + if (sigs.length > 0) { + result.push({ + kind: "function", + name: sym.getName(), + signatures: sigs.map(sig => ({ + type: checker.typeToString(type, decl), + returnType: checker.typeToString(checker.getReturnTypeOfSignature(sig), decl), + params: sig.getParameters().map(p => { + const pd: any = p.getDeclarations()?.[0] ?? decl + const pt = checker.getTypeOfSymbolAtLocation(p, pd) + return { + name: p.getName(), + type: checker.typeToString(pt, pd), + optional: !!pd.questionToken || !!pd.initializer, + rest: !!pd.dotDotDotToken, + default: pd.initializer ? pd.initializer.getText() : null + } + }) + })) + }) + } else { + let value: string | null = null + if (ts.isVariableDeclaration(decl) && decl.initializer) { + value = decl.initializer.getText() + } + result.push({ + kind: "value", + name: sym.getName(), + type: checker.typeToString(type, decl), + value + }) + } + } + + return result +} + +if (import.meta.main) { + const path = Bun.argv[2] + if (!path) { + console.error("usage: sniff ") + process.exit(1) + } + console.log(await allExports(path)) +} \ No newline at end of file