esm-check-migration.mjs 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import minimist from 'minimist'
  4. const APP_CODE_PATH = ['app', 'modules', 'scripts', 'test']
  5. // These have already been converted but don't have a `.mjs` extension
  6. const converted = new Set([
  7. 'scripts/ukamf/check-certs.js',
  8. 'scripts/ukamf/check-idp-metadata.js',
  9. 'scripts/ukamf/metadata-processor.js',
  10. 'scripts/ukamf/ukamf-db.js',
  11. 'scripts/ukamf/ukamf-entity.js',
  12. 'scripts/translations/checkCoverage.js',
  13. 'scripts/translations/checkSanitizeOptions.js',
  14. 'scripts/translations/checkVariables.js',
  15. 'scripts/translations/cleanupUnusedLocales.js',
  16. 'scripts/translations/config.js',
  17. 'scripts/translations/download.js',
  18. 'scripts/translations/insertHTMLFragments.js',
  19. 'scripts/translations/replaceLinkFragments.js',
  20. 'scripts/translations/sanitize.js',
  21. 'scripts/translations/sort.js',
  22. 'scripts/translations/transformLocales.js',
  23. 'scripts/translations/translateLocales.js',
  24. 'scripts/translations/upload.js',
  25. 'scripts/translations/uploadNonEnglish.js',
  26. 'scripts/translations/utils.js',
  27. ])
  28. // These files are not to be converted (e.g. they use CommonJS features that are not available in ES Modules)
  29. const excluded = new Set([
  30. 'modules/server-ce-scripts/scripts/create-user.js', // must be CJS for backwards compatibility
  31. 'test/acceptance/config/settings.test.saas.js', // must be CJS for @overleaf/settings module
  32. 'test/acceptance/config/settings.test.server-pro.js', // must be CJS for @overleaf/settings module
  33. 'app/src/infrastructure/PackageVersions.js', // required by webpack
  34. ])
  35. function fileIsESM(file) {
  36. const relativePath = file.replace(process.cwd() + '/', '')
  37. return file.endsWith('.mjs') || converted.has(relativePath)
  38. }
  39. function fileCanBeConvertedToESM(file) {
  40. const relativePath = file.replace(process.cwd() + '/', '')
  41. if (fileIsESM(relativePath)) {
  42. return false
  43. }
  44. return !excluded.has(relativePath)
  45. }
  46. const {
  47. _: args,
  48. files,
  49. help,
  50. json,
  51. } = minimist(process.argv.slice(2), {
  52. boolean: ['files', 'help', 'json'],
  53. alias: {
  54. files: 'f',
  55. help: 'h',
  56. json: 'j',
  57. },
  58. default: {
  59. files: false,
  60. help: false,
  61. json: false,
  62. },
  63. })
  64. const paths = args.length > 0 ? args : APP_CODE_PATH
  65. function usage() {
  66. console.error(`Usage: node check-esm-migration.js [OPTS...] dir1 dir2
  67. node check-esm-migration.js file
  68. Usage with directories
  69. ----------------------
  70. When the arguments are a list of directories it prints the status of ES Modules migration within those directories.
  71. When no directory is provided, it checks app/ and modules/, which represent the entire codebase.
  72. With the --files (-f) option, it prints the list of JS files that:
  73. - Are not migrated to ESM
  74. - Are not required by any file that is not migrated yet to ESM (in the entire codebase)
  75. These files should be the most immediate candidates to be migrated.
  76. WARNING: please note that this script only looks up literals in require() statements, so paths
  77. built dynamically (such as those in infrastructure/Modules.js) are not being taken into account.
  78. Usage with a JS file
  79. --------------------
  80. When the argument is a JS file, the script outputs the files that depend on this file that have not been converted
  81. yet to ES Modules.
  82. The files in the list must to be converted to ES Modules before converting the JS file.
  83. Example:
  84. node scrips/check-esm-migration.js --files modules/admin-panel
  85. node scrips/check-esm-migration.js app/src/router.js
  86. Options:
  87. --files Prints the files that are not imported by app code via CommonJS
  88. --json Prints the result in JSON format, including the list of files from --files
  89. --help Prints this help
  90. `)
  91. }
  92. function resolveImportPaths(dir, file) {
  93. const absolutePath = path.resolve(dir, file)
  94. if (fs.existsSync(absolutePath)) {
  95. return absolutePath
  96. } else if (fs.existsSync(absolutePath + '.js')) {
  97. return absolutePath + '.js'
  98. } else if (fs.existsSync(absolutePath + '.mjs')) {
  99. return absolutePath + '.mjs'
  100. } else {
  101. return null
  102. }
  103. }
  104. function collectJsFiles(dir, files = []) {
  105. const items = fs.readdirSync(dir)
  106. items.forEach(item => {
  107. const fullPath = path.join(dir, item)
  108. const stat = fs.statSync(fullPath)
  109. if (stat.isDirectory()) {
  110. const basename = path.basename(fullPath)
  111. // skipping directories from search
  112. if (!['frontend', 'node_modules'].includes(basename)) {
  113. collectJsFiles(fullPath, files)
  114. }
  115. } else if (
  116. stat.isFile() &&
  117. (fullPath.endsWith('.js') || fullPath.endsWith('.mjs'))
  118. ) {
  119. files.push(fullPath)
  120. }
  121. })
  122. return files
  123. }
  124. function extractImports(filePath) {
  125. const fileContent = fs.readFileSync(filePath, 'utf-8')
  126. // not 100% compliant (string escaping, etc.) but does the work here
  127. const contentWithoutComments = fileContent.replace(
  128. /\/\/.*|\/\*[\s\S]*?\*\//g,
  129. ''
  130. )
  131. const requireRegex = /require\s*\(\s*['"](.+?)['"]\s*\)/g
  132. const dependencies = []
  133. while (true) {
  134. const match = requireRegex.exec(contentWithoutComments)
  135. if (!match) {
  136. break
  137. }
  138. dependencies.push(match[1])
  139. }
  140. // build absolute path for the imported file
  141. return dependencies
  142. .map(depPath => resolveImportPaths(path.dirname(filePath), depPath))
  143. .filter(path => path !== null)
  144. }
  145. // Main function to process a list of directories and create the Map of dependencies
  146. function findJSAndImports(directories) {
  147. const fileDependenciesMap = new Map()
  148. directories.forEach(dir => {
  149. if (fs.existsSync(dir)) {
  150. const jsFiles = collectJsFiles(dir)
  151. jsFiles.forEach(filePath => {
  152. const imports = extractImports(filePath)
  153. fileDependenciesMap.set(filePath, imports)
  154. })
  155. } else {
  156. console.error(`Directory not found: ${dir}`)
  157. process.exit(1)
  158. }
  159. })
  160. return fileDependenciesMap
  161. }
  162. function printDirectoriesReport(allFilesAndImports) {
  163. // collect all files that are imported via CommonJS in the entire backend codebase
  164. const filesImportedViaCjs = new Set()
  165. allFilesAndImports.forEach((imports, file) => {
  166. if (!fileIsESM(file)) {
  167. imports.forEach(imprt => filesImportedViaCjs.add(imprt))
  168. }
  169. })
  170. // collect js files from the selected paths
  171. const selectedFiles = Array.from(
  172. findJSAndImports(paths.map(dir => path.resolve(dir))).keys()
  173. ).filter(file => !file.endsWith('settings.test.js'))
  174. const nonMigratedFiles = selectedFiles.filter(fileCanBeConvertedToESM)
  175. const migratedFileCount = selectedFiles.filter(fileIsESM).length
  176. // collect files in the selected paths that are not imported via CommonJs in the entire backend codebase
  177. const filesNotImportedViaCjs = nonMigratedFiles.filter(
  178. file => !filesImportedViaCjs.has(file)
  179. )
  180. if (json) {
  181. console.log(
  182. JSON.stringify(
  183. {
  184. fileCount: selectedFiles.length,
  185. migratedFileCount,
  186. filesNotImportedViaCjs,
  187. },
  188. null,
  189. 2
  190. )
  191. )
  192. } else {
  193. console.log(`Found ${selectedFiles.length} files in ${paths}:
  194. - ${migratedFileCount} have been migrated to ES Modules (progress=${((migratedFileCount / selectedFiles.length) * 100).toFixed(2)}%)
  195. - ${filesNotImportedViaCjs.length} are ready to migrate (these are not imported via CommonJS in the entire codebase)
  196. `)
  197. if (files) {
  198. console.log(`Files that are ready to migrate:`)
  199. filesNotImportedViaCjs.forEach(file =>
  200. console.log(` - ${file.replace(process.cwd() + '/', '')}`)
  201. )
  202. }
  203. }
  204. }
  205. function printFileReport(allFilesAndImports) {
  206. const filePath = path.resolve(paths[0])
  207. if (fileIsESM(filePath)) {
  208. console.log(`${filePath} is already migrated to ESM`)
  209. return
  210. }
  211. const filePathWithoutExtension = filePath.replace('.js', '')
  212. const importingFiles = []
  213. allFilesAndImports.forEach((imports, file) => {
  214. if (fileIsESM(file)) {
  215. return
  216. }
  217. if (
  218. imports.some(
  219. imprt => imprt === filePath || imprt === filePathWithoutExtension
  220. )
  221. ) {
  222. importingFiles.push(file)
  223. }
  224. })
  225. if (json) {
  226. console.log(
  227. JSON.stringify(
  228. {
  229. importingFiles,
  230. },
  231. null,
  232. 2
  233. )
  234. )
  235. } else {
  236. console.log(`${filePath} is required by ${importingFiles.length} CJS file`)
  237. importingFiles.forEach(file =>
  238. console.log(` - ${file.replace(process.cwd() + '/', '')}`)
  239. )
  240. }
  241. }
  242. function main() {
  243. if (help) {
  244. usage()
  245. process.exit(0)
  246. }
  247. // collect all the js files in the entire backend codebase (app/ + modules/) with its imports
  248. const allFilesAndImports = findJSAndImports(
  249. APP_CODE_PATH.map(dir => path.resolve(dir))
  250. )
  251. const entryPoint = fs.existsSync('app.js') ? 'app.js' : 'app.mjs'
  252. allFilesAndImports.set(path.resolve(entryPoint), extractImports(entryPoint))
  253. const isFileReport = fs.statSync(paths[0]).isFile()
  254. if (isFileReport) {
  255. printFileReport(allFilesAndImports)
  256. } else {
  257. printDirectoriesReport(allFilesAndImports)
  258. }
  259. }
  260. main()