2026-07-06 22:09:30 +08:00
/**
* Verify JSDoc completeness for EVERY module-level exported name of every
* non-vendored package (each `packages/<group>/<pkg>/src/` tree). This is the
* mechanical form of the AGENTS.md rule "every export has a JSDoc explaining
* semantics", generalizing the cordis-surface gate (`gen-cordis-catalog.ts`,
* which owns `interface Events` members and `ctx.<key>` service classes) to
* the whole export surface; the parsing + check helpers are shared via
* `scripts/jsdoc.ts` so "documented" means the same thing on both.
*
* `tsx scripts/verify-export-jsdoc.ts` → exit 1 listing every offender
*
* The contract, per exported declaration kind:
*
* - Every exported name needs JSDoc with non-empty description prose (prose
* ends at the first block tag, standard JSDoc semantics).
2026-07-06 23:25:33 +08:00
* - A function-like export (function declaration, a const with a function
2026-07-06 23:55:16 +08:00
* initializer or an INLINE callable annotation, or a non-identifier
2026-07-06 23:25:33 +08:00
* function default export) additionally needs a non-empty `@param` per
* parameter (`this` receiver annotations exempt; a stale `@param` errors)
* and a non-empty `@returns` unless the return type is `void` /
2026-07-06 23:55:16 +08:00
* `Promise<void>`. Wrapper expressions (parentheses, `as` / `satisfies`
* casts, non-null assertions) are peeled before classifying. The walk
* classifies returns syntactically, so the return type must be ANNOTATED —
* except a const whose declarator is annotated with a NAMED type (e.g.
* `export const f: Handler = …`), where that type's own declaration owns
* the signature contract and `@returns` stays optional; an inline
* `(x: T) => U` annotation or single-call-signature literal is the surface
* signature itself and gets the full contract, and a literal mixing
* call/construct signatures with anything else is refused (extract a named
* type).
2026-07-06 22:09:30 +08:00
* - An exported class needs class-level JSDoc; its public methods (static
* included — they are reachable on the exported name) follow the function
* contract, and public properties and accessors need description prose (on
2026-07-06 23:25:33 +08:00
* a get/set pair the getter's doc covers both). A member declared by an
* `extends`/`implements` heritage type is EXEMPT — the seam declaration is
* the doc's one home, the IDE inherits it, and re-documenting every
* implementation invites drift — UNLESS the override grows surface the
* base never documented: a protected-only base member does not exempt a
2026-07-07 20:30:34 +08:00
* public override, parameters the base never names keep their `@param`
* duty, and a concrete result above a void base return keeps its
* `@returns` duty. Heritage members (and classifying an unannotated
* override's inferred return above a void base) are the questions the walk
* asks the TYPE CHECKER; everything else is pure AST.
2026-07-06 23:25:33 +08:00
* Constructors are exempt like the cordis gate's: plugin classes are
* framework-constructed, and the class doc owns the story.
2026-07-06 22:09:30 +08:00
* - Exported interfaces, type aliases, enums: description prose on the
* declaration (member-level docs stay review's job; the highest-value
* member surface — seam service classes — is already under the cordis
* gate).
* - An exported namespace recurses (its exported members are package
2026-07-06 23:25:33 +08:00
* surface; in an ambient `declare` namespace every member exports
* implicitly); the namespace itself needs prose only when it does not
* merge with an already-documented same-name declaration (the
* Config-namespace idiom documents the class/function once, not twice).
2026-07-06 22:09:30 +08:00
* - The cordis plugin-protocol slots are exempt: top-level `name` / `inject`
* / `reusable` / `Config` consts and the `apply` entry, plus the same
* slots as statics on a plugin class. Their shape is fixed by the
* framework, so a doc would restate the protocol — the module doc comment
* and the `interface Config` carry the plugin's real semantics. (These
* names are reserved by cordis convention; documenting one anyway is
* allowed, only absence goes unchecked.)
* - Overload groups: each overload signature carries its own docs; the
* implementation signature is exempt (callers never see it).
* - Skipped: `declare module` / `declare global` augmentation bodies (the
2026-07-06 23:55:16 +08:00
* cordis gate's turf; an augmentation is not an export of the package) and
* re-export statements with a module specifier (`export … from`) — the
* defining module is walked on its own, and external definitions are not
* ours to document. An `export import X = N.member` alias documents
2026-07-07 00:20:55 +08:00
* ITSELF, and only prose-only target kinds are gate-supported: a callable,
* class, or namespace target carries signature/member contracts the alias
* cannot hold and is refused (export the declaration directly).
2026-07-06 23:25:33 +08:00
* - Everything else fails CLOSED: `export =` is refused outright, and an
* exported statement kind the dispatch does not recognize is itself a
* violation, so no export form can pass unchecked by omission.
2026-07-06 22:09:30 +08:00
*/
import { existsSync , globSync } from 'node:fs'
import { resolve } from 'node:path'
import ts from 'typescript'
import { checkParams , checkReturns , parseJsDoc , parseTags , pointer , rawJsDoc } from './jsdoc.ts'
const root = resolve ( import . meta . dirname , '..' )
/** Plugin-protocol slot names exempt as statics on an exported class. */
const PROTOCOL_STATICS = new Set ( [ 'Config' , 'inject' , 'name' , 'reusable' ] )
/** Plugin-protocol slot names exempt as top-level exports (const or function). */
const PROTOCOL_EXPORTS = new Set ( [ 'Config' , 'inject' , 'name' , 'reusable' , 'apply' ] )
/** Per-file walk state threaded through the scope recursion. */
interface Walk {
/** Repo-relative path of the file being walked. */
rel : string
/** The parsed source file. */
sf : ts.SourceFile
/** Raw file text (rawJsDoc reads comment ranges out of it). */
text : string
/** The program's checker, consulted only for heritage-member lookups. */
checker : ts.TypeChecker
/** The aggregate violation list, appended in place. */
violations : string [ ]
}
/** True when a statement carries the `export` modifier. */
function isExported ( stmt : ts.Statement ) : boolean {
return ts . canHaveModifiers ( stmt ) && ( ts . getModifiers ( stmt ) ? . some ( m = > m . kind === ts . SyntaxKind . ExportKeyword ) ? ? false )
}
/** True for a class member a consumer cannot reach: `private`/`protected`/`#name`. */
function isNonPublic ( member : ts.ClassElement ) : boolean {
const mods = ts . canHaveModifiers ( member ) ? ts . getModifiers ( member ) : undefined
return ( mods ? . some ( m = > m . kind === ts . SyntaxKind . PrivateKeyword || m . kind === ts . SyntaxKind . ProtectedKeyword ) ? ? false )
|| ( 'name' in member && ts . isPrivateIdentifier ( member . name ) )
}
/** True when a class member carries the `static` modifier. */
function isStatic ( member : ts.ClassElement ) : boolean {
const mods = ts . canHaveModifiers ( member ) ? ts . getModifiers ( member ) : undefined
return mods ? . some ( m = > m . kind === ts . SyntaxKind . StaticKeyword ) ? ? false
}
/** The `this`-receiver exemption every function-like check shares. */
function thisReceiver ( p : ts.ParameterDeclaration ) : boolean {
return ts . isIdentifier ( p . name ) && p . name . text === 'this'
}
2026-07-06 23:55:16 +08:00
/**
* Peel wrapper expressions that carry no surface of their own — parentheses,
* `as` / `satisfies` / angle-bracket casts, non-null assertions — so a
* wrapped function expression is still classified as function-like.
* @param e - the expression to unwrap.
* @returns the innermost non-wrapper expression.
*/
function unwrapExpression ( e : ts.Expression ) : ts . Expression {
let inner = e
while (
ts . isParenthesizedExpression ( inner ) || ts . isAsExpression ( inner ) || ts . isSatisfiesExpression ( inner )
|| ts . isNonNullExpression ( inner ) || ts . isTypeAssertionExpression ( inner )
) inner = inner . expression
return inner
}
/**
* Classify a declarator's type annotation for the function contract: an
* inline function type or a type literal that is EXACTLY one call signature
* is the surface signature itself; a literal mixing call/construct
* signatures with anything else cannot be classified syntactically and is
* refused (fail closed — extract a named type); everything else is a plain
* value shape.
* @param type - the declarator's type annotation.
* @returns the signature to check, 'refuse' for an unclassifiable callable literal, or null for a non-callable shape.
*/
function callableAnnotation ( type : ts . TypeNode ) : ts . SignatureDeclarationBase | 'refuse' | null {
if ( ts . isFunctionTypeNode ( type ) ) return type
if ( ! ts . isTypeLiteralNode ( type ) ) return null
const signatures = type . members . filter ( m = > ts . isCallSignatureDeclaration ( m ) || ts . isConstructSignatureDeclaration ( m ) )
if ( signatures . length === 0 ) return null
if ( signatures . length === 1 && type . members . length === 1 && signatures [ 0 ] !== undefined && ts . isCallSignatureDeclaration ( signatures [ 0 ] ) ) {
return signatures [ 0 ]
}
return 'refuse'
}
2026-07-06 22:09:30 +08:00
/**
2026-07-06 23:25:33 +08:00
* The heritage-member exemption for one class member. When the member's name
* is declared by an `extends`/`implements` heritage type, the seam declaration
* is the doc's one home (the IDE inherits it on hover) and the member needs no
* doc of its own — EXCEPT where the override grows public surface the base
* never documented: a base member that is protected on every declaration does
2026-07-07 20:30:34 +08:00
* not exempt a public override (consumers could not call it before);
2026-07-06 23:25:33 +08:00
* parameters the base never names keep their own `@param` duty (the caller
* reads the seam doc, which cannot describe them; an underscore-prefixed
* rename of a base parameter — the deliberately-unused marker — is the same
2026-07-07 20:30:34 +08:00
* parameter, not new surface); and a void base return carried no `@returns`
* duty, so an override returning a concrete result documents it itself.
* Static members are looked up on the base CONSTRUCTOR type (only an
* `extends` expression has one; an unresolvable or interface expression
* yields no property and therefore no exemption).
2026-07-06 22:09:30 +08:00
* @param cls - the class whose heritage to search.
* @param name - the member name to look up.
* @param staticSide - whether to search the constructor side instead of the instance side.
* @param checker - the program's type checker.
2026-07-06 23:25:33 +08:00
* @returns null when no exemption applies; otherwise the parameter names the
2026-07-07 20:30:34 +08:00
* base declarations carry (`baseParams: null` when not syntactically
* recoverable — a complex heritage type — exempting all parameters) plus
* whether every recoverable base return annotation is `void`-like
* (`baseVoidReturn: null` when none is recoverable, exempting the result).
2026-07-06 22:09:30 +08:00
*/
2026-07-06 23:25:33 +08:00
function heritageExemption (
cls : ts.ClassDeclaration ,
name : string ,
staticSide : boolean ,
checker : ts.TypeChecker ,
2026-07-07 20:30:34 +08:00
) : { baseParams : Set < string > | null ; baseVoidReturn : boolean | null } | null {
2026-07-06 23:25:33 +08:00
const isProtected = ( d : ts.Declaration ) : boolean = >
( ts . canHaveModifiers ( d ) ? ts . getModifiers ( d ) : undefined ) ? . some ( m = > m . kind === ts . SyntaxKind . ProtectedKeyword ) ? ? false
2026-07-06 22:09:30 +08:00
for ( const clause of cls . heritageClauses ? ? [ ] ) {
for ( const t of clause . types ) {
const type = staticSide ? checker . getTypeAtLocation ( t . expression ) : checker . getTypeAtLocation ( t )
2026-07-06 23:25:33 +08:00
const prop = type . getProperty ( name )
if ( prop === undefined ) continue
const decls = prop . declarations ? ? [ ]
if ( decls . length > 0 && decls . every ( isProtected ) ) continue // public override of a protected base: new surface
let baseParams : Set < string > | null = null
2026-07-07 20:30:34 +08:00
let baseVoidReturn : boolean | null = null
2026-07-06 23:25:33 +08:00
for ( const d of decls ) {
let params : readonly ts . ParameterDeclaration [ ] | undefined
2026-07-07 20:30:34 +08:00
let returnType : ts.TypeNode | undefined
if ( ts . isMethodDeclaration ( d ) || ts . isMethodSignature ( d ) ) {
params = d . parameters
returnType = d . type
} else if ( ( ts . isPropertySignature ( d ) || ts . isPropertyDeclaration ( d ) ) && d . type !== undefined && ts . isFunctionTypeNode ( d . type ) ) {
2026-07-06 23:25:33 +08:00
params = d . type . parameters
2026-07-07 20:30:34 +08:00
returnType = d . type . type
2026-07-06 23:25:33 +08:00
} else continue
baseParams ? ? = new Set ( )
// Leading underscores are the deliberately-unused marker (eslint
// argsIgnorePattern), not a rename: `_cwd` overriding `cwd` is the
// same parameter, so compare underscore-stripped on both sides.
for ( const p of params ) if ( ts . isIdentifier ( p . name ) ) baseParams . add ( p . name . text . replace ( /^_+/ , '' ) )
2026-07-07 20:30:34 +08:00
if ( returnType !== undefined ) {
const voidish = /^(void|Promise<void>)$/ . test ( returnType . getText ( d . getSourceFile ( ) ) . replace ( /\s+/g , ' ' ) )
baseVoidReturn = ( baseVoidReturn ? ? true ) && voidish
}
2026-07-06 23:25:33 +08:00
}
2026-07-07 20:30:34 +08:00
return { baseParams , baseVoidReturn }
2026-07-06 22:09:30 +08:00
}
}
2026-07-06 23:25:33 +08:00
return null
2026-07-06 22:09:30 +08:00
}
2026-07-07 20:30:34 +08:00
/**
* True when a method's INFERRED return type is void-like (void, undefined,
* never, or a promise of one) — the one return the walk asks the checker to
* classify: an unannotated override above a void heritage member, where
* demanding an annotation just to prove faithfulness would be boilerplate.
* @param m - a method declaration with no return type annotation.
* @param checker - the program's type checker.
* @returns true when the inferred result carries nothing to document.
*/
function inferredReturnIsVoidish ( m : ts.MethodDeclaration , checker : ts.TypeChecker ) : boolean {
const sig = checker . getSignatureFromDeclaration ( m )
if ( sig === undefined ) return true // no callable signature: nothing classifiable to document
const returned = checker . getReturnTypeOfSignature ( sig )
const awaited = checker . getAwaitedType ( returned ) ? ? returned
return ( awaited . flags & ( ts . TypeFlags . Void | ts . TypeFlags . Undefined | ts . TypeFlags . Never ) ) !== 0
}
2026-07-06 22:09:30 +08:00
/**
* Check description-prose presence for one labeled declaration: JSDoc must
* exist and carry prose above its block tags.
* @param where - the offender label violations open with.
* @param raw - the declaration's raw JSDoc block ('' if none).
* @param w - the walk state violations append to.
*/
function checkDescribed ( where : string , raw : string , w : Walk ) : void {
if ( ! raw ) w . violations . push ( ` ${ where } has no JSDoc. ` )
else if ( ! parseJsDoc ( raw ) . doc ) w . violations . push ( ` ${ where } has no description prose above its block tags. ` )
}
/**
* Check the full function contract for one labeled function-like declaration:
* description prose, `@param` per parameter, `@returns` on a non-void result.
* @param where - the offender label violations open with.
* @param raw - the declaration's raw JSDoc block ('' if none).
* @param parameters - the declaration's parameter list.
* @param returnType - the return type annotation, or undefined when inferred.
* @param returnsWaived - suppress the `@returns`/annotation requirement (a
* declarator-annotated const defers its return contract to the named type).
* @param w - the walk state violations append to.
*/
function checkFunctionLike (
where : string ,
raw : string ,
parameters : readonly ts . ParameterDeclaration [ ] ,
returnType : ts.TypeNode | undefined ,
returnsWaived : boolean ,
w : Walk ,
) : void {
if ( ! raw ) { w . violations . push ( ` ${ where } has no JSDoc. ` ) ; return }
if ( ! parseJsDoc ( raw ) . doc ) w . violations . push ( ` ${ where } has no description prose above its block tags. ` )
const { params , returns } = parseTags ( raw )
checkParams ( where , 'export' , parameters , params , w . sf , thisReceiver , w . violations )
if ( ! returnsWaived ) checkReturns ( where , returnType , returns , w . sf , w . violations )
}
/**
* Check one exported class: class-level prose, the function contract on every
* public method (overload implementations exempt), and description prose on
* public properties and accessors (a get/set pair is covered by the getter's
2026-07-06 23:25:33 +08:00
* doc). Heritage-declared members are exempt per heritageExemption (an
* override's extra parameters keep their @param duty); plugin-protocol
* statics are exempt; constructors are not checked (framework-constructed
* plugins, and the class doc owns the story).
2026-07-06 22:09:30 +08:00
* @param cls - the exported class declaration.
* @param name - the class's surface name (namespace-qualified).
* @param w - the walk state violations append to.
*/
function checkClass ( cls : ts.ClassDeclaration , name : string , w : Walk ) : void {
checkDescribed ( ` exported class ' ${ name } ' ( ${ pointer ( w . rel , w . sf , cls ) } ) ` , rawJsDoc ( w . text , cls ) , w )
const overloadSigs = new Set < string > ( )
const documentedGetters = new Set < string > ( )
for ( const m of cls . members ) {
if ( 'name' in m && ts . isComputedPropertyName ( m . name ) ) continue
if ( ts . isMethodDeclaration ( m ) && ! m . body ) overloadSigs . add ( m . name . getText ( w . sf ) )
if ( ts . isGetAccessorDeclaration ( m ) ) documentedGetters . add ( m . name . getText ( w . sf ) )
}
for ( const m of cls . members ) {
if ( isNonPublic ( m ) || ts . isConstructorDeclaration ( m ) ) continue
if ( ! ( 'name' in m ) || ts . isComputedPropertyName ( m . name ) ) continue // computed/symbol members
const mname = m . name . getText ( w . sf )
if ( isStatic ( m ) && PROTOCOL_STATICS . has ( mname ) ) continue // cordis plugin-protocol slot
2026-07-06 23:25:33 +08:00
const exemption = heritageExemption ( cls , mname , isStatic ( m ) , w . checker )
2026-07-06 22:09:30 +08:00
if ( ts . isMethodDeclaration ( m ) ) {
if ( m . body && overloadSigs . has ( mname ) ) continue // overload implementation: the signatures carry the docs
2026-07-06 23:25:33 +08:00
const where = ` exported class method ' ${ name } . ${ mname } ' ( ${ pointer ( w . rel , w . sf , m ) } ) `
if ( exemption !== null ) {
2026-07-07 20:30:34 +08:00
const raw = rawJsDoc ( w . text , m )
// The heritage declaration owns the prose; parameters the base never
// names — including binding patterns, which no base declaration can
// name — are new surface and keep their @param duty.
2026-07-06 23:25:33 +08:00
const base = exemption . baseParams
const inBase = ( p : ts.ParameterDeclaration ) : boolean = >
base !== null && ts . isIdentifier ( p . name ) && base . has ( p . name . text . replace ( /^_+/ , '' ) )
2026-07-06 23:55:16 +08:00
if ( base !== null && m . parameters . some ( p = > ! thisReceiver ( p ) && ! inBase ( p ) ) ) {
2026-07-07 20:30:34 +08:00
checkParams ( where , 'export' , m . parameters , parseTags ( raw ) . params , w . sf ,
2026-07-06 23:25:33 +08:00
p = > thisReceiver ( p ) || inBase ( p ) , w . violations )
}
2026-07-07 20:30:34 +08:00
// A void base return carried no @returns duty, so an override growing
// a concrete result documents it itself. An annotated override runs
// the standard check; an inferred one is classified by the checker
// (this branch is already the checker's domain), so a faithful void
// override stays exempt without a boilerplate annotation.
if ( exemption . baseVoidReturn === true ) {
if ( m . type !== undefined ) {
checkReturns ( where , m . type , parseTags ( raw ) . returns , w . sf , w . violations )
} else if ( ! inferredReturnIsVoidish ( m , w . checker ) ) {
w . violations . push ( ` ${ where } returns a non-void result its heritage declaration does not document; annotate the return type and add @returns. ` )
}
}
2026-07-06 23:25:33 +08:00
continue
}
checkFunctionLike ( where , rawJsDoc ( w . text , m ) , m . parameters , m . type , false , w )
} else if ( exemption !== null ) {
continue // the heritage declaration owns the doc (properties/accessors carry no own parameters)
2026-07-06 22:09:30 +08:00
} else if ( ts . isGetAccessorDeclaration ( m ) || ts . isPropertyDeclaration ( m ) ) {
const kind = ts . isPropertyDeclaration ( m ) ? 'property' : 'accessor'
checkDescribed ( ` exported class ${ kind } ' ${ name } . ${ mname } ' ( ${ pointer ( w . rel , w . sf , m ) } ) ` , rawJsDoc ( w . text , m ) , w )
} else if ( ts . isSetAccessorDeclaration ( m ) && ! documentedGetters . has ( mname ) ) {
checkDescribed ( ` exported class accessor ' ${ name } . ${ mname } ' ( ${ pointer ( w . rel , w . sf , m ) } ) ` , rawJsDoc ( w . text , m ) , w )
}
// index signatures / static blocks: not named surface
}
}
/**
2026-07-06 23:25:33 +08:00
* Check one exported declaration statement, dispatching on its kind. Any
* exported statement kind the dispatch does not recognize is a violation
* (fail closed), so no export form can pass unchecked by omission.
2026-07-06 22:09:30 +08:00
* @param stmt - the exported statement (export modifier or export-list target).
* @param prefix - the namespace qualification for surface names ('' at top level).
* @param overloadSigs - names in this scope declared as bodyless function overload signatures.
* @param byName - this scope's named declarations (for namespace/sibling-merge lookups).
2026-07-06 23:25:33 +08:00
* @param ambient - whether the enclosing scope is ambient (`declare`), where members export implicitly.
2026-07-06 22:09:30 +08:00
* @param w - the walk state violations append to.
2026-07-07 17:05:32 +08:00
* @param only - for a multi-declarator variable statement reached through an
* export list (or a default-export identifier), the declarator names that
* are actually exported; `null` means the whole statement is surface
* (direct `export` modifier or ambient scope). Non-variable statements
* declare exactly one name, so the filter never applies to them.
2026-07-06 22:09:30 +08:00
*/
function checkDecl (
stmt : ts.Statement ,
prefix : string ,
overloadSigs : Set < string > ,
byName : Map < string , ts.Statement [ ] > ,
2026-07-06 23:25:33 +08:00
ambient : boolean ,
2026-07-06 22:09:30 +08:00
w : Walk ,
2026-07-07 17:05:32 +08:00
only : ReadonlySet < string > | null = null ,
2026-07-06 22:09:30 +08:00
) : void {
const at = ( n : ts.Node ) : string = > ` ( ${ pointer ( w . rel , w . sf , n ) } ) `
if ( ts . isFunctionDeclaration ( stmt ) ) {
const name = stmt . name ? . text ? ? 'default'
if ( prefix === '' && PROTOCOL_EXPORTS . has ( name ) ) return // cordis plugin-protocol slot
if ( stmt . body && overloadSigs . has ( name ) ) return // overload implementation: the signatures carry the docs
checkFunctionLike ( ` exported function ' ${ prefix } ${ name } ' ${ at ( stmt ) } ` , rawJsDoc ( w . text , stmt ) ,
stmt . parameters , stmt . type , false , w )
return
}
if ( ts . isClassDeclaration ( stmt ) ) {
checkClass ( stmt , ` ${ prefix } ${ stmt . name ? . text ? ? 'default' } ` , w )
return
}
if ( ts . isInterfaceDeclaration ( stmt ) ) {
checkDescribed ( ` exported interface ' ${ prefix } ${ stmt . name . text } ' ${ at ( stmt ) } ` , rawJsDoc ( w . text , stmt ) , w )
return
}
if ( ts . isTypeAliasDeclaration ( stmt ) ) {
checkDescribed ( ` exported type ' ${ prefix } ${ stmt . name . text } ' ${ at ( stmt ) } ` , rawJsDoc ( w . text , stmt ) , w )
return
}
if ( ts . isEnumDeclaration ( stmt ) ) {
checkDescribed ( ` exported enum ' ${ prefix } ${ stmt . name . text } ' ${ at ( stmt ) } ` , rawJsDoc ( w . text , stmt ) , w )
return
}
if ( ts . isVariableStatement ( stmt ) ) {
const raw = rawJsDoc ( w . text , stmt ) // JSDoc sits on the statement, not the declarator
for ( const d of stmt . declarationList . declarations ) {
const name = ts . isIdentifier ( d . name ) ? d.name.text : d.name.getText ( w . sf )
2026-07-07 17:05:32 +08:00
if ( only !== null && ! only . has ( name ) ) continue // sibling declarator the export list never named: not surface
2026-07-06 22:09:30 +08:00
if ( prefix === '' && PROTOCOL_EXPORTS . has ( name ) ) continue // cordis plugin-protocol slot
const where = ` exported const ' ${ prefix } ${ name } ' ${ at ( d ) } `
2026-07-06 23:55:16 +08:00
const annotation = d . type !== undefined ? callableAnnotation ( d . type ) : null
const init = d . initializer !== undefined ? unwrapExpression ( d . initializer ) : undefined
if ( annotation === 'refuse' ) {
// A literal mixing call/construct signatures with other members (or
// overloading them) has no single signature the walk can hold the
// tags against — fail closed rather than silently narrow the check.
w . violations . push ( ` ${ where } : its callable type literal is not gate-classifiable; extract a named type and document it there. ` )
} else if ( annotation !== null ) {
// An INLINE callable annotation is the surface signature itself: its
// parameters and result need docs right here. (A NAMED reference
2026-07-06 23:25:33 +08:00
// type carries its docs at the type's own declaration instead.)
2026-07-06 23:55:16 +08:00
checkFunctionLike ( where , raw , annotation . parameters , annotation . type , false , w )
} else if ( init !== undefined && ( ts . isArrowFunction ( init ) || ts . isFunctionExpression ( init ) ) ) {
2026-07-06 23:25:33 +08:00
// A named declarator type annotation (`const f: Handler = …`) hands
// the return contract to the named type; the arrow's own annotation is
2026-07-06 22:09:30 +08:00
// still checked when it is the only signature the reader has.
checkFunctionLike ( where , raw , init . parameters , init . type , init . type === undefined && d . type !== undefined , w )
} else {
checkDescribed ( where , raw , w )
}
}
return
}
if ( ts . isModuleDeclaration ( stmt ) && ts . isIdentifier ( stmt . name ) ) {
// A namespace merging with a documented same-name sibling (the
// Config-namespace idiom) needs no second doc block of its own.
const siblings = ( byName . get ( stmt . name . text ) ? ? [ ] ) . filter ( s = > s !== stmt )
const merged = siblings . some ( s = > parseJsDoc ( rawJsDoc ( w . text , s ) ) . doc !== '' )
if ( ! merged ) checkDescribed ( ` exported namespace ' ${ prefix } ${ stmt . name . text } ' ${ at ( stmt ) } ` , rawJsDoc ( w . text , stmt ) , w )
let body = stmt . body
let nsPrefix = ` ${ prefix } ${ stmt . name . text } . `
while ( body !== undefined && ts . isModuleDeclaration ( body ) ) { // dotted `namespace A.B`
nsPrefix += ` ${ body . name . getText ( w . sf ) } . `
body = body . body
}
2026-07-06 23:25:33 +08:00
// In an ambient (`declare`) namespace body, members are implicitly
// exported — no `export` modifier required — so the recursion must treat
// every statement as surface.
const declared = ambient
|| ( ( ts . canHaveModifiers ( stmt ) ? ts . getModifiers ( stmt ) : undefined ) ? . some ( m = > m . kind === ts . SyntaxKind . DeclareKeyword ) ? ? false )
if ( body !== undefined && ts . isModuleBlock ( body ) ) checkScope ( body . statements , nsPrefix , w , declared )
return
}
if ( ts . isImportEqualsDeclaration ( stmt ) ) {
2026-07-07 00:20:55 +08:00
const where = ` exported alias ' ${ prefix } ${ stmt . name . text } ' ${ at ( stmt ) } `
// An alias is a distinct exported name whose target may be a non-exported
// namespace member no walk ever visits, so it documents ITSELF — which
// matches the gate's strength only for prose-only target kinds. A
// callable, class, or namespace target carries signature or member
// contracts the alias prose cannot hold: refuse those (fail closed) and
// demand the declaration be exported directly. An unresolvable target is
// refused for the same reason.
const sym = w . checker . getSymbolAtLocation ( stmt . name )
const target = sym !== undefined && ( sym . flags & ts . SymbolFlags . Alias ) !== 0 ? w . checker . getAliasedSymbol ( sym ) : sym
const RICH_TARGETS = ts . SymbolFlags . Function | ts . SymbolFlags . Class | ts . SymbolFlags . ValueModule | ts . SymbolFlags . NamespaceModule
const rich = target === undefined
|| ( target . flags & RICH_TARGETS ) !== 0
|| w . checker . getTypeOfSymbol ( target ) . getCallSignatures ( ) . length > 0
if ( rich ) {
w . violations . push ( ` ${ where } aliases a callable, class, or namespace target whose signature/member contract the alias cannot carry; export the declaration directly instead. ` )
return
}
checkDescribed ( where , rawJsDoc ( w . text , stmt ) , w )
2026-07-06 23:55:16 +08:00
return
2026-07-06 22:09:30 +08:00
}
2026-07-06 23:25:33 +08:00
// Fail CLOSED: an exported statement kind this dispatch does not recognize
// must never pass silently — the gate's whole promise is that unchecked
// surface cannot exist. New TypeScript export forms extend the gate here.
w . violations . push ( ` exported statement ${ at ( stmt ) } uses an export form verify-export-jsdoc does not handle; extend the gate. ` )
2026-07-06 22:09:30 +08:00
}
/**
* Walk one lexical scope (file top level or a namespace body): check every
* exported declaration, resolving `export { … }` lists (no module specifier)
* to their local declarations.
* @param statements - the scope's statements.
* @param prefix - the namespace qualification for surface names ('' at top level).
* @param w - the walk state violations append to.
2026-07-06 23:25:33 +08:00
* @param ambient - whether this scope is ambient (`declare` namespace or a declaration file), where members export implicitly.
2026-07-06 22:09:30 +08:00
*/
2026-07-06 23:25:33 +08:00
function checkScope ( statements : readonly ts . Statement [ ] , prefix : string , w : Walk , ambient : boolean ) : void {
2026-07-06 22:09:30 +08:00
const byName = new Map < string , ts.Statement [ ] > ( )
const overloadSigs = new Set < string > ( )
const add = ( name : string , stmt : ts.Statement ) : void = > {
byName . set ( name , [ . . . ( byName . get ( name ) ? ? [ ] ) , stmt ] )
}
for ( const stmt of statements ) {
if ( ts . isFunctionDeclaration ( stmt ) ) {
if ( stmt . name ) add ( stmt . name . text , stmt )
if ( ! stmt . body && stmt . name ) overloadSigs . add ( stmt . name . text )
} else if ( ts . isClassDeclaration ( stmt ) || ts . isInterfaceDeclaration ( stmt )
|| ts . isTypeAliasDeclaration ( stmt ) || ts . isEnumDeclaration ( stmt ) ) {
if ( stmt . name ) add ( stmt . name . text , stmt )
} else if ( ts . isModuleDeclaration ( stmt ) && ts . isIdentifier ( stmt . name ) ) {
add ( stmt . name . text , stmt )
} else if ( ts . isVariableStatement ( stmt ) ) {
for ( const d of stmt . declarationList . declarations ) {
if ( ts . isIdentifier ( d . name ) ) add ( d . name . text , stmt )
}
}
}
2026-07-07 17:05:32 +08:00
// Two-phase dispatch. Phase one accumulates WHICH statements are surface
// and, for a variable statement reached by name (an export list or a
// default-export identifier), which of its declarators the exports actually
// name — `null` marks the whole statement as surface (a direct `export`
// modifier, or an ambient scope). Requests for the same statement merge:
// `null` absorbs any name set, and name sets union, so
// `export { a }; export { b }` over one `const a = …, b = …` checks both
// declarators while a never-exported sibling stays out of the surface.
// Phase two runs each surfaced statement exactly once. (Checking a
// statement eagerly per request would either re-check on the second list or
// — deduplicated — silently drop the second list's declarators.)
const requested = new Map < ts.Statement , Set < string > | null > ( )
const request = ( stmt : ts.Statement , name : string | null ) : void = > {
const prior = requested . get ( stmt )
if ( name === null || prior === null ) {
requested . set ( stmt , null )
return
}
requested . set ( stmt , prior === undefined ? new Set ( [ name ] ) : prior . add ( name ) )
2026-07-06 22:09:30 +08:00
}
for ( const stmt of statements ) {
if ( ts . isModuleDeclaration ( stmt )
&& ( ts . isStringLiteral ( stmt . name ) || ( stmt . flags & ts . NodeFlags . GlobalAugmentation ) !== 0 ) ) {
continue // `declare module '…'` / `declare global` augmentation: not an export of this package
}
if ( ts . isExportDeclaration ( stmt ) ) {
if ( stmt . moduleSpecifier ) continue // re-export: the defining module is walked on its own
if ( stmt . exportClause && ts . isNamedExports ( stmt . exportClause ) ) {
for ( const el of stmt . exportClause . elements ) {
2026-07-07 17:05:32 +08:00
const local = ( el . propertyName ? ? el . name ) . text
for ( const decl of byName . get ( local ) ? ? [ ] ) request ( decl , local )
2026-07-06 22:09:30 +08:00
// a name with no local declaration is an imported binding re-exported
// without a specifier — its defining module is walked on its own
}
}
continue
}
2026-07-06 23:25:33 +08:00
if ( ts . isExportAssignment ( stmt ) ) {
if ( stmt . isExportEquals ) {
// `export =` has no ESM consumer surface in this repo and the walk
// cannot classify its operand's shape; refuse rather than fail open.
w . violations . push ( ` export-equals assignment ( ${ pointer ( w . rel , w . sf , stmt ) } ) is not a gate-supported export form; use ESM named exports. ` )
continue
}
const where = ` default export ( ${ pointer ( w . rel , w . sf , stmt ) } ) `
2026-07-06 23:55:16 +08:00
const expr = unwrapExpression ( stmt . expression )
if ( ts . isIdentifier ( expr ) ) {
2026-07-07 17:05:32 +08:00
for ( const decl of byName . get ( expr . text ) ? ? [ ] ) request ( decl , expr . text )
2026-07-06 23:55:16 +08:00
} else if ( ts . isArrowFunction ( expr ) || ts . isFunctionExpression ( expr ) ) {
checkFunctionLike ( where , rawJsDoc ( w . text , stmt ) , expr . parameters , expr . type , false , w )
2026-07-06 22:09:30 +08:00
} else {
2026-07-06 23:25:33 +08:00
checkDescribed ( where , rawJsDoc ( w . text , stmt ) , w )
2026-07-06 22:09:30 +08:00
}
continue
}
2026-07-07 17:05:32 +08:00
if ( isExported ( stmt ) || ( ambient && ! ts . isImportDeclaration ( stmt ) ) ) request ( stmt , null )
}
for ( const stmt of statements ) {
const only = requested . get ( stmt )
if ( only !== undefined ) checkDecl ( stmt , prefix , overloadSigs , byName , ambient , w , only )
2026-07-06 22:09:30 +08:00
}
}
/**
* Compiler options for the walk's program. The real repo hands over its
* tsconfig.base.json (whose `paths` map resolves cross-package imports to
* source, so heritage-member lookups see seam types); a fixture root without
2026-07-07 16:22:43 +08:00
* one gets `noLib` + no `@types` — fixtures are single-file and
* self-contained, nothing in the walk resolves a lib symbol, and default-lib
* parsing is ~99% of per-program cost (it made the fixture spec time out
* under CI coverage instrumentation). Emit-side options are stripped: the
* walk never emits or asks for diagnostics, it only binds types on demand.
2026-07-06 22:09:30 +08:00
* @param scanRoot - the root being scanned.
* @returns compiler options for ts.createProgram.
*/
function loadCompilerOptions ( scanRoot : string ) : ts . CompilerOptions {
const cfgPath = resolve ( scanRoot , 'tsconfig.base.json' )
2026-07-07 16:22:43 +08:00
if ( ! existsSync ( cfgPath ) ) return { skipLibCheck : true , noLib : true , types : [ ] }
2026-07-06 22:09:30 +08:00
const cfg = ts . readConfigFile ( cfgPath , ts . sys . readFile . bind ( ts . sys ) ) as { config? : unknown }
const parsed = ts . parseJsonConfigFileContent ( cfg . config ? ? { } , ts . sys , scanRoot )
return {
. . . parsed . options ,
noEmit : true ,
composite : false ,
declaration : false ,
declarationMap : false ,
sourceMap : false ,
incremental : false ,
}
}
/**
* Walk every non-vendored package source file and collect JSDoc-completeness
* violations for its module-level exports. Returns findings instead of
* throwing so tests assert on the list; the CLI entry turns a non-empty list
* into exit 1.
* @param scanRoot - the repo root to scan; tests pass a fixture dir.
* @returns every violation, in file order, one human-readable line each.
*/
export function collectExportJsdocViolations ( scanRoot : string = root ) : string [ ] {
const violations : string [ ] = [ ]
const rels = globSync ( 'packages/*/*/src/**/*.ts' , { cwd : scanRoot } ) . sort ( )
const program = ts . createProgram ( rels . map ( rel = > resolve ( scanRoot , rel ) ) , loadCompilerOptions ( scanRoot ) )
const checker = program . getTypeChecker ( )
for ( const rel of rels ) {
const sf = program . getSourceFile ( resolve ( scanRoot , rel ) )
if ( ! sf ) continue // program root files always resolve; guard for narrowing
2026-07-06 23:25:33 +08:00
// A script-style declaration file (no imports/exports) is one big ambient
// scope; a module-style .d.ts still honors explicit export modifiers.
checkScope ( sf . statements , '' , { rel , sf , text : sf.text , checker , violations } , sf . isDeclarationFile && ! ts . isExternalModule ( sf ) )
2026-07-06 22:09:30 +08:00
}
return violations
}
/** CLI entry: list every violation and exit 1, or confirm a clean surface. */
function main ( ) : void {
const violations = collectExportJsdocViolations ( )
if ( violations . length === 0 ) {
console . log ( 'verify-export-jsdoc: every exported name on the package surface is documented.' )
return
}
console . error ( ` verify-export-jsdoc: ${ violations . length } JSDoc completeness violation(s) (see AGENTS.md): ` )
for ( const v of violations ) console . error ( ` ${ v } ` )
process . exit ( 1 )
}
// Run only when invoked as a script, not when imported by a test.
if ( process . argv [ 1 ] && import . meta . filename === resolve ( process . argv [ 1 ] ) ) {
main ( )
}