2026-07-14 21:25:58 +08:00
/**
2026-07-28 23:05:20 +08:00
* Validate Cordis Loader entry metadata and package resolution.
2026-07-14 21:25:58 +08:00
*
* The Loader interpolates only a plugin entry's `config`; expression objects in
* fields such as `disabled` remain truthy data and silently change composition.
2026-07-28 23:05:20 +08:00
* Example configs and the dsh Web composition resolve named plugins from their
* owning workspace manifests. Local example packages must also be in the root
2026-07-17 23:38:05 +08:00
* TypeScript project graph.
2026-07-14 21:25:58 +08:00
*/
import { globSync , readFileSync } from 'node:fs'
2026-07-17 23:38:05 +08:00
import { dirname , relative , resolve } from 'node:path'
2026-07-14 21:25:58 +08:00
import * as yaml from 'js-yaml'
2026-07-17 23:38:05 +08:00
import ts from 'typescript'
2026-07-23 00:39:55 +08:00
import { cordisConfigFiles } from './cordis-config-files.ts'
2026-07-14 21:25:58 +08:00
interface JsExpr {
__jsExpr : string
}
2026-07-17 23:38:05 +08:00
interface PackageManifest {
name? : string
dependencies? : Record < string , string >
}
interface PluginReference {
file : string
name : string
}
2026-07-14 21:25:58 +08:00
const root = resolve ( import . meta . dirname , '..' )
2026-07-30 14:56:39 +08:00
// These example files are overlays consumed by the built dsh app, so their bare
// specifiers resolve from apps/cli rather than the examples workspace.
2026-07-31 01:57:19 -07:00
const appOverlayFiles = new Set ( [
'examples/web-cordis/cordis.yml' ,
. . . globSync ( 'examples/mcp-memory/*.cordis.yml' , { cwd : root } ) ,
] )
2026-07-14 21:25:58 +08:00
const metadataFields = [ 'id' , 'name' , 'group' , 'disabled' , 'inject' , 'intercept' , 'isolate' ] as const
2026-07-29 21:09:02 +08:00
/** The adaptive directory-picker chooser package (mounts a backend row at boot). */
const CHOOSER_PACKAGE = '@deepseek-ai/dsh-host-directory-picker-auto'
/**
* The backends the chooser mounts by runtime string (mirror of its exported
* `BACKEND_PACKAGES`), invisible to yml-row scanning: a composition mounting
* the chooser must resolve both, or keyless Linux CI (which only ever
* resolves `browse`) hides a dropped `-native` dependency until a macOS boot.
*/
const CHOOSER_BACKEND_PACKAGES = [
'@deepseek-ai/dsh-host-directory-picker-native' ,
'@deepseek-ai/dsh-host-directory-picker-browse' ,
]
2026-07-14 21:25:58 +08:00
const jsExprType = new yaml . Type ( 'tag:yaml.org,2002:js' , {
kind : 'scalar' ,
resolve : data = > typeof data === 'string' ,
construct : ( data : unknown ) : JsExpr = > {
if ( typeof data !== 'string' ) throw new TypeError ( '!!js requires a scalar string' )
return { __jsExpr : data }
} ,
} )
const schema = yaml . JSON_SCHEMA . extend ( jsExprType )
2026-07-23 00:39:55 +08:00
const files = cordisConfigFiles ( root )
2026-07-14 21:25:58 +08:00
const errors : string [ ] = [ ]
2026-07-28 23:05:20 +08:00
const pluginReferences : PluginReference [ ] = [ ]
2026-07-14 21:25:58 +08:00
for ( const file of files ) {
const document : unknown = yaml . load ( readFileSync ( resolve ( root , file ) , 'utf8' ) , { schema } )
if ( ! isUnknownArray ( document ) ) {
errors . push ( ` ${ file } : root must be a Loader entry array ` )
continue
}
for ( let index = 0 ; index < document . length ; index ++ ) {
validateEntry ( document [ index ] , file , ` [ ${ index } ] ` )
}
}
2026-07-17 23:38:05 +08:00
errors . push ( . . . validateExampleResolution ( ) )
2026-07-28 23:05:20 +08:00
errors . push ( . . . validateAppResolution ( ) )
2026-07-30 21:34:23 +08:00
errors . push ( . . . validateSourcePlaneResolution ( ) )
2026-07-17 23:38:05 +08:00
2026-07-14 21:25:58 +08:00
if ( errors . length > 0 ) {
2026-07-28 23:05:20 +08:00
console . error ( 'verify-cordis-config: invalid Loader metadata or plugin package resolution:' )
2026-07-14 21:25:58 +08:00
for ( const error of errors ) console . error ( ` - ${ error } ` )
process . exitCode = 1
} else {
console . log ( ` verify-cordis-config: ${ files . length } config files passed. ` )
}
function validateEntry ( value : unknown , file : string , path : string ) : void {
if ( ! isRecord ( value ) ) {
errors . push ( ` ${ file } ${ path } : entry must be an object ` )
return
}
2026-07-28 23:05:20 +08:00
recordPlugin ( value , file )
2026-07-14 21:25:58 +08:00
validateMetadata ( value , file , path )
if ( ( value . group === true || value . name === '@cordisjs/plugin-group' ) && isUnknownArray ( value . config ) ) {
for ( let index = 0 ; index < value . config . length ; index ++ ) {
validateEntry ( value . config [ index ] , file , ` ${ path } .config[ ${ index } ] ` )
}
}
2026-07-29 17:34:50 +08:00
if ( isUnknownArray ( value . insert ) ) {
for ( let index = 0 ; index < value . insert . length ; index ++ ) {
validateEntry ( value . insert [ index ] , file , ` ${ path } .insert[ ${ index } ] ` )
}
}
2026-07-14 21:25:58 +08:00
if ( value . name !== '@cordisjs/plugin-include' ) return
const config = value . config
if ( ! isRecord ( config ) || ! isUnknownArray ( config . patches ) ) return
for ( let index = 0 ; index < config . patches . length ; index ++ ) {
const patch = config . patches [ index ]
const patchPath = ` ${ path } .config.patches[ ${ index } ] `
if ( ! isRecord ( patch ) ) continue
2026-07-28 23:05:20 +08:00
recordPlugin ( patch , file )
2026-07-14 21:25:58 +08:00
validateMetadata ( patch , file , patchPath )
if ( ! isUnknownArray ( patch . insert ) ) continue
for ( let insertIndex = 0 ; insertIndex < patch . insert . length ; insertIndex ++ ) {
validateEntry ( patch . insert [ insertIndex ] , file , ` ${ patchPath } .insert[ ${ insertIndex } ] ` )
}
}
}
2026-07-28 23:05:20 +08:00
function recordPlugin ( entry : Record < string , unknown > , file : string ) : void {
if ( typeof entry . name === 'string' ) pluginReferences . push ( { file , name : entry.name } )
2026-07-17 23:38:05 +08:00
}
function validateExampleResolution ( ) : string [ ] {
const violations : string [ ] = [ ]
const exampleManifest = readManifest ( 'examples/package.json' )
const dependencies = exampleManifest . dependencies ? ? { }
const localPackages = localPackageDirectories ( )
const rootReferences = rootProjectReferences ( )
2026-07-30 14:56:39 +08:00
const exampleReferences = pluginReferences . filter ( reference = > reference . file . startsWith ( 'examples/' ) && ! appOverlayFiles . has ( reference . file ) )
2026-07-28 23:05:20 +08:00
violations . push ( . . . missingPluginDependencies ( exampleReferences , dependencies , 'examples/package.json' ) )
const requiredPackages = new Set ( exampleReferences . map ( reference = > packageNameFromSpecifier ( reference . name ) ) )
2026-07-17 23:38:05 +08:00
const localExamplePackages = new Set ( [
. . . Object . keys ( dependencies ) ,
2026-07-28 23:05:20 +08:00
. . . [ . . . requiredPackages ] . filter ( packageName = > packageName !== undefined ) ,
2026-07-17 23:38:05 +08:00
] )
for ( const packageName of localExamplePackages ) {
const packageDirectory = localPackages . get ( packageName )
if ( packageDirectory === undefined || rootReferences . has ( packageDirectory ) ) continue
const repoPath = relative ( root , packageDirectory ) . replaceAll ( '\\' , '/' )
violations . push ( ` tsconfig.json: missing project reference for ${ packageName } ( ${ repoPath } ) ` )
}
return violations
}
2026-07-28 23:05:20 +08:00
function validateAppResolution ( ) : string [ ] {
const dependencies = readManifest ( 'apps/cli/package.json' ) . dependencies ? ? { }
2026-07-30 14:56:39 +08:00
const shipped = new Set ( globSync ( '*.cordis.yml' , { cwd : resolve ( root , 'apps/cli/config' ) } )
. map ( file = > ` apps/cli/config/ ${ file } ` ) )
const references = pluginReferences . filter ( reference = > shipped . has ( reference . file ) || appOverlayFiles . has ( reference . file ) )
2026-07-28 23:05:20 +08:00
return missingPluginDependencies ( references , dependencies , 'apps/cli/package.json' )
}
2026-07-30 21:34:23 +08:00
/**
* Every configured specifier of a local workspace package must resolve through
* the tsconfig `paths` facade to a `.ts`/`.tsx` source file. The `dsh` source
* launch (tsx) and vitest resolve in the source plane; without a `paths` match
* they fall back to package `exports`, which reach built `lib/` — present on a
* built dev tree, absent on a clean one — so a missing mapping boots locally
* yet breaks every clean checkout. Anything but a `.ts`/`.tsx` hit (a `.d.ts`
* or `.js` under built `lib/`) is that artifact-plane fallback, not source.
*/
function validateSourcePlaneResolution ( ) : string [ ] {
const violations : string [ ] = [ ]
const localPackages = localPackageDirectories ( )
const config = ts . readConfigFile ( resolve ( root , 'tsconfig.base.json' ) , path = > ts . sys . readFile ( path ) )
if ( config . error !== undefined ) {
throw new Error ( ts . flattenDiagnosticMessageText ( config . error . messageText , '\n' ) )
}
const { options , errors : optionErrors } = ts . convertCompilerOptionsFromJson (
( config . config as { compilerOptions? : unknown } ) . compilerOptions ,
root ,
'tsconfig.base.json' ,
)
if ( optionErrors . length > 0 ) {
throw new Error ( optionErrors . map ( error = > ts . flattenDiagnosticMessageText ( error . messageText , '\n' ) ) . join ( '\n' ) )
}
// convertCompilerOptionsFromJson leaves `pathsBasePath` unset, so relative
// `paths` targets resolve against the host's current directory; anchor it to
// the repository root to keep the gate cwd-independent.
const host : ts.ModuleResolutionHost = {
fileExists : path = > ts . sys . fileExists ( path ) ,
readFile : path = > ts . sys . readFile ( path ) ,
directoryExists : path = > ts . sys . directoryExists ( path ) ,
getCurrentDirectory : ( ) = > root ,
}
const sourceExtensions = new Set < string > ( [ ts . Extension . Ts , ts . Extension . Tsx ] )
const containingFile = resolve ( root , 'scripts/verify-cordis-config.ts' )
const locationsBySpecifier = new Map < string , Set < string > > ( )
for ( const reference of pluginReferences ) {
const packageName = packageNameFromSpecifier ( reference . name )
if ( packageName === undefined || ! localPackages . has ( packageName ) ) continue
const locations = locationsBySpecifier . get ( reference . name ) ? ? new Set < string > ( )
locations . add ( reference . file )
locationsBySpecifier . set ( reference . name , locations )
}
for ( const [ specifier , locations ] of locationsBySpecifier ) {
const resolved = ts . resolveModuleName ( specifier , containingFile , options , host ) . resolvedModule
if ( resolved !== undefined && sourceExtensions . has ( resolved . extension ) ) continue
violations . push ( ` ${ [ . . . locations ] . join ( ', ' ) } : ${ specifier } does not resolve to workspace source through tsconfig.base.json paths (add a mapping so the tsx source launch does not depend on built lib/) ` )
}
return violations
}
2026-07-28 23:05:20 +08:00
function missingPluginDependencies (
references : readonly PluginReference [ ] ,
dependencies : Readonly < Record < string , string > > ,
manifestPath : string ,
) : string [ ] {
const requiredPackages = new Map < string , Set < string > > ( )
2026-07-29 21:09:02 +08:00
const require = ( packageName : string , file : string ) : void = > {
const locations = requiredPackages . get ( packageName ) ? ? new Set < string > ( )
locations . add ( file )
requiredPackages . set ( packageName , locations )
}
2026-07-28 23:05:20 +08:00
for ( const reference of references ) {
const packageName = packageNameFromSpecifier ( reference . name )
if ( packageName === undefined ) continue
2026-07-29 21:09:02 +08:00
require ( packageName , reference . file )
if ( packageName === CHOOSER_PACKAGE ) {
for ( const backend of CHOOSER_BACKEND_PACKAGES ) require ( backend , reference . file )
}
2026-07-28 23:05:20 +08:00
}
return [ . . . requiredPackages ] . flatMap ( ( [ packageName , locations ] ) = > packageName in dependencies
? [ ]
: ` ${ [ . . . locations ] . join ( ', ' ) } : ${ packageName } must be declared in ${ manifestPath } dependencies ` )
}
2026-07-17 23:38:05 +08:00
function readManifest ( path : string ) : PackageManifest {
return JSON . parse ( readFileSync ( resolve ( root , path ) , 'utf8' ) ) as PackageManifest
}
function localPackageDirectories ( ) : Map < string , string > {
const manifests = globSync ( [ 'packages/*/*/package.json' , 'vendor/*/package.json' ] , { cwd : root } )
const packages = new Map < string , string > ( )
for ( const manifestPath of manifests ) {
const manifest = readManifest ( manifestPath )
if ( manifest . name !== undefined ) packages . set ( manifest . name , resolve ( root , dirname ( manifestPath ) ) )
}
return packages
}
function rootProjectReferences ( ) : Set < string > {
2026-07-22 23:58:37 +08:00
// The root solution references the host and client aggregates (the two
// sides merge cordis Context under the same keys, so one program cannot see
// both — but this BFS only collects reference paths, it never forms a
// program). Seed the solution and follow nested aggregate references to
// collect the covered leaf project set.
2026-07-19 21:17:57 +08:00
const collected = new Set < string > ( )
2026-07-22 23:58:37 +08:00
const queue = [ resolve ( root , 'tsconfig.json' ) ]
2026-07-19 21:17:57 +08:00
const seen = new Set < string > ( )
for ( let file = queue . pop ( ) ; file !== undefined ; file = queue . pop ( ) ) {
if ( seen . has ( file ) ) continue
seen . add ( file )
const config = ts . readConfigFile ( file , path = > ts . sys . readFile ( path ) )
if ( config . error !== undefined ) {
throw new Error ( ts . flattenDiagnosticMessageText ( config . error . messageText , '\n' ) )
}
const references = ( config . config as { references? : Array < { path? : unknown } > } ) . references ? ? [ ]
for ( const reference of references ) {
if ( typeof reference . path !== 'string' ) continue
const target = resolve ( dirname ( file ) , reference . path )
if ( target . endsWith ( '.json' ) ) queue . push ( target )
else collected . add ( target )
}
2026-07-17 23:38:05 +08:00
}
2026-07-19 21:17:57 +08:00
return collected
2026-07-17 23:38:05 +08:00
}
function packageNameFromSpecifier ( specifier : string ) : string | undefined {
2026-07-28 23:05:20 +08:00
if ( specifier . startsWith ( '.' ) || specifier . startsWith ( '/' ) || /^[a-z][a-z+.-]*:/i . test ( specifier ) ) return undefined
2026-07-17 23:38:05 +08:00
const segments = specifier . split ( '/' )
if ( specifier . startsWith ( '@' ) ) {
return segments . length >= 2 ? ` ${ segments [ 0 ] } / ${ segments [ 1 ] } ` : undefined
}
return segments [ 0 ] || undefined
}
2026-07-14 21:25:58 +08:00
function validateMetadata ( entry : Record < string , unknown > , file : string , path : string ) : void {
for ( const field of metadataFields ) {
if ( ! ( field in entry ) ) continue
const expressionPaths : string [ ] = [ ]
collectExpressionPaths ( entry [ field ] , ` ${ path } . ${ field } ` , expressionPaths )
for ( const expressionPath of expressionPaths ) errors . push ( ` ${ file } ${ expressionPath } : !!js is not interpolated here ` )
}
}
function collectExpressionPaths ( value : unknown , path : string , output : string [ ] ) : void {
if ( isJsExpr ( value ) ) {
output . push ( path )
return
}
if ( isUnknownArray ( value ) ) {
for ( let index = 0 ; index < value . length ; index ++ ) collectExpressionPaths ( value [ index ] , ` ${ path } [ ${ index } ] ` , output )
return
}
if ( ! isRecord ( value ) ) return
for ( const [ key , child ] of Object . entries ( value ) ) collectExpressionPaths ( child , ` ${ path } . ${ key } ` , output )
}
function isJsExpr ( value : unknown ) : value is JsExpr {
return isRecord ( value ) && typeof value . __jsExpr === 'string'
}
function isRecord ( value : unknown ) : value is Record < string , unknown > {
return value !== null && typeof value === 'object'
}
function isUnknownArray ( value : unknown ) : value is unknown [ ] {
return Array . isArray ( value )
}