2026-06-20 16:24:37 +08:00
/**
2026-07-12 03:36:43 +08:00
* Verify every `ts type-equiv` block against the source symbol named by the
* manifest. Blocks and entries have a one-to-one relationship; comparison
* ignores comments and whitespace but preserves declaration structure.
2026-06-20 16:24:37 +08:00
*/
2026-07-06 12:16:31 +08:00
import { globSync , readFileSync , existsSync } from 'node:fs'
2026-07-06 02:28:44 +08:00
import { resolve , sep } from 'node:path'
2026-06-20 16:24:37 +08:00
import ts from 'typescript'
const root = resolve ( import . meta . dirname , '..' )
2026-07-13 23:27:00 +08:00
/** Scan doc-typecheck's full Markdown scope so unmanifested blocks also fail. */
2026-07-16 18:12:22 +08:00
const MARKDOWN_GLOBS = [ 'README.md' , 'docs/**/*.md' , 'packages/*/*.md' , 'packages/*/*/*.md' , 'website/zh-CN/**/*.md' ]
2026-06-20 17:29:42 +08:00
2026-06-20 16:24:37 +08:00
/** One manifest entry: a documented type-equiv block and its source symbol. */
interface ManifestEntry {
/** Doc file (repo-relative) containing the ` ```ts type-equiv ` block. */
doc : string
/** The declared symbol the block must match (e.g. `SessionEvent`). */
symbol : string
/** Source file (repo-relative) that exports the symbol. */
source : string
}
/** One extracted ` ```ts type-equiv ` block. */
interface EquivBlock {
doc : string
/** 1-based line of the opening fence (for diagnostics). */
line : number
/** Symbol name parsed from the block's declaration. */
symbol : string
/** Block body (the pasted declaration). */
code : string
}
2026-07-13 23:27:00 +08:00
/**
* Remove comments and normalize whitespace so prose-only edits do not drift
* structural copies. This is intentionally not a general tokenizer: repo type
* declarations do not contain comment delimiters inside string literals.
*/
2026-06-20 16:24:37 +08:00
function normalize ( code : string ) : string {
return code
. replace ( /\/\*[\s\S]*?\*\//g , '' )
. replace ( /(^|[^:])\/\/.*$/gm , '$1' )
. replace ( /\s+/g , ' ' )
. trim ( )
}
2026-07-12 03:36:43 +08:00
/** Strip source-only export modifiers. */
2026-06-20 16:24:37 +08:00
function stripExport ( code : string ) : string {
return code . replace ( /^export\s+(default\s+)?/ , '' )
}
/** Parse the declared symbol name from a type-equiv block body. */
function blockSymbol ( code : string ) : string | null {
2026-06-20 17:29:42 +08:00
const m = /(?:export\s+(?:default\s+)?)?(?:abstract\s+)?(?:interface|type|class|enum)\s+([A-Za-z0-9_]+)/ . exec ( code )
2026-06-20 16:24:37 +08:00
return m ? . [ 1 ] ? ? null
}
/** Extract every ` ```ts type-equiv ` block from one Markdown file. */
function extractEquivBlocks ( docRel : string ) : EquivBlock [ ] {
const text = readFileSync ( resolve ( root , docRel ) , 'utf8' )
const lines = text . split ( '\n' )
const blocks : EquivBlock [ ] = [ ]
let open : { line : number ; body : string [ ] } | null = null
for ( let i = 0 ; i < lines . length ; i ++ ) {
const raw = lines [ i ] ? ? ''
const fence = /^```(\s*)(\S.*)?$/ . exec ( raw )
if ( ! fence ) {
if ( open ) open . body . push ( raw )
continue
}
if ( open ) {
const code = open . body . join ( '\n' )
const symbol = blockSymbol ( code )
if ( ! symbol ) {
throw new Error ( ` verify-type-equiv: ${ docRel } : ${ open . line } — type-equiv block has no parseable interface/type/class declaration ` )
}
blocks . push ( { doc : docRel , line : open.line , symbol , code } )
open = null
continue
}
if ( ( fence [ 2 ] ? ? '' ) . trim ( ) === 'ts type-equiv' ) open = { line : i + 1 , body : [ ] }
}
if ( open ) throw new Error ( ` verify-type-equiv: ${ docRel } : ${ open . line } — unterminated type-equiv block ` )
return blocks
}
/** The declaration text of `symbol` in `sourceRel`, with `export` stripped, or
* null when the symbol is not declared there. Uses the TS parser so it spans
* interfaces, type aliases (including mapped/generic ones), classes, and enums
* uniformly, and excludes the leading JSDoc (getStart skips leading trivia)
* while keeping inline member comments. */
function sourceDeclaration ( sourceRel : string , symbol : string ) : string | null {
const abs = resolve ( root , sourceRel )
const text = readFileSync ( abs , 'utf8' )
const sf = ts . createSourceFile ( abs , text , ts . ScriptTarget . Latest , /* setParentNodes */ true )
for ( const stmt of sf . statements ) {
const named =
ts . isInterfaceDeclaration ( stmt ) || ts . isTypeAliasDeclaration ( stmt )
|| ts . isClassDeclaration ( stmt ) || ts . isEnumDeclaration ( stmt )
if ( named && stmt . name ? . text === symbol ) {
return stripExport ( stmt . getText ( sf ) )
}
}
return null
}
const manifestRaw = readFileSync ( resolve ( root , 'scripts/type-equiv.manifest.json' ) , 'utf8' )
const manifest = JSON . parse ( manifestRaw ) as { entries : ManifestEntry [ ] }
const entries = manifest . entries
// Key a block/entry by doc + symbol (a symbol may be documented in more than one
// doc, but at most once per doc).
const keyOf = ( x : { doc : string ; symbol : string } ) : string = > ` ${ x . doc } :: ${ x . symbol } `
2026-06-20 17:29:42 +08:00
// Collect every type-equiv block across ALL docs in scope — not only the docs
// the manifest names — so a block in an unmanifested doc is found and reported
// as an orphan rather than silently skipped.
const docSet = new Set < string > ( )
for ( const pattern of MARKDOWN_GLOBS ) {
2026-07-06 02:28:44 +08:00
for ( const match of globSync ( pattern , { cwd : root } ) ) docSet . add ( match . split ( sep ) . join ( '/' ) )
2026-06-20 17:29:42 +08:00
}
const blocks : EquivBlock [ ] = [ . . . docSet ] . sort ( ) . flatMap ( extractEquivBlocks )
2026-06-20 16:24:37 +08:00
const errors : string [ ] = [ ]
2026-06-20 17:29:42 +08:00
// A manifest entry naming a doc that does not exist (or is outside the scanned
// scope, so no block could ever match it) is an error in its own right.
for ( const d of [ . . . new Set ( entries . map ( e = > e . doc ) ) ] ) {
if ( ! existsSync ( resolve ( root , d ) ) ) errors . push ( ` manifest references ${ d } , which does not exist ` )
else if ( ! docSet . has ( d ) ) errors . push ( ` manifest references ${ d } , which is outside the scanned markdown scope ( ${ MARKDOWN_GLOBS . join ( ', ' ) } ) ` )
}
2026-06-20 16:24:37 +08:00
// Duplicate-block guard: the same symbol twice in one doc is ambiguous.
const blockByKey = new Map < string , EquivBlock > ( )
for ( const b of blocks ) {
const k = keyOf ( b )
const prior = blockByKey . get ( k )
if ( prior ) {
errors . push ( ` duplicate type-equiv block for ${ b . symbol } in ${ b . doc } (lines ${ prior . line } and ${ b . line } ) ` )
continue
}
blockByKey . set ( k , b )
}
// Duplicate-entry guard in the manifest.
const entryByKey = new Map < string , ManifestEntry > ( )
for ( const e of entries ) {
const k = keyOf ( e )
if ( entryByKey . has ( k ) ) {
errors . push ( ` duplicate manifest entry for ${ e . symbol } in ${ e . doc } ` )
continue
}
entryByKey . set ( k , e )
}
// 1:1 correspondence: orphan blocks (no entry) and orphan entries (no block).
for ( const b of blocks ) {
if ( ! entryByKey . has ( keyOf ( b ) ) ) {
errors . push ( ` type-equiv block ${ b . symbol } ( ${ b . doc } : ${ b . line } ) has no manifest entry — add one to scripts/type-equiv.manifest.json ` )
}
}
for ( const e of entries ) {
if ( ! blockByKey . has ( keyOf ( e ) ) ) {
errors . push ( ` manifest entry ${ e . symbol } ( ${ e . doc } ) has no matching type-equiv block — remove it or add the block ` )
}
}
// Verbatim check: each matched block must equal its source declaration.
let verified = 0
for ( const e of entries ) {
const b = blockByKey . get ( keyOf ( e ) )
if ( ! b ) continue // already reported as an orphan entry
const decl = sourceDeclaration ( e . source , e . symbol )
if ( decl === null ) {
errors . push ( ` symbol ${ e . symbol } not found in ${ e . source } (manifest entry for ${ e . doc } ) ` )
continue
}
if ( normalize ( decl ) !== normalize ( stripExport ( b . code ) ) ) {
errors . push (
` DRIFT: ${ e . doc } : ${ b . line } — type-equiv block for ${ e . symbol } does not match ${ e . source } . \ n `
+ ` source: ${ normalize ( decl ) } \ n `
+ ` doc: ${ normalize ( stripExport ( b . code ) ) } ` ,
)
continue
}
verified ++
}
if ( errors . length === 0 ) {
console . log ( ` verify-type-equiv: ${ verified } type-equiv block(s) match source (1:1 with manifest). ` )
process . exit ( 0 )
}
console . error ( 'verify-type-equiv: type-equiv verification failed:' )
for ( const e of errors ) console . error ( ` ${ e } ` )
2026-06-20 17:29:42 +08:00
console . error ( ` \ n(checked ${ blocks . length } block(s) across ${ new Set ( blocks . map ( b = > b . doc ) ) . size } doc(s); manifest at scripts/type-equiv.manifest.json) ` )
2026-06-20 16:24:37 +08:00
process . exit ( 1 )