repo-files.spec.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join, relative } from 'node:path'
  4. import { describe, expect, it } from 'vitest'
  5. import { uniqueRepoFiles } from './repo-files.ts'
  6. interface Tree {
  7. root: string
  8. clean: () => void
  9. }
  10. function makeTree(): Tree {
  11. const parent = mkdtempSync(join(tmpdir(), 'repo-files-'))
  12. const root = join(parent, 'root')
  13. const outside = join(parent, 'outside') // reachable only through a symlinked dir
  14. mkdirSync(join(root, 'a'), { recursive: true })
  15. writeFileSync(join(root, 'a', 'snap.md'), 'real\n')
  16. mkdirSync(join(root, 'd'), { recursive: true })
  17. symlinkSync(join(root, 'a', 'snap.md'), join(root, 'd', 'snap.md'))
  18. mkdirSync(outside, { recursive: true })
  19. writeFileSync(join(outside, 'snap.md'), 'behind a symlinked dir\n')
  20. symlinkSync(outside, join(root, 'linked-dir'))
  21. mkdirSync(join(root, '.hidden'), { recursive: true })
  22. writeFileSync(join(root, '.hidden', 'snap.md'), 'hidden by dot\n')
  23. return { root, clean: () => { rmSync(parent, { recursive: true, force: true }) } }
  24. }
  25. describe('uniqueRepoFiles', () => {
  26. it('enumerates ** matches without probing a symlinked file as a directory', () => {
  27. const tree = makeTree()
  28. try {
  29. // Node's internal glob (from some 24.x releases) lstat-probes
  30. // <symlink>/snap.md while expanding `**/snap.md` and throws ENOTDIR.
  31. // The walker must return the real files, deduplicated by canonical
  32. // target, without throwing on any node version.
  33. const files = uniqueRepoFiles(tree.root, ['**/snap.md'])
  34. const rootReal = realpathSync(tree.root)
  35. const reals = files.map(file => relative(rootReal, file.real)).sort()
  36. expect(reals).toEqual([join('a', 'snap.md')])
  37. } finally {
  38. tree.clean()
  39. }
  40. })
  41. it('does not follow symlinked directories under ** or wildcard-match dot names', () => {
  42. const tree = makeTree()
  43. try {
  44. const files = uniqueRepoFiles(tree.root, ['**/*.md'])
  45. const paths = files.map(file => relative(tree.root, file.abs)).sort()
  46. // The symlinked d/snap.md dedupes onto its a/snap.md target; the
  47. // linked-dir and .hidden targets must not appear at all.
  48. expect(paths).toEqual([join('a', 'snap.md')])
  49. } finally {
  50. tree.clean()
  51. }
  52. })
  53. it('follows a literal segment that names a symlinked directory', () => {
  54. const tree = makeTree()
  55. try {
  56. // Node glob resolves literal segments with stat, so `linked-dir/**` and
  57. // `linked-dir/*` enter the symlinked directory's target; `**` and
  58. // wildcard segments resolve with dirent types and do not.
  59. const files = uniqueRepoFiles(tree.root, ['linked-dir/**/*.md'])
  60. expect(files.map(file => relative(tree.root, file.abs))).toEqual([join('linked-dir', 'snap.md')])
  61. expect(uniqueRepoFiles(tree.root, ['linked-dir/*.md']).map(file => relative(tree.root, file.abs)))
  62. .toEqual([join('linked-dir', 'snap.md')])
  63. // A wildcard first segment never enters the symlinked directory.
  64. expect(uniqueRepoFiles(tree.root, ['*/snap.md']).map(file => relative(tree.root, file.abs)))
  65. .toEqual([join('a', 'snap.md')])
  66. } finally {
  67. tree.clean()
  68. }
  69. })
  70. it('follows repeated literal symlinked directories like node glob and terminates', () => {
  71. const parent = mkdtempSync(join(tmpdir(), 'repo-files-cycle-'))
  72. try {
  73. const root = join(parent, 'root')
  74. mkdirSync(join(root, 'a'), { recursive: true })
  75. writeFileSync(join(root, 'a', 'snap.md'), 'x\n')
  76. symlinkSync(root, join(root, 'cyc'))
  77. // Each literal `cyc` segment resolves through stat and enters the
  78. // symlinked directory again, exactly as node glob does for a repeated
  79. // literal; recursion stays bounded because each literal consumes one
  80. // pattern segment and `**` only enters real directories.
  81. expect(uniqueRepoFiles(root, ['cyc/**/snap.md']).map(file => relative(root, file.abs)))
  82. .toEqual([join('cyc', 'a', 'snap.md')])
  83. expect(uniqueRepoFiles(root, ['cyc/cyc/**/snap.md']).map(file => relative(root, file.abs)))
  84. .toEqual([join('cyc', 'cyc', 'a', 'snap.md')])
  85. expect(uniqueRepoFiles(root, ['cyc/cyc/cyc/**/snap.md']).map(file => relative(root, file.abs)))
  86. .toEqual([join('cyc', 'cyc', 'cyc', 'a', 'snap.md')])
  87. } finally {
  88. rmSync(parent, { recursive: true, force: true })
  89. }
  90. })
  91. it('reports a matched file canonical target for downstream existence checks', () => {
  92. const tree = makeTree()
  93. try {
  94. const files = uniqueRepoFiles(tree.root, ['a/*.md'])
  95. expect(files).toHaveLength(1)
  96. expect(files[0]!.real).toBe(join(realpathSync(tree.root), 'a', 'snap.md'))
  97. } finally {
  98. tree.clean()
  99. }
  100. })
  101. it('fails loudly on a broken symlink instead of shrinking the corpus', () => {
  102. const tree = makeTree()
  103. try {
  104. // A healthy tree with symlinked files must not throw.
  105. expect(() => uniqueRepoFiles(tree.root, ['a/*.md'])).not.toThrow()
  106. } finally {
  107. tree.clean()
  108. }
  109. const parent = mkdtempSync(join(tmpdir(), 'repo-files-broken-'))
  110. try {
  111. const root = join(parent, 'root')
  112. mkdirSync(root, { recursive: true })
  113. writeFileSync(join(root, 'gone.md'), 'x\n')
  114. symlinkSync(join(root, 'gone.md'), join(root, 'broken-link.md'))
  115. rmSync(join(root, 'gone.md'))
  116. // realpathSync on the matched broken link must throw, exactly as it did
  117. // under the node-glob implementation, instead of silently shrinking the
  118. // scanned corpus.
  119. expect(() => uniqueRepoFiles(root, ['*.md'])).toThrow()
  120. // A broken symlink under a literal non-final segment matches nothing,
  121. // again as node glob silently returns no match for it.
  122. symlinkSync(join(root, 'gone-dir'), join(root, 'broken-dir'))
  123. expect(uniqueRepoFiles(root, ['broken-dir/*.md'])).toEqual([])
  124. } finally {
  125. rmSync(parent, { recursive: true, force: true })
  126. }
  127. })
  128. it('rejects glob syntax the walker does not model instead of matching nothing', () => {
  129. const tree = makeTree()
  130. try {
  131. // Node glob would interpret the bracket class; the walker must fail
  132. // loudly rather than expand the pattern as a literal and quietly match
  133. // nothing.
  134. expect(() => uniqueRepoFiles(tree.root, ['**/*.[cm]d'])).toThrow(/does not model glob syntax/)
  135. } finally {
  136. tree.clean()
  137. }
  138. })
  139. it('rejects a trailing ** segment instead of silently returning nothing', () => {
  140. const tree = makeTree()
  141. try {
  142. expect(() => uniqueRepoFiles(tree.root, ['a/**'])).toThrow(/trailing \*\*/)
  143. // A trailing slash yields an empty final segment that can never match.
  144. expect(() => uniqueRepoFiles(tree.root, ['a/**/'])).toThrow(/empty segments/)
  145. } finally {
  146. tree.clean()
  147. }
  148. })
  149. it('folds a . segment and rejects a .. segment like node glob semantics', () => {
  150. const tree = makeTree()
  151. try {
  152. // Node glob normalizes a `.` segment away, so `./a/snap.md` matches
  153. // a/snap.md instead of looking for a directory named `.`.
  154. expect(uniqueRepoFiles(tree.root, ['./a/snap.md']).map(file => relative(tree.root, file.abs)))
  155. .toEqual([join('a', 'snap.md')])
  156. expect(uniqueRepoFiles(tree.root, ['a/./snap.md']).map(file => relative(tree.root, file.abs)))
  157. .toEqual([join('a', 'snap.md')])
  158. // A `..` segment escapes the scanned root; the walker must fail loudly
  159. // rather than silently match nothing.
  160. expect(() => uniqueRepoFiles(tree.root, ['a/../snap.md'])).toThrow(/does not model \.\. segments/)
  161. } finally {
  162. tree.clean()
  163. }
  164. })
  165. })