publint-all.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. /** Run publint over the exact manifest-declared publication view of every package. */
  2. import {
  3. globSync,
  4. readdirSync,
  5. readFileSync,
  6. statSync,
  7. } from 'node:fs'
  8. import { availableParallelism } from 'node:os'
  9. import { dirname, posix, relative, resolve, sep } from 'node:path'
  10. import { parseArgs } from 'node:util'
  11. import { publint, type Message, type PackFile } from 'publint'
  12. import { formatMessage } from 'publint/utils'
  13. import ts from 'typescript'
  14. const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
  15. const repositoryRoot = resolve(import.meta.dirname, '..')
  16. const { values: options } = parseArgs({
  17. args: process.argv.slice(2),
  18. options: { 'packages-root': { type: 'string' } },
  19. })
  20. const packagesRoot = resolve(options['packages-root'] ?? repositoryRoot)
  21. interface PackageTarget {
  22. path: string
  23. directory: string
  24. manifest: PackageManifest
  25. }
  26. interface PackageManifest {
  27. name?: string
  28. files?: unknown
  29. }
  30. type PublintResult =
  31. | {
  32. path: string
  33. status: 'passed'
  34. messages: Message[]
  35. closureViolations: string[]
  36. manifest: Record<string, unknown>
  37. }
  38. | {
  39. path: string
  40. status: 'failed'
  41. messages: Message[]
  42. closureViolations: string[]
  43. manifest: Record<string, unknown>
  44. failure?: string
  45. }
  46. function workspacePackages(): PackageTarget[] {
  47. return globSync('packages/*/*/package.json', { cwd: packagesRoot })
  48. .sort()
  49. .map((manifestPath) => {
  50. const absoluteManifestPath = resolve(packagesRoot, manifestPath)
  51. const manifest = JSON.parse(readFileSync(absoluteManifestPath, 'utf8')) as PackageManifest
  52. return { path: dirname(manifestPath), directory: dirname(absoluteManifestPath), manifest }
  53. })
  54. }
  55. function publintConcurrency(total: number): number {
  56. if (total === 0) return 0
  57. const raw = process.env[CONCURRENCY_ENV]
  58. if (raw !== undefined && raw !== '') {
  59. const parsed = Number.parseInt(raw, 10)
  60. if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
  61. throw new Error(`publint-all: ${CONCURRENCY_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`)
  62. }
  63. return Math.min(total, parsed)
  64. }
  65. return Math.min(total, availableParallelism())
  66. }
  67. function publicationFiles(target: PackageTarget): PackFile[] {
  68. const paths = new Set<string>()
  69. addPath(resolve(target.directory, 'package.json'), paths)
  70. const declared = Array.isArray(target.manifest.files)
  71. ? target.manifest.files.filter((value): value is string => typeof value === 'string')
  72. : []
  73. for (const pattern of [
  74. ...declared,
  75. 'README*',
  76. 'LICENSE*',
  77. 'LICENCE*',
  78. 'CHANGELOG*',
  79. 'CHANGES*',
  80. 'HISTORY*',
  81. 'NOTICE*',
  82. ]) {
  83. for (const match of globSync(pattern, { cwd: target.directory })) {
  84. addPath(resolve(target.directory, match), paths)
  85. }
  86. }
  87. return [...paths]
  88. .sort()
  89. .map(path => ({
  90. name: `package/${relative(target.directory, path).split(sep).join('/')}`,
  91. data: readFileSync(path),
  92. }))
  93. }
  94. function addPath(path: string, paths: Set<string>): void {
  95. const stat = statSync(path)
  96. if (stat.isDirectory()) {
  97. // readdirSync, not globSync: `**/*` skips dot-prefixed segments, but npm
  98. // pack publishes dotfiles inside included directories, and this view must
  99. // match what npm publishes.
  100. for (const entry of readdirSync(path, { recursive: true, withFileTypes: true })) {
  101. if (entry.isFile()) paths.add(resolve(entry.parentPath, entry.name))
  102. }
  103. } else if (stat.isFile()) {
  104. paths.add(path)
  105. }
  106. }
  107. interface RelativeImport {
  108. specifier: string
  109. line: number
  110. }
  111. /** Return relative imports whose targets are absent from the publication view. */
  112. function publicationClosureViolations(target: PackageTarget, files: readonly PackFile[]): string[] {
  113. const published = new Set(files.map(file => file.name))
  114. const violations: string[] = []
  115. for (const file of files) {
  116. if (!/\.(?:js|mjs|cjs)$/.test(file.name)) continue
  117. const bytes = file.data instanceof ArrayBuffer ? new Uint8Array(file.data) : file.data
  118. const source = typeof bytes === 'string' ? bytes : Buffer.from(bytes).toString('utf8')
  119. for (const imported of relativeImports(file.name, source)) {
  120. const resolved = posix.normalize(posix.join(posix.dirname(file.name), imported.specifier))
  121. if (resolutionCandidates(resolved).some(candidate => published.has(candidate))) continue
  122. violations.push(
  123. `${target.path}/${file.name.slice('package/'.length)}:${String(imported.line)}`
  124. + ` imports ${JSON.stringify(imported.specifier)}, but ${target.manifest.name ?? target.path}`
  125. + ` does not publish ${JSON.stringify(resolved.slice('package/'.length))}`,
  126. )
  127. }
  128. }
  129. return violations
  130. }
  131. /** Paths a relative JavaScript module request can resolve to in a published package. */
  132. function resolutionCandidates(target: string): string[] {
  133. const base = target.replace(/\/+$/, '')
  134. return [
  135. target,
  136. ...['.js', '.mjs', '.cjs', '/index.js', '/index.mjs', '/index.cjs'].map(suffix => base + suffix),
  137. ]
  138. }
  139. /** Extract relative static imports, re-exports, dynamic imports, and requires. */
  140. function relativeImports(file: string, sourceText: string): RelativeImport[] {
  141. const source = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, false, ts.ScriptKind.JS)
  142. const imports: RelativeImport[] = []
  143. const record = (node: ts.Node, literal: ts.Expression | undefined): void => {
  144. if (literal === undefined || !ts.isStringLiteralLike(literal) || !literal.text.startsWith('.')) return
  145. imports.push({
  146. specifier: literal.text,
  147. line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
  148. })
  149. }
  150. const visit = (node: ts.Node): void => {
  151. if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
  152. record(node, node.moduleSpecifier)
  153. } else if (ts.isCallExpression(node)
  154. && (node.expression.kind === ts.SyntaxKind.ImportKeyword
  155. || ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
  156. record(node, node.arguments[0])
  157. }
  158. ts.forEachChild(node, visit)
  159. }
  160. visit(source)
  161. return imports
  162. }
  163. /**
  164. * publint reads the CommonJS interop preamble inside the prebuilt browser
  165. * bundles and reports CJS-written-as-ESM. Node never resolves those files:
  166. * `lib/client.js` is evaluated by the page module system as a classic script,
  167. * and `lib/worker.js` by `new Worker(url, { type: 'module' })` — both outside
  168. * the Node resolution publint models. Exactly that verdict on exactly those
  169. * files is suppressed; every other publint error stays fatal.
  170. */
  171. function isBrowserBundleFormatFalsePositive(message: Message): boolean {
  172. if (message.code !== 'FILE_INVALID_FORMAT') return false
  173. const filePath = (message.args as { actualFilePath?: string }).actualFilePath ?? ''
  174. const exportKey = Array.isArray(message.path) ? message.path.join('/') : ''
  175. return /(^|\/)lib\/(client|worker)\.js$/.test(filePath)
  176. || /(^|\/)\.\/(client|worker)$/.test(exportKey)
  177. }
  178. async function runPublint(target: PackageTarget): Promise<PublintResult> {
  179. try {
  180. const files = publicationFiles(target)
  181. const closureViolations = publicationClosureViolations(target, files)
  182. const result = await publint({
  183. pkgDir: 'package',
  184. pack: { files },
  185. })
  186. const manifest = result.pkg as Record<string, unknown>
  187. const messages = result.messages.filter(message => !isBrowserBundleFormatFalsePositive(message))
  188. return messages.some(message => message.type === 'error') || closureViolations.length > 0
  189. ? { path: target.path, status: 'failed', messages, closureViolations, manifest }
  190. : { path: target.path, status: 'passed', messages, closureViolations, manifest }
  191. } catch (error: unknown) {
  192. return {
  193. path: target.path,
  194. status: 'failed',
  195. messages: [],
  196. closureViolations: [],
  197. manifest: target.manifest as Record<string, unknown>,
  198. failure: error instanceof Error ? error.message : String(error),
  199. }
  200. }
  201. }
  202. async function runAll(targets: PackageTarget[], concurrency: number): Promise<PublintResult[]> {
  203. let next = 0
  204. const results: Array<PublintResult | undefined> = []
  205. await Promise.all(Array.from({ length: concurrency }, async () => {
  206. for (;;) {
  207. const index = next
  208. next += 1
  209. const target = targets[index]
  210. if (target === undefined) return
  211. results[index] = await runPublint(target)
  212. }
  213. }))
  214. return targets.map((target, index) => {
  215. const result = results[index]
  216. if (result === undefined) throw new Error(`publint-all: missing result for ${target.path}.`)
  217. return result
  218. })
  219. }
  220. function printResult(result: PublintResult): void {
  221. console.log(`Running publint for ${result.path}...`)
  222. if ('failure' in result) console.error(result.failure)
  223. for (const message of result.messages) {
  224. console.log(formatMessage(message, result.manifest, { color: false }) ?? message.code)
  225. }
  226. for (const violation of result.closureViolations) console.error(violation)
  227. if (result.status === 'passed' && result.messages.length === 0 && result.closureViolations.length === 0) {
  228. console.log('All good!')
  229. }
  230. }
  231. const packages = workspacePackages()
  232. const concurrency = publintConcurrency(packages.length)
  233. console.log(`publint-all: linting ${packages.length} package(s) with ${concurrency} worker(s).`)
  234. const results = await runAll(packages, concurrency)
  235. for (const result of results) printResult(result)
  236. if (results.some(result => result.status === 'failed')) process.exit(1)