repo-files.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. /** Shared repository file discovery and line-oriented reference scanning. */
  2. import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
  3. import { join, relative, resolve, sep } from 'node:path'
  4. /** One authored path plus its canonical target for symlink deduplication. */
  5. export interface RepoFile {
  6. /** Absolute path matched by the caller's glob. */
  7. abs: string
  8. /** Absolute canonical path used only for deduplication. */
  9. real: string
  10. }
  11. /** A rejected line-oriented repository reference. */
  12. export interface ReferenceViolation {
  13. /** Repo-relative file containing the reference. */
  14. file: string
  15. /** 1-based line containing the reference. */
  16. line: number
  17. /** Normalized reference text. */
  18. ref: string
  19. }
  20. /** Whether a repository path is frozen Agent Note history, not evolving source prose. */
  21. export function isArchivedAgentNotePath(path: string): boolean {
  22. return path.replaceAll('\\', '/').startsWith('.agents/notes/archived/')
  23. }
  24. /**
  25. * Whether a pattern segment matches a directory or file name. Supports `*` and
  26. * `?` inside a segment and mirrors node's glob `dot: false`: a segment whose
  27. * first character is a wildcard does not match dot names. `**` is handled as a
  28. * whole segment by the walker, never here.
  29. */
  30. function segmentMatches(pattern: string, name: string): boolean {
  31. if (name.startsWith('.') && (pattern.startsWith('*') || pattern.startsWith('?'))) return false
  32. let expression = ''
  33. for (const character of pattern) {
  34. if (character === '*') expression += '.*'
  35. else if (character === '?') expression += '.'
  36. else expression += character.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  37. }
  38. return new RegExp(`^${expression}$`).test(name)
  39. }
  40. /**
  41. * Reject glob syntax the walker does not model. Node's `fs.glob` understands
  42. * character classes, brace alternation, and extglobs; expanding those
  43. * silently as literals would match nothing and quietly shrink a gate's
  44. * corpus, so a pattern segment using them fails loudly instead. Pure literal
  45. * segments must therefore contain none of the rejected metacharacters
  46. * either, even where node glob would read them literally.
  47. */
  48. const UNSUPPORTED_GLOB = /[[\]{}()!+@\\]/
  49. function assertSupportedSegment(segment: string): void {
  50. if (UNSUPPORTED_GLOB.test(segment)) {
  51. throw new TypeError(`repo-files walker does not model glob syntax in segment: ${segment}`)
  52. }
  53. }
  54. /**
  55. * Walk `root` matching one repository-relative glob without node's `fs.glob`.
  56. * The repository's own walker exists because node's internal glob, from some
  57. * 24.x releases, lstat-probes `<matched>/<next segment>` for symlinked files
  58. * while expanding `**` and throws ENOTDIR instead of skipping (observed on
  59. * node 24.13.0 scanning `snapshots/acp/image-compaction`'s symlinked
  60. * `system-prompt.expected.md`). The walker decides directoryhood from dirent
  61. * types and stat results, never by probing a path under a file, so the same
  62. * tree enumerates identically on every node version.
  63. *
  64. * Segment semantics: literal segments resolve through `stat`, so a literal
  65. * naming a symlinked directory enters that directory exactly as node glob
  66. * does — including a literal repeated across segments, which node glob
  67. * resolves each time; wildcard segments and `**` resolve through dirent
  68. * types, so they never enter symlinked directories and never match dot
  69. * names. Node documents `follow: false` only for `**` expansion, so the
  70. * wildcard-side behavior is this walker's own contract, pinned by
  71. * repo-files.spec.ts, rather than a cross-version node guarantee; the four
  72. * consuming gates' current patterns contain no wildcard segment over a
  73. * symlinked directory, so the corpus is unchanged. `**` spans zero or more
  74. * directories. The final segment matches files and symlinks; a broken or
  75. * cyclic symlink then fails loudly in the caller's realpathSync rather than
  76. * shrinking the scanned corpus, while a broken symlink under a literal
  77. * non-final segment matches nothing, again as node glob does. Traversal
  78. * terminates without a visited set: each literal segment consumes one pattern
  79. * segment per recursion, and `**` recurses only into real directories, which
  80. * form a finite tree because symlinked directories are never expanded by it.
  81. * Pattern segments support `*`, `?`, and literals that contain none of
  82. * `[]{}()!+@\`; other node-glob syntax, including a trailing `**`, `..`, and
  83. * empty segments, is rejected loudly up front, while a `.` segment is folded
  84. * away exactly as node glob normalizes it. Returns repository-relative slash
  85. * paths, sorted.
  86. */
  87. function expandGlob(root: string, pattern: string): string[] {
  88. const segments: string[] = []
  89. for (const segment of pattern.split('/')) {
  90. if (segment === '.') {
  91. // Node glob normalizes a `.` segment away; dropping it here keeps
  92. // `./README.md` and `a/./b` matching exactly as node glob does instead
  93. // of silently matching nothing.
  94. continue
  95. }
  96. if (segment === '..') {
  97. // A `..` segment escapes the scanned root and interacts with `**` in
  98. // ways node glob special-cases; no gate pattern uses one, so the walker
  99. // rejects the form loudly instead of silently matching nothing.
  100. throw new TypeError(`repo-files walker does not model .. segments in pattern: ${pattern}`)
  101. }
  102. if (segment === '') {
  103. // A leading, doubled, or trailing slash yields an empty segment that
  104. // can never match an entry; node glob would tolerate the form, so the
  105. // walker must reject it loudly instead of silently returning nothing.
  106. throw new TypeError(`repo-files walker does not model empty segments in pattern: ${pattern}`)
  107. }
  108. segments.push(segment)
  109. }
  110. if (segments.length === 0) {
  111. throw new TypeError(`repo-files walker does not model a pattern with no segments: ${pattern}`)
  112. }
  113. for (const segment of segments) {
  114. if (segment !== '**') assertSupportedSegment(segment)
  115. }
  116. // A trailing `**` would match files, directories, and symlinks below the
  117. // prefix; the walker models `**` only as a directory-spanning segment, so
  118. // it must reject the form loudly instead of silently returning nothing.
  119. if (segments[segments.length - 1] === '**') {
  120. throw new TypeError(`repo-files walker does not model a trailing ** segment in pattern: ${pattern}`)
  121. }
  122. const out: string[] = []
  123. const visit = (dirAbs: string, dirRel: string, index: number): void => {
  124. if (index >= segments.length) return
  125. if (segments[index] !== '**') {
  126. visitSegment(dirAbs, dirRel, index)
  127. return
  128. }
  129. // `**` consumes zero directories here and one directory per recursion.
  130. visitSegment(dirAbs, dirRel, index + 1)
  131. for (const entry of readdirSync(dirAbs, { withFileTypes: true })) {
  132. if (entry.name.startsWith('.') || !entry.isDirectory()) continue
  133. visit(join(dirAbs, entry.name), dirRel === '.' ? entry.name : `${dirRel}/${entry.name}`, index)
  134. }
  135. }
  136. const visitSegment = (dirAbs: string, dirRel: string, index: number): void => {
  137. if (index >= segments.length) return
  138. const segment = segments[index]
  139. if (segment === undefined) return
  140. if (segment === '**') {
  141. visit(dirAbs, dirRel, index)
  142. return
  143. }
  144. const last = index === segments.length - 1
  145. for (const entry of readdirSync(dirAbs, { withFileTypes: true })) {
  146. if (!segmentMatches(segment, entry.name)) continue
  147. const childAbs = join(dirAbs, entry.name)
  148. const childRel = dirRel === '.' ? entry.name : `${dirRel}/${entry.name}`
  149. if (last) {
  150. // Files and symlinks match; a broken or cyclic symlink then fails
  151. // loudly in the caller's realpathSync exactly as node glob did,
  152. // rather than silently shrinking the scanned corpus.
  153. if (entry.isFile() || entry.isSymbolicLink()) out.push(childRel)
  154. continue
  155. }
  156. if (entry.isDirectory()) {
  157. visitSegment(childAbs, childRel, index + 1)
  158. continue
  159. }
  160. // A literal non-final segment resolves through stat like node glob's,
  161. // so it enters a symlinked directory; wildcard segments never reach
  162. // this branch because they are matched from dirent types above.
  163. if (!entry.isSymbolicLink() || hasWildcard(segment)) continue
  164. let target: ReturnType<typeof statSync>
  165. try {
  166. target = statSync(childAbs)
  167. } catch {
  168. // A broken symlink matches nothing under a literal segment, as node
  169. // glob silently returns no match for it.
  170. continue
  171. }
  172. if (!target.isDirectory()) continue
  173. visitSegment(childAbs, childRel, index + 1)
  174. }
  175. }
  176. visit(root, '.', 0)
  177. return out.sort()
  178. }
  179. /** Whether a pattern segment contains `*` or `?` and is therefore wildcard. */
  180. function hasWildcard(segment: string): boolean {
  181. return segment.includes('*') || segment.includes('?')
  182. }
  183. /**
  184. * Expand repository-relative globs and deduplicate symlinked files.
  185. * @param root - absolute repository root.
  186. * @param patterns - repository-relative glob patterns, processed in order.
  187. * @param isExcluded - optional predicate over each matched relative path.
  188. * @returns matched files in stable first-seen order.
  189. */
  190. export function uniqueRepoFiles(
  191. root: string,
  192. patterns: readonly string[],
  193. isExcluded: (relativePath: string) => boolean = () => false,
  194. ): RepoFile[] {
  195. const seen = new Set<string>()
  196. const files: RepoFile[] = []
  197. for (const pattern of patterns) {
  198. for (const repoPath of expandGlob(root, pattern)) {
  199. if (isExcluded(repoPath)) continue
  200. const abs = resolve(root, repoPath)
  201. const real = realpathSync(abs)
  202. if (seen.has(real)) continue
  203. seen.add(real)
  204. files.push({ abs, real })
  205. }
  206. }
  207. return files
  208. }
  209. /**
  210. * Scan regex matches line by line and return the normalized matches rejected by
  211. * a caller predicate.
  212. * @param root - absolute repository root used for violation paths.
  213. * @param absPath - absolute text-file path to scan.
  214. * @param pattern - global regex matched independently against each line.
  215. * @param normalize - maps raw regex text to the reference the gate evaluates.
  216. * @param isViolation - returns true when the normalized reference is invalid.
  217. * @returns every rejected reference in source order.
  218. */
  219. export function findReferenceViolations(
  220. root: string,
  221. absPath: string,
  222. pattern: RegExp,
  223. normalize: (raw: string) => string,
  224. isViolation: (ref: string) => boolean,
  225. ): ReferenceViolation[] {
  226. const file = relative(root, absPath).split(sep).join('/')
  227. const out: ReferenceViolation[] = []
  228. const lines = readFileSync(absPath, 'utf8').split('\n')
  229. for (let i = 0; i < lines.length; i++) {
  230. const line = lines[i]
  231. if (line === undefined) continue
  232. for (const match of line.matchAll(pattern)) {
  233. const ref = normalize(match[0])
  234. if (isViolation(ref)) out.push({ file, line: i + 1, ref })
  235. }
  236. }
  237. return out
  238. }