story-research-zapwall/scripts/findLongFunctions.js
2026-01-10 09:41:57 +01:00

226 lines
5.7 KiB
JavaScript

// This script is a local dev helper to identify functions likely violating ESLint `max-lines-per-function`
// without running ESLint. It uses the TypeScript AST to compute line spans.
//
// Usage:
// node scripts/findLongFunctions.js [--max 40] [--limit 50]
//
// Notes:
// - Counts *raw* line span (endLine - startLine + 1). ESLint uses a different metric
// (skipBlankLines/skipComments). This script is used to pick candidates efficiently.
//
// It is intentionally plain JS to avoid requiring a TS build step.
const fs = require('fs')
const path = require('path')
const ts = require('typescript')
/**
* @typedef {Readonly<{ file: string; name: string; startLine: number; endLine: number; span: number }>} LongFn
*/
/**
* @param {string} value
* @returns {number | undefined}
*/
function readNumberArg(value) {
const n = Number(value)
if (Number.isFinite(n) && n > 0) {
return n
}
return undefined
}
/**
* @param {string[]} argv
* @returns {{ max: number; limit: number }}
*/
function readArgs(argv) {
let max = 40
let limit = 50
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i]
const next = argv[i + 1]
if (token === '--max' && next) {
max = readNumberArg(next) ?? max
i += 1
continue
}
if (token === '--limit' && next) {
limit = readNumberArg(next) ?? limit
i += 1
}
}
return { max, limit }
}
/**
* @param {string} p
* @returns {boolean}
*/
function isIgnoredPath(p) {
const normalized = p.split(path.sep).join('/')
return (
normalized.includes('/node_modules/') ||
normalized.includes('/.next/') ||
normalized.includes('/dist/') ||
normalized.includes('/out/')
)
}
/**
* @param {string} dir
* @param {string[]} out
* @returns {void}
*/
function collectSourceFiles(dir, out) {
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (isIgnoredPath(full)) {
continue
}
if (entry.isDirectory()) {
collectSourceFiles(full, out)
continue
}
if (!entry.isFile()) {
continue
}
if (full.endsWith('.ts') || full.endsWith('.tsx')) {
out.push(full)
}
}
}
/**
* @param {ts.Node} node
* @returns {string}
*/
function getReadableName(node) {
if (ts.isFunctionDeclaration(node) && node.name) {
return node.name.getText()
}
if (ts.isMethodDeclaration(node) && node.name) {
return node.name.getText()
}
if (ts.isConstructorDeclaration(node)) {
return 'constructor'
}
if (ts.isGetAccessorDeclaration(node) && node.name) {
return `get ${node.name.getText()}`
}
if (ts.isSetAccessorDeclaration(node) && node.name) {
return `set ${node.name.getText()}`
}
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
const parent = node.parent
if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
return parent.name.text
}
if (ts.isPropertyAssignment(parent) && ts.isIdentifier(parent.name)) {
return parent.name.text
}
return '<anonymous>'
}
return '<unknown>'
}
/**
* @param {ts.Node} node
* @returns {node is (ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction | ts.MethodDeclaration | ts.ConstructorDeclaration | ts.GetAccessorDeclaration | ts.SetAccessorDeclaration)}
*/
function isFunctionLikeWithBody(node) {
if (!ts.isFunctionLike(node)) {
return false
}
// Arrow functions may have expression bodies. We only flag block bodies for line spans.
// Function/method declarations use block bodies.
return Boolean(node.body)
}
/**
* @param {string} filename
* @param {number} max
* @returns {LongFn[]}
*/
function findLongFunctionsInFile(filename, max) {
const sourceText = fs.readFileSync(filename, 'utf8')
const sourceFile = ts.createSourceFile(
filename,
sourceText,
ts.ScriptTarget.Latest,
true,
filename.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
)
/** @type {LongFn[]} */
const results = []
/**
* @param {ts.Node} node
* @returns {void}
*/
function visit(node) {
if (isFunctionLikeWithBody(node)) {
const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile, false))
const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd())
const startLine = start.line + 1
const endLine = end.line + 1
const span = endLine - startLine + 1
if (span > max) {
results.push({
file: filename,
name: getReadableName(node),
startLine,
endLine,
span,
})
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return results
}
/**
* @param {LongFn} fn
* @returns {string}
*/
function formatFn(fn) {
const rel = path.relative(process.cwd(), fn.file).split(path.sep).join('/')
return `${fn.span.toString().padStart(4, ' ')} ${rel}:${fn.startLine}-${fn.endLine} ${fn.name}`
}
function main() {
const { max, limit } = readArgs(process.argv.slice(2))
/** @type {string[]} */
const files = []
for (const dir of ['components', 'hooks', 'lib', 'pages', 'types', 'scripts']) {
const full = path.join(process.cwd(), dir)
if (fs.existsSync(full)) {
collectSourceFiles(full, files)
}
}
/** @type {LongFn[]} */
const all = []
for (const file of files) {
const found = findLongFunctionsInFile(file, max)
all.push(...found)
}
all.sort((a, b) => b.span - a.span || a.file.localeCompare(b.file) || a.startLine - b.startLine)
const top = all.slice(0, limit)
console.log(`Found ${all.length} function-like nodes with raw span > ${max} lines.`)
console.log(`Showing top ${top.length} (sorted by span desc):\n`)
for (const fn of top) {
console.log(formatFn(fn))
}
}
main()