verify-doc-budgets.ts 3.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
  1. /**
  2. * Doc-sync gate: enforce word-count ceilings on the standing docs that accrete
  3. * (docs/AGENTS.md § "Budgets and the ceiling gate"). Instruction files and the
  4. * architecture overview grow a paragraph per PR unless something pushes back;
  5. * this gate is the pushback — when a ceiling is hit, the fix is to relocate or
  6. * condense per the documentation standard, not to raise the ceiling. Raising a
  7. * ceiling is allowed but is a deliberate, reviewable manifest diff that the PR
  8. * description must justify.
  9. *
  10. * Scope is deliberately NARROW: only the files listed in
  11. * scripts/doc-budgets.manifest.json (path → max words). Reference docs, RFCs,
  12. * and package READMEs are unbudgeted — length is legitimate there (a feature
  13. * matrix is the right kind of long), and the standard governs them through
  14. * review, not a ceiling.
  15. *
  16. * The manifest is an enforcement frontier, i18n-rollout style: a ceiling sits
  17. * at least 5% above the doc's current size (working headroom, so routine
  18. * wording edits pass while real growth trips the gate) and ratchets DOWN,
  19. * keeping that margin, as the doc is brought to its target budget. A manifest entry whose file is missing
  20. * fails the gate, so a rename cannot silently orphan its budget.
  21. *
  22. * Words are counted `wc -w` style over the whole file (whitespace-delimited
  23. * tokens, fenced code included) so a ceiling is reproducible with standard
  24. * tools. This is a checker, not a formatter: it reports and never rewrites.
  25. *
  26. * Run: `tsx scripts/verify-doc-budgets.ts` (or `--list` to print every
  27. * budgeted doc's current count vs ceiling without failing).
  28. */
  29. import { existsSync, readFileSync } from 'node:fs'
  30. import { resolve } from 'node:path'
  31. const root = resolve(import.meta.dirname, '..')
  32. const MANIFEST_PATH = resolve(root, 'scripts/doc-budgets.manifest.json')
  33. /** `wc -w` equivalent: count whitespace-delimited tokens. */
  34. function countWords(text: string): number {
  35. return text.split(/\s+/).filter(Boolean).length
  36. }
  37. const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as Record<string, number>
  38. const listOnly = process.argv.includes('--list')
  39. const failures: string[] = []
  40. const rows: string[] = []
  41. for (const [path, ceiling] of Object.entries(manifest)) {
  42. if (!Number.isInteger(ceiling) || ceiling <= 0) {
  43. rows.push(`BAD ${'—'.padStart(6)} / ${String(ceiling).padEnd(6)} ${path}`)
  44. failures.push(`${path}: ceiling must be a positive integer, got ${ceiling}`)
  45. continue
  46. }
  47. const abs = resolve(root, path)
  48. if (!existsSync(abs)) {
  49. rows.push(`MISS ${'—'.padStart(6)} / ${String(ceiling).padEnd(6)} ${path}`)
  50. failures.push(`${path}: budgeted file does not exist (renamed or deleted? update scripts/doc-budgets.manifest.json in the same change)`)
  51. continue
  52. }
  53. const words = countWords(readFileSync(abs, 'utf8'))
  54. rows.push(`${words <= ceiling ? 'ok ' : 'OVER'} ${String(words).padStart(6)} / ${String(ceiling).padEnd(6)} ${path}`)
  55. if (words > ceiling) {
  56. failures.push(`${path}: ${words} words exceeds the ${ceiling}-word ceiling — relocate or condense per docs/AGENTS.md (raising the ceiling requires justification in the PR)`)
  57. }
  58. }
  59. if (listOnly) {
  60. console.log(rows.join('\n'))
  61. process.exit(0)
  62. }
  63. if (failures.length > 0) {
  64. console.error('verify-doc-budgets failed:\n')
  65. for (const failure of failures) console.error(` ${failure}`)
  66. console.error('\nSee docs/AGENTS.md for the documentation standard and the relocation-first rule.')
  67. process.exit(1)
  68. }
  69. console.log(`verify-doc-budgets: ${Object.keys(manifest).length} budgeted docs within ceiling.`)