344 lines
10 KiB
JavaScript
344 lines
10 KiB
JavaScript
import fs from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
import process from 'node:process'
|
|
|
|
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
|
|
import ts from 'typescript-api'
|
|
|
|
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts', '.cts'])
|
|
const SKIP_PATH_PARTS = new Set(['node_modules', 'dist', 'out', '.git', '__snapshots__'])
|
|
const SCAN_ROOTS = ['src', 'config/scripts', 'tools', 'tests', 'mobile']
|
|
|
|
const LOOP_KINDS = new Set([
|
|
ts.SyntaxKind.ForStatement,
|
|
ts.SyntaxKind.ForInStatement,
|
|
ts.SyntaxKind.ForOfStatement,
|
|
ts.SyntaxKind.WhileStatement,
|
|
ts.SyntaxKind.DoStatement
|
|
])
|
|
|
|
const ASSIGNMENT_OPERATORS = new Set([
|
|
ts.SyntaxKind.EqualsToken,
|
|
ts.SyntaxKind.PlusEqualsToken,
|
|
ts.SyntaxKind.QuestionQuestionEqualsToken,
|
|
ts.SyntaxKind.BarBarEqualsToken,
|
|
ts.SyntaxKind.AmpersandAmpersandEqualsToken
|
|
])
|
|
|
|
function normalizeReferenceText(text) {
|
|
return text.replaceAll(/\s+/g, '')
|
|
}
|
|
|
|
// The variable a member/call chain ultimately reads, so `carry.subarray(0, n)`
|
|
// still resolves to `carry`. `this.x` stops at the property: the class field is
|
|
// the accumulator.
|
|
function rootReferenceText(node) {
|
|
if (ts.isIdentifier(node)) {
|
|
return node.text
|
|
}
|
|
if (ts.isPropertyAccessExpression(node)) {
|
|
return node.expression.kind === ts.SyntaxKind.ThisKeyword
|
|
? normalizeReferenceText(node.getText())
|
|
: rootReferenceText(node.expression)
|
|
}
|
|
if (
|
|
ts.isElementAccessExpression(node) ||
|
|
ts.isCallExpression(node) ||
|
|
ts.isNonNullExpression(node) ||
|
|
ts.isParenthesizedExpression(node) ||
|
|
ts.isAsExpression(node)
|
|
) {
|
|
return rootReferenceText(node.expression)
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function isBufferConcatCall(node) {
|
|
return (
|
|
ts.isCallExpression(node) &&
|
|
ts.isPropertyAccessExpression(node.expression) &&
|
|
node.expression.name.text === 'concat' &&
|
|
ts.isIdentifier(node.expression.expression) &&
|
|
node.expression.expression.text === 'Buffer' &&
|
|
node.arguments.length > 0 &&
|
|
ts.isArrayLiteralExpression(node.arguments[0])
|
|
)
|
|
}
|
|
|
|
function enclosingLoop(node) {
|
|
for (let current = node.parent; current; current = current.parent) {
|
|
if (LOOP_KINDS.has(current.kind)) {
|
|
return current
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
// A `for (let acc = ...; ;)` binding is loop-carried even though it sits inside
|
|
// the loop node, so the initializer does not count as loop-local.
|
|
function isDeclaredInsideLoop(declarationStart, loop) {
|
|
if (declarationStart < loop.getStart() || declarationStart >= loop.end) {
|
|
return false
|
|
}
|
|
const initializer = ts.isForStatement(loop) ? loop.initializer : undefined
|
|
if (!initializer) {
|
|
return true
|
|
}
|
|
return declarationStart < initializer.getStart() || declarationStart >= initializer.end
|
|
}
|
|
|
|
function collectBindingNames(name, into) {
|
|
if (ts.isIdentifier(name)) {
|
|
into.push(name)
|
|
return
|
|
}
|
|
if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) {
|
|
for (const element of name.elements) {
|
|
if (ts.isBindingElement(element)) {
|
|
collectBindingNames(element.name, into)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function collectDeclarations(sourceFile) {
|
|
const declarations = new Map()
|
|
const record = (identifier, node) => {
|
|
const existing = declarations.get(identifier.text)
|
|
const start = node.getStart(sourceFile)
|
|
if (existing) {
|
|
existing.push(start)
|
|
} else {
|
|
declarations.set(identifier.text, [start])
|
|
}
|
|
}
|
|
|
|
const visit = (node) => {
|
|
if (ts.isVariableDeclaration(node) || ts.isParameter(node)) {
|
|
const names = []
|
|
collectBindingNames(node.name, names)
|
|
for (const identifier of names) {
|
|
record(identifier, node)
|
|
}
|
|
}
|
|
ts.forEachChild(node, visit)
|
|
}
|
|
|
|
visit(sourceFile)
|
|
return declarations
|
|
}
|
|
|
|
function collectAssignedRoots(loop) {
|
|
const assigned = new Set()
|
|
const visit = (node) => {
|
|
if (ts.isBinaryExpression(node) && ASSIGNMENT_OPERATORS.has(node.operatorToken.kind)) {
|
|
const root = rootReferenceText(node.left)
|
|
if (root) {
|
|
assigned.add(root)
|
|
}
|
|
}
|
|
ts.forEachChild(node, visit)
|
|
}
|
|
visit(loop.statement)
|
|
return assigned
|
|
}
|
|
|
|
// Unwrap the wrappers a concat result passes through before it lands on the
|
|
// left-hand side, so `acc = cond ? Buffer.concat([acc, c]) : c` still counts.
|
|
function assignmentTargetOf(call) {
|
|
let node = call
|
|
let parent = node.parent
|
|
while (
|
|
parent &&
|
|
(ts.isParenthesizedExpression(parent) ||
|
|
ts.isAsExpression(parent) ||
|
|
ts.isNonNullExpression(parent) ||
|
|
(ts.isConditionalExpression(parent) && parent.condition !== node))
|
|
) {
|
|
node = parent
|
|
parent = parent.parent
|
|
}
|
|
if (
|
|
parent &&
|
|
ts.isBinaryExpression(parent) &&
|
|
parent.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
parent.right === node
|
|
) {
|
|
return {
|
|
text: normalizeReferenceText(parent.left.getText()),
|
|
root: rootReferenceText(parent.left)
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function concatOperands(call) {
|
|
return call.arguments[0].elements.map((element) => {
|
|
const spread = ts.isSpreadElement(element)
|
|
const expression = spread ? element.expression : element
|
|
return {
|
|
spread,
|
|
text: normalizeReferenceText(expression.getText()),
|
|
root: rootReferenceText(expression)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A binding reset on every iteration cannot accumulate; only one that outlives
|
|
// the iteration carries the cost forward.
|
|
function isLoopCarried(root, loop, declarations) {
|
|
const starts = declarations.get(root)
|
|
if (!starts) {
|
|
return true
|
|
}
|
|
return !starts.some((start) => isDeclaredInsideLoop(start, loop))
|
|
}
|
|
|
|
function quadraticAccumulator(call, loop, declarations, assignedRoots) {
|
|
const operands = concatOperands(call)
|
|
const target = assignmentTargetOf(call)
|
|
// The result feeds straight back into its own input: every iteration re-copies
|
|
// everything accumulated so far.
|
|
const selfOperand = target
|
|
? operands.find((operand) => operand.text === target.text || operand.root === target.root)
|
|
: undefined
|
|
if (selfOperand && target.root && isLoopCarried(target.root, loop, declarations)) {
|
|
return target.text
|
|
}
|
|
|
|
// No direct self-assignment, so look for a loop-carried operand reassigned
|
|
// inside the loop: the concat result reaches it by some other path. Spread
|
|
// operands are the sanctioned chunk-list fix, not this bug, so skip them.
|
|
for (const operand of operands) {
|
|
if (operand.spread || !operand.root || !assignedRoots.has(operand.root)) {
|
|
continue
|
|
}
|
|
if (isLoopCarried(operand.root, loop, declarations)) {
|
|
return operand.root
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
export function reportQuadraticBufferConcat(filePath, sourceText) {
|
|
if (!sourceText.includes('Buffer.concat')) {
|
|
return []
|
|
}
|
|
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true)
|
|
const declarations = collectDeclarations(sourceFile)
|
|
const assignedRootsByLoop = new Map()
|
|
const reports = []
|
|
const seen = new Set()
|
|
|
|
const visit = (node) => {
|
|
if (isBufferConcatCall(node)) {
|
|
const loop = enclosingLoop(node)
|
|
if (loop) {
|
|
let assignedRoots = assignedRootsByLoop.get(loop)
|
|
if (!assignedRoots) {
|
|
assignedRoots = collectAssignedRoots(loop)
|
|
assignedRootsByLoop.set(loop, assignedRoots)
|
|
}
|
|
const accumulator = quadraticAccumulator(node, loop, declarations, assignedRoots)
|
|
if (accumulator) {
|
|
const start = node.getStart(sourceFile)
|
|
if (!seen.has(start)) {
|
|
seen.add(start)
|
|
const { line, character } = sourceFile.getLineAndCharacterOfPosition(start)
|
|
reports.push({
|
|
filePath,
|
|
line: line + 1,
|
|
column: character + 1,
|
|
accumulator,
|
|
text: node.getText()
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ts.forEachChild(node, visit)
|
|
}
|
|
|
|
visit(sourceFile)
|
|
return reports
|
|
}
|
|
|
|
export function normalizePath(root, filePath) {
|
|
return path.relative(root, filePath).split(path.sep).join('/')
|
|
}
|
|
|
|
function isSkippedFile(root, filePath) {
|
|
const relative = normalizePath(root, filePath)
|
|
// Benchmarks keep the pre-fix shape on purpose so they can measure against it.
|
|
if (
|
|
relative.includes('.test.') ||
|
|
relative.includes('.spec.') ||
|
|
relative.includes('-benchmark.')
|
|
) {
|
|
return true
|
|
}
|
|
return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part))
|
|
}
|
|
|
|
async function collectSourceFiles(root, dir) {
|
|
let entries
|
|
try {
|
|
entries = await fs.readdir(dir, { withFileTypes: true })
|
|
} catch {
|
|
return []
|
|
}
|
|
const files = []
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dir, entry.name)
|
|
if (entry.isDirectory()) {
|
|
if (!SKIP_PATH_PARTS.has(entry.name)) {
|
|
files.push(...(await collectSourceFiles(root, fullPath)))
|
|
}
|
|
} else if (
|
|
entry.isFile() &&
|
|
SOURCE_EXTENSIONS.has(path.extname(entry.name)) &&
|
|
!isSkippedFile(root, fullPath)
|
|
) {
|
|
files.push(fullPath)
|
|
}
|
|
}
|
|
|
|
return files
|
|
}
|
|
|
|
function formatReports(root, reports) {
|
|
return reports
|
|
.map(
|
|
(report) =>
|
|
`${normalizePath(root, report.filePath)}:${report.line}:${report.column} ${report.accumulator} — ${report.text.replaceAll(/\s+/g, ' ')}`
|
|
)
|
|
.join('\n')
|
|
}
|
|
|
|
export async function main(root = process.cwd()) {
|
|
const reports = []
|
|
for (const scanRoot of SCAN_ROOTS) {
|
|
const files = await collectSourceFiles(root, path.join(root, scanRoot))
|
|
for (const filePath of files) {
|
|
const sourceText = await fs.readFile(filePath, 'utf8')
|
|
reports.push(...reportQuadraticBufferConcat(filePath, sourceText))
|
|
}
|
|
}
|
|
if (reports.length === 0) {
|
|
return 0
|
|
}
|
|
|
|
console.error('Buffer.concat must not rebuild a loop-carried accumulator.')
|
|
console.error('Each iteration re-copies everything accumulated so far, so the loop is O(n^2).')
|
|
console.error('Collect the pieces in a Buffer[] and Buffer.concat(chunks) once, after the loop.')
|
|
console.error('')
|
|
console.error(formatReports(root, reports))
|
|
return 1
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
process.exit(await main())
|
|
}
|