clean.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. import { lstat, readdir, realpath, rm } from 'node:fs/promises'
  2. import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
  3. import { fileURLToPath } from 'node:url'
  4. import ts from 'typescript'
  5. import { repositoryConfigHost } from './ts-project.ts'
  6. const knownOrphanEntries = new Set(['node_modules', 'lib', '.typecheck'])
  7. function isMissing(error: unknown): boolean {
  8. return error instanceof Error && 'code' in error && error.code === 'ENOENT'
  9. }
  10. async function exists(path: string): Promise<boolean> {
  11. try {
  12. await lstat(path)
  13. return true
  14. } catch (error) {
  15. if (isMissing(error)) return false
  16. throw error
  17. }
  18. }
  19. async function childDirectories(path: string): Promise<string[]> {
  20. try {
  21. const entries = await readdir(path, { withFileTypes: true })
  22. return entries.filter(entry => entry.isDirectory()).map(entry => join(path, entry.name))
  23. } catch (error) {
  24. if (isMissing(error)) return []
  25. throw error
  26. }
  27. }
  28. function repositoryPath(root: string, path: string): string {
  29. return relative(root, path).split(sep).join('/')
  30. }
  31. function parseConfig(configPath: string): ts.ParsedCommandLine {
  32. const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, repositoryConfigHost)
  33. if (!parsed) throw new Error(`clean: cannot parse TypeScript config ${configPath}`)
  34. if (parsed.errors.length > 0) {
  35. throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n'))
  36. }
  37. return parsed
  38. }
  39. /** Plans and removes repository-owned build output without crossing the repository boundary. */
  40. export class RepositoryCleaner {
  41. private readonly root: string
  42. constructor(root: string) {
  43. this.root = resolve(root)
  44. }
  45. /**
  46. * Remove generated build state and package directories containing only known residue.
  47. * @returns Repository-relative paths that were removed.
  48. */
  49. async clean(): Promise<string[]> {
  50. const targets = await this.plan()
  51. // Planning validates every target first, so an unsafe orphan prevents all deletion.
  52. for (const target of targets) await rm(target, { recursive: true, force: true })
  53. return targets.map(target => repositoryPath(this.root, target))
  54. }
  55. private async plan(): Promise<string[]> {
  56. const targets = new Set<string>()
  57. const unsafeOrphans: string[] = []
  58. const canonicalRoot = await realpath(this.root)
  59. await this.addIfPresent(targets, join(this.root, '.dsh-build'), canonicalRoot)
  60. // These checks cover legacy root-level incremental state emitted by older configs.
  61. await this.addIfPresent(targets, join(this.root, '.typecheck'), canonicalRoot)
  62. for (const entry of await readdir(this.root, { withFileTypes: true })) {
  63. if (entry.isFile() && entry.name.endsWith('.tsbuildinfo')) targets.add(join(this.root, entry.name))
  64. }
  65. await this.addIfPresent(
  66. targets,
  67. join(this.root, 'native/landlock-run/tsconfig.tsbuildinfo'),
  68. canonicalRoot,
  69. )
  70. // The root project-reference graph is the source of truth for live build targets.
  71. // Each emitting project declares lib/types as outDir; its parent lib also owns
  72. // the sibling runtime bundles, so the complete build output root is removed.
  73. for (const outputDirectory of this.buildOutputDirectories()) {
  74. await this.addIfPresent(targets, outputDirectory, canonicalRoot)
  75. }
  76. for (const groupDirectory of await childDirectories(join(this.root, 'packages'))) {
  77. for (const packageDirectory of await childDirectories(groupDirectory)) {
  78. // A package.json marks a live package; its output was discovered from the
  79. // project graph above, and its package-local node_modules must be preserved.
  80. if (await exists(join(packageDirectory, 'package.json'))) {
  81. continue
  82. }
  83. // A manifest-less package directory is stale only when every remaining
  84. // entry is known generated residue; unknown files make the whole clean fail.
  85. const entries = await readdir(packageDirectory)
  86. const unknown = entries.filter(entry => !knownOrphanEntries.has(entry) && !entry.endsWith('.tsbuildinfo'))
  87. if (unknown.length > 0) {
  88. unsafeOrphans.push(...unknown.map(entry => repositoryPath(this.root, join(packageDirectory, entry))))
  89. } else {
  90. await this.addIfPresent(targets, packageDirectory, canonicalRoot)
  91. }
  92. }
  93. }
  94. if (unsafeOrphans.length > 0) {
  95. throw new Error([
  96. 'clean: refusing to remove package directories without package.json; unknown entries remain:',
  97. ...unsafeOrphans.sort().map(path => ` ${path}`),
  98. ].join('\n'))
  99. }
  100. return [...targets].sort()
  101. }
  102. private buildOutputDirectories(): string[] {
  103. const outputs = new Set<string>()
  104. const pending = [join(this.root, 'tsconfig.json')]
  105. const visited = new Set<string>()
  106. const nativeEntryOutput = join(this.root, 'native/landlock-run/packages/entry/lib')
  107. while (pending.length > 0) {
  108. const nextConfigPath = pending.pop()
  109. if (nextConfigPath === undefined) break
  110. const configPath = resolve(nextConfigPath)
  111. if (visited.has(configPath)) continue
  112. visited.add(configPath)
  113. const parsed = parseConfig(configPath)
  114. if (parsed.options.outDir !== undefined) {
  115. const typesDirectory = resolve(parsed.options.outDir)
  116. const outputDirectory = basename(typesDirectory) === 'types'
  117. ? dirname(typesDirectory)
  118. : typesDirectory === nativeEntryOutput
  119. ? typesDirectory
  120. : undefined
  121. if (outputDirectory === undefined) {
  122. throw new Error(`clean: expected TypeScript outDir to end in /types: ${repositoryPath(this.root, typesDirectory)}`)
  123. }
  124. this.assertRepositoryTarget(outputDirectory)
  125. outputs.add(outputDirectory)
  126. }
  127. for (const reference of parsed.projectReferences ?? []) {
  128. pending.push(ts.resolveProjectReferencePath(reference))
  129. }
  130. }
  131. return [...outputs]
  132. }
  133. private assertRepositoryTarget(path: string): void {
  134. this.assertDescendant(this.root, path, path)
  135. }
  136. private assertDescendant(root: string, path: string, displayPath: string): void {
  137. const repositoryRelative = relative(root, path)
  138. if (repositoryRelative === '' || repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`) || isAbsolute(repositoryRelative)) {
  139. throw new Error(`clean: refusing deletion target outside repository: ${displayPath}`)
  140. }
  141. }
  142. private async addIfPresent(targets: Set<string>, path: string, canonicalRoot: string): Promise<void> {
  143. // Missing outputs are normal on a clean checkout; only existing paths become deletion targets.
  144. if (!await exists(path)) return
  145. // Resolve the parent rather than the final entry: rm unlinks a final symlink,
  146. // but a symlink in an ancestor would make deletion cross the repository boundary.
  147. const canonicalParent = await realpath(dirname(path))
  148. this.assertDescendant(canonicalRoot, join(canonicalParent, basename(path)), path)
  149. targets.add(path)
  150. }
  151. }
  152. const scriptPath = fileURLToPath(import.meta.url)
  153. if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) {
  154. try {
  155. const removed = await new RepositoryCleaner(resolve(dirname(scriptPath), '..')).clean()
  156. if (removed.length === 0) {
  157. console.log('clean: already clean')
  158. } else {
  159. console.log(`clean: removed ${removed.length} paths`)
  160. }
  161. } catch (error) {
  162. console.error(error instanceof Error ? error.message : error)
  163. process.exitCode = 1
  164. }
  165. }