import { readdirSync, statSync } from "node:fs"; import { extname, join } from "node:path"; import * as typescript from "typescript"; export interface CheckerOptions { noComments?: boolean; } const defaultOptions: CheckerOptions = { noComments: true, }; type StatementCategory = | "import" | "variableDeclaration" | "typeAlias" | "if" | "for" | "while" | "return" | "throw" | "expression" | "other"; interface StatementInfo { category: StatementCategory; startLine: number; endLine: number; hasBlock: boolean; } function getStatementCategory(node: typescript.Statement): StatementCategory { if (typescript.isImportDeclaration(node)) { return "import"; } if (typescript.isVariableStatement(node)) { return "variableDeclaration"; } if (typescript.isTypeAliasDeclaration(node)) { return "typeAlias"; } if (typescript.isIfStatement(node)) { return "if"; } if ( typescript.isForStatement(node) || typescript.isForInStatement(node) || typescript.isForOfStatement(node) ) { return "for"; } if (typescript.isWhileStatement(node) || typescript.isDoStatement(node)) { return "while"; } if (typescript.isReturnStatement(node)) { return "return"; } if (typescript.isThrowStatement(node)) { return "throw"; } if (typescript.isExpressionStatement(node)) { return "expression"; } return "other"; } function statementHasBlock(node: typescript.Statement): boolean { if (typescript.isIfStatement(node)) { return typescript.isBlock(node.thenStatement); } if ( typescript.isForStatement(node) || typescript.isForInStatement(node) || typescript.isForOfStatement(node) ) { return typescript.isBlock(node.statement); } if (typescript.isWhileStatement(node)) { return typescript.isBlock(node.statement); } if (typescript.isDoStatement(node)) { return typescript.isBlock(node.statement); } if (typescript.isTypeAliasDeclaration(node)) { return typescript.isTypeLiteralNode(node.type); } return false; } function getStatementsFromBlock( block: typescript.Block | typescript.SourceFile, sourceFile: typescript.SourceFile, ): StatementInfo[] { const statements: StatementInfo[] = []; for (const statement of block.statements) { const category = getStatementCategory(statement); const startPosition = statement.getStart(sourceFile); const endPosition = statement.getEnd(); const startLine = sourceFile.getLineAndCharacterOfPosition(startPosition).line + 1; const endLine = sourceFile.getLineAndCharacterOfPosition(endPosition).line + 1; const hasBlock = statementHasBlock(statement); statements.push({ category, startLine, endLine, hasBlock }); } return statements; } function countBlankLinesBetween( previousEndLine: number, currentStartLine: number, sourceFile: typescript.SourceFile, ): number { const text = sourceFile.getFullText(); const lines = text.split("\n"); let blankCount = 0; for ( let lineIndex = previousEndLine; lineIndex < currentStartLine - 1; lineIndex++ ) { const line = lines[lineIndex]; if (line !== undefined && line.trim() === "") { blankCount++; } } return blankCount; } function hasCommentBetween( previousEndLine: number, currentStartLine: number, sourceFile: typescript.SourceFile, ): boolean { const text = sourceFile.getFullText(); const lines = text.split("\n"); for ( let lineIndex = previousEndLine; lineIndex < currentStartLine - 1; lineIndex++ ) { const line = lines[lineIndex]; if (line !== undefined) { const trimmed = line.trim(); if ( trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*") || trimmed.endsWith("*/") ) { return true; } } } return false; } function checkStatementSpacing( statements: StatementInfo[], sourceFile: typescript.SourceFile, filePath: string, allowComments: boolean, ): string[] { const errors: string[] = []; for (let index = 1; index < statements.length; index++) { const previous = statements[index - 1]; const current = statements[index]; if (previous === undefined || current === undefined) { continue; } if (previous.category === "other" || current.category === "other") { continue; } const blankLines = countBlankLinesBetween( previous.endLine, current.startLine, sourceFile, ); const sameCategory = previous.category === current.category; const hasComment = hasCommentBetween( previous.endLine, current.startLine, sourceFile, ); if (previous.category === "import" && current.category === "import") { if (blankLines > 0 && !(allowComments && hasComment)) { errors.push( `${filePath}:${current.startLine}: Blank line between imports`, ); } continue; } const eitherHasBlock = previous.hasBlock || current.hasBlock; if (eitherHasBlock && blankLines === 0) { errors.push( `${filePath}:${current.startLine}: Missing blank line around scoped block`, ); continue; } if ( sameCategory && !eitherHasBlock && blankLines > 0 && !(allowComments && hasComment) ) { errors.push( `${filePath}:${current.startLine}: Unnecessary blank line between same statement types (${current.category})`, ); } if (!sameCategory && !eitherHasBlock && blankLines === 0 && !hasComment) { errors.push( `${filePath}:${current.startLine}: Missing blank line between different statement types (${previous.category} -> ${current.category})`, ); } } return errors; } function visitBlocks( node: typescript.Node, sourceFile: typescript.SourceFile, filePath: string, errors: string[], allowComments: boolean, ): void { if (typescript.isBlock(node)) { const statements = getStatementsFromBlock(node, sourceFile); const blockErrors = checkStatementSpacing( statements, sourceFile, filePath, allowComments, ); errors.push(...blockErrors); } typescript.forEachChild(node, (childNode) => { visitBlocks(childNode, sourceFile, filePath, errors, allowComments); }); } function checkForComments( sourceFile: typescript.SourceFile, filePath: string, ): string[] { const errors: string[] = []; const text = sourceFile.getFullText(); function visit(node: typescript.Node): void { const leadingComments = typescript.getLeadingCommentRanges( text, node.getFullStart(), ); const trailingComments = typescript.getTrailingCommentRanges( text, node.getEnd(), ); if (leadingComments) { for (const comment of leadingComments) { const line = sourceFile.getLineAndCharacterOfPosition(comment.pos).line + 1; errors.push(`${filePath}:${line}: Comment detected`); } } if (trailingComments) { for (const comment of trailingComments) { const line = sourceFile.getLineAndCharacterOfPosition(comment.pos).line + 1; errors.push(`${filePath}:${line}: Comment detected`); } } typescript.forEachChild(node, visit); } visit(sourceFile); const uniqueErrors = [...new Set(errors)]; return uniqueErrors; } export function checkFile( filePath: string, options: CheckerOptions = defaultOptions, ): string[] { const program = typescript.createProgram([filePath], { target: typescript.ScriptTarget.ESNext, module: typescript.ModuleKind.ESNext, jsx: typescript.JsxEmit.ReactJSX, allowJs: true, noEmit: true, skipLibCheck: true, }); const sourceFile = program.getSourceFile(filePath); if (!sourceFile) { return []; } const allowComments = !options.noComments; const errors: string[] = []; const topLevelStatements = getStatementsFromBlock(sourceFile, sourceFile); const topLevelErrors = checkStatementSpacing( topLevelStatements, sourceFile, filePath, allowComments, ); errors.push(...topLevelErrors); visitBlocks(sourceFile, sourceFile, filePath, errors, allowComments); if (options.noComments) { const commentErrors = checkForComments(sourceFile, filePath); errors.push(...commentErrors); } return errors; } export function walkDirectory(directory: string): string[] { const files: string[] = []; for (const entry of readdirSync(directory)) { const fullPath = join(directory, entry); const stat = statSync(fullPath); if (stat.isDirectory()) { if ( !entry.includes("node_modules") && !entry.includes("dist") && !entry.includes(".next") ) { files.push(...walkDirectory(fullPath)); } } else if ( [".ts", ".tsx"].includes(extname(entry)) && !entry.endsWith(".d.ts") ) { files.push(fullPath); } } return files; } export function checkDirectory( directory: string, options: CheckerOptions = defaultOptions, ): { errors: string[]; hasErrors: boolean } { const files = walkDirectory(directory); const allErrors: string[] = []; for (const file of files) { const errors = checkFile(file, options); allErrors.push(...errors); } return { errors: allErrors, hasErrors: allErrors.length > 0 }; }