gen-tsconfig-paths.ts 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. /**
  2. * Expand the workspace path aliases that a wildcard would otherwise resolve by
  3. * probing every package group in turn.
  4. *
  5. * `tsconfig.base.json` is the resolution facade for the whole repository, and
  6. * two of its aliases used one key per *group* rather than per package:
  7. * `@deepseek-ai/dsh-*` listed 49 candidate globs and `@deepseek-ai/dsh-*\/invariant`
  8. * listed 45. TypeScript and tsx try those candidates in order, so a specifier
  9. * whose package sits late in the list pays for every earlier miss. Under tsx's
  10. * ESM hook each miss is an `ERR_MODULE_NOT_FOUND` that Node decorates with a
  11. * full CommonJS resolution walk, which dominated source-launch boot.
  12. *
  13. * This generator writes one explicit entry per package into a marked region of
  14. * `tsconfig.base.json`, leaving every hand-written alias and comment outside
  15. * that region untouched. `--check` reports drift instead of writing, so a new
  16. * package that needs an alias fails a gate rather than silently resolving
  17. * through a fallback that no longer exists.
  18. *
  19. * @module scripts/gen-tsconfig-paths
  20. */
  21. import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs'
  22. import { join, resolve } from 'node:path'
  23. import { fileURLToPath } from 'node:url'
  24. const ROOT = fileURLToPath(new URL('..', import.meta.url))
  25. const CONFIG = join(ROOT, 'tsconfig.base.json')
  26. const BEGIN = ' // BEGIN generated package aliases — pnpm run gen-tsconfig-paths'
  27. const END = ' // END generated package aliases'
  28. /** Package-name prefix the expanded aliases cover. */
  29. const PREFIX = '@deepseek-ai/dsh-'
  30. /** One workspace package the generated region maps. */
  31. interface PackageAlias {
  32. /** Bare specifier, e.g. `@deepseek-ai/dsh-session`. */
  33. readonly specifier: string
  34. /** Repository-relative source directory, e.g. `./packages/session/session/src`. */
  35. readonly source: string
  36. /** Whether the package carries `src/invariant.ts`, which earns a second alias. */
  37. readonly hasInvariant: boolean
  38. }
  39. /**
  40. * Read a workspace manifest's declared name.
  41. * @param manifest - absolute path to a `package.json`.
  42. * @returns The declared name, or undefined when the file is absent or nameless.
  43. */
  44. function packageName(manifest: string): string | undefined {
  45. let parsed: unknown
  46. try {
  47. parsed = JSON.parse(readFileSync(manifest, 'utf8'))
  48. } catch (_absentOrUnreadableManifest) {
  49. return undefined
  50. }
  51. if (typeof parsed !== 'object' || parsed === null) return undefined
  52. const name: unknown = (parsed as { name?: unknown }).name
  53. return typeof name === 'string' ? name : undefined
  54. }
  55. /** One workspace package directory and the name its manifest declares. */
  56. interface WorkspacePackage {
  57. readonly group: string
  58. readonly directory: string
  59. readonly packageDir: string
  60. readonly name: string
  61. }
  62. /**
  63. * Walk `packages/<group>/<directory>` once, in a stable order.
  64. * @returns Every directory whose manifest names a `@deepseek-ai/dsh-` package and that carries `src`.
  65. */
  66. function workspacePackages(): WorkspacePackage[] {
  67. const packages = join(ROOT, 'packages')
  68. const found: WorkspacePackage[] = []
  69. for (const group of readdirSync(packages).sort()) {
  70. const groupDir = join(packages, group)
  71. if (!statSync(groupDir).isDirectory()) continue
  72. for (const directory of readdirSync(groupDir).sort()) {
  73. const packageDir = join(groupDir, directory)
  74. const name = packageName(join(packageDir, 'package.json'))
  75. if (name === undefined || !name.startsWith(PREFIX)) continue
  76. if (existsSync(join(packageDir, 'src'))) found.push({ group, directory, packageDir, name })
  77. }
  78. }
  79. return found
  80. }
  81. /**
  82. * Collect every package the removed wildcards could resolve.
  83. *
  84. * A wildcard substituted the specifier's suffix into `packages/<group>/<suffix>/src`,
  85. * so it only ever resolved a package whose declared name is exactly
  86. * `@deepseek-ai/dsh-<directory>`. Packages named after something other than
  87. * their directory already carry a hand-written alias and are skipped here.
  88. *
  89. * @returns Aliases sorted by specifier.
  90. * @throws When two package directories claim one specifier, which the removed
  91. * wildcards resolved by group order and an explicit map cannot express.
  92. */
  93. export function collectPackageAliases(): PackageAlias[] {
  94. const bySpecifier = new Map<string, PackageAlias & { directory: string }>()
  95. for (const { group, directory, packageDir, name } of workspacePackages()) {
  96. if (name !== `${PREFIX}${directory}`) continue
  97. const previous = bySpecifier.get(name)
  98. if (previous !== undefined) {
  99. throw new Error(
  100. `gen-tsconfig-paths: ${name} is claimed by packages/${previous.directory} and packages/${group}/${directory}; `
  101. + 'an explicit alias cannot express the group-order tiebreak the wildcard used.',
  102. )
  103. }
  104. bySpecifier.set(name, {
  105. specifier: name,
  106. source: `./packages/${group}/${directory}/src`,
  107. hasInvariant: existsSync(join(packageDir, 'src', 'invariant.ts')),
  108. directory: `${group}/${directory}`,
  109. })
  110. }
  111. return [...bySpecifier.values()]
  112. .map(({ specifier, source, hasInvariant }) => ({ specifier, source, hasInvariant }))
  113. .sort((left, right) => left.specifier.localeCompare(right.specifier))
  114. }
  115. /**
  116. * Collect every workspace package the aliases must cover.
  117. *
  118. * Unlike {@link collectPackageAliases} this keeps packages whose name does not
  119. * match their directory. The generator cannot map those — only a hand-written
  120. * alias can — but they still have to be mapped by something, because deleting
  121. * the group wildcards removed the fallback that used to catch them.
  122. *
  123. * @returns Declared names of every `@deepseek-ai/dsh-` package carrying a `src` directory.
  124. */
  125. export function collectPackageNames(): string[] {
  126. return workspacePackages()
  127. .map(({ name }) => name)
  128. .sort((left, right) => left.localeCompare(right))
  129. }
  130. /**
  131. * Read the bare package specifiers a config maps, generated region included.
  132. * @param text - `tsconfig.base.json` contents.
  133. * @returns Specifiers mapped without a subpath.
  134. */
  135. export function mappedSpecifiers(text: string): Set<string> {
  136. const keys = new Set<string>()
  137. for (const match of text.matchAll(/^\s*"(@deepseek-ai\/dsh-[^"/]+)":/gm)) {
  138. const key = match[1]
  139. if (key !== undefined) keys.add(key)
  140. }
  141. return keys
  142. }
  143. /**
  144. * Report packages that no alias maps.
  145. *
  146. * A package missing from `paths` still resolves — through the workspace symlink
  147. * and the package's own `exports` — but to built `lib/` output rather than to
  148. * source, which is the artifact-plane leak the explicit aliases exist to avoid.
  149. * Naming it here turns that into a gate failure instead of a silent difference.
  150. *
  151. * @param packages - every workspace package that needs an alias.
  152. * @param mapped - bare specifiers the config maps.
  153. * @returns Unmapped package names, in the order given.
  154. */
  155. export function uncoveredPackages(
  156. packages: readonly string[],
  157. mapped: ReadonlySet<string>,
  158. ): string[] {
  159. return packages.filter(name => !mapped.has(name))
  160. }
  161. /**
  162. * Render the generated region's alias lines.
  163. * @param aliases - packages to map, in emission order.
  164. * @param handWritten - specifiers already mapped outside the region; a duplicate key would shadow one silently.
  165. * @returns The region body, one JSON member per line.
  166. */
  167. export function renderAliases(aliases: readonly PackageAlias[], handWritten: ReadonlySet<string>): string {
  168. const lines: string[] = []
  169. for (const alias of aliases) {
  170. if (!handWritten.has(alias.specifier)) {
  171. lines.push(` ${JSON.stringify(alias.specifier)}: [${JSON.stringify(alias.source)}]`)
  172. }
  173. const invariant = `${alias.specifier}/invariant`
  174. if (alias.hasInvariant && !handWritten.has(invariant)) {
  175. lines.push(` ${JSON.stringify(invariant)}: [${JSON.stringify(`${alias.source}/invariant.ts`)}]`)
  176. }
  177. }
  178. // The region closes `paths`, so the last member carries no trailing comma.
  179. return lines.join(',\n')
  180. }
  181. /**
  182. * Replace the generated region of a config's text.
  183. * @param text - current `tsconfig.base.json` contents.
  184. * @param body - rendered alias lines.
  185. * @returns The updated contents.
  186. * @throws When the markers are missing or out of order.
  187. */
  188. export function writeRegion(text: string, body: string): string {
  189. const begin = text.indexOf(BEGIN)
  190. const end = text.indexOf(END)
  191. if (begin < 0 || end < begin) {
  192. throw new Error(`gen-tsconfig-paths: ${CONFIG} is missing the generated-region markers.`)
  193. }
  194. return `${text.slice(0, begin)}${BEGIN}\n${body}\n${END}${text.slice(end + END.length)}`
  195. }
  196. /**
  197. * Parse the config's `paths` keys, ignoring the generated region.
  198. * @param text - current `tsconfig.base.json` contents.
  199. * @returns Specifiers mapped by hand.
  200. */
  201. function handWrittenSpecifiers(text: string): Set<string> {
  202. const begin = text.indexOf(BEGIN)
  203. const end = text.indexOf(END)
  204. const outside = begin < 0 || end < begin ? text : text.slice(0, begin) + text.slice(end)
  205. const keys = new Set<string>()
  206. for (const match of outside.matchAll(/^\s*"(@deepseek-ai\/[^"]+)":/gm)) {
  207. const key = match[1]
  208. if (key !== undefined) keys.add(key)
  209. }
  210. return keys
  211. }
  212. if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
  213. const check = process.argv.includes('--check')
  214. const current = readFileSync(CONFIG, 'utf8')
  215. const next = writeRegion(current, renderAliases(collectPackageAliases(), handWrittenSpecifiers(current)))
  216. const uncovered = uncoveredPackages(collectPackageNames(), mappedSpecifiers(next))
  217. if (uncovered.length > 0) {
  218. console.error(
  219. 'gen-tsconfig-paths: no alias maps '
  220. + `${uncovered.join(', ')}; add a hand-written entry, because a package named after `
  221. + 'something other than its directory cannot be generated.',
  222. )
  223. process.exitCode = 1
  224. } else if (current === next) {
  225. console.log('gen-tsconfig-paths: tsconfig.base.json package aliases are current.')
  226. } else if (check) {
  227. console.error('gen-tsconfig-paths: tsconfig.base.json is stale; run `pnpm run gen-tsconfig-paths`.')
  228. process.exitCode = 1
  229. } else {
  230. writeFileSync(CONFIG, next)
  231. console.log('gen-tsconfig-paths: rewrote tsconfig.base.json package aliases.')
  232. }
  233. }