import ts from 'typescript' const roots = [ 'src', 'packages/core/src', 'packages/vue/src', 'packages/cli/src', 'packages/mcp/src', 'tests', 'scripts' ] type ShapeLocation = { file: string; line: number; name: string } const files: string[] = [] for (const root of roots) { for await (const path of new Bun.Glob('**/*.{ts,tsx}').scan(root)) { if (path.endsWith('.d.ts')) continue files.push(`${root}/${path}`) } } function entityNameText(name: ts.EntityName): string { return ts.isIdentifier(name) ? name.text : `${entityNameText(name.left)}.${name.right.text}` } function literalText(node: ts.LiteralTypeNode) { const literal = node.literal if (ts.isStringLiteral(literal)) return `string:${literal.text}` if (ts.isNumericLiteral(literal)) return `number:${literal.text}` if (literal.kind === ts.SyntaxKind.TrueKeyword) return 'boolean:true' if (literal.kind === ts.SyntaxKind.FalseKeyword) return 'boolean:false' return ts.SyntaxKind[literal.kind] } function typeText(node: ts.TypeNode | undefined): string { if (!node) return 'unknown' if (ts.isTypeReferenceNode(node)) { return `ref:${entityNameText(node.typeName)}<${(node.typeArguments ?? []).map(typeText).join(',')}>` } if (ts.isArrayTypeNode(node)) return `array<${typeText(node.elementType)}>` if (ts.isUnionTypeNode(node)) return `union<${node.types.map(typeText).sort().join('|')}>` if (ts.isTypeLiteralNode(node)) return `object{${membersText(node.members)}}` if (ts.isParenthesizedTypeNode(node)) return typeText(node.type) if (ts.isLiteralTypeNode(node)) return `literal:${literalText(node)}` return ts.SyntaxKind[node.kind] } function keyText(name: ts.PropertyName): string | null { if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text return null } function memberText(member: ts.TypeElement): string | null { if (ts.isPropertySignature(member) && member.name) { const key = keyText(member.name) if (!key) return null return `prop:${key}${member.questionToken ? '?' : ''}:${typeText(member.type)}` } if (ts.isIndexSignatureDeclaration(member)) { return `index:${member.parameters.map((param) => typeText(param.type)).join(',')}:${typeText(member.type)}` } return null } function membersText(members: ts.NodeArray) { return members .map(memberText) .filter((member): member is string => member !== null) .sort() .join(';') } function collectShape(node: ts.Node): { name: string; shape: string } | null { if (ts.isInterfaceDeclaration(node)) return { name: node.name.text, shape: membersText(node.members) } if (ts.isTypeAliasDeclaration(node) && ts.isTypeLiteralNode(node.type)) { return { name: node.name.text, shape: membersText(node.type.members) } } return null } const shapes = new Map() for (const file of files) { const source = await Bun.file(file).text() const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind) function visit(node: ts.Node) { const collected = collectShape(node) if (collected) { const memberCount = collected.shape.split(';').filter(Boolean).length if (memberCount >= 2) { const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)) const locations = shapes.get(collected.shape) ?? [] locations.push({ file, line: position.line + 1, name: collected.name }) shapes.set(collected.shape, locations) } } ts.forEachChild(node, visit) } visit(sourceFile) } let duplicates = 0 for (const locations of shapes.values()) { if (locations.length < 2) continue duplicates++ console.error('\nDuplicate object type shape:') for (const location of locations) { console.error(` ${location.file}:${location.line} ${location.name}`) } } if (duplicates > 0) { console.error(`\nFound ${duplicates} duplicate object type shape${duplicates === 1 ? '' : 's'}.`) process.exit(1) } console.log('No duplicate object type shapes found.')