verify-concrete-terms.ts 3.8 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  1. /** Reject one ambiguous origin label from maintained tracked files. */
  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. const root = resolve(import.meta.dirname, '..')
  7. const blockedTerm = 'prove' + 'nance'
  8. const excludedPrefixes = ['vendor/', '.agents/notes/archived/'] as const
  9. /** One blocked term occurrence in a tracked path or text line. */
  10. export interface ConcreteTermViolation {
  11. /** Repository-relative tracked path. */
  12. file: string
  13. /** One-based source line, or null when the path contains the term. */
  14. line: number | null
  15. }
  16. function isExcluded(file: string): boolean {
  17. return excludedPrefixes.some(prefix => file.startsWith(prefix))
  18. }
  19. function containsBlockedTerm(value: string): boolean {
  20. return value.normalize('NFKC').toLowerCase().includes(blockedTerm)
  21. }
  22. /**
  23. * Find the blocked term in one maintained tracked file.
  24. * @param file - repository-relative tracked path.
  25. * @param source - text contents or symlink target.
  26. * @returns path and line violations; vendored sources and frozen Agent Notes are excluded.
  27. */
  28. export function findConcreteTermViolations(file: string, source: string): ConcreteTermViolation[] {
  29. if (isExcluded(file)) return []
  30. const violations: ConcreteTermViolation[] = []
  31. if (containsBlockedTerm(file)) violations.push({ file, line: null })
  32. for (const [index, line] of source.split('\n').entries()) {
  33. if (containsBlockedTerm(line)) violations.push({ file, line: index + 1 })
  34. }
  35. return violations
  36. }
  37. function trackedFiles(repoRoot: string): string[] {
  38. const files = execFileSync('git', ['ls-files', '-z'], { cwd: repoRoot, encoding: 'utf8' })
  39. .split('\0')
  40. .filter(file => file !== '')
  41. if (!files.includes('AGENTS.md')
  42. || !files.some(file => file.startsWith('packages/'))
  43. || !files.some(file => file.startsWith('docs/'))
  44. || !files.some(file => file.startsWith('vendor/'))
  45. || !files.some(file => file.startsWith('.agents/notes/archived/'))) {
  46. throw new Error('verify-concrete-terms: tracked-file discovery omitted a required repository area')
  47. }
  48. return files
  49. }
  50. /**
  51. * Read one tracked file without following a symlink to its target.
  52. * @param repoRoot - Repository root containing the tracked path.
  53. * @param file - Repository-relative tracked path.
  54. * @returns File text, the symlink target, or undefined when the path is absent or not a file.
  55. */
  56. export function readTrackedSource(repoRoot: string, file: string): string | undefined {
  57. const path = resolve(repoRoot, file)
  58. const stat = lstatSync(path, { throwIfNoEntry: false })
  59. if (stat === undefined) return undefined
  60. if (stat.isSymbolicLink()) return readlinkSync(path)
  61. return stat.isFile() ? readFileSync(path, 'utf8') : undefined
  62. }
  63. function scanRepository(repoRoot: string): ConcreteTermViolation[] {
  64. const violations: ConcreteTermViolation[] = []
  65. for (const file of trackedFiles(repoRoot)) {
  66. const source = readTrackedSource(repoRoot, file)
  67. if (source === undefined) continue
  68. violations.push(...findConcreteTermViolations(file, source))
  69. }
  70. return violations
  71. }
  72. const invokedPath = process.argv[1]
  73. const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href
  74. if (isMain) {
  75. const violations = scanRepository(root)
  76. if (violations.length === 0) {
  77. console.log(`verify-concrete-terms: maintained tracked files contain no ${blockedTerm}.`)
  78. } else {
  79. console.error(`verify-concrete-terms: ${blockedTerm} is forbidden; name the exact source, field, identity, or evidence:`)
  80. for (const violation of violations) {
  81. console.error(violation.line === null
  82. ? ` ${violation.file} (path)`
  83. : ` ${violation.file}:${String(violation.line)}`)
  84. }
  85. process.exitCode = 1
  86. }
  87. }