🌳
pt0/deployF/acornF/libAcornF.mts
1import * as _ from 'lodash-es'
5import fs from 'fs'
6import ts from 'typescript'
7import { type absFileDirPath } from '../../ptDirF.mts'
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) {
31 throw new Error(
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.`
35 )
36 }
38export const resetAcornParseCount = () => { acornParseCount = 0 }
40export const getTsExtContents = async ({ptPath}: {ptPath: absFileDirPath}) => {
41 assertString(ptPath, {ptPath})
42 assertAcornParseBudget()
43 const isMaybeBorked = (
44 isProdPatched(ptPath) || _.includes(ptPath, '/deprec/')
45 )
47 let contents = await read1File(pathDownJoin(ptDir, ptPath))
48 return {tsExt: getIsTsExt(ptPath), contents, isMaybeBorked}
51export const tsSrcFile = ({ptPath, contents}: {ptPath: absFileDirPath, contents: string}) => {
52 return ts.createSourceFile(
53 ptPath, contents,
54 ts.ScriptTarget.Latest,
55 true
56 )
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
71 }
72 }
73 }
74 return undefined
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
82 cb(node)
83 for (const key in node) {
84 if (key === 'parent') continue
85 const val = node[key]
86 if (Array.isArray(val)) val.forEach(visit)
87 else if (val && typeof val === 'object' && typeof val.type === 'string') visit(val)
88 }
89 }
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})
101 })
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)
107 const absPath = pathDownJoin(ptDir, ptPath)
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>> => {
114 if (isPtExtNotCode(ptPath)) return new Set()
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
122 if (tsExt) {
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)
129 }
130 }
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)
135 }
136 }
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)
144 }
145 }
146 }
147 ts.forEachChild(node, visit)
148 }
149 visit(sourceFile)
150 } else {
151 const ast = acornParse({contents, ptPath})
152 walkJsImportNodes(ast, node => specifiers.add(node.source.value))
153 }
155 return specifiers