repo-files.ts 3.0 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
  1. /** Shared repository file discovery and line-oriented reference scanning. */
  2. import { globSync, readFileSync, realpathSync } from 'node:fs'
  3. import { 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. * Expand repository-relative globs and deduplicate symlinked files.
  26. * @param root - absolute repository root.
  27. * @param patterns - repository-relative glob patterns, processed in order.
  28. * @param isExcluded - optional predicate over each matched relative path.
  29. * @returns matched files in stable first-seen order.
  30. */
  31. export function uniqueRepoFiles(
  32. root: string,
  33. patterns: readonly string[],
  34. isExcluded: (relativePath: string) => boolean = () => false,
  35. ): RepoFile[] {
  36. const seen = new Set<string>()
  37. const files: RepoFile[] = []
  38. for (const pattern of patterns) {
  39. for (const match of globSync(pattern, { cwd: root })) {
  40. const repoPath = match.split(sep).join('/')
  41. if (isExcluded(repoPath)) continue
  42. const abs = resolve(root, repoPath)
  43. const real = realpathSync(abs)
  44. if (seen.has(real)) continue
  45. seen.add(real)
  46. files.push({ abs, real })
  47. }
  48. }
  49. return files
  50. }
  51. /**
  52. * Scan regex matches line by line and return the normalized matches rejected by
  53. * a caller predicate.
  54. * @param root - absolute repository root used for violation paths.
  55. * @param absPath - absolute text-file path to scan.
  56. * @param pattern - global regex matched independently against each line.
  57. * @param normalize - maps raw regex text to the reference the gate evaluates.
  58. * @param isViolation - returns true when the normalized reference is invalid.
  59. * @returns every rejected reference in source order.
  60. */
  61. export function findReferenceViolations(
  62. root: string,
  63. absPath: string,
  64. pattern: RegExp,
  65. normalize: (raw: string) => string,
  66. isViolation: (ref: string) => boolean,
  67. ): ReferenceViolation[] {
  68. const file = relative(root, absPath).split(sep).join('/')
  69. const out: ReferenceViolation[] = []
  70. const lines = readFileSync(absPath, 'utf8').split('\n')
  71. for (let i = 0; i < lines.length; i++) {
  72. const line = lines[i]
  73. if (line === undefined) continue
  74. for (const match of line.matchAll(pattern)) {
  75. const ref = normalize(match[0])
  76. if (isViolation(ref)) out.push({ file, line: i + 1, ref })
  77. }
  78. }
  79. return out
  80. }