2026-07-06 21:57:17 +08:00
/**
2026-07-12 03:36:43 +08:00
* Generate `docs/config-catalog.md` from package entry points, config types,
* JSDoc, and static Schemastery schemas. Every package must classify, referenced
2026-07-13 23:27:00 +08:00
* types must resolve without collisions, and every enumerable schema path must
* exist on the declared config type. External and dynamic shapes stay unknown;
* declared runtime-only fields need not appear in the schema. `--check` verifies
* the committed artifact.
2026-07-06 21:57:17 +08:00
*/
import { globSync , readFileSync , writeFileSync } from 'node:fs'
2026-07-06 02:28:44 +08:00
import { dirname , resolve , sep } from 'node:path'
2026-07-06 21:57:17 +08:00
import ts from 'typescript'
2026-07-07 09:34:12 +08:00
import { LINK_MAP } from './gen-cordis-catalog.ts'
import { parseJsDoc , pointer , rawJsDoc } from './jsdoc.ts'
2026-07-06 21:57:17 +08:00
const root = resolve ( import . meta . dirname , '..' )
const OUT = 'docs/config-catalog.md'
/** The fenced-block info string for pasted config declarations (skipped by
* doc-typecheck, since a lone declaration referencing imports is not
* standalone-compilable). */
const FENCE = 'ts config-catalog'
/** TypeScript/Node global type names a config declaration may reference
* without importing; never treated as unresolved. Extend when a new global
* legitimately appears — the generator hard-errors on unknown names, so an
* omission is loud, not silent. */
const GLOBAL_TYPES = new Set ( [
'Array' , 'ReadonlyArray' , 'Record' , 'Partial' , 'Required' , 'Readonly' , 'Pick' , 'Omit' ,
'Promise' , 'Map' , 'Set' , 'Date' , 'Error' , 'RegExp' , 'Exclude' , 'Extract' , 'NonNullable' ,
'ReturnType' , 'Parameters' , 'AbortSignal' , 'URL' , 'Buffer' , 'NodeJS' , 'Iterable' , 'AsyncIterable' ,
] )
/** How a package classifies for the catalog. */
type Kind = 'config' | 'no-config' | 'seam' | 'library'
/** One name a pasted declaration references but the paste does not contain. */
interface TypeRef {
/** The name as it appears in the pasted text (the local import alias). */
alias : string
/** The name the source module exports it under (pre-alias). */
imported : string
/** The import module specifier (package name or external module). */
specifier : string
}
/** One verbatim declaration paste. */
interface Paste {
/** Full source text: leading JSDoc (when present) through the closing token. */
text : string
/** Source pointer `packages/…/file.ts:line` of the declaration. */
source : string
}
/** One package's catalog entry. */
export interface CatalogEntry {
/** npm package name, e.g. `@deepseek-ai/dsh-agent-loop`. */
pkg : string
/** Repo-relative package dir, e.g. `packages/core/agent-loop`. */
dir : string
/** Repo-relative entry file, `<dir>/src/index.ts`. */
entry : string
kind : Kind
/** Service keys the plugin `inject`s (empty when none declared). */
inject : string [ ]
/** Seam/service class name (kinds `seam` and class-based plugins). */
className? : string
/** Name of the config type (kind `config`). */
configTypeName? : string
/** Verbatim declaration pastes, the config type first (kind `config`). */
pastes? : Paste [ ]
/** References the pastes leave unresolved locally (kind `config`). */
refs? : TypeRef [ ]
2026-07-06 23:06:03 +08:00
/** Top-level keys and nested key paths (`agents[].id`) of the runtime
* schema, `null` when no schema exists (kind `config`). */
2026-07-06 21:57:17 +08:00
schemaKeys? : string [ ] | null
/** Package names whose schemas an intersect composes (kind `config`). */
schemaComposes? : string [ ]
}
/** A parsed source file plus its import map (local name → origin). */
interface FileCtx {
abs : string
rel : string
text : string
sf : ts.SourceFile
/** Local binding name → `{ imported, specifier }`; default imports record
* `imported: 'default'`. */
imports : Map < string , { imported : string ; specifier : string } >
}
/** Throw one aggregate error for every violation the walk collected. */
function report ( violations : string [ ] ) : void {
if ( violations . length === 0 ) return
throw new Error (
` gen-config-catalog: ${ violations . length } violation(s): \ n `
+ violations . map ( v = > ` ${ v } ` ) . join ( '\n' ) ,
)
}
/** Parse a source file and index its import declarations. */
function loadFile ( abs : string , rel : string , cache : Map < string , FileCtx > ) : FileCtx {
const cached = cache . get ( abs )
if ( cached ) return cached
const text = readFileSync ( abs , 'utf8' )
const sf = ts . createSourceFile ( abs , text , ts . ScriptTarget . Latest , true )
const imports = new Map < string , { imported : string ; specifier : string } > ( )
for ( const stmt of sf . statements ) {
if ( ! ts . isImportDeclaration ( stmt ) || ! ts . isStringLiteral ( stmt . moduleSpecifier ) ) continue
const specifier = stmt . moduleSpecifier . text
const clause = stmt . importClause
if ( ! clause ) continue
if ( clause . name ) imports . set ( clause . name . text , { imported : 'default' , specifier } )
if ( clause . namedBindings && ts . isNamedImports ( clause . namedBindings ) ) {
for ( const el of clause . namedBindings . elements ) {
imports . set ( el . name . text , { imported : ( el . propertyName ? ? el . name ) . text , specifier } )
}
}
if ( clause . namedBindings && ts . isNamespaceImport ( clause . namedBindings ) ) {
imports . set ( clause . namedBindings . name . text , { imported : '*' , specifier } )
}
}
const ctx = { abs , rel , text , sf , imports }
cache . set ( abs , ctx )
return ctx
}
/** A type declaration a paste can contain. */
type TypeDecl = ts . InterfaceDeclaration | ts . TypeAliasDeclaration
/** Find an interface/type-alias declaration by name in a file, or null. */
function findTypeDecl ( ctx : FileCtx , name : string ) : TypeDecl | null {
for ( const stmt of ctx . sf . statements ) {
if ( ( ts . isInterfaceDeclaration ( stmt ) || ts . isTypeAliasDeclaration ( stmt ) ) && stmt . name . text === name ) return stmt
}
return null
}
/**
* Resolve a type name from a file to its declaration (following package-local
* relative imports transitively) or to the import that brings it in. Returns
* `null` when the name is neither declared, imported, nor a known global.
*/
function resolveTypeName (
ctx : FileCtx ,
name : string ,
cache : Map < string , FileCtx > ,
violations : string [ ] ,
) : { decl : TypeDecl ; ctx : FileCtx } | { ref : TypeRef } | null {
const local = findTypeDecl ( ctx , name )
if ( local ) return { decl : local , ctx }
const imp = ctx . imports . get ( name )
if ( ! imp ) return null
if ( imp . specifier . startsWith ( '.' ) ) {
if ( ! imp . specifier . endsWith ( '.ts' ) ) {
violations . push ( ` ${ ctx . rel } : relative import ' ${ imp . specifier } ' lacks the explicit .ts extension the repo convention requires. ` )
return null
}
if ( imp . imported !== name ) {
violations . push ( ` ${ ctx . rel } : ' ${ name } ' aliases ' ${ imp . imported } ' across a package-local import; the catalog pastes declarations verbatim, so keep package-local config types unaliased. ` )
return null
}
const abs = resolve ( dirname ( ctx . abs ) , imp . specifier )
const rel = ctx . rel . slice ( 0 , ctx . rel . lastIndexOf ( '/' ) + 1 ) + imp . specifier . replace ( /^\.\// , '' )
const target = loadFile ( abs , rel , cache )
return resolveTypeName ( target , imp . imported , cache , violations )
}
return { ref : { alias : name , imported : imp.imported , specifier : imp.specifier } }
}
/** Collect every type NAME referenced in type positions under a node. */
function collectTypeNames ( node : ts.Node , out : Set < string > ) : void {
const visit = ( n : ts.Node ) : void = > {
if ( ts . isTypeReferenceNode ( n ) ) {
let head : ts.EntityName = n . typeName
while ( ts . isQualifiedName ( head ) ) head = head . left
out . add ( head . text )
} else if ( ts . isExpressionWithTypeArguments ( n ) && ts . isIdentifier ( n . expression ) ) {
out . add ( n . expression . text ) // heritage clause: `extends X`
}
ts . forEachChild ( n , visit )
}
visit ( node )
}
/** The verbatim paste text of a declaration: leading JSDoc through the end. */
function pasteText ( ctx : FileCtx , decl : TypeDecl ) : string {
const raw = rawJsDoc ( ctx . text , decl )
const start = raw ? ctx . text . indexOf ( raw , decl . getFullStart ( ) ) : decl . getStart ( ctx . sf )
return ctx . text . slice ( start , decl . end )
}
/** Enforce non-empty JSDoc prose on every property of a pasted declaration,
* recursing into nested type literals (e.g. an array-of-objects field). */
function checkMemberDocs ( ctx : FileCtx , decl : TypeDecl , violations : string [ ] ) : void {
const walkMembers = ( members : ts.NodeArray < ts.TypeElement > , path : string ) : void = > {
for ( const member of members ) {
if ( ! ts . isPropertySignature ( member ) ) continue
const name = member . name . getText ( ctx . sf )
const where = ` config field ' ${ path } . ${ name } ' ( ${ pointer ( ctx . rel , ctx . sf , member ) } ) `
if ( ! parseJsDoc ( rawJsDoc ( ctx . text , member ) ) . doc ) violations . push ( ` ${ where } has no JSDoc prose. ` )
if ( member . type ) walkNested ( member . type , ` ${ path } . ${ name } ` )
}
}
const walkNested = ( type : ts . Node , path : string ) : void = > {
if ( ts . isTypeLiteralNode ( type ) ) walkMembers ( type . members , path )
else ts . forEachChild ( type , ( n ) = > { walkNested ( n , path ) } )
}
if ( ts . isInterfaceDeclaration ( decl ) ) walkMembers ( decl . members , decl . name . text )
else walkNested ( decl . type , decl . name . text )
}
2026-07-06 23:06:03 +08:00
/** Cross-file resolution context for the schema-path check. */
interface World {
scanRoot : string
cache : Map < string , FileCtx >
/** Workspace package name → repo-relative package dir. */
pkgDirByName : Map < string , string >
}
/** How a schema key path fared against the declared config type: definitely
* present, definitely absent, or crossing a shape the walk cannot enumerate
* (only `missing` is a violation — `unknown` must never mis-report). */
type PathLookup = 'found' | 'missing' | 'unknown'
/** One step of a schema key path: a named member, or an array-element hop. */
type PathStep = { member : string } | { array : true }
/** Parse a schema key path (`agents[].id`) into member/array steps. */
function parsePath ( path : string ) : PathStep [ ] {
const steps : PathStep [ ] = [ ]
for ( const seg of path . split ( '.' ) ) {
let name = seg
let arrays = 0
while ( name . endsWith ( '[]' ) ) {
name = name . slice ( 0 , - 2 )
arrays += 1
}
steps . push ( { member : name } )
for ( let i = 0 ; i < arrays ; i += 1 ) steps . push ( { array : true } )
2026-07-06 21:57:17 +08:00
}
2026-07-06 23:06:03 +08:00
return steps
}
/** Load a package-relative import target as a FileCtx. */
function loadRelative ( world : World , from : FileCtx , specifier : string ) : FileCtx {
const abs = resolve ( dirname ( from . abs ) , specifier )
const rel = from . rel . slice ( 0 , from . rel . lastIndexOf ( '/' ) + 1 ) + specifier . replace ( /^\.\// , '' )
return loadFile ( abs , rel , world . cache )
}
/** Find a type declaration EXPORTED (directly or via re-export chains) from a
* file, following `export … from './x.ts'` and `export * from './x.ts'`. */
function findExportedTypeDecl ( world : World , ctx : FileCtx , name : string , seen = new Set < string > ( ) ) : { decl : TypeDecl ; ctx : FileCtx } | null {
const key = ` ${ ctx . abs } # ${ name } `
if ( seen . has ( key ) ) return null
seen . add ( key )
const local = findTypeDecl ( ctx , name )
if ( local ) return { decl : local , ctx }
for ( const stmt of ctx . sf . statements ) {
if ( ! ts . isExportDeclaration ( stmt ) || ! stmt . moduleSpecifier || ! ts . isStringLiteral ( stmt . moduleSpecifier ) ) continue
const spec = stmt . moduleSpecifier . text
if ( ! spec . startsWith ( '.' ) || ! spec . endsWith ( '.ts' ) ) continue
let lookFor : string | null = null
if ( ! stmt . exportClause ) {
lookFor = name // export * from './x.ts'
} else if ( ts . isNamedExports ( stmt . exportClause ) ) {
const el = stmt . exportClause . elements . find ( e = > e . name . text === name )
if ( el ) lookFor = ( el . propertyName ? ? el . name ) . text
}
if ( lookFor === null ) continue
const hit = findExportedTypeDecl ( world , loadRelative ( world , ctx , spec ) , lookFor , seen )
if ( hit ) return hit
2026-07-06 21:57:17 +08:00
}
return null
}
2026-07-06 23:06:03 +08:00
/** Resolve a referenced type NAME to its declaration: declared locally, via a
* package-relative import, or via a workspace-package import (entry file +
* re-export chains). `'unknown'` = external or otherwise out of reach. */
function declForTypeName ( world : World , ctx : FileCtx , name : string ) : { decl : TypeDecl ; ctx : FileCtx } | 'unknown' {
const local = findTypeDecl ( ctx , name )
if ( local ) return { decl : local , ctx }
const imp = ctx . imports . get ( name )
if ( ! imp ) return 'unknown'
if ( imp . specifier . startsWith ( '.' ) ) {
if ( ! imp . specifier . endsWith ( '.ts' ) ) return 'unknown'
return findExportedTypeDecl ( world , loadRelative ( world , ctx , imp . specifier ) , imp . imported ) ? ? 'unknown'
}
const dir = world . pkgDirByName . get ( imp . specifier )
if ( dir === undefined ) return 'unknown'
const entryRel = ` ${ dir } /src/index.ts `
let entry : FileCtx
try {
entry = loadFile ( resolve ( world . scanRoot , entryRel ) , entryRel , world . cache )
} catch {
// A workspace package without a readable entry is reported by its own
2026-07-13 23:27:00 +08:00
// classification pass; for a lookup it is out of reach.
2026-07-06 23:06:03 +08:00
return 'unknown'
}
return findExportedTypeDecl ( world , entry , imp . imported ) ? ? 'unknown'
}
/** Utility wrappers that pass a member lookup through to their type argument. */
const PASSTHROUGH_WRAPPERS = new Set ( [ 'Partial' , 'Required' , 'Readonly' , 'NonNullable' ] )
/**
* Walk a schema key path against a declared type. This is a PRESENCE check,
* not a shape check: it answers "does the declared config type have a member
* here", resolving interfaces (heritage included), type aliases, literals,
* intersections, unions, arrays, indexed access, pass-through utility
* wrappers, and type references across package-local and workspace imports.
* Anything it cannot see through resolves `'unknown'`, never `'missing'`.
*/
function lookupPath ( world : World , ctx : FileCtx , node : ts.Node , steps : PathStep [ ] , seen : Set < string > ) : PathLookup {
if ( steps . length === 0 ) return 'found'
2026-07-13 23:27:00 +08:00
// Guard only named declarations, where recursive types can loop. Structural
// children can share a source position with their parent, so guarding them
2026-07-06 23:06:03 +08:00
// would mistake ordinary descent for a cycle.
if ( ts . isInterfaceDeclaration ( node ) || ts . isTypeAliasDeclaration ( node ) ) {
const key = ` ${ ctx . abs } : ${ node . pos } : ${ steps . length } `
if ( seen . has ( key ) ) return 'unknown' // recursive type — bail rather than loop
seen . add ( key )
}
const step = steps [ 0 ]
if ( step === undefined ) return 'found'
// Combine branch results: any found wins, else any unknown taints, else missing.
const combine = ( results : PathLookup [ ] ) : PathLookup = > {
if ( results . includes ( 'found' ) ) return 'found'
if ( results . includes ( 'unknown' ) ) return 'unknown'
return 'missing'
}
const intoMembers = ( members : ts.NodeArray < ts.TypeElement > ) : PathLookup | null = > {
if ( ! ( 'member' in step ) ) return null
for ( const m of members ) {
if ( ! ts . isPropertySignature ( m ) || m . name . getText ( ctx . sf ) !== step . member ) continue
if ( steps . length === 1 ) return 'found'
return m . type ? lookupPath ( world , ctx , m . type , steps . slice ( 1 ) , seen ) : 'unknown'
}
return null // not among these members; caller consults heritage/parts
}
if ( ts . isInterfaceDeclaration ( node ) ) {
if ( ! ( 'member' in step ) ) return 'unknown' // an array step cannot land on an interface
const direct = intoMembers ( node . members )
if ( direct !== null ) return direct
const bases : PathLookup [ ] = [ ]
for ( const clause of node . heritageClauses ? ? [ ] ) {
for ( const base of clause . types ) {
if ( ! ts . isIdentifier ( base . expression ) ) {
bases . push ( 'unknown' )
continue
}
const resolved = declForTypeName ( world , ctx , base . expression . text )
bases . push ( resolved === 'unknown' ? 'unknown' : lookupPath ( world , resolved . ctx , resolved . decl , steps , seen ) )
}
}
return bases . length ? combine ( bases ) : 'missing'
}
if ( ts . isTypeAliasDeclaration ( node ) ) return lookupPath ( world , ctx , node . type , steps , seen )
if ( ts . isTypeLiteralNode ( node ) ) {
if ( ! ( 'member' in step ) ) return 'unknown'
return intoMembers ( node . members ) ? ? 'missing'
}
if ( ts . isParenthesizedTypeNode ( node ) ) return lookupPath ( world , ctx , node . type , steps , seen )
if ( ts . isIntersectionTypeNode ( node ) ) {
return combine ( node . types . map ( t = > lookupPath ( world , ctx , t , steps , seen ) ) )
}
if ( ts . isUnionTypeNode ( node ) ) {
// Presence on a union is only definite when every branch agrees.
const results = node . types . map ( t = > lookupPath ( world , ctx , t , steps , seen ) )
if ( results . every ( r = > r === 'found' ) ) return 'found'
if ( results . every ( r = > r === 'missing' ) ) return 'missing'
return 'unknown'
}
if ( ts . isArrayTypeNode ( node ) ) {
return 'array' in step ? lookupPath ( world , ctx , node . elementType , steps . slice ( 1 ) , seen ) : 'unknown'
}
if ( ts . isTypeOperatorNode ( node ) ) return lookupPath ( world , ctx , node . type , steps , seen )
if ( ts . isIndexedAccessTypeNode ( node ) ) {
const index = node . indexType
if ( ts . isLiteralTypeNode ( index ) && ts . isStringLiteral ( index . literal ) ) {
return lookupPath ( world , ctx , node . objectType , [ { member : index.literal.text } , . . . steps ] , seen )
}
return 'unknown'
}
if ( ts . isTypeReferenceNode ( node ) ) {
let head : ts.EntityName = node . typeName
while ( ts . isQualifiedName ( head ) ) head = head . left
const name = head . text
if ( PASSTHROUGH_WRAPPERS . has ( name ) && node . typeArguments ? . [ 0 ] ) {
return lookupPath ( world , ctx , node . typeArguments [ 0 ] , steps , seen )
}
if ( ( name === 'Array' || name === 'ReadonlyArray' ) && node . typeArguments ? . [ 0 ] ) {
return 'array' in step ? lookupPath ( world , ctx , node . typeArguments [ 0 ] , steps . slice ( 1 ) , seen ) : 'unknown'
}
if ( ! ts . isIdentifier ( node . typeName ) ) return 'unknown' // namespace-qualified: out of reach
const resolved = declForTypeName ( world , ctx , name )
return resolved === 'unknown' ? 'unknown' : lookupPath ( world , resolved . ctx , resolved . decl , steps , seen )
}
return 'unknown'
}
2026-07-06 21:57:17 +08:00
/** Unwrap `as` / `satisfies` / parenthesized wrappers around an expression. */
function unwrapExpr ( expr : ts.Expression ) : ts . Expression {
let e = expr
while ( ts . isAsExpression ( e ) || ts . isSatisfiesExpression ( e ) || ts . isParenthesizedExpression ( e ) ) e = e . expression
return e
}
/**
2026-07-06 23:06:03 +08:00
* Statically walk a schemastery schema expression to its key paths plus the
* packages whose schemas an intersect composes. A key path is the top-level
* key or a nested path through object/array compositions (`agents[].id`).
* Handles the shapes the repo declares — `z.object({…})` (possibly behind
* chained calls) and `z.intersect([X.Config, …])` — and hard-errors on
* anything else, so a schema the walk cannot see fails the gate instead of
* silently thinning it. Nested values that are neither `object` nor `array`
* compositions (primitives, unions, dynamic-key dicts) contribute no paths.
2026-07-06 21:57:17 +08:00
*/
function walkSchemaExpr (
ctx : FileCtx ,
expr : ts.Expression ,
where : string ,
violations : string [ ] ,
) : { keys : string [ ] ; composes : string [ ] } {
const keys : string [ ] = [ ]
const composes : string [ ] = [ ]
2026-07-06 23:06:03 +08:00
// Nested paths under one object property's VALUE expression: recurse through
// chained refinements toward the base call, descending into object/array.
const collectValuePaths = ( value : ts.Expression , base : string ) : void = > {
const call = unwrapExpr ( value )
if ( ! ts . isCallExpression ( call ) || ! ts . isPropertyAccessExpression ( call . expression ) ) return
const method = call . expression . name . text
if ( method === 'object' && call . arguments [ 0 ] && ts . isObjectLiteralExpression ( call . arguments [ 0 ] ) ) {
for ( const prop of call . arguments [ 0 ] . properties ) {
if ( ! ts . isPropertyAssignment ( prop ) ) continue
const key = ts . isStringLiteral ( prop . name ) ? prop.name.text : prop.name.getText ( ctx . sf )
keys . push ( ` ${ base } . ${ key } ` )
collectValuePaths ( prop . initializer , ` ${ base } . ${ key } ` )
}
return
}
if ( method === 'array' && call . arguments [ 0 ] ) {
collectValuePaths ( call . arguments [ 0 ] , ` ${ base } [] ` )
return
}
const inner = unwrapExpr ( call . expression . expression )
if ( ts . isCallExpression ( inner ) ) collectValuePaths ( inner , base )
}
2026-07-06 21:57:17 +08:00
const visit = ( e : ts.Expression ) : void = > {
const call = unwrapExpr ( e )
if ( ! ts . isCallExpression ( call ) || ! ts . isPropertyAccessExpression ( call . expression ) ) {
violations . push ( ` ${ where } : schema expression is not a statically walkable schemastery call. ` )
return
}
const method = call . expression . name . text
if ( method === 'object' && call . arguments [ 0 ] && ts . isObjectLiteralExpression ( call . arguments [ 0 ] ) ) {
for ( const prop of call . arguments [ 0 ] . properties ) {
if ( ts . isPropertyAssignment ( prop ) || ts . isShorthandPropertyAssignment ( prop ) ) {
2026-07-06 23:06:03 +08:00
const key = ts . isStringLiteral ( prop . name ) ? prop.name.text : prop.name.getText ( ctx . sf )
keys . push ( key )
if ( ts . isPropertyAssignment ( prop ) ) collectValuePaths ( prop . initializer , key )
2026-07-06 21:57:17 +08:00
} else {
violations . push ( ` ${ where } : schema object property ' ${ prop . getText ( ctx . sf ) } ' is not a plain key. ` )
}
}
return
}
if ( method === 'intersect' && call . arguments [ 0 ] && ts . isArrayLiteralExpression ( call . arguments [ 0 ] ) ) {
for ( const el of call . arguments [ 0 ] . elements ) {
const part = unwrapExpr ( el )
if ( ts . isPropertyAccessExpression ( part ) && part . name . text === 'Config' && ts . isIdentifier ( part . expression ) ) {
const imp = ctx . imports . get ( part . expression . text )
if ( imp && ! imp . specifier . startsWith ( '.' ) ) { composes . push ( imp . specifier ) ; continue }
}
if ( ts . isCallExpression ( part ) ) { visit ( part ) ; continue }
violations . push ( ` ${ where } : intersect element ' ${ part . getText ( ctx . sf ) } ' is neither a workspace plugin's Config nor an inline schema call. ` )
}
return
2026-07-07 23:21:54 +08:00
}
// A union of objects (discriminated union config): collect keys from all
// variants. Each variant is visited the same way as an intersect element.
if ( method === 'union' && call . arguments [ 0 ] && ts . isArrayLiteralExpression ( call . arguments [ 0 ] ) ) {
for ( const el of call . arguments [ 0 ] . elements ) {
const part = unwrapExpr ( el )
if ( ts . isCallExpression ( part ) ) { visit ( part ) ; continue }
}
return
2026-07-06 21:57:17 +08:00
}
// A chained refinement (`z.object({…}).default(…)` etc.): the keys live on
// the call the chain hangs off — keep unwrapping toward it.
const base = unwrapExpr ( call . expression . expression )
if ( ts . isCallExpression ( base ) ) { visit ( base ) ; return }
violations . push ( ` ${ where } : schema call ' ${ method } ' is not object/intersect and hangs off no walkable base call. ` )
}
visit ( expr )
return { keys , composes }
}
/** Find a plugin's schemastery schema expression: an exported `const Config`
* in the entry file, else a `static Config` on the plugin class. */
function findSchemaExpr ( ctx : FileCtx , pluginClass : ts.ClassDeclaration | null ) : ts . Expression | null {
for ( const stmt of ctx . sf . statements ) {
if ( ! ts . isVariableStatement ( stmt ) ) continue
if ( ! stmt . modifiers ? . some ( m = > m . kind === ts . SyntaxKind . ExportKeyword ) ) continue
for ( const decl of stmt . declarationList . declarations ) {
if ( ts . isIdentifier ( decl . name ) && decl . name . text === 'Config' && decl . initializer ) return decl . initializer
}
}
for ( const member of pluginClass ? . members ? ? [ ] ) {
if ( ! ts . isPropertyDeclaration ( member ) || member . name . getText ( ) !== 'Config' ) continue
if ( ! member . modifiers ? . some ( m = > m . kind === ts . SyntaxKind . StaticKeyword ) ) continue
if ( member . initializer ) return member . initializer
}
return null
}
/** Read an `inject` service-key list: `export const inject = […]` in the entry
* file, else `static inject = […]` on the plugin class. */
function findInject ( ctx : FileCtx , pluginClass : ts.ClassDeclaration | null , violations : string [ ] ) : string [ ] {
const fromArray = ( expr : ts.Expression , where : string ) : string [ ] = > {
if ( ! ts . isArrayLiteralExpression ( expr ) ) {
violations . push ( ` ${ where } : inject is not a plain string-array literal; teach the generator the new shape. ` )
return [ ]
}
return expr . elements . map ( el = > ts . isStringLiteral ( el ) ? el.text : el.getText ( ctx . sf ) )
}
for ( const stmt of ctx . sf . statements ) {
if ( ! ts . isVariableStatement ( stmt ) ) continue
for ( const decl of stmt . declarationList . declarations ) {
if ( ts . isIdentifier ( decl . name ) && decl . name . text === 'inject' && decl . initializer ) {
return fromArray ( decl . initializer , ctx . rel )
}
}
}
for ( const member of pluginClass ? . members ? ? [ ] ) {
if ( ts . isPropertyDeclaration ( member ) && member . name . getText ( ) === 'inject' && member . initializer ) {
return fromArray ( member . initializer , ctx . rel )
}
}
return [ ]
}
/** Resolve the entry file's default export to its class/function declaration
* (mirroring the Loader's `unwrapExports`), or null when there is none. */
function defaultExport ( ctx : FileCtx ) : ts . ClassDeclaration | ts . FunctionDeclaration | null {
for ( const stmt of ctx . sf . statements ) {
if ( ts . isExportAssignment ( stmt ) && ! stmt . isExportEquals && ts . isIdentifier ( stmt . expression ) ) {
const name = stmt . expression . text
for ( const s of ctx . sf . statements ) {
if ( ( ts . isClassDeclaration ( s ) || ts . isFunctionDeclaration ( s ) ) && s . name ? . text === name ) return s
}
return null
}
if ( ( ts . isClassDeclaration ( stmt ) || ts . isFunctionDeclaration ( stmt ) )
&& stmt . modifiers ? . some ( m = > m . kind === ts . SyntaxKind . DefaultKeyword ) ) return stmt
}
return null
}
/** Find the exported `apply` function declaration in the entry file, or null. */
function applyExport ( ctx : FileCtx ) : ts . FunctionDeclaration | null {
for ( const stmt of ctx . sf . statements ) {
if ( ts . isFunctionDeclaration ( stmt ) && stmt . name ? . text === 'apply'
&& stmt . modifiers ? . some ( m = > m . kind === ts . SyntaxKind . ExportKeyword ) ) return stmt
}
return null
}
/**
* Walk every `packages/<group>/<pkg>` entry and build the catalog entries.
* Hard-errors (aggregated) on any violation listed in the module doc.
* `scanRoot` defaults to the repo root; tests pass a fixture dir.
*/
export function collectConfigCatalog ( scanRoot : string = root ) : CatalogEntry [ ] {
const violations : string [ ] = [ ]
const cache = new Map < string , FileCtx > ( )
const entries : CatalogEntry [ ] = [ ]
2026-07-06 23:06:03 +08:00
// Pre-pass: package name → dir, so schema-path lookups can follow
// workspace-package imports while individual packages are still being walked.
const pkgDirByName = new Map < string , string > ( )
const manifests : { dir : string ; pkg : string } [ ] = [ ]
2026-07-06 02:28:44 +08:00
for ( const manifestRel of globSync ( 'packages/*/*/package.json' , { cwd : scanRoot } ) . map ( path = > path . split ( sep ) . join ( '/' ) ) . sort ( ) ) {
2026-07-06 21:57:17 +08:00
const dir = manifestRel . slice ( 0 , - '/package.json' . length )
2026-07-09 16:44:32 +08:00
const manifest = JSON . parse ( readFileSync ( resolve ( scanRoot , manifestRel ) , 'utf8' ) ) as { name? : string ; os? : string [ ] ; cpu? : string [ ] }
const pkg = manifest . name
2026-07-06 23:06:03 +08:00
if ( ! pkg ) {
violations . push ( ` ${ manifestRel } has no "name". ` )
continue
}
2026-07-09 16:44:32 +08:00
if ( manifest . os !== undefined && manifest . cpu !== undefined ) {
// A per-platform native-binary package (npm os/cpu selection) ships no
// JavaScript at all — nothing to classify, no Config to catalog.
continue
}
2026-07-06 23:06:03 +08:00
pkgDirByName . set ( pkg , dir )
manifests . push ( { dir , pkg } )
}
const world : World = { scanRoot , cache , pkgDirByName }
for ( const { dir , pkg } of manifests ) {
2026-07-06 21:57:17 +08:00
const entryRel = ` ${ dir } /src/index.ts `
let ctx : FileCtx
try {
ctx = loadFile ( resolve ( scanRoot , entryRel ) , entryRel , cache )
} catch {
// A package without src/index.ts cannot be classified — that is the
// violation itself; nothing else in this loop body can run without it.
violations . push ( ` ${ pkg } : entry ${ entryRel } is missing or unreadable. ` )
continue
}
// Classify, mirroring the Loader's unwrapExports: the default export IS
// the plugin when present; else an exported `apply` makes the module
// namespace the plugin; else the package is a plain library.
const dflt = defaultExport ( ctx )
const apply = applyExport ( ctx )
let pluginClass : ts.ClassDeclaration | null = null
let configParam : ts.ParameterDeclaration | undefined
let kind : Kind
let className : string | undefined
if ( dflt && ts . isClassDeclaration ( dflt ) ) {
className = dflt . name ? . text
if ( dflt . modifiers ? . some ( m = > m . kind === ts . SyntaxKind . AbstractKeyword ) ) {
kind = 'seam'
} else {
pluginClass = dflt
const ctor = dflt . members . find ( ts . isConstructorDeclaration )
configParam = ctor ? . parameters [ 1 ]
kind = configParam ? 'config' : 'no-config'
}
} else if ( dflt ) {
configParam = dflt . parameters [ 1 ]
kind = configParam ? 'config' : 'no-config'
} else if ( apply ) {
configParam = apply . parameters [ 1 ]
kind = configParam ? 'config' : 'no-config'
} else {
kind = 'library'
}
const entry : CatalogEntry = {
pkg ,
dir ,
entry : entryRel ,
kind ,
inject : kind === 'library' || kind === 'seam' ? [ ] : findInject ( ctx , pluginClass , violations ) ,
. . . className !== undefined ? { className } : { } ,
}
entries . push ( entry )
if ( kind !== 'config' || ! configParam ) continue
// Resolve the config type and paste its package-local transitive closure.
if ( ! configParam . type || ! ts . isTypeReferenceNode ( configParam . type ) || ! ts . isIdentifier ( configParam . type . typeName ) ) {
violations . push ( ` ${ pkg } : config parameter type ( ${ pointer ( entryRel , ctx . sf , configParam ) } ) is not a plain type-name reference; declare a named config type. ` )
continue
}
const typeName = configParam . type . typeName . text
entry . configTypeName = typeName
const pastes : Paste [ ] = [ ]
const refs = new Map < string , TypeRef > ( )
2026-07-07 00:00:34 +08:00
// A bare name is the fence's whole namespace: two DIFFERENT declarations
// (or a declaration in one file and an import in another) sharing a name
// cannot both render unambiguously, so every resolution is identity-checked
// by source pointer and a collision is a violation, never a silent skip.
const pastedDeclByName = new Map < string , string > ( )
2026-07-06 21:57:17 +08:00
const queue : { name : string ; from : FileCtx } [ ] = [ { name : typeName , from : ctx } ]
for ( let item = queue . shift ( ) ; item !== undefined ; item = queue . shift ( ) ) {
const { name , from } = item
const resolved = resolveTypeName ( from , name , cache , violations )
if ( resolved === null ) {
violations . push ( ` ${ pkg } : config declaration references ' ${ name } ' (via ${ from . rel } ), which is neither declared in the package, imported, nor a known global type. ` )
continue
}
if ( 'ref' in resolved ) {
2026-07-07 00:00:34 +08:00
if ( name === typeName ) {
violations . push ( ` ${ pkg } : config type ' ${ name } ' is imported from ' ${ resolved . ref . specifier } '; a plugin's config type must live in its own package. ` )
continue
}
if ( pastedDeclByName . has ( name ) ) {
violations . push ( ` ${ pkg } : ' ${ name } ' resolves to a package-local declaration ( ${ pastedDeclByName . get ( name ) ? ? '' } ) in one file and an import from ' ${ resolved . ref . specifier } ' in another; rename one so the fence is unambiguous. ` )
continue
}
const existing = refs . get ( name )
if ( existing && ( existing . specifier !== resolved . ref . specifier || existing . imported !== resolved . ref . imported ) ) {
violations . push ( ` ${ pkg } : ' ${ name } ' is imported from both ' ${ existing . specifier } ' ( ${ existing . imported } ) and ' ${ resolved . ref . specifier } ' ( ${ resolved . ref . imported } ) across the pasted closure; disambiguate the aliases. ` )
continue
}
refs . set ( name , resolved . ref )
continue
}
const declKey = pointer ( resolved . ctx . rel , resolved . ctx . sf , resolved . decl )
const prior = pastedDeclByName . get ( name )
if ( prior === declKey ) continue // same declaration reached again — benign
if ( prior !== undefined ) {
violations . push ( ` ${ pkg } : type name ' ${ name } ' resolves to two different declarations ( ${ prior } and ${ declKey } ) across the pasted closure; rename one — a verbatim fence cannot carry two same-named declarations. ` )
continue
}
if ( refs . has ( name ) ) {
violations . push ( ` ${ pkg } : ' ${ name } ' resolves to an import from ' ${ refs . get ( name ) ? . specifier ? ? '' } ' in one file and a package-local declaration ( ${ declKey } ) in another; rename one so the fence is unambiguous. ` )
2026-07-06 21:57:17 +08:00
continue
}
2026-07-07 00:00:34 +08:00
pastedDeclByName . set ( name , declKey )
pastes . push ( { text : pasteText ( resolved . ctx , resolved . decl ) , source : declKey } )
2026-07-06 21:57:17 +08:00
checkMemberDocs ( resolved . ctx , resolved . decl , violations )
const names = new Set < string > ( )
collectTypeNames ( resolved . decl , names )
for ( const n of names ) {
2026-07-07 00:00:34 +08:00
if ( GLOBAL_TYPES . has ( n ) ) continue
2026-07-06 21:57:17 +08:00
queue . push ( { name : n , from : resolved . ctx } )
}
}
entry . pastes = pastes
entry . refs = [ . . . refs . values ( ) ] . sort ( ( a , b ) = > a . alias . localeCompare ( b . alias ) )
// Statically walk the runtime schema (when one exists) for the subset check.
const schemaExpr = findSchemaExpr ( ctx , pluginClass )
if ( schemaExpr ) {
const { keys , composes } = walkSchemaExpr ( ctx , unwrapExpr ( schemaExpr ) , ` ${ pkg } ( ${ entryRel } ) ` , violations )
entry . schemaKeys = keys
entry . schemaComposes = composes
} else {
entry . schemaKeys = null
}
}
2026-07-13 23:27:00 +08:00
// Fold composed schemas' key paths in, then check each path against the type.
// Only a definite miss fails; shapes the walk cannot enumerate stay unknown.
2026-07-06 21:57:17 +08:00
const byName = new Map ( entries . map ( e = > [ e . pkg , e ] ) )
for ( const entry of entries ) {
if ( entry . kind !== 'config' || entry . schemaKeys === null || entry . schemaKeys === undefined ) continue
const seen = new Set < string > ( )
const foldComposed = ( e : CatalogEntry ) : string [ ] = > {
if ( seen . has ( e . pkg ) ) return [ ]
seen . add ( e . pkg )
const keys = [ . . . e . schemaKeys ? ? [ ] ]
for ( const composed of e . schemaComposes ? ? [ ] ) {
const target = byName . get ( composed )
if ( ! target ) {
violations . push ( ` ${ entry . pkg } : schema intersects ' ${ composed } ', which is not a workspace package the walk collected. ` )
continue
}
keys . push ( . . . foldComposed ( target ) )
}
return keys
}
const allKeys = foldComposed ( entry )
const mainPaste = entry . pastes ? . [ 0 ]
const mainFile = mainPaste ? . source . split ( ':' ) [ 0 ]
const mainCtx = mainFile !== undefined ? cache . get ( resolve ( scanRoot , mainFile ) ) : undefined
2026-07-06 23:06:03 +08:00
const mainDecl = mainCtx && entry . configTypeName !== undefined ? findTypeDecl ( mainCtx , entry . configTypeName ) : null
if ( ! mainCtx || ! mainDecl ) {
violations . push ( ` ${ entry . pkg } : cannot locate config type ' ${ entry . configTypeName ? ? '' } ' for the schema-path check. ` )
2026-07-06 21:57:17 +08:00
continue
}
2026-07-06 23:06:03 +08:00
for ( const keyPath of allKeys ) {
if ( lookupPath ( world , mainCtx , mainDecl , parsePath ( keyPath ) , new Set ( ) ) === 'missing' ) {
violations . push ( ` ${ entry . pkg } : schema validates key ' ${ keyPath } ' but config type ' ${ entry . configTypeName ? ? '' } ' declares no such member — the catalog paste would hide a loader-accepted field. ` )
2026-07-06 21:57:17 +08:00
}
}
}
report ( violations )
return entries . sort ( ( a , b ) = > a . pkg . localeCompare ( b . pkg ) )
}
/** GitHub-style anchor slug for a `## \`pkg\`` heading. */
function slug ( heading : string ) : string {
return heading . toLowerCase ( ) . replace ( /[^a-z0-9 -]/g , '' ) . replace ( / /g , '-' )
}
/** Render the `Requires:` service-key line, or '' when the plugin injects nothing. */
function requiresLine ( inject : string [ ] ) : string {
return inject . length ? ` Requires: ${ inject . map ( k = > ` \` ${ k } \` ` ) . join ( ' · ' ) } ` : ''
}
/** Render one reference as a link: another plugin's config type → its section,
* a curated core-data-structures name → its page, any other workspace type →
* its source file, an external type → named with its module, unlinked. */
function refLink ( ref : TypeRef , byName : Map < string , CatalogEntry > ) : string {
const target = byName . get ( ref . specifier )
if ( target ? . kind === 'config' && ref . imported === target . configTypeName ) {
return ` [ \` ${ ref . alias } \` ](# ${ slug ( target . pkg ) } ) `
}
const page = LINK_MAP [ ref . imported ]
if ( page ) return ` [ \` ${ ref . alias } \` ](core-data-structures/ ${ page } ) `
if ( target ) return ` [ \` ${ ref . alias } \` ](../ ${ target . entry } ) `
return ` \` ${ ref . alias } \` ( \` ${ ref . specifier } \` ) `
}
/** Render one configurable plugin's section. */
function renderConfigEntry ( entry : CatalogEntry , byName : Map < string , CatalogEntry > ) : string [ ] {
const out = [ ` ## \` ${ entry . pkg } \` ` , '' ]
const requires = requiresLine ( entry . inject )
if ( requires ) out . push ( requires , '' )
out . push ( '```' + FENCE , . . . ( entry . pastes ? ? [ ] ) . map ( p = > p . text ) . join ( '\n\n' ) . split ( '\n' ) , '```' , '' )
if ( entry . refs && entry . refs . length > 0 ) {
out . push ( ` Depends on: ${ entry . refs . map ( r = > refLink ( r , byName ) ) . join ( ' · ' ) } ` , '' )
}
const source = entry . pastes ? . [ 0 ] ? . source ? ? entry . entry
out . push ( ` Source: [ \` ${ source } \` ](../ ${ source . split ( ':' ) [ 0 ] } ) ` , '' )
return out
}
/** Render one terse list line (the no-config / seam / library sections). */
function renderTerse ( entry : CatalogEntry , detail : string ) : string {
const requires = entry . inject . length ? ` — requires ${ entry . inject . map ( k = > ` \` ${ k } \` ` ) . join ( ' · ' ) } ` : ''
return ` - \` ${ entry . pkg } \` ${ detail } ${ requires } ([ \` ${ entry . entry } \` ](../ ${ entry . entry } )) `
}
/** Render the full catalog (pure, deterministic given sorted entries). */
export function render ( entries : CatalogEntry [ ] ) : string {
const byName = new Map ( entries . map ( e = > [ e . pkg , e ] ) )
const lines : string [ ] = [
'<!-- Generated by scripts/gen-config-catalog.ts — do not edit by hand.' ,
' Run `pnpm run gen-config-catalog` to regenerate. -->' ,
'' ,
'# Plugin Config Catalog' ,
'' ,
2026-07-06 23:16:29 +08:00
'Every `config:` block a `cordis.yml` entry can set: for each loadable harness package, the verbatim config declaration (JSDoc included) its `apply` function or service constructor receives, with every referenced type pasted alongside (package-local types) or linked (everything else). The paste is the plugin\'s full declared config type — a field the runtime schema deliberately excludes is a runtime-only seam (its own JSDoc says so) and is not settable from `cordis.yml`. This is the **deployment**-axis reference — the wiring a plugin author works against is the cordis [events](cordis-catalog/events.md) + [services](cordis-catalog/services.md) catalogs, the model-facing tool schemas are the [tool catalog](tool-catalog.md), and [core-data-structures/](core-data-structures/core.md) documents the types these declarations reference.' ,
2026-07-06 21:57:17 +08:00
'' ,
2026-07-06 23:06:03 +08:00
'This file is GENERATED from source (`scripts/gen-config-catalog.ts`) and verified fresh by `pnpm run verify-config-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks use a `ts config-catalog` fence (skipped by doc-typecheck, since a lone declaration referencing imports is not standalone-compilable). The generator also cross-checks the runtime schemastery schema against the pasted declaration — every schema-validated key, nested keys included, must be locatable on the declared config type — so the paste cannot hide a loader-accepted field.' ,
2026-07-06 21:57:17 +08:00
'' ,
'A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` tree must also load providers for those services. Scope is the harness tier (`packages/`); the vendored cordis plugins a config tree may also load (`hmr`, the console logger, …) are pinned upstream source ([vendoring policy](../vendor/README.md)) and not catalogued here.' ,
'' ,
]
for ( const entry of entries . filter ( e = > e . kind === 'config' ) ) {
lines . push ( . . . renderConfigEntry ( entry , byName ) )
}
lines . push (
'## Loadable plugins with no config' ,
'' ,
'These load from a `cordis.yml` entry with no `config:` block; they declare no config surface.' ,
'' ,
. . . entries . filter ( e = > e . kind === 'no-config' ) . map ( e = > renderTerse ( e , '' ) ) ,
'' ,
'## Seam packages (not directly loadable)' ,
'' ,
2026-07-19 22:50:49 +08:00
'Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)).' ,
2026-07-06 21:57:17 +08:00
'' ,
. . . entries . filter ( e = > e . kind === 'seam' ) . map ( e = > renderTerse ( e , ` — abstract \` ${ e . className ? ? '' } \` ` ) ) ,
'' ,
'## Library packages (no plugin entry)' ,
'' ,
'Imported as libraries by other packages; a `cordis.yml` cannot load them.' ,
'' ,
. . . entries . filter ( e = > e . kind === 'library' ) . map ( e = > renderTerse ( e , '' ) ) ,
'' ,
)
return lines . join ( '\n' )
}
/** CLI entry: default writes the catalog, `--check` fails if the committed
* copy is stale. Guarded behind an entry-point check so importing this module
* for tests neither regenerates the committed file nor calls process.exit. */
function main ( ) : void {
const content = render ( collectConfigCatalog ( ) )
if ( process . argv . includes ( '--check' ) ) {
let committed : string | null = null
try {
committed = readFileSync ( resolve ( root , OUT ) , 'utf8' )
} catch {
// Only ENOENT (not yet generated) is expected; a present-but-unreadable
// file is not a state this repo produces. Either way the remedy is the
// same — regenerate — so treat a read failure as "stale".
committed = null
}
if ( committed === content ) {
console . log ( ` gen-config-catalog: ${ OUT } is up to date. ` )
process . exit ( 0 )
}
console . error ( ` gen-config-catalog: ${ OUT } is stale. Run \` pnpm run gen-config-catalog \` and commit ${ OUT } . ` )
process . exit ( 1 )
}
writeFileSync ( resolve ( root , OUT ) , content )
console . log ( ` gen-config-catalog: wrote ${ OUT } . ` )
}
// 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 ( )
}