verify-repository-references.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. /** Reject maintained references to repository commits and the disallowed organization URL. */
  2. import { execFileSync } from 'node:child_process'
  3. import { lstatSync, readFileSync, readlinkSync } from 'node:fs'
  4. import { resolve } from 'node:path'
  5. import { pathToFileURL } from 'node:url'
  6. import { canonicalReferenceText } from './verify-public-repository-links.ts'
  7. const root = resolve(import.meta.dirname, '..')
  8. const organization = ['deepseek', 'harness'].join('-')
  9. const organizationUrl = new RegExp(`\\bgithub\\.com/${organization}(?![a-z0-9-])`)
  10. const commitCandidate = /(?<![a-z0-9])[\da-f]{7,40}(?![a-z0-9])/gi
  11. const excludedPrefixes = ['vendor/', '.agents/notes/archived/']
  12. const gitOutputLimit = 64 * 1024 * 1024
  13. /** One prohibited reference in a maintained source file. */
  14. export interface RepositoryReference {
  15. /** Repository-relative path, with forward slashes. */
  16. file: string
  17. /** One-based source line containing the reference. */
  18. line: number
  19. /** Whether the line names a repository commit or the disallowed organization URL. */
  20. kind: 'commit-hash' | 'organization-url'
  21. }
  22. function isMaintained(file: string): boolean {
  23. return !excludedPrefixes.some(prefix => file.startsWith(prefix))
  24. }
  25. /**
  26. * Inspect a maintained source file against known commit identifiers.
  27. * @param file - Repository-relative path used in diagnostics and exclusions.
  28. * @param source - File text or a symlink's stored target.
  29. * @param commits - Lowercase, unambiguous full or abbreviated commit identifiers.
  30. * @returns One finding per line and reference kind; digests and other Git object types are accepted.
  31. */
  32. export function findRepositoryReferences(
  33. file: string,
  34. source: string,
  35. commits: ReadonlySet<string>,
  36. ): RepositoryReference[] {
  37. if (!isMaintained(file)) return []
  38. const references: RepositoryReference[] = []
  39. for (const [index, line] of source.split('\n').entries()) {
  40. if (organizationUrl.test(canonicalReferenceText(line))) {
  41. references.push({ file, line: index + 1, kind: 'organization-url' })
  42. }
  43. if ([...line.matchAll(commitCandidate)].some(match => commits.has(match[0].toLowerCase()))) {
  44. references.push({ file, line: index + 1, kind: 'commit-hash' })
  45. }
  46. }
  47. return references
  48. }
  49. function readMaintainedFiles(repoRoot: string): Map<string, string> {
  50. const files = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], {
  51. cwd: repoRoot,
  52. encoding: 'utf8',
  53. maxBuffer: gitOutputLimit,
  54. }).split('\0').filter(file => file !== '' && isMaintained(file))
  55. const sources = new Map<string, string>()
  56. for (const file of files) {
  57. const path = resolve(repoRoot, file)
  58. const stat = lstatSync(path, { throwIfNoEntry: false })
  59. if (stat?.isSymbolicLink() === true) sources.set(file, readlinkSync(path))
  60. else if (stat?.isFile() === true) sources.set(file, readFileSync(path, 'utf8'))
  61. }
  62. return sources
  63. }
  64. function repositoryCommits(repoRoot: string, sources: Iterable<string>): Set<string> {
  65. const candidates = [...new Set([...sources].flatMap(source =>
  66. [...source.matchAll(commitCandidate)].map(match => match[0].toLowerCase())))]
  67. if (candidates.length === 0) return new Set()
  68. const results = execFileSync('git', ['cat-file', '--batch-check=%(objectname) %(objecttype)'], {
  69. cwd: repoRoot,
  70. env: { ...process.env, GIT_NO_LAZY_FETCH: '1' },
  71. encoding: 'utf8',
  72. input: `${candidates.join('\n')}\n`,
  73. maxBuffer: gitOutputLimit,
  74. stdio: ['pipe', 'pipe', 'pipe'],
  75. }).trimEnd().split('\n')
  76. // Git resolves prefixes across all available objects, including unreachable ones.
  77. // Ambiguous prefixes do not identify one object and cannot establish a commit reference.
  78. return new Set(candidates.filter((candidate, index) => {
  79. const [object, type] = results[index]?.split(' ') ?? []
  80. return type === 'commit' && object?.startsWith(candidate) === true
  81. }))
  82. }
  83. /**
  84. * Scan tracked and nonignored new files using only the local Git object database.
  85. * @param repoRoot - Working tree whose files and Git objects are inspected.
  86. * @returns Prohibited references outside vendor and frozen Agent Notes; absent shallow-history objects cannot match.
  87. */
  88. export function scanRepositoryReferences(repoRoot: string): RepositoryReference[] {
  89. const sources = readMaintainedFiles(repoRoot)
  90. const commits = repositoryCommits(repoRoot, sources.values())
  91. return [...sources].flatMap(([file, source]) => findRepositoryReferences(file, source, commits))
  92. }
  93. const invokedPath = process.argv[1]
  94. if (invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href) {
  95. const references = scanRepositoryReferences(root)
  96. if (references.length === 0) {
  97. console.log('verify-repository-references: maintained files contain no repository commit identifiers or disallowed organization URLs.')
  98. } else {
  99. console.error('verify-repository-references: use release tags or maintained repository links:')
  100. for (const { file, line, kind } of references) console.error(` ${file}:${String(line)} ${kind}`)
  101. process.exitCode = 1
  102. }
  103. }