verify-package-paths.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. /**
  2. * Doc-sync gate: catch DRIFTED `packages/<path>` references — a path to a
  3. * package that has MOVED, written as prose in Markdown or in a TypeScript
  4. * comment/string. Docs and comments cite package locations by root-relative
  5. * path (`packages/core/tools/src/index.ts`, `see packages/ui/acp`);
  6. * `verify-md-links` only parses Markdown LINK targets and `verify-doc-refs`
  7. * only checks `docs/*.md` tokens, so a `packages/…` path sitting in backtick
  8. * prose or a code comment goes unchecked. The package-hierarchy reorg is the
  9. * motivating case: it moved every package under a `{group}/` folder, so a stale
  10. * `packages/tools` (now `packages/core/tools`) reads fine to a human but points
  11. * at nothing.
  12. *
  13. * The check is drift-scoped, NOT a blanket existence test: a broken
  14. * `packages/<path>` token is a violation ONLY when one of its path segments is
  15. * the directory name of a package that actually exists on disk — i.e. the
  16. * package is real and the path is merely stale. A token naming a package that
  17. * exists NOWHERE (`packages/code-runtime` in a forward-looking proposal, an
  18. * illustrative `packages/<name>/` skeleton) is left alone: this gate reports
  19. * MOVED paths, not hypothetical or future ones, so it applies uniformly to
  20. * proposed/implemented/rejected docs without per-lifecycle exclusions. This is
  21. * checker, not fixer: it reports and never rewrites.
  22. *
  23. * Detection is a token scan, NOT an AST walk: package refs live in free prose,
  24. * backticks, and comments. We match `packages/<path>` tokens whose path is made
  25. * of plain path characters, so a glob, a `<placeholder>`, or a `{brace,expansion}`
  26. * terminates the match before those chars and is never probed.
  27. *
  28. * Scope mirrors the other doc gates plus repo-authored TypeScript: Markdown
  29. * across README/docs/packages/AGENTS, and `.ts` under packages/** and
  30. * examples/** (excluding built `lib/`, `*.d.ts`, and vendored upstream source).
  31. * A reference to a package's build OUTPUT (`packages/<group>/<pkg>/lib/…`,
  32. * e.g. `packages/ui/acp-agent/lib/bin.js` cited by a built-bin smoke) is also
  33. * skipped — it is emitted only by `pnpm run build`, which CI runs AFTER this
  34. * gate, so flagging it would be a false positive on a path that is correct but
  35. * not yet on disk. That skip is scoped to a REAL package root: a stale
  36. * group-less `packages/acp-agent/lib/bin.js` is still flagged (its root does not
  37. * exist — exactly the moved-package drift this gate catches).
  38. *
  39. * Run: `tsx scripts/verify-package-paths.ts`.
  40. */
  41. import { existsSync, readdirSync } from 'node:fs'
  42. import { resolve } from 'node:path'
  43. import { findReferenceViolations, uniqueRepoFiles, type ReferenceViolation as Violation } from './repo-files.ts'
  44. const root = resolve(import.meta.dirname, '..')
  45. /** Markdown + repo-authored TypeScript that may cite package paths. */
  46. const PATTERNS = [
  47. 'README.md',
  48. 'docs/**/*.md',
  49. 'packages/*/*.md',
  50. 'packages/*/*/*.md',
  51. 'AGENTS.md',
  52. 'packages/AGENTS.md',
  53. 'packages/**/*.ts',
  54. 'examples/**/*.ts',
  55. ]
  56. /** Paths excluded from the scan: built output and vendored upstream source. */
  57. const isExcluded = (p: string): boolean =>
  58. p.includes('/lib/') || p.endsWith('.d.ts') || p.startsWith('vendor/')
  59. /**
  60. * Directory names of every real package, `packages/<group>/<pkg>`. A broken
  61. * reference is only flagged when one of its segments is in this set — that is
  62. * what scopes the gate to DRIFT (a moved real package) rather than typos or
  63. * not-yet-existing packages named in a proposal.
  64. */
  65. function realPackageNames(): Set<string> {
  66. const names = new Set<string>()
  67. const pkgRoot = resolve(root, 'packages')
  68. for (const group of readdirSync(pkgRoot, { withFileTypes: true })) {
  69. if (!group.isDirectory()) continue
  70. for (const pkg of readdirSync(resolve(pkgRoot, group.name), { withFileTypes: true })) {
  71. if (pkg.isDirectory()) names.add(pkg.name)
  72. }
  73. }
  74. return names
  75. }
  76. const packageNames = realPackageNames()
  77. /**
  78. * Match a `packages/<path>` reference token. The character class is plain path
  79. * characters only, so a glob (`*`), placeholder (`<`, `>`), or brace expansion
  80. * (`{`, `}`, `,`) terminates the match before those chars and is never probed —
  81. * those are patterns, not real paths. A trailing `.`/`/` (e.g. a sentence-ending
  82. * period) is trimmed before the existence check.
  83. */
  84. const PKG_REF = /\bpackages\/[A-Za-z0-9._/-]+/g
  85. function isDriftedPackageReference(ref: string): boolean {
  86. if (existsSync(resolve(root, ref))) return false
  87. // A reference INTO a package's built `lib/` is a build OUTPUT, not an
  88. // authored-source location: it does not exist until `pnpm run build` emits
  89. // it, and CI runs this gate BEFORE the build step. Skip it — but ONLY when
  90. // the `packages/<group>/<pkg>` ROOT it sits under is real and on disk, so
  91. // `packages/ui/acp-agent/lib/bin.js` (correct, just not yet built) is
  92. // exempt while a stale `packages/acp-agent/lib/bin.js` (group-less, the
  93. // exact moved-package drift this gate exists to catch) still flags. A bare
  94. // `lib` segment is not a blanket escape hatch.
  95. const parts = ref.split('/')
  96. const libAt = parts.indexOf('lib')
  97. if (libAt === 3 && existsSync(resolve(root, parts.slice(0, 3).join('/')))) return false
  98. // Only a stale path to a REAL (moved) package is a violation; a segment
  99. // matching a live package name is the drift signal.
  100. return ref.split('/').slice(1).some(segment => packageNames.has(segment))
  101. }
  102. /**
  103. * Find every DRIFTED `packages/…` reference in one file: a token that does not
  104. * resolve on disk AND names a real package in one of its segments (so it is a
  105. * moved path, not a typo or a not-yet-existing package). The same real-package
  106. * test also screens out a bare `packages` (no segment) and illustrative
  107. * skeletons whose segment is not a package.
  108. */
  109. function findViolations(absPath: string): Violation[] {
  110. return findReferenceViolations(
  111. root,
  112. absPath,
  113. PKG_REF,
  114. // Trim a trailing path separator or sentence punctuation that the greedy
  115. // class may have swallowed (`packages/core/tools.` / `…/tools/`).
  116. ref => ref.replace(/[./]+$/, ''),
  117. isDriftedPackageReference,
  118. )
  119. }
  120. const files = uniqueRepoFiles(root, PATTERNS, isExcluded)
  121. const all = files.flatMap(file => findViolations(file.real))
  122. const checked = files.length
  123. if (all.length === 0) {
  124. console.log(`verify-package-paths: ${checked} file(s) checked, all packages/* references resolve.`)
  125. process.exit(0)
  126. }
  127. console.error('verify-package-paths: broken packages/* references found (target does not exist):')
  128. for (const v of all) {
  129. console.error(` ${v.file}:${v.line} ${v.ref}`)
  130. }
  131. process.exit(1)