2026-07-06 22:09:30 +08:00
/**
2026-07-14 00:47:30 +08:00
* Shared JSDoc parsing and completeness checks for the Cordis, persistence,
* and config catalogs and the export-surface gate.
2026-07-06 22:09:30 +08:00
*/
import ts from 'typescript'
/** Repo-relative source pointer `file:line` for a node's first character. */
export function pointer ( rel : string , sf : ts.SourceFile , node : ts.Node ) : string {
const { line } = sf . getLineAndCharacterOfPosition ( node . getStart ( sf ) )
return ` ${ rel } : ${ line + 1 } `
}
/** The raw `/** … * /` JSDoc block immediately preceding a node, or '' if none. */
export function rawJsDoc ( text : string , node : ts.Node ) : string {
const ranges = ts . getLeadingCommentRanges ( text , node . getFullStart ( ) ) ? ? [ ]
const jsdoc = ranges . filter ( r = > text . slice ( r . pos , r . pos + 3 ) === '/**' ) . at ( - 1 )
return jsdoc ? text . slice ( jsdoc . pos , jsdoc . end ) : ''
}
/** A dispatch mode, rendered as the badge after an event name in the catalog. */
export type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial'
/**
2026-07-13 23:27:00 +08:00
* Parse a raw JSDoc block into description prose and an optional `@mode`. Prose
* ends at the first block tag, paragraphs collapse to one line, bullet items
* remain separate lines, and `{@link X}` renders as `X`.
2026-07-06 22:09:30 +08:00
* @param raw - the raw comment text including the JSDoc delimiters.
2026-07-14 00:24:04 +08:00
* @returns the collapsed description prose, parsed valid `@mode` (or null),
* and whether any `@mode` tag was present.
2026-07-06 22:09:30 +08:00
*/
2026-07-14 00:24:04 +08:00
export function parseJsDoc ( raw : string ) : { doc : string ; mode : Mode | null ; hasMode : boolean } {
2026-07-06 22:09:30 +08:00
const inner = raw
. replace ( /^\/\*\*/ , '' )
. replace ( /\*\/$/ , '' )
. split ( '\n' )
. map ( l = > l . replace ( /^\s*\*?\s?/ , '' ) . replace ( /\s+$/ , '' ) )
let mode : Mode | null = null
2026-07-14 00:24:04 +08:00
let hasMode = false
2026-07-06 22:09:30 +08:00
let inTags = false
const blocks : string [ ] = [ ]
let para : string [ ] = [ ]
let list : string [ ] = [ ]
let item : string [ ] = [ ]
const join = ( parts : string [ ] ) : string = > parts . join ( ' ' ) . replace ( /\s+/g , ' ' ) . trim ( )
const flushItem = ( ) : void = > {
if ( item . length ) list . push ( join ( item ) )
item = [ ]
}
const flushList = ( ) : void = > {
flushItem ( )
if ( list . length ) blocks . push ( list . join ( '\n' ) ) // one block, items on own lines
list = [ ]
}
const flushPara = ( ) : void = > {
flushList ( )
if ( para . length ) blocks . push ( join ( para ) )
para = [ ]
}
for ( const line of inner ) {
2026-07-14 00:24:04 +08:00
const tagLine = line . trimStart ( )
const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/ . exec ( tagLine )
if ( m ) { mode = m [ 1 ] as Mode ; hasMode = true ; flushPara ( ) ; inTags = true ; continue }
if ( /^@mode\b/ . test ( tagLine ) ) { hasMode = true ; flushPara ( ) ; inTags = true ; continue }
if ( tagLine . startsWith ( '@' ) ) { flushPara ( ) ; inTags = true ; continue }
2026-07-06 22:09:30 +08:00
if ( inTags ) continue // block-tag territory: continuations are never prose
if ( line . trim ( ) === '' ) { flushPara ( ) ; continue }
if ( /^-\s+/ . test ( line ) ) {
// A list item starts: a pending paragraph (e.g. an intro line directly
// above the list, no blank between) flushes FIRST so it renders above.
flushItem ( )
if ( para . length ) { blocks . push ( join ( para ) ) ; para = [ ] }
item . push ( line )
continue
}
if ( item . length ) { item . push ( line ) ; continue } // continuation of current item
para . push ( line )
}
flushPara ( )
const doc = blocks . join ( '\n\n' ) . replace ( /\{@link\s+([^}]+)\}/g , '$1' ) . trim ( )
2026-07-14 00:24:04 +08:00
return { doc , mode , hasMode }
2026-07-06 22:09:30 +08:00
}
/**
2026-07-13 23:27:00 +08:00
* Parse `@param` and `@returns` descriptions, including continuation lines.
* Parameter separators are optional and `[optional]` names unwrap.
2026-07-06 22:09:30 +08:00
* @param raw - the raw comment text including the JSDoc delimiters.
* @returns the `@param` name→description map plus the `@returns` description
* (null when the tag is absent, '' when present but empty).
*/
export function parseTags ( raw : string ) : { params : Map < string , string > ; returns : string | null } {
const inner = raw
. replace ( /^\/\*\*/ , '' )
. replace ( /\*\/$/ , '' )
. split ( '\n' )
. map ( l = > l . replace ( /^\s*\*?\s?/ , '' ) . replace ( /\s+$/ , '' ) )
const params = new Map < string , string > ( )
let returns : string | null = null
let sink : ( ( text : string ) = > void ) | null = null
for ( const line of inner ) {
const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/ . exec ( line )
if ( param ) {
const name = ( param [ 1 ] ? ? '' ) . replace ( /^\[|\]$/g , '' )
let acc = param [ 2 ] ? ? ''
params . set ( name , acc )
sink = ( t ) = > { acc = acc ? ` ${ acc } ${ t } ` : t ; params . set ( name , acc ) }
continue
}
const ret = /^@returns?(?:\s+[-—–]?\s*(.*))?$/ . exec ( line )
if ( ret ) {
let acc = ret [ 1 ] ? ? ''
returns = acc
sink = ( t ) = > { acc = acc ? ` ${ acc } ${ t } ` : t ; returns = acc }
continue
}
if ( line . startsWith ( '@' ) || line . trim ( ) === '' ) { sink = null ; continue }
sink ? . ( line . trim ( ) )
}
return { params , returns }
}
/**
2026-07-13 23:27:00 +08:00
* Require a non-empty tag for each non-exempt identifier parameter, reject
* binding-pattern parameters, and reject stale tags. Exempt parameters may
* still be documented.
2026-07-06 22:09:30 +08:00
* @param where - the offender label violations open with, e.g. `event 'x' (file:1)`.
2026-07-13 23:27:00 +08:00
* @param surface - surface noun used in binding-pattern diagnostics.
2026-07-06 22:09:30 +08:00
* @param parameters - the declaration's parameter list.
* @param tags - the parsed `@param` name→description map from parseTags.
2026-07-12 03:36:43 +08:00
* @param sf - source file used to render binding patterns.
2026-07-13 23:27:00 +08:00
* @param isExempt - parameters whose tag is optional, such as `this` or waterfall `next`.
2026-07-06 22:09:30 +08:00
* @param violations - the aggregate list violations append to.
*/
export function checkParams (
where : string ,
surface : string ,
parameters : readonly ts . ParameterDeclaration [ ] ,
tags : Map < string , string > ,
sf : ts.SourceFile ,
isExempt : ( p : ts.ParameterDeclaration ) = > boolean ,
violations : string [ ] ,
) : void {
for ( const p of parameters ) {
if ( ! ts . isIdentifier ( p . name ) ) {
violations . push ( ` ${ where } : parameter ' ${ p . name . getText ( sf ) } ' is a binding pattern; the ${ surface } surface needs simple identifier parameters so @param can name them. ` )
continue
}
if ( isExempt ( p ) ) continue
const desc = tags . get ( p . name . text )
if ( desc === undefined ) violations . push ( ` ${ where } is missing @param ${ p . name . text } . ` )
else if ( ! desc . trim ( ) ) violations . push ( ` ${ where } : @param ${ p . name . text } has an empty description. ` )
}
for ( const tag of tags . keys ( ) ) {
if ( ! parameters . some ( p = > ts . isIdentifier ( p . name ) && p . name . text === tag ) ) {
violations . push ( ` ${ where } : @param ${ tag } does not match any parameter (stale tag?). ` )
}
}
}
/**
2026-07-12 03:36:43 +08:00
* Check the `@returns` half of the completeness contract: a non-`void` / `Promise<void>`
* return needs a non-empty `@returns`, and the return type must be ANNOTATED — a pure-AST
2026-07-13 23:27:00 +08:00
* walk cannot classify an inferred return. Void returns may still carry an
* optional tag, for example to document resolution timing.
2026-07-06 22:09:30 +08:00
* @param where - the offender label violations open with.
* @param typeNode - the declared return type annotation, or undefined when inferred.
* @param returns - the parsed `@returns` description from parseTags (null when absent).
* @param sf - the source file (for rendering the annotation's text).
* @param violations - the aggregate list violations append to.
*/
export function checkReturns (
where : string ,
typeNode : ts.TypeNode | undefined ,
returns : string | null ,
sf : ts.SourceFile ,
violations : string [ ] ,
) : void {
if ( typeNode === undefined ) {
violations . push ( ` ${ where } has no return type annotation; annotate it explicitly so the gate can classify the result. ` )
return
}
const rt = typeNode . getText ( sf ) . replace ( /\s+/g , ' ' )
if ( /^(void|Promise<void>)$/ . test ( rt ) ) return
if ( returns === null ) violations . push ( ` ${ where } is missing @returns (return type: ${ rt } ). ` )
else if ( ! returns . trim ( ) ) violations . push ( ` ${ where } : @returns has an empty description. ` )
}
/**
* Throw one aggregate error for every completeness violation a walk collected.
* Aggregation (vs failing fast) is deliberate: a remediation pass sees the
* whole list at once instead of replaying the gate once per offender.
* @param gate - the reporting gate's name, prefixed to the error message.
* @param violations - the collected violation lines; no-op when empty.
*/
export function reportViolations ( gate : string , violations : string [ ] ) : void {
if ( violations . length === 0 ) return
throw new Error (
` ${ gate } : ${ violations . length } JSDoc completeness violation(s) (see AGENTS.md): \ n `
+ violations . map ( v = > ` ${ v } ` ) . join ( '\n' ) ,
)
}