verify-repository-references.ts 5.2 KB

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