feat(typert): add compiler-independent type pipeline

This commit is contained in:
imccyu
2026-07-28 23:47:57 +08:00
parent 2b7bc0c195
commit f773985e71
84 changed files with 15378 additions and 15 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/typert/loader/README.md
README.md: ab9293de1630fdbe8c560bb9e6d00c272cc34161
README.zh.md: 7ececd07ac9a12bc04dca8206e348c25adc4ee76

View File

@@ -0,0 +1,24 @@
# @deepseek-ai/dsh-typert-loader
English | [中文](README.zh.md)
Node-only Loader integration for generated Typert artifacts. The plugin requires `ctx.loader` and `ctx.typert`; it does not provide the registry itself.
During activation it scans existing Loader entries. It then follows Cordis `internal/plugin` lifecycle notifications, resolves each entry package's `package.json`, imports `./typert` when exported, validates its `TYPERT` manifest, and registers the contribution until the entry or this plugin unmounts. An import that settles after either owner is gone is discarded.
`packages` lists additional package artifacts to register for plugins nested behind another Loader entry. Cordis fibers do not retain those nested plugins' npm specifiers, so this boundary is explicit; every configured package must resolve from the config tree and export `./typert`.
Packages without the export are skipped. Package resolution and imported manifests are cached for the process lifetime, so adding an export requires a restart. A malformed artifact fails activation when already mounted; a later failure is logged without preventing unrelated packages from registering.
## Model Experience
None, as the loader only feeds [`ctx.typert`](../registry/README.md); consumers own any model-visible projection.
#### KV Cache effect
No direct effect.
## Known Limitations and Deferred Work
- Discovery imports only the host face; client runtimes need a separate composition owner before equivalent discovery is added.
- Loader entries are discovered automatically. Nested or non-Loader plugins require an explicit `packages` entry or direct `ctx.typert.register()` ownership.

View File

@@ -0,0 +1,24 @@
# @deepseek-ai/dsh-typert-loader
[English](README.md) | 中文
生成的 Typert 产物所用的 Loader 集成,仅支持 Node。该插件需要 `ctx.loader``ctx.typert`;它本身不提供注册表。
激活时,该插件会扫描现有的 Loader 配置项。随后它会监听 Cordis `internal/plugin` 生命周期通知解析每个配置项所属包package`package.json`,在其导出 `./typert` 时导入该子路径,校验其 `TYPERT` manifest元数据清单并注册该贡献项直到配置项或本插件卸载。如果导入操作在配置项或本插件卸载后才结束系统会丢弃其结果。
`packages` 用于列出需要为嵌套在另一 Loader 配置项下的插件额外注册的包产物。Cordis fiber 不会保留这些嵌套插件的 npm 包说明符,因此这里通过显式配置划定边界;配置中列出的每个包都必须能从配置树解析,并导出 `./typert`
未导出该子路径的包会被跳过。包解析结果和已导入的 manifest 会在整个进程生命周期内缓存,因此新增该导出后必须重启进程。如果已经挂载的产物格式错误,插件激活会失败;后续失败只会记录到日志,不会阻止无关包完成注册。
## 模型体验
无。loader 只向 [`ctx.typert`](../registry/README.md) 提供注册项;任何模型可见投影均由消费方负责。
#### KV Cache 影响
无直接影响。
## 已知限制与暂缓工作
- 发现机制只会导入宿主侧产物;若要为客户端运行时添加等价的发现机制,需要先有独立的组合所有者。
- Loader 配置项会自动发现。嵌套插件或非 Loader 插件需要显式加入 `packages`,或由组合所有者直接负责调用 `ctx.typert.register()`

View File

@@ -0,0 +1,45 @@
{
"name": "@deepseek-ai/dsh-typert-loader",
"description": "Loader integration for generated Typert package contributions",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"cordis": "^4.0.0-rc.7",
"zod": "^4.4.3"
}
}

View File

