1import * as _ from 'lodash-es' 6import ts from 'typescript' 14import type { Node as AcornBaseNode, Program as AcornProgram } from 'acorn' 16export type AcornNode = AcornBaseNode & {[k: string]: any} 17export type { AcornProgram } 19export const getIsTsExt = (path: absFileDirPath) => { 20 return _.find(ptTsFileExtA, (ext) => _.endsWith(path, '.' + ext)) 23// Circuit breaker: prevents OOM from bulk file parsing. 24// Normal app deploy parses ~500 files (bounded by import tree). 25// If a code path parses >1500 unique files in one process, it's likely 26// an unbounded bulk scan — throw before V8 accumulates enough AST objects to OOM. 27let acornParseCount = 0 28const acornParseBudget = 1500 29export const assertAcornParseBudget = () => { 30 if (++acornParseCount > acornParseBudget) { 32 `acorn parse budget exhausted (${acornParseCount} files parsed). ` + 33 `This code path is bulk-parsing files without bounds — likely an unbounded scan. ` + 34 `Normal app deploy parses ~500 files. Use ptMadge (disk-cached) instead of raw acornSingleFile/getRawImportSpecifiers for bulk operations.` 38export const resetAcornParseCount = () => { acornParseCount = 0 } 40export const getTsExtContents = async ({ptPath}: {ptPath: absFileDirPath}) => { 42 assertAcornParseBudget() 43 const isMaybeBorked = ( 48 return {tsExt: getIsTsExt(ptPath), contents, isMaybeBorked} 51export const tsSrcFile = ({ptPath, contents}: {ptPath: absFileDirPath, contents: string}) => { 52 return ts.createSourceFile( 54 ts.ScriptTarget.Latest, 59export const getExportedBool = async ({ptPath, exportName}: {ptPath: absFileDirPath, exportName: string}): Promise<boolean | undefined> => { 60 const {contents} = await getTsExtContents({ptPath}) 61 const sourceFile = tsSrcFile({ptPath, contents}) 62 for (const stmt of sourceFile.statements) { 63 if (!ts.isVariableStatement(stmt)) continue 64 const hasExport = ts.getModifiers(stmt)?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) 65 if (!hasExport) continue 66 for (const decl of stmt.declarationList.declarations) { 67 if (!ts.isIdentifier(decl.name) || decl.name.text !== exportName) continue 68 const init = decl.initializer 69 if (init && (init.kind === ts.SyntaxKind.TrueKeyword || init.kind === ts.SyntaxKind.FalseKeyword)) { 70 return init.kind === ts.SyntaxKind.TrueKeyword 77export const isShellScript = (contents: string) => contents.startsWith('#!/bin/sh') || contents.startsWith('#!/bin/bash') 79export const walkAcornNodes = (ast: AcornProgram | AcornNode, cb: (node: AcornNode) => void) => { 80 const visit = (node: AcornNode) => { 81 if (!node || typeof node !== 'object') return 83 for (const key in node) { 84 if (key === 'parent') continue 86 if (Array.isArray(val)) val.forEach(visit) 87 else if (val && typeof val === 'object' && typeof val.type === 'string') visit(val) 90 if ('body' in ast && Array.isArray(ast.body)) for (const node of ast.body) visit(node as AcornNode) 91 else visit(ast as AcornNode) 94export type JsImportNode = {source: {value: string, start: number, end: number}, start: number, end: number} 95export const walkJsImportNodes = (ast: AcornProgram, cb: (node: JsImportNode) => void) => { 96 walkAcornNodes(ast, (node) => { 97 if (node.type === 'ImportDeclaration') cb(node as unknown as JsImportNode) 98 if (node.type === 'ExportAllDeclaration') cb(node as unknown as JsImportNode) 99 if (node.type === 'ExportNamedDeclaration' && node.source) cb(node as unknown as JsImportNode) 100 if (node.type === 'ImportExpression' && node.source?.type === 'Literal' && node.source.value) cb({source: node.source, start: node.start, end: node.end}) 104/** Extract all raw import specifiers from a file (both relative and npm, unresolved). Disk-memoized per file+mtime so bulk scans (pkgBloat, validateBareImports) don't re-parse and trip the acorn budget. */ 105export const getRawImportSpecifiers = async (ptPath: absFileDirPath, providedContents?: string): Promise<Set<string>> => { 106 if (providedContents !== undefined) return getRawImportSpecifiersUncached(ptPath, providedContents) 108 const mtime = fs.statSync(absPath, { throwIfNoEntry: false })?.mtimeMs || 0 109 const cachedA = await runMemoTempfile({ cacheKeyA: ['rawImportSpecifiers-v1', ptPath, String(mtime)] }, async () => [...await getRawImportSpecifiersUncached(ptPath)]) 110 return new Set(cachedA) 113const getRawImportSpecifiersUncached = async (ptPath: absFileDirPath, providedContents?: string): Promise<Set<string>> => { 116 const specifiers = new Set<string>() 117 const tsExt = getIsTsExt(ptPath) 118 const contents = providedContents !== undefined ? providedContents : (await getTsExtContents({ptPath})).contents 120 if (isShellScript(contents)) return specifiers 123 const sourceFile = tsSrcFile({ptPath, contents}) 124 const visit = (node: ts.Node) => { 125 if (node.kind === ts.SyntaxKind.ImportDeclaration) { 126 const spec = (node as ts.ImportDeclaration).moduleSpecifier 127 if (spec?.kind === ts.SyntaxKind.StringLiteral) { 128 specifiers.add((spec as ts.StringLiteral).text) 131 if (node.kind === ts.SyntaxKind.ExportDeclaration) { 132 const spec = (node as ts.ExportDeclaration).moduleSpecifier 133 if (spec?.kind === ts.SyntaxKind.StringLiteral) { 134 specifiers.add((spec as ts.StringLiteral).text) 137 // Dynamic import: import('pkg') 138 if (node.kind === ts.SyntaxKind.CallExpression) { 139 const call = node as ts.CallExpression 140 if (call.expression?.kind === ts.SyntaxKind.ImportKeyword) { 141 const arg = call.arguments?.[0] 142 if (arg?.kind === ts.SyntaxKind.StringLiteral) { 143 specifiers.add((arg as ts.StringLiteral).text) 147 ts.forEachChild(node, visit) 152 walkJsImportNodes(ast, node => specifiers.add(node.source.value))