2026-07-10 01:51:50 +08:00
/**
* Doc-sync gate: every package README carries the standard
* `## Known Limitations and Deferred Work` section — the per-package home for
* consumer-visible gaps and consciously postponed work that the
* [documentation standard](../docs/AGENTS.md) assigns to the package-README
* tier. One canonical heading instead of per-package variants ("Limitations",
* "What is NOT here", …) keeps the section greppable across the repo and makes
* its absence a gate failure rather than an oversight.
*
* A package with genuinely nothing to declare is listed in NO_LIMITATIONS
* below and must NOT carry the section — an empty section invites boilerplate,
* and a whitelisted package that gains real limitations leaves the whitelist
* in the same change. Whitelist entries are validated against the scanned
* package set, so a rename or removal fails loud instead of silently
* un-gating a README.
*
2026-07-12 02:08:35 +08:00
* The package set comes from `packages/<group>/<package>/package.json`, so a manifest with no
* sibling README fails instead of escaping a README-only glob. Checks, per
* package README (fenced code excluded):
2026-07-10 01:51:50 +08:00
* 1. Non-whitelisted: exactly one limitations-like heading, byte-equal to the
* canonical h2, with at least one top-level `- ` bullet before the next
* heading.
* 2. Whitelisted: no limitations-like heading at all.
* 3. Every whitelist entry names a scanned package.
*
* "Limitations-like" also matches near-miss headings at any level ("known
* limitations", "deferred work", "what is not here", a heading starting with
* "limitations"/"deferred") so a drifted heading cannot impersonate the
* canonical section and a second competing section cannot coexist with it.
*
* Checker, not fixer: it reports and never rewrites.
2026-07-14 00:22:52 +08:00
* Run: `tsx scripts/verify-package-readme-limitations.ts`.
2026-07-10 01:51:50 +08:00
*/
2026-07-12 02:08:35 +08:00
import { existsSync , globSync , readFileSync } from 'node:fs'
2026-07-10 01:51:50 +08:00
import { resolve } from 'node:path'
const root = resolve ( import . meta . dirname , '..' )
/** The one canonical section heading, required verbatim as an h2. */
const CANONICAL = '## Known Limitations and Deferred Work'
/**
* Packages with genuinely no known limitations or deferred work (keyed by
* package directory relative to the repo root). Their READMEs must NOT carry
* the section; adding one moves the package off this list in the same change.
*/
2026-07-12 02:08:35 +08:00
const NO_LIMITATIONS : Readonly < Record < string , string > > = {
'packages/util/brand' : 'Type-only nominal-branding primitive with no runtime behavior or deferred work.' ,
}
2026-07-10 01:51:50 +08:00
/** A heading that reads as a limitations section — canonical or drifted. */
function isLimitationsLike ( headingText : string ) : boolean {
return (
2026-07-12 02:08:35 +08:00
/\blimitations?\b/i . test ( headingText )
2026-07-10 01:51:50 +08:00
|| /deferred work/i . test ( headingText )
|| /what is not here/i . test ( headingText )
|| /^deferred\b/i . test ( headingText )
2026-07-12 02:08:35 +08:00
|| /^non-goals?\b/i . test ( headingText )
2026-07-10 01:51:50 +08:00
)
}
interface Line {
index : number
raw : string
}
2026-07-12 02:08:35 +08:00
const ATX_HEADING = /^ {0,3}#{1,6}[ \t]+/
2026-07-10 01:51:50 +08:00
/** Split a README into prose lines (fenced code dropped), keeping 1-based line numbers. */
function proseLines ( text : string ) : Line [ ] {
2026-07-12 02:08:35 +08:00
let fence : { marker : '`' | '~' ; length : number } | undefined
2026-07-10 01:51:50 +08:00
const kept : Line [ ] = [ ]
text . split ( '\n' ) . forEach ( ( raw , i ) = > {
2026-07-12 02:08:35 +08:00
const token = /^ {0,3}(`{3,}|~{3,})/ . exec ( raw ) ? . [ 1 ]
if ( token !== undefined ) {
const marker = token [ 0 ] as '`' | '~'
if ( fence === undefined ) {
fence = { marker , length : token.length }
} else if ( marker === fence . marker && token . length >= fence . length ) {
fence = undefined
}
2026-07-10 01:51:50 +08:00
return
}
2026-07-12 02:08:35 +08:00
if ( fence === undefined ) kept . push ( { index : i + 1 , raw } )
2026-07-10 01:51:50 +08:00
} )
return kept
}
2026-07-12 02:08:35 +08:00
const packageJsons = globSync ( 'packages/*/*/package.json' , { cwd : root } ) . sort ( )
const scannedPackages = new Set ( packageJsons . map ( path = > path . slice ( 0 , - '/package.json' . length ) ) )
2026-07-10 01:51:50 +08:00
const failures : string [ ] = [ ]
2026-07-12 02:08:35 +08:00
for ( const [ entry , reason ] of Object . entries ( NO_LIMITATIONS ) ) {
2026-07-10 01:51:50 +08:00
if ( ! scannedPackages . has ( entry ) ) {
2026-07-14 00:22:52 +08:00
failures . push ( ` whitelist entry ${ entry } does not name a scanned package — renamed or removed? update NO_LIMITATIONS in scripts/verify-package-readme-limitations.ts in the same change ` )
2026-07-10 01:51:50 +08:00
}
2026-07-12 02:08:35 +08:00
if ( reason . trim ( ) . length === 0 ) {
failures . push ( ` whitelist entry ${ entry } has no justification — state why a limitations section would be empty boilerplate ` )
}
2026-07-10 01:51:50 +08:00
}
2026-07-12 02:08:35 +08:00
for ( const pkg of scannedPackages ) {
const readme = ` ${ pkg } /README.md `
if ( ! existsSync ( resolve ( root , readme ) ) ) {
failures . push ( ` ${ readme } : package manifest has no sibling README with the \` ${ CANONICAL } \` section ` )
continue
}
2026-07-10 01:51:50 +08:00
const lines = proseLines ( readFileSync ( resolve ( root , readme ) , 'utf8' ) )
2026-07-12 02:08:35 +08:00
const headings = lines . filter ( line = > ATX_HEADING . test ( line . raw ) )
const limitations = headings . filter ( line = > isLimitationsLike ( line . raw . replace ( ATX_HEADING , '' ) ) )
2026-07-10 01:51:50 +08:00
2026-07-12 02:08:35 +08:00
if ( Object . hasOwn ( NO_LIMITATIONS , pkg ) ) {
2026-07-10 01:51:50 +08:00
for ( const heading of limitations ) {
failures . push ( ` ${ readme } : ${ heading . index } : whitelisted as having no known limitations, but carries ${ JSON . stringify ( heading . raw ) } — drop the section or remove the package from NO_LIMITATIONS ` )
}
continue
}
const heading = limitations . at ( 0 )
if ( heading === undefined ) {
2026-07-14 00:22:52 +08:00
failures . push ( ` ${ readme } : missing the \` ${ CANONICAL } \` section (a package with genuinely nothing to declare joins NO_LIMITATIONS in scripts/verify-package-readme-limitations.ts instead) ` )
2026-07-10 01:51:50 +08:00
continue
}
if ( limitations . length > 1 ) {
failures . push ( ` ${ readme } : ${ limitations . length } limitations-like headings (lines ${ limitations . map ( line = > line . index ) . join ( ', ' ) } ) — keep exactly one \` ${ CANONICAL } \` section ` )
continue
}
if ( heading . raw . trimEnd ( ) !== CANONICAL ) {
failures . push ( ` ${ readme } : ${ heading . index } : non-canonical heading ${ JSON . stringify ( heading . raw ) } — use \` ${ CANONICAL } \` ` )
continue
}
const headingAt = lines . indexOf ( heading )
const body = lines . slice ( headingAt + 1 )
2026-07-12 02:08:35 +08:00
const end = body . findIndex ( line = > ATX_HEADING . test ( line . raw ) )
2026-07-10 01:51:50 +08:00
const section = end === - 1 ? body : body.slice ( 0 , end )
if ( ! section . some ( line = > /^- / . test ( line . raw ) ) ) {
failures . push ( ` ${ readme } : ${ heading . index } : the \` ${ CANONICAL } \` section has no top-level \` - \` bullet — state the limitations, or whitelist the package if there are genuinely none ` )
}
}
if ( failures . length > 0 ) {
2026-07-14 00:22:52 +08:00
console . error ( 'verify-package-readme-limitations: violations found:' )
2026-07-10 01:51:50 +08:00
for ( const failure of failures ) console . error ( ` ${ failure } ` )
process . exit ( 1 )
}
2026-07-14 00:22:52 +08:00
console . log ( ` verify-package-readme-limitations: ${ scannedPackages . size } package READMEs checked ( ${ Object . keys ( NO_LIMITATIONS ) . length } whitelisted), all conform. ` )