@@ -0,0 +1,351 @@
/**
* Typert Loader integration: automatic registration for mounted plugin packages.
*
* When a loader entry mounts, this plugin resolves the entry's package.json; a
* package exporting `./typert` has its host face imported and its
* `TYPERT` manifest registered into `ctx.typert`, and the registration is
* withdrawn when the entry unmounts. Explicit `packages` cover plugins nested
* behind another Loader entry, whose Cordis fibers carry no resolvable package
* specifier. Packages without the export are skipped silently when discovered
* from Loader entries; an explicit package or declared artifact that is broken
* fails loud — aggregated into this plugin's activation throw for existing
* entries, contained to a logged error per package in steady state.
*
* Scanning is incremental per entry name, mirroring the client-modules node
* half: every cordis `internal/plugin` emission marks the fiber's entry name
* dirty and a microtask flush reconciles each dirty name against the live
* loader entries; the activation pass seeds the same dirty set with all
* current entries. Package verdicts and imported manifests are cached per
* package name and never expire — plugin-set changes take effect on restart.
*
* Manual `ctx.typert.register()` remains the escape hatch for contributions
* that do not ride a `./typert` artifact (hand-written contract schemas,
* tests, non-loader compositions).
*
* @module @deepseek-ai/dsh-typert-loader
*/
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@cordisjs/plugin-loader'
import type {} from '@deepseek-ai/dsh-typert-registry'
import type { TypertContribution } from '@deepseek-ai/dsh-typert-registry/types'
/** The package.json exports key naming a package's host-face typert artifact. */
export const TYPERT_HOST_EXPORT = './typert'
/** Cordis plugin name. */
export const name = 'typert-loader'
/** Services required before registration: the registry this plugin feeds and the Loader it observes. */
export const inject = ['typert', 'loader']
/** Additional package artifacts whose owning plugins are nested behind another Loader entry. */
export interface Config {
/** Exact npm package names that must resolve and export `./typert`. */
packages?: string[]
}
/** Validate explicit package names and default to Loader-entry discovery only. */
export const Config: z<Config> = z.object({
packages: z.array(z.string().min(1)).default([]),
})
type ResolvedConfig = Required<Config>
const MEMBER_KINDS = new Set(['property', 'method', 'getter', 'setter', 'call', 'construct', 'index'])
/** Resolve the `./typert` export to a relative path, accepting the string and one-level conditional forms. */
function typertExportOf(pkgName: string, exportsField: unknown): string | undefined {
if (typeof exportsField !== 'object' || exportsField === null) return undefined
const target = (exportsField as Record<string, unknown>)[TYPERT_HOST_EXPORT]
if (target === undefined) return undefined
if (typeof target === 'string') return target
if (typeof target === 'object' && target !== null) {
const fallback = (target as Record<string, unknown>).default
if (typeof fallback === 'string') return fallback
}
throw new Error(`typert-loader: ${pkgName} exports["${TYPERT_HOST_EXPORT}"] has an unsupported shape`)
}
/**
* Narrow a dynamically imported typert module's `TYPERT` export to a
* contribution owned by `pkgName`. This is the module/file boundary: the
* manifest crosses from a build artifact into the typed registry, so every
* field is checked and every failure names the package and the defect.
* @param pkgName - the package whose typert face was imported.
* @param exported - the module's `TYPERT` export.
* @returns the validated contribution.
*/
export function validateTypertManifest(pkgName: string, exported: unknown): TypertContribution {
if (typeof exported !== 'object' || exported === null) {
throw new Error(`typert-loader: ${pkgName} exports "${TYPERT_HOST_EXPORT}" but its module has no TYPERT manifest object`)
}
const manifest = exported as Record<string, unknown>
if (manifest.package !== pkgName) {
throw new Error(
`typert-loader: ${pkgName} TYPERT manifest names package ${JSON.stringify(manifest.package)} — the manifest must be owned by the package that exports it`,
)
}
if (manifest.face !== 'host') {
throw new Error(`typert-loader: ${pkgName} exports "${TYPERT_HOST_EXPORT}" but TYPERT.face is not "host"`)
}
if (!Array.isArray(manifest.schemas)) {
throw new Error(`typert-loader: ${pkgName} TYPERT.schemas must be an array`)
}
for (const value of manifest.schemas as unknown[]) {
if (typeof value !== 'object' || value === null) {
throw new Error(`typert-loader: ${pkgName} TYPERT.schemas contains a non-object schema`)
}
const schema = value as Record<string, unknown>
requireString(pkgName, schema, 'name', 'schema')
if (typeof schema.schema !== 'object' || schema.schema === null || !('_zod' in schema.schema)) {
throw new Error(`typert-loader: ${pkgName} TYPERT schema "${schema.name as string}" is not a zod v4 schema instance`)
}
}
const model = requireObject(pkgName, manifest.model, 'TYPERT.model')
const services = requireArray(pkgName, model.services, 'TYPERT.model.services')
const events = requireArray(pkgName, model.events, 'TYPERT.model.events')
const objects = requireArray(pkgName, model.objects, 'TYPERT.model.objects')
for (const value of services) {
const service = requireObject(pkgName, value, 'service')
requireDocumentation(pkgName, service, 'service')
requireString(pkgName, service, 'key', 'service')
requireString(pkgName, service, 'exportName', 'service')
requireMembers(pkgName, service.members, `service "${service.key as string}"`)
requireTypes(pkgName, service.types, `service "${service.key as string}"`)
}
for (const value of events) {
const event = requireObject(pkgName, value, 'event')
requireDocumentation(pkgName, event, 'event')
requireString(pkgName, event, 'name', 'event')
requireString(pkgName, event, 'signature', `event "${event.name as string}"`)
if (event.mode !== undefined && typeof event.mode !== 'string') {
throw new Error(`typert-loader: ${pkgName} event "${event.name as string}" mode must be a string`)
}
}
for (const value of objects) {
const object = requireObject(pkgName, value, 'object')
requireDocumentation(pkgName, object, 'object')
requireString(pkgName, object, 'name', 'object')
requireString(pkgName, object, 'exportName', 'object')
requireMembers(pkgName, object.members, `object "${object.name as string}"`)
requireTypes(pkgName, object.types, `object "${object.name as string}"`)
}
return manifest as unknown as TypertContribution
}
function requireObject(pkgName: string, value: unknown, subject: string): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`typert-loader: ${pkgName} ${subject} must be an object`)
}
return value as Record<string, unknown>
}
function requireArray(pkgName: string, value: unknown, subject: string): unknown[] {
if (!Array.isArray(value)) throw new Error(`typert-loader: ${pkgName} ${subject} must be an array`)
return value
}
function requireString(pkgName: string, value: Record<string, unknown>, key: string, subject: string): void {
if (typeof value[key] !== 'string' || value[key].length === 0) {
throw new Error(`typert-loader: ${pkgName} ${subject} has a missing or empty ${key}`)
}
}
function requireDocumentation(pkgName: string, value: Record<string, unknown>, subject: string): void {
requireArray(pkgName, value.tags, `${subject}.tags`)
for (const key of ['description', 'summary', 'jsDoc'] as const) {
if (value[key] !== undefined && typeof value[key] !== 'string') {
throw new Error(`typert-loader: ${pkgName} ${subject}.${key} must be a string`)
}
}
}
function requireMembers(pkgName: string, value: unknown, subject: string): void {
for (const item of requireArray(pkgName, value, `${subject}.members`)) {
const member = requireObject(pkgName, item, `${subject} member`)
requireString(pkgName, member, 'name', `${subject} member`)
requireString(pkgName, member, 'signature', `${subject} member`)
if (typeof member.kind !== 'string' || !MEMBER_KINDS.has(member.kind)) {
throw new Error(`typert-loader: ${pkgName} ${subject} member "${member.name as string}" has invalid kind`)
}
}
}
function requireTypes(pkgName: string, value: unknown, subject: string): void {
for (const item of requireArray(pkgName, value, `${subject}.types`)) {
const type = requireObject(pkgName, item, `${subject} type`)
requireString(pkgName, type, 'name', `${subject} type`)
requireString(pkgName, type, 'declaration', `${subject} type`)
}
}
/**
* Scan current Loader entries during activation, then follow entry mounts and
* unmounts for this plugin's lifetime.
* @param ctx - plugin context carrying `typert` and `loader`.
* @param config - explicit package artifacts in addition to Loader entries.
*/
export async function apply(ctx: Context, config: Config): Promise<void> {
// Resolution anchor: the config tree's baseUrl (the cordis.yml directory,
// whose package declares every composed plugin as a dependency). This
// package's own URL would miss sibling packages under pnpm's isolated
// node_modules.
if (ctx.baseUrl === undefined) {
throw new Error('typert-loader: ctx.baseUrl is unset — the loader needs the config-tree anchor to resolve plugin packages')
}
const require = createRequire(ctx.baseUrl)
const configured = new Set((config as ResolvedConfig).packages)
// Registered contributions by entry name; the disposer withdraws the entry's registration.
const registered = new Map<string, () => void>()
// In-flight import/register tasks by entry name.
const pending = new Map<string, Promise<void>>()
// Artifact paths by package name. Negative verdicts (unresolvable specifier —
// loader builtins, subpath rows — or no typert export) are cached as null and
// never expire: plugin-set changes take effect on restart.
const artifactPath = new Map<string, string | null>()
// Imported+validated manifests by package name (one import per package per process).
const manifests = new Map<string, Promise<TypertContribution>>()
const dirty = new Set<string>()
let flushQueued = false
let active = true
ctx.effect(function* () {
yield () => {
active = false
dirty.clear()
}
}, 'typert loader lifetime')
const resolveArtifact = (pkgName: string): string | null => {
const cached = artifactPath.get(pkgName)
if (cached !== undefined) return cached
let pkgPath: string
try {
pkgPath = require.resolve(`${pkgName}/package.json`)
} catch (cause) {
if (configured.has(pkgName)) {
throw new Error(
`typert-loader: configured package "${pkgName}" cannot be resolved from the config tree — add it to the composition package dependencies or remove it from packages`,
{ cause },
)
}
// Not a resolvable package root: loader builtins (cordis:include) and
// subpath entries land here — permanently not a typert contributor.
artifactPath.set(pkgName, null)
return null
}
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
const rel = typertExportOf(pkgName, pkg.exports)
if (rel === undefined && configured.has(pkgName)) {
throw new Error(`typert-loader: configured package "${pkgName}" does not export "${TYPERT_HOST_EXPORT}"`)
}
const resolved = rel === undefined ? null : join(dirname(pkgPath), rel)
artifactPath.set(pkgName, resolved)
return resolved
}
const loadManifest = (pkgName: string, path: string): Promise<TypertContribution> => {
let loading = manifests.get(pkgName)
if (loading === undefined) {
loading = import(pathToFileURL(path).href).then(
(mod: Record<string, unknown>) => validateTypertManifest(pkgName, mod.TYPERT),
(cause: unknown) => {
throw new Error(
`typert-loader: ${pkgName} exports "${TYPERT_HOST_EXPORT}" but importing ${path} failed: ${String(cause)}`,
)
},
)
manifests.set(pkgName, loading)
}
return loading
}
const qualifies = (entryName: string): boolean => {
if (configured.has(entryName)) return true
for (const entry of ctx.loader.entries()) {
if (entry.options.name === entryName && entry.fiber !== undefined && !entry.disabled) return true
}
return false
}
/** Reconcile one entry name against the live loader entries; a mount returns its async task. */
const processOne = (entryName: string): Promise<void> | undefined => {
if (!qualifies(entryName)) {
const dispose = registered.get(entryName)
if (dispose !== undefined) {
registered.delete(entryName)
dispose()
}
return undefined
}
if (registered.has(entryName) || pending.has(entryName)) return undefined
const path = resolveArtifact(entryName)
if (path === null) return undefined
const task = loadManifest(entryName, path).then((manifest) => {
// The entry may have unmounted (or already re-registered) while the import was in flight.
if (!active || !qualifies(entryName) || registered.has(entryName)) return
registered.set(entryName, ctx.typert.register(manifest))
})
pending.set(entryName, task)
// Two-armed settle: a bare .finally() would mint a second, unhandled rejection.
const settle = (): void => { pending.delete(entryName) }
void task.then(settle, settle)
return task
}
const flush = (onError: (error: Error) => void): Promise<void>[] => {
const tasks: Promise<void>[] = []
for (const entryName of [...dirty]) {
dirty.delete(entryName)
try {
const task = processOne(entryName)
if (task !== undefined) tasks.push(task.catch((error: unknown) => { onError(toError(error)) }))
} catch (error) {
// Steady state: one broken package must not poison the others; the
// activation pass aggregates these into a loud throw instead.
onError(toError(error))
}
}
return tasks
}
// Subscribe before seeding so an entry arriving mid-activation lands in the
// same dirty set (Set idempotence makes the overlap harmless). An entry-less
// fiber is a child plugin or a manual mount — never a loader row; O(1) drop.
ctx.on('internal/plugin', (fiber) => {
const entryName = fiber.entry?.options.name
if (entryName === undefined) return
dirty.add(entryName)
if (flushQueued) return
flushQueued = true
queueMicrotask(() => {
flushQueued = false
if (!active) return
for (const task of flush((err) => { ctx.logger.error(err) })) void task
})
})
// Activation pass: the initial scan IS the incremental path over the current
// entries; a malformed typert contributor among the already-loaded entries
// aggregates into one loud throw (FAILED loader fiber; the boot sweep reports it).
for (const packageName of configured) dirty.add(packageName)
for (const entry of ctx.loader.entries()) dirty.add(entry.options.name)
const failures: Error[] = []
await Promise.all(flush((err) => { failures.push(err) }))
if (failures.length > 0) {
throw new AggregateError(
failures,
`typert-loader: ${String(failures.length)} typert contributor(s) failed to register:\n${failures.map(e => ` - ${e.message}`).join('\n')}`,
)
}
}
/** Normalize an arbitrary import or manifest failure to an Error. */
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-typert-loader`.
* @module @deepseek-ai/dsh-typert-loader/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-typert-loader'
/** Cordis companion plugin name. */
export const name = 'typert-loader-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the Loader entry lifecycle directly owns each exact
* registry disposer, and integration tests observe registration and removal.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,462 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import * as typertLoader from '@deepseek-ai/dsh-typert-loader'
import { validateTypertManifest } from '@deepseek-ai/dsh-typert-loader'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
Reflect.deleteProperty(globalThis, '__dshTypertLoaderGate')
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
/** Write a fake installed package under the fixture root's node_modules. */
async function writePackage(
base: string,
pkgName: string,
options: {
typertExport?: boolean
typertTarget?: unknown
typertSource?: string
pluginSource?: string
omitExports?: boolean
} = {},
): Promise<void> {
const dir = join(base, 'node_modules', ...pkgName.split('/'))
await mkdir(dir, { recursive: true })
const exportsField: Record<string, unknown> = { '.': './index.js', './package.json': './package.json' }
if (options.typertExport !== false && options.typertSource !== undefined) {
exportsField['./typert'] = options.typertTarget ?? './typert.host.js'
}
await writeFile(join(dir, 'package.json'), JSON.stringify({
name: pkgName,
type: 'module',
...(options.omitExports ? { main: './index.js' } : { exports: exportsField }),
}))
await writeFile(join(dir, 'index.js'), options.pluginSource ?? 'export function apply() {}\n')
if (options.typertSource !== undefined) {
await writeFile(join(dir, 'typert.host.js'), options.typertSource)
}
}
function typertSource(pkgName: string, entryName: string): string {
return [
'import { z } from \'zod\'',
`export const ${entryName} = z.object({ id: z.string() })`,
'export const TYPERT = {',
` package: '${pkgName}',`,
' face: \'host\',',
` schemas: [{ name: '${entryName}', schema: ${entryName} }],`,
' model: { services: [], events: [], objects: [] },',
'}',
'',
].join('\n')
}
/** Boot a real Loader over a fixture root; plugin modules resolve from its node_modules. */
async function boot(): Promise<Context> {
context = new Context()
context.baseUrl = pathToFileURL(join(root as string, 'cordis.yml')).href
await context.plugin(TypertRegistry)
await context.plugin(Loader)
// zod must be resolvable from the fixture packages; link the workspace copy.
await mkdir(join(root as string, 'node_modules'), { recursive: true })
return context
}
async function linkZod(base: string): Promise<void> {
const { symlink } = await import('node:fs/promises')
const target = join(base, 'node_modules', 'zod')
const source = new URL(import.meta.resolve('zod/package.json')).pathname.replace(/\/package\.json$/, '')
await mkdir(join(base, 'node_modules'), { recursive: true })
await symlink(source, target, 'dir')
}
function mountTypertLoader(ctx: Context, config: typertLoader.Config = {}): ReturnType<Context['plugin']> {
return ctx.plugin(typertLoader, config)
}
// Fixture setup writes fake installed packages and boots a real Loader; the
// default 5s deadline is too tight on slow CI filesystems.
const LOADER_TEST_TIMEOUT = { timeout: 60_000 }
describe('typert loader', () => {
it('registers an explicit package without a Loader entry and withdraws it with the loader', LOADER_TEST_TIMEOUT, async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
await linkZod(root)
await writePackage(root, '@fixture/nested', { typertSource: typertSource('@fixture/nested', 'Nested') })
const ctx = await boot()
const fiber = mountTypertLoader(ctx, { packages: ['@fixture/nested'] })
await fiber
expect(ctx.typert.get('@fixture/nested#Nested')).toBeDefined()
await fiber.dispose()
expect(ctx.typert.getPackage('@fixture/nested')).toBeUndefined()
})
it('fails loud when an explicit package is absent or has no Typert export', LOADER_TEST_TIMEOUT, async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
await writePackage(root, '@fixture/plain')
const ctx = await boot()
let failure: unknown
try {
await mountTypertLoader(ctx, { packages: ['@fixture/missing', '@fixture/plain'] })
} catch (error) {
failure = error
}
expect(failure).toBeInstanceOf(AggregateError)
expect((failure as Error).message).toContain('configured package "@fixture/missing" cannot be resolved')
expect((failure as Error).message).toContain('configured package "@fixture/plain" does not export "./typert"')
})
it('auto-registers a mounted package exporting ./typert and withdraws it on unmount', LOADER_TEST_TIMEOUT, async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
await linkZod(root)
await writePackage(root, '@fixture/with-typert', { typertSource: typertSource('@fixture/with-typert', 'Thing') })
await writePackage(root, '@fixture/plain')
const ctx = await boot()
const id = await ctx.loader.create({ name: '@fixture/with-typert' })
const plainId = await ctx.loader.create({ name: '@fixture/plain' })
await ctx.loader.await()
await mountTypertLoader(ctx)
await ctx.loader.await()
const record = ctx.typert.get('@fixture/with-typert#Thing')
expect(record).toMatchObject({ package: '@fixture/with-typert', face: 'host', name: 'Thing' })
expect(record?.schema.safeParse({ id: 'x' }).success).toBe(true)
// The plain package is silently skipped.
expect(ctx.typert.list().map(r => r.key)).toEqual(['@fixture/with-typert#Thing'])
const mounted = [...ctx.loader.entries()].find(entry => entry.options.name === '@fixture/with-typert')
if (mounted?.fiber === undefined) throw new Error('fixture loader entry has no fiber')
ctx.emit('internal/plugin', mounted.fiber)
ctx.emit('internal/plugin', mounted.fiber)
await new Promise(resolve => setTimeout(resolve, 20))
expect(ctx.typert.list()).toHaveLength(1)
ctx.loader.remove(id)
await ctx.loader.await()
// The unmount reconciliation rides a queued microtask flush.
await new Promise(resolve => setTimeout(resolve, 20))
expect(ctx.typert.get('@fixture/with-typert#Thing')).toBeUndefined()
ctx.loader.remove(plainId)
await ctx.loader.await()
await new Promise(resolve => setTimeout(resolve, 20))
await ctx.loader.create({ name: '@fixture/with-typert' })
await ctx.loader.await()
await new Promise(resolve => setTimeout(resolve, 20))
expect(ctx.typert.get('@fixture/with-typert#Thing')).toBeDefined()
})
it('follows entries mounted after activation', LOADER_TEST_TIMEOUT, async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
await linkZod(root)
await writePackage(root, '@fixture/late', { typertSource: typertSource('@fixture/late', 'Late') })
const ctx = await boot()
await mountTypertLoader(ctx)
expect(ctx.typert.get('@fixture/late#Late')).toBeUndefined()
await ctx.loader.create({ name: '@fixture/late' })
await ctx.loader.await()
// The microtask flush and the dynamic import need a turn to settle.
await new Promise(resolve => setTimeout(resolve, 20))
expect(ctx.typert.get('@fixture/late#Late')).toBeDefined()
})
it('drops an in-flight manifest when the loader is disposed before import settles', LOADER_TEST_TIMEOUT, async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
await linkZod(root)
let markStarted: (() => void) | undefined
const started = new Promise<void>((resolve) => { markStarted = resolve })
let releaseImport: (() => void) | undefined
const wait = new Promise<void>((resolve) => { releaseImport = resolve })
Reflect.set(globalThis, '__dshTypertLoaderGate', {
started: (): void => { markStarted?.() },
wait,
})
await writePackage(root, '@fixture/pending', {
typertSource: [
'import { z } from \'zod\'',
'globalThis.__dshTypertLoaderGate.started()',
'await globalThis.__dshTypertLoaderGate.wait',
'export const Pending = z.object({ id: z.string() })',
'export const TYPERT = {',
' package: \'@fixture/pending\',',
' face: \'host\',',
' schemas: [{ name: \'Pending\', schema: Pending }],',
' model: { services: [], events: [], objects: [] },',
'}',
'',
].join('\n'),
})
const ctx = await boot()
const loaderFiber = mountTypertLoader(ctx)
await loaderFiber
await ctx.loader.create({ name: '@fixture/pending' })
await ctx.loader.await()
await started
const mounted = [...ctx.loader.entries()].find(entry => entry.options.name === '@fixture/pending')
if (mounted?.fiber === undefined) throw new Error('fixture loader entry has no fiber')
ctx.emit('internal/plugin', mounted.fiber)
await Promise.resolve()
let queued: (() => void) | undefined
const queue = vi.spyOn(globalThis, 'queueMicrotask').mockImplementation((callback) => { queued = callback })
ctx.emit('internal/plugin', mounted.fiber)
queue.mockRestore()
await loaderFiber.dispose()
queued?.()
releaseImport?.()
await new Promise(resolve => setTimeout(resolve, 20))
expect(ctx.typert.getPackage('@fixture/pending')).toBeUndefined()
})
it('fails activation loud when an already-mounted contributor is malformed', LOADER_TEST_TIMEOUT, async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
await linkZod(root)
await writePackage(root, '@fixture/broken', {
typertSource: 'export const TYPERT = { package: \'@fixture/broken\', face: \'host\', schemas: [{ name: \'\', schema: {} }], model: { services: [], events: [], objects: [] } }\n',
})
const ctx = await boot()
await ctx.loader.create({ name: '@fixture/broken' })
await ctx.loader.await()
await expect(mountTypertLoader(ctx)).rejects.toThrow(/typert contributor\(s\) failed to register/)
})
it('fails loud when the declared typert module cannot be imported', LOADER_TEST_TIMEOUT, async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
await linkZod(root)
await writePackage(root, '@fixture/no-module', {
typertSource: 'import { missing } from \'./nope.js\'\nexport const TYPERT = missing\n',
})
const ctx = await boot()
await ctx.loader.create({ name: '@fixture/no-module' })
await ctx.loader.await()
await expect(mountTypertLoader(ctx)).rejects.toThrow(/importing .* failed/)
})
it('accepts conditional artifact exports and skips packages with no exports field', LOADER_TEST_TIMEOUT, async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
await linkZod(root)
await writePackage(root, '@fixture/conditional', {
typertSource: typertSource('@fixture/conditional', 'Conditional'),
typertTarget: { default: './typert.host.js' },
})
await writePackage(root, '@fixture/no-exports', { omitExports: true })
const ctx = await boot()
await ctx.loader.create({ name: '@fixture/conditional' })
await ctx.loader.create({ name: '@fixture/no-exports' })
await ctx.loader.await()
await mountTypertLoader(ctx)
expect(ctx.typert.get('@fixture/conditional#Conditional')).toBeDefined()
expect(ctx.typert.getPackage('@fixture/no-exports')).toBeUndefined()
})
it('aggregates unsupported package export shapes during activation', LOADER_TEST_TIMEOUT, async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
await linkZod(root)
await writePackage(root, '@fixture/export-shape', {
typertSource: typertSource('@fixture/export-shape', 'Shape'),
typertTarget: { default: 1 },
})
await writePackage(root, '@fixture/export-primitive', {
typertSource: typertSource('@fixture/export-primitive', 'Primitive'),
typertTarget: 1,
})
const ctx = await boot()
await ctx.loader.create({ name: '@fixture/export-shape' })
await ctx.loader.create({ name: '@fixture/export-primitive' })
await ctx.loader.await()
await expect(mountTypertLoader(ctx)).rejects.toThrow('unsupported shape')
})
it('caches a negative verdict for loader entries without a package root', LOADER_TEST_TIMEOUT, async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
const ctx = await boot()
ctx.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (specifier !== 'virtual-plugin') throw new Error(`unexpected fixture import ${specifier}`)
return { apply() {} }
},
} as unknown as NonNullable<typeof ctx.loader.internal>
await ctx.loader.create({ name: 'virtual-plugin' })
await ctx.loader.await()
await mountTypertLoader(ctx)
expect(ctx.typert.getPackage('virtual-plugin')).toBeUndefined()
})
it('requires a config-tree resolution anchor', LOADER_TEST_TIMEOUT, async () => {
context = new Context()
await context.plugin(TypertRegistry)
await context.plugin(Loader)
await expect(mountTypertLoader(context)).rejects.toThrow('ctx.baseUrl is unset')
})
it('contains steady-state registration failures and normalizes non-Error throws', LOADER_TEST_TIMEOUT, async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
await linkZod(root)
await writePackage(root, '@fixture/steady-failure', {
typertSource: typertSource('@fixture/steady-failure', 'Steady'),
})
const ctx = await boot()
await mountTypertLoader(ctx)
const logged = vi.spyOn(ctx.logger, 'error').mockImplementation(() => undefined)
vi.spyOn(ctx.typert, 'register').mockImplementation(() => { throw 'register failed' })
await ctx.loader.create({ name: '@fixture/steady-failure' })
await ctx.loader.await()
await new Promise(resolve => setTimeout(resolve, 20))
expect(logged).toHaveBeenCalledWith(expect.objectContaining({ message: 'register failed' }))
expect(ctx.typert.getPackage('@fixture/steady-failure')).toBeUndefined()
})
})
describe('validateTypertManifest', () => {
const zodish = { _zod: {} }
it('accepts a well-formed manifest and rejects each malformed field loudly', () => {
expect(validateTypertManifest('pkg', {
package: 'pkg',
face: 'host',
schemas: [{ name: 'A', schema: zodish }],
model: { services: [], events: [], objects: [] },
}).schemas).toHaveLength(1)
expect(() => validateTypertManifest('pkg', undefined)).toThrow('no TYPERT manifest object')
expect(() => validateTypertManifest('pkg', { package: 'other' })).toThrow('must be owned by the package')
expect(() => validateTypertManifest('pkg', { package: 'pkg', face: 'client' })).toThrow('TYPERT.face is not "host"')
expect(() => validateTypertManifest('pkg', { package: 'pkg', face: 'host', schemas: 'x' })).toThrow('schemas must be an array')
expect(() => validateTypertManifest('pkg', { package: 'pkg', face: 'host', schemas: [null] })).toThrow('non-object schema')
expect(() => validateTypertManifest('pkg', { package: 'pkg', face: 'host', schemas: [{ name: '', schema: zodish }] }))
.toThrow('missing or empty name')
expect(() => validateTypertManifest('pkg', { package: 'pkg', face: 'host', schemas: [{ name: 'A', schema: {} }] }))
.toThrow('not a zod v4 schema instance')
expect(() => validateTypertManifest('pkg', {
package: 'pkg',
face: 'host',
schemas: [],
model: { services: [{ key: 'tools', exportName: 'ToolRegistry', tags: [], members: 'x', types: [] }], events: [], objects: [] },
})).toThrow('service "tools".members must be an array')
})
it('validates service, event, object, member, type, and documentation records', () => {
const complete = completeManifest(zodish)
expect(validateTypertManifest('pkg', complete)).toBe(complete)
expect(() => validateTypertManifest('pkg', { ...complete, model: [] }))
.toThrow('TYPERT.model must be an object')
expect(() => validateTypertManifest('pkg', { ...complete, model: { ...complete.model, services: [null] } }))
.toThrow('service must be an object')
expect(() => validateTypertManifest('pkg', {
...complete,
model: { ...complete.model, services: [{ ...complete.model.services[0], tags: 'bad' }] },
})).toThrow('service.tags must be an array')
expect(() => validateTypertManifest('pkg', {
...complete,
model: { ...complete.model, services: [{ ...complete.model.services[0], description: 1 }] },
})).toThrow('service.description must be a string')
expect(() => validateTypertManifest('pkg', {
...complete,
model: { ...complete.model, services: [{ ...complete.model.services[0], key: '' }] },
})).toThrow('service has a missing or empty key')
expect(() => validateTypertManifest('pkg', {
...complete,
model: { ...complete.model, services: [{ ...complete.model.services[0], members: [null] }] },
})).toThrow('member must be an object')
expect(() => validateTypertManifest('pkg', {
...complete,
model: {
...complete.model,
services: [{ ...complete.model.services[0], members: [{ name: 'member', signature: 'member(): void', kind: 1 }] }],
},
})).toThrow('has invalid kind')
expect(() => validateTypertManifest('pkg', {
...complete,
model: {
...complete.model,
services: [{ ...complete.model.services[0], members: [{ name: 'member', signature: 'member(): void', kind: 'future' }] }],
},
})).toThrow('has invalid kind')
expect(() => validateTypertManifest('pkg', {
...complete,
model: { ...complete.model, services: [{ ...complete.model.services[0], types: [null] }] },
})).toThrow('type must be an object')
expect(() => validateTypertManifest('pkg', {
...complete,
model: {
...complete.model,
services: [{ ...complete.model.services[0], types: [{ name: 'Type', declaration: '' }] }],
},
})).toThrow('type has a missing or empty declaration')
expect(() => validateTypertManifest('pkg', {
...complete,
model: { ...complete.model, events: [{ ...complete.model.events[0], mode: 1 }] },
})).toThrow('mode must be a string')
expect(() => validateTypertManifest('pkg', { ...complete, model: { ...complete.model, objects: [null] } }))
.toThrow('object must be an object')
expect(() => validateTypertManifest('pkg', {
...complete,
model: { ...complete.model, objects: [{ ...complete.model.objects[0], exportName: '' }] },
})).toThrow('object has a missing or empty exportName')
})
})
function completeManifest(zodish: object) {
const member = { name: 'member', signature: 'member(): void', kind: 'method' }
const type = { name: 'Value', declaration: 'export interface Value {}' }
return {
package: 'pkg',
face: 'host',
schemas: [{ name: 'Schema', schema: zodish }],
model: {
services: [{
key: 'service',
exportName: 'Service',
description: 'Service description.',
summary: 'Service description.',
jsDoc: '/** Service description. */',
tags: [],
members: [member],
types: [type],
}],
events: [
{ name: 'event/with-mode', mode: 'emit', signature: "'event/with-mode'(): void", tags: [] },
{ name: 'event/without-mode', signature: "'event/without-mode'(): void", tags: [] },
],
objects: [{
name: 'Object',
exportName: 'Object',
tags: [],
members: [member],
types: [type],
}],
},
}
}

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/loader"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../registry"
},
{
"path": "../../support/invariants"
}
]
}