feat(typert): add compiler-independent type pipeline
This commit is contained in:
6
packages/typert/loader/README.i18n.yaml
Normal file
6
packages/typert/loader/README.i18n.yaml
Normal 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
|
||||
24
packages/typert/loader/README.md
Normal file
24
packages/typert/loader/README.md
Normal 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.
|
||||
24
packages/typert/loader/README.zh.md
Normal file
24
packages/typert/loader/README.zh.md
Normal 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()`。
|
||||
45
packages/typert/loader/package.json
Normal file
45
packages/typert/loader/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
351
packages/typert/loader/src/index.ts
Normal file
351
packages/typert/loader/src/index.ts
Normal 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))
|
||||
}
|
||||
30
packages/typert/loader/src/invariant.ts
Normal file
30
packages/typert/loader/src/invariant.ts
Normal 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 */
|
||||
462
packages/typert/loader/tests/loader.spec.ts
Normal file
462
packages/typert/loader/tests/loader.spec.ts
Normal 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],
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
30
packages/typert/loader/tsconfig.json
Normal file
30
packages/typert/loader/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user