verify-doc-refs.ts 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
  1. /**
  2. * Doc-sync gate: verify that doc references written in TypeScript COMMENTS
  3. * resolve to a file that exists. Source comments cite docs by root-relative
  4. * prose path — `see docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md`,
  5. * `docs/architecture.md § plugin checklist`. `verify-md-links` parses Markdown
  6. * link AST and never sees these, so a doc rename or move could silently orphan
  7. * a `.ts` comment that points at it. The RFC classification reorg
  8. * ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md))
  9. * is the motivating case: it moved every RFC under a `{class}/` folder, and
  10. * several `.ts` doc comments cite RFC paths that changed.
  11. *
  12. * Detection is a token scan, NOT an AST walk: doc refs live in free prose inside
  13. * comments, not in a structured form. We match `docs/<path>.md` tokens and
  14. * REQUIRE the `.md` extension, so extensionless prose (`docs/postmortem/0001`,
  15. * `docs/architecture.md § plugin checklist` — the section suffix is outside the
  16. * token) is left alone rather than misread as a path. Each token is resolved
  17. * ROOT-RELATIVE (the way the comments are written) and must exist on disk. This
  18. * is checker, not fixer: it reports and never rewrites.
  19. *
  20. * Scope is repo-authored TypeScript under `packages/**` and `examples/**`,
  21. * excluding built output (`lib/`, `*.d.ts`) and `vendor/` (pinned upstream
  22. * source we do not own). The scan is purely textual, so it does not distinguish
  23. * a token in a comment from one in a string literal — a `docs/….md` string in
  24. * code is checked too, which is harmless (such a path should resolve anyway).
  25. *
  26. * Run: `tsx scripts/verify-doc-refs.ts`.
  27. */
  28. import { existsSync, readFileSync } from 'node:fs'
  29. import { relative, resolve } from 'node:path'
  30. import { glob } from 'node:fs/promises'
  31. const root = resolve(import.meta.dirname, '..')
  32. /** Repo-authored TypeScript that may cite docs in comments. */
  33. const PATTERNS = ['packages/**/*.ts', 'examples/**/*.ts']
  34. /** Paths excluded from the scan: built output and vendored upstream source. */
  35. const isExcluded = (p: string): boolean =>
  36. p.includes('/lib/') || p.endsWith('.d.ts') || p.startsWith('vendor/')
  37. /**
  38. * Match a `docs/…​.md` reference token. The `.md` extension is required so a
  39. * bare `docs/postmortem/0001` (no extension) does not register as a path. The
  40. * character class stops at whitespace, backticks, parens, and the section sign,
  41. * so trailing prose (`… .md § plugin checklist`) is not swallowed into the path.
  42. */
  43. const DOC_REF = /\bdocs\/[A-Za-z0-9._/-]+\.md/g
  44. /** A broken doc reference: a root-relative `docs/….md` token with no file. */
  45. interface Violation {
  46. file: string
  47. /** 1-based line where the reference appears. */
  48. line: number
  49. ref: string
  50. }
  51. /** Find every broken `docs/….md` reference in one TypeScript file. */
  52. function findViolations(absPath: string): Violation[] {
  53. const file = relative(root, absPath)
  54. const source = readFileSync(absPath, 'utf8')
  55. const out: Violation[] = []
  56. const lines = source.split('\n')
  57. for (let i = 0; i < lines.length; i++) {
  58. const line = lines[i]
  59. if (line === undefined) continue
  60. for (const m of line.matchAll(DOC_REF)) {
  61. const ref = m[0]
  62. if (!existsSync(resolve(root, ref))) {
  63. out.push({ file, line: i + 1, ref })
  64. }
  65. }
  66. }
  67. return out
  68. }
  69. const all: Violation[] = []
  70. let checked = 0
  71. for (const pattern of PATTERNS) {
  72. for await (const match of glob(pattern, { cwd: root })) {
  73. if (isExcluded(match)) continue
  74. checked++
  75. all.push(...findViolations(resolve(root, match)))
  76. }
  77. }
  78. if (all.length === 0) {
  79. console.log(`verify-doc-refs: ${checked} file(s) checked, all docs/*.md references resolve.`)
  80. process.exit(0)
  81. }
  82. console.error('verify-doc-refs: broken docs/*.md references found in source comments (target does not exist):')
  83. for (const v of all) {
  84. console.error(` ${v.file}:${v.line} ${v.ref}`)
  85. }
  86. process.exit(1)