openpencil/scripts/type-shapes.ts
Danila Poyarkov f4b3453fbd chore(lint): check duplicate type shapes across files
- Add a repo-wide TypeScript AST duplicate shape check to the quality gate
- Extract shared named types for existing cross-file duplicates
- Keep same-file oxlint coverage for editor feedback
2026-05-25 00:55:29 +03:00

124 lines
4.1 KiB
TypeScript

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<ts.TypeElement>) {
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<string, ShapeLocation[]>()
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.')