import fs from 'node:fs' import path from 'node:path' import ts from 'typescript' const projectRoot = path.resolve(import.meta.dirname, '..') const upstreamPath = path.join(projectRoot, 'vendor/obsidian-upstream.d.ts') const localEntry = path.join(projectRoot, 'src/plugin/obsidian-compat/index.ts') const tempDir = path.join(projectRoot, 'node_modules/.tmp/obsidian-api-contract') const emittedLocalPath = path.join(tempDir, 'src/plugin/obsidian-compat/index.d.ts') const reportPath = path.join(tempDir, 'report.json') const compareTypeFormatFlags = ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope | ts.TypeFormatFlags.WriteArrowStyleSignature | ts.TypeFormatFlags.UseFullyQualifiedType function normalizeText(text) { return text .replace(/\r\n/g, '\n') .replace(/import\([^)]*\)\./g, '') .replace(/typeof import\([^)]*\)\./g, '') .replace(/\s+/g, ' ') .replace(/\s*([{}()[\]<>,:;|&=?])\s*/g, '$1') .trim() } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }) } function readTsConfigOptions() { const tsconfigPath = path.join(projectRoot, 'tsconfig.json') const config = ts.readConfigFile(tsconfigPath, ts.sys.readFile) if (config.error) { throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n')) } return ts.parseJsonConfigFileContent(config.config, ts.sys, projectRoot).options } function emitLocalDeclarations() { fs.rmSync(tempDir, { recursive: true, force: true }) ensureDir(tempDir) const baseOptions = readTsConfigOptions() const compilerOptions = { ...baseOptions, declaration: true, emitDeclarationOnly: true, noEmit: false, noEmitOnError: false, declarationMap: false, outDir: tempDir, rootDir: projectRoot, noUnusedLocals: false, noUnusedParameters: false, tsBuildInfoFile: undefined, // Strip @internal-marked members from emitted declarations. // This allows Reams-internal methods to remain accessible at runtime // while not appearing in the public Obsidian-compatible contract. stripInternal: true, } const program = ts.createProgram([localEntry], compilerOptions) const emitResult = program.emit() if (!fs.existsSync(emittedLocalPath)) { const diagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics) const rendered = diagnostics .map(diagnostic => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')) .join('\n\n') throw new Error(`Expected emitted declaration at ${emittedLocalPath}\n\n${rendered}`) } } function createCompareProgram() { const compilerOptions = { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, lib: ['lib.es2020.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'], skipLibCheck: true, strict: false, baseUrl: projectRoot, allowJs: false, noEmit: true, } return ts.createProgram([upstreamPath, emittedLocalPath], compilerOptions) } function getModuleSymbol(sourceFile) { if (!sourceFile?.symbol) { throw new Error(`Could not resolve module symbol for ${sourceFile?.fileName ?? 'unknown file'}`) } return sourceFile.symbol } function resolveSymbol(symbol, checker) { if (symbol.flags & ts.SymbolFlags.Alias) { return checker.getAliasedSymbol(symbol) } return symbol } function getNodeName(node) { if ('name' in node && node.name) { if (ts.isIdentifier(node.name) || ts.isPrivateIdentifier(node.name)) { return node.name.text } return node.name.getText() } return null } function typeTextForNode(node, checker) { if (node.type) { return normalizeText(checker.typeToString(checker.getTypeFromTypeNode(node.type), node, compareTypeFormatFlags)) } return normalizeText(checker.typeToString(checker.getTypeAtLocation(node), node, compareTypeFormatFlags)) } function serializeSignatureFromDeclaration(declaration, checker) { const signature = checker.getSignatureFromDeclaration(declaration) if (!signature) { return { parameters: [], returnType: 'unknown', typeParameters: [], } } return { typeParameters: declaration.typeParameters?.map(param => ({ name: param.name.text, constraint: param.constraint ? normalizeText(param.constraint.getText()) : null, default: param.default ? normalizeText(param.default.getText()) : null, })) ?? [], parameters: declaration.parameters.map(param => ({ optional: Boolean(param.questionToken || param.initializer), rest: Boolean(param.dotDotDotToken), type: typeTextForNode(param, checker), })), returnType: normalizeText(checker.typeToString(signature.getReturnType(), declaration, compareTypeFormatFlags)), } } function serializeFunctionLikeDeclarations(declarations, checker) { return declarations .map(declaration => serializeSignatureFromDeclaration(declaration, checker)) .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))) } function serializeAccessorEntry(existing, kind, declaration, checker) { const next = existing ?? { kind: 'accessor', getterType: null, setterParameters: [], static: false, } next.static ||= Boolean(ts.getModifiers(declaration)?.some(modifier => modifier.kind === ts.SyntaxKind.StaticKeyword)) if (kind === 'get') { next.getterType = typeTextForNode(declaration, checker) } else { next.setterParameters = declaration.parameters.map(param => ({ optional: Boolean(param.questionToken || param.initializer), rest: Boolean(param.dotDotDotToken), type: typeTextForNode(param, checker), })) } return next } function serializeMembers(typeLikeDeclarations, checker) { const members = new Map() for (const declaration of typeLikeDeclarations) { const heritage = declaration.heritageClauses ?? [] for (const clause of heritage) { const clauseKey = clause.token === ts.SyntaxKind.ExtendsKeyword ? '__extends__' : '__implements__' const existing = members.get(clauseKey) ?? [] for (const typeNode of clause.types) { existing.push(normalizeText(typeNode.getText())) } members.set(clauseKey, existing) } for (const member of declaration.members) { if (ts.isConstructorDeclaration(member)) { // Skip private/protected constructors — they're implementation details, not public API. // TypeScript emits private constructors with empty signatures in .d.ts files, but // upstream Obsidian's hand-written .d.ts omits them entirely. Skipping them here // ensures we compare only the public contract surface. const modifiers = ts.getModifiers(member) ?? [] const isPrivate = modifiers.some(m => m.kind === ts.SyntaxKind.PrivateKeyword) const isProtected = modifiers.some(m => m.kind === ts.SyntaxKind.ProtectedKeyword) if (isPrivate || isProtected) { continue } const existing = members.get('constructor') ?? { kind: 'constructor', signatures: [] } existing.signatures.push(serializeSignatureFromDeclaration(member, checker)) members.set('constructor', existing) continue } if (ts.isIndexSignatureDeclaration(member)) { const key = `__index__:${members.size}` members.set(key, { kind: 'index', signatures: [serializeSignatureFromDeclaration(member, checker)], readonly: Boolean(ts.getModifiers(member)?.some(modifier => modifier.kind === ts.SyntaxKind.ReadonlyKeyword)), }) continue } if (ts.isCallSignatureDeclaration(member)) { const key = `__call__:${members.size}` members.set(key, { kind: 'call', signatures: [serializeSignatureFromDeclaration(member, checker)], }) continue } if (ts.isConstructSignatureDeclaration(member)) { const key = `__construct__:${members.size}` members.set(key, { kind: 'construct-signature', signatures: [serializeSignatureFromDeclaration(member, checker)], }) continue } const memberName = getNodeName(member) if (!memberName) continue // Skip ES private fields (e.g. #private) - they are implementation details, not public API if (memberName.startsWith('#')) continue // Skip private/protected members — they're implementation details, not public API. // TypeScript emits private/protected members in .d.ts files, but they're not part // of the Obsidian-compatible contract surface. const modifiers = ts.getModifiers(member) ?? [] const isPrivate = modifiers.some(m => m.kind === ts.SyntaxKind.PrivateKeyword) const isProtected = modifiers.some(m => m.kind === ts.SyntaxKind.ProtectedKeyword) if (isPrivate || isProtected) continue const staticMember = Boolean(modifiers.some(modifier => modifier.kind === ts.SyntaxKind.StaticKeyword)) const key = `${staticMember ? 'static:' : 'instance:'}${memberName}` if (ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) { const existing = members.get(key) ?? { kind: 'method', signatures: [], static: staticMember } existing.signatures.push(serializeSignatureFromDeclaration(member, checker)) members.set(key, existing) continue } if (ts.isPropertyDeclaration(member) || ts.isPropertySignature(member)) { members.set(key, { kind: 'property', optional: Boolean(member.questionToken), readonly: Boolean(ts.getModifiers(member)?.some(modifier => modifier.kind === ts.SyntaxKind.ReadonlyKeyword)), static: staticMember, type: typeTextForNode(member, checker), }) continue } if (ts.isGetAccessorDeclaration(member)) { members.set(key, serializeAccessorEntry(members.get(key), 'get', member, checker)) continue } if (ts.isSetAccessorDeclaration(member)) { members.set(key, serializeAccessorEntry(members.get(key), 'set', member, checker)) } } } const normalizedEntries = [...members.entries()].map(([name, value]) => { if (Array.isArray(value)) { return [name, value.sort()] } if (value?.signatures) { value.signatures.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))) } return [name, value] }) normalizedEntries.sort(([left], [right]) => left.localeCompare(right)) return Object.fromEntries(normalizedEntries) } function serializeSymbol(symbol, checker) { const target = resolveSymbol(symbol, checker) const declarations = target.getDeclarations() ?? [] const classDeclarations = declarations.filter(ts.isClassDeclaration) const interfaceDeclarations = declarations.filter(ts.isInterfaceDeclaration) const typeAliasDeclaration = declarations.find(ts.isTypeAliasDeclaration) const functionDeclarations = declarations.filter(ts.isFunctionDeclaration) const variableDeclaration = declarations.find(ts.isVariableDeclaration) const enumDeclaration = declarations.find(ts.isEnumDeclaration) if (classDeclarations.length > 0) { return { kind: 'class', abstract: classDeclarations.some(declaration => ts.getModifiers(declaration)?.some(modifier => modifier.kind === ts.SyntaxKind.AbstractKeyword) ), members: serializeMembers(classDeclarations, checker), } } if (interfaceDeclarations.length > 0) { return { kind: 'interface', members: serializeMembers(interfaceDeclarations, checker), } } if (typeAliasDeclaration) { return { kind: 'type-alias', type: typeTextForNode(typeAliasDeclaration, checker), } } if (functionDeclarations.length > 0) { return { kind: 'function', signatures: serializeFunctionLikeDeclarations(functionDeclarations, checker), } } if (variableDeclaration) { return { kind: 'variable', type: typeTextForNode(variableDeclaration, checker), } } if (enumDeclaration) { return { kind: 'enum', members: enumDeclaration.members.map(member => ({ name: getNodeName(member), value: member.initializer ? normalizeText(member.initializer.getText()) : null, })), } } return { kind: declarations[0]?.kind ?? 'unknown', declarations: declarations.map(declaration => declaration.getText()).sort(), } } function collectModuleExports(sourceFile, checker) { const moduleSymbol = getModuleSymbol(sourceFile) const exports = new Map() for (const exportedSymbol of checker.getExportsOfModule(moduleSymbol)) { if (exportedSymbol.name === 'default') continue exports.set(exportedSymbol.name, serializeSymbol(exportedSymbol, checker)) } return exports } function diffExports(upstreamExports, localExports) { const missing = [] const extra = [] const mismatched = [] for (const [name, upstreamValue] of upstreamExports) { if (!localExports.has(name)) { missing.push(name) continue } const localValue = localExports.get(name) if (JSON.stringify(upstreamValue) !== JSON.stringify(localValue)) { mismatched.push({ name, upstream: upstreamValue, local: localValue, }) } } for (const name of localExports.keys()) { if (!upstreamExports.has(name)) { extra.push(name) } } missing.sort() extra.sort() mismatched.sort((left, right) => left.name.localeCompare(right.name)) return { missing, extra, mismatched } } function printSection(title, items, formatter = value => value) { if (items.length === 0) { console.log(`${title}: 0`) return } console.log(`${title}: ${items.length}`) for (const item of items) { console.log(`- ${formatter(item)}`) } } function main() { if (!fs.existsSync(upstreamPath)) { throw new Error(`Missing upstream d.ts at ${upstreamPath}`) } emitLocalDeclarations() const program = createCompareProgram() const checker = program.getTypeChecker() const upstreamSource = program.getSourceFile(upstreamPath) const localSource = program.getSourceFile(emittedLocalPath) if (!upstreamSource || !localSource) { throw new Error('Could not load upstream and local declaration files') } const upstreamExports = collectModuleExports(upstreamSource, checker) const localExports = collectModuleExports(localSource, checker) const diff = diffExports(upstreamExports, localExports) ensureDir(tempDir) fs.writeFileSync( reportPath, JSON.stringify( { upstreamExportCount: upstreamExports.size, localExportCount: localExports.size, ...diff, }, null, 2 ) ) console.log('== Obsidian public API contract report ==') console.log(`Upstream source: vendor/obsidian-upstream.d.ts`) console.log(`Local source: ${path.relative(projectRoot, emittedLocalPath)}`) console.log(`Report: ${path.relative(projectRoot, reportPath)}`) console.log(`Upstream exports: ${upstreamExports.size}`) console.log(`Local exports: ${localExports.size}`) console.log(`Missing exports: ${diff.missing.length}`) console.log(`Extra exports: ${diff.extra.length}`) console.log(`Contract mismatches: ${diff.mismatched.length}`) printSection('Missing', diff.missing) printSection('Extra', diff.extra) printSection('Mismatched', diff.mismatched, item => item.name) if (diff.missing.length > 0 || diff.extra.length > 0 || diff.mismatched.length > 0) { process.exitCode = 1 } } main()