fix(typert): satisfy workspace static gates

This commit is contained in:
imccyu
2026-08-05 23:18:35 +08:00
parent 64a963da0b
commit 9a0a9350c4
29 changed files with 986 additions and 74 deletions

View File

@@ -399,21 +399,9 @@ export class FaceModelEmitter {
scoped: boolean,
): void {
const signature = this.remoteSignature(invocation, referenceNames, scoped)
const line = ` ${signature}`
lines.push(line)
const generatedLine = lines.length
const keyLength = signature.indexOf(': (')
if (keyLength < 0) throw new TypertEmitError(`Remote signature ${invocation.id} has no property delimiter`)
const source = remoteDeclarationSource(packageModel, invocation)
addMapping(sourceMap, {
generated: { line: generatedLine, column: 4 },
source,
original: { line: invocation.location.line, column: invocation.location.column - 1 },
name: invocation.method,
})
addMapping(sourceMap, {
generated: { line: generatedLine, column: 4 + keyLength },
})
this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, keyLength)
}
private pushRemoteNamespaceSignature(
@@ -424,6 +412,17 @@ export class FaceModelEmitter {
referenceNames: ReadonlyMap<SymbolId, string>,
): void {
const signature = `${invocation.method}: ${this.remoteFunctionType(invocation, referenceNames, false)}`
this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, invocation.method.length)
}
private pushMappedRemoteSignature(
lines: string[],
sourceMap: GenMapping,
packageModel: PackageModel,
invocation: InvocationModel,
signature: string,
keyLength: number,
): void {
lines.push(` ${signature}`)
const generatedLine = lines.length
const source = remoteDeclarationSource(packageModel, invocation)
@@ -434,7 +433,7 @@ export class FaceModelEmitter {
name: invocation.method,
})
addMapping(sourceMap, {
generated: { line: generatedLine, column: 4 + invocation.method.length },
generated: { line: generatedLine, column: 4 + keyLength },
})
}

View File

@@ -1,23 +1,27 @@
/**
* Optional tsdown (rolldown) plugin face of the typert generator. When added
* to a workspace tsdown config, it runs after each opted-in package bundle is
* written and re-emits its model-driven face artifact at the package output
* root. Packages without a Typert or Remote export are skipped.
* Optional tsdown (rolldown) plugin face of the typert generator. It lowers
* standard decorators in TypeScript dependencies before bundling, then emits
* model-driven face artifacts at the package output root. Packages without a
* Typert or Remote export are skipped.
* @module @deepseek-ai/dsh-typert-generator/tsdown
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import ts from 'typescript'
import { WorkspaceTypertGenerator } from './workspace.ts'
import type { WorkspaceEmitResult } from './workspace.ts'
import type { TypertFace } from './model.ts'
/** The subset of the rolldown output-plugin contract this plugin uses (structural; avoids a rolldown type dependency). */
/** The subset of the rolldown plugin contract used here (structural; avoids a rolldown type dependency). */
interface TypertPlugin {
name: string
transform: (code: string, id: string) => { code: string; map: string | undefined } | undefined
writeBundle: (options: { dir?: string }) => void
}
const DECORATOR_SYNTAX = /^\s*@[A-Za-z_$][\w$]*/m
/** Generation scope selected by a tsdown build phase. */
export interface TypertPluginOptions {
/** Package mode emits only the package being bundled; workspace mode emits every explicit contributor once. */
@@ -27,15 +31,32 @@ export interface TypertPluginOptions {
}
/**
* Create the typert generation plugin for the root tsdown config.
* Create the decorator-lowering and typert-generation plugin for the root tsdown config.
* @param pluginOptions - package/workspace emission mode and independent program faces.
* @returns a rolldown-compatible plugin that emits local face and Host-for-Client Remote artifacts.
* @returns a rolldown-compatible plugin that lowers source decorators and emits local and Host-for-Client artifacts.
*/
export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlugin {
const artifactsByRoot = new Map<string, readonly WorkspaceEmitResult[]>()
const emittedWorkspaces = new Set<string>()
return {
name: 'dsh-typert-generator',
transform(code, id) {
const file = id.split('?', 1)[0] ?? id
if (!/\.[cm]?tsx?$/.test(file) || !DECORATOR_SYNTAX.test(code)) return
const result = ts.transpileModule(code, {
fileName: file,
compilerOptions: {
target: ts.ScriptTarget.ES2024,
module: ts.ModuleKind.ESNext,
...(file.endsWith('x') ? { jsx: ts.JsxEmit.ReactJSX } : {}),
sourceMap: true,
},
})
return {
code: result.outputText.replace(/\n?\/\/# sourceMappingURL=.*$/u, '\n'),
map: result.sourceMapText,
}
},
writeBundle(bundleOptions) {
// options.dir is the package's absolute outDir (<package>/lib); its
// nearest package.json owns the bundle even when a custom config writes

View File

@@ -64,6 +64,13 @@ afterEach(() => {
})
describe('typertPlugin', () => {
it('lowers standard decorators in TypeScript source dependencies', () => {
const plugin = typertPlugin()
expect(plugin.transform('export const value = 1\n', '/workspace/src/plain.ts')).toBeUndefined()
expect(plugin.transform('@sealed\nexport class Example {}\n', '/workspace/src/example.ts')?.code)
.not.toContain('@sealed')
})
it('skips outputs that do not identify a Typert contributor', async () => {
const plugin = typertPlugin()
expect(plugin.name).toBe('dsh-typert-generator')

View File

@@ -149,8 +149,10 @@ class DescriptorStore {
for (const descriptor of descriptors) {
const endpoint = typertEndpoint(descriptor)
const entry = this.entries.get(endpoint)
/* v8 ignore next -- duplicate registration is rejected, so no later owner can replace this entry before its effect disposes. */
if (entry?.owner !== owner) continue
this.entries.delete(endpoint)
/* v8 ignore next -- ids and endpoints are committed and withdrawn together under the same unique owner. */
if (this.ids.get(descriptor.id) === entry) this.ids.delete(descriptor.id)
removed.push(endpoint)
}
@@ -200,6 +202,7 @@ class RemoteStore {
packages.set(contribution.package, owner)
descriptors.commit(owner, contribution.descriptors)
yield () => {
/* v8 ignore else -- duplicate package registration is rejected, so this effect remains the package's unique owner. */
if (packages.get(contribution.package) === owner) packages.delete(contribution.package)
descriptors.withdraw(owner, contribution.descriptors)
}
@@ -244,6 +247,7 @@ class LookupStore {
providers.set(key, entry)
changes.emit({ kind: 'lookup', key })
yield () => {
/* v8 ignore next -- duplicate registration is rejected, so this effect remains the key's unique owner. */
if (providers.get(key) !== entry) return
providers.delete(key)
changes.emit({ kind: 'lookup', key })
@@ -303,6 +307,7 @@ class ContextStore {
table.set(key, entry)
changes.emit({ kind, key })
yield () => {
/* v8 ignore next -- duplicate registration is rejected, so this effect remains the key's unique owner. */
if (table.get(key) !== entry) return
table.delete(key)
changes.emit({ kind, key })
@@ -381,8 +386,10 @@ export class TypertRegistry extends Service implements TypeRTService {
for (const record of schemaRecords) schemas.set(record.key, record)
localStore.commit(owner, invocations)
yield () => {
/* v8 ignore else -- duplicate package-face registration is rejected, so this effect remains its unique owner. */
if (packages.get(packageRecord.key) === packageRecord) packages.delete(packageRecord.key)
for (const record of schemaRecords) {
/* v8 ignore else -- duplicate schema registration is rejected, so this contribution remains each record's unique owner. */
if (schemas.get(record.key) === record) schemas.delete(record.key)
}
localStore.withdraw(owner, invocations)

View File

@@ -13,6 +13,7 @@ import type {
TypeRTLookup,
TypeRTRemoteContribution,
} from '@deepseek-ai/dsh-type-meta'
import { apply as applyClientRegistry, inject as clientRegistryInject } from '../src/client/index.ts'
declare module '@deepseek-ai/dsh-type-meta' {
interface TypeRTLookupMap {
@@ -222,6 +223,29 @@ describe('TypertRegistry', () => {
expect(changes).toEqual(['local:goals/create', 'local:goals/create'])
})
it('rejects duplicate invocation endpoints and ids atomically', async () => {
const ctx = await makeCtx()
const first = invocation()
ctx.typert.register({ ...toolsContribution(), invocations: [first] })
expect(() => ctx.typert.remotes.register({
package: '@fixture/duplicate-endpoint',
descriptors: [invocation('@fixture/remote#first'), invocation('@fixture/remote#second')],
})).toThrow('endpoint "goals/create" is already registered')
expect(() => ctx.typert.remotes.register({
package: '@fixture/duplicate-id',
descriptors: [
invocation('@fixture/remote#same'),
{ ...invocation('@fixture/remote#same'), method: 'rename' },
],
})).toThrow('invocation id "@fixture/remote#same" is already registered')
expect(() => ctx.typert.register({
...toolsContribution(),
package: '@fixture/existing-endpoint',
invocations: [{ ...first, id: '@fixture/local#other' }],
})).toThrow('endpoint "goals/create" is already registered')
})
it('mounts Remote contributions in the calling fiber and withdraws them exactly', async () => {
const ctx = await makeCtx()
const descriptor = invocation()
@@ -314,6 +338,131 @@ describe('TypertRegistry', () => {
expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined()
})
it('publishes provider changes, rejects duplicate providers, and disposes subscriptions', async () => {
const ctx = await makeCtx()
const changes: string[] = []
const disposeLookupSubscription = ctx.typert.lookups.subscribe((change) => {
changes.push(`${change.kind}:${change.key}`)
})
const disposeContextSubscription = ctx.typert.contexts.subscribe((change) => {
changes.push(`${change.kind}:${change.key}`)
})
const lookup = {
parameter: 'agent',
wire: 'agentId',
hostTypeSymbol: '@fixture#Agent',
wireTypeSymbol: '@fixture#AgentId',
resolve: () => undefined,
}
const host = {
wire: 'agentId',
wireTypeSymbol: '@fixture#AgentId',
resolve: () => undefined,
}
const client = { identity: () => undefined }
const disposeLookup = ctx.typert.lookups.register('fixture', lookup)
const disposeHost = ctx.typert.contexts.registerHost('registryFixture', host)
const disposeClient = ctx.typert.contexts.registerClient('registryFixture', client)
expect(() => ctx.typert.lookups.register('fixture', lookup)).toThrow('already registered')
expect(() => ctx.typert.contexts.registerHost('registryFixture', host)).toThrow('already registered')
expect(() => ctx.typert.contexts.registerClient('registryFixture', client)).toThrow('already registered')
await Promise.all([disposeLookup(), disposeHost(), disposeClient()])
expect(changes).toEqual([
'lookup:fixture',
'host-context:registryFixture',
'client-context:registryFixture',
'lookup:fixture',
'host-context:registryFixture',
'client-context:registryFixture',
])
await Promise.all([disposeLookupSubscription(), disposeContextSubscription()])
ctx.typert.lookups.register('fixture', lookup)
expect(changes).toHaveLength(6)
})
it('validates every invocation and provider boundary', async () => {
const ctx = await makeCtx()
const strict = {
mode: 'strict' as const,
typeSymbol: '@fixture#Value',
schema: z.string(),
}
const strictInvocation: InvocationDescriptor = {
...invocation('@fixture/remote#strict'),
implementation: 'remoteExportCreate',
parameters: [{ name: 'request', wire: 'request', source: 'json', codec: strict }],
result: strict,
}
const dispose = ctx.typert.remotes.register({ package: '@fixture/strict', descriptors: [strictInvocation] })
await dispose()
const malformed: readonly [InvocationDescriptor, string][] = [
[{ ...invocation(), id: '' }, 'invocation id'],
[{ ...invocation(), namespace: 'bad/name' }, 'namespace'],
[{ ...invocation(), implementation: 'bad/name' }, 'implementation method'],
[{
...invocation(),
parameters: [
...invocation().parameters,
{ name: 'other', wire: 'request', source: 'json', codec: { mode: 'src-json' } },
],
}, 'repeats wire field'],
[{
...invocation(),
parameters: [{ name: 'agent', wire: 'agentId', source: 'lookup', codec: { mode: 'src-json' } }],
}, 'has no lookup key'],
[{
...invocation(),
parameters: [{
name: 'request', wire: 'request', source: 'json', lookup: 'fixture', codec: { mode: 'src-json' },
}],
}, 'JSON parameter'],
[{
...invocation(),
invocation: {
kind: 'context', context: 'registryFixture', wire: 'request', codec: { mode: 'src-json' },
},
}, 'repeats wire field'],
[{
...invocation(),
result: { mode: 'strict', typeSymbol: '', schema: z.string() },
}, 'type symbol'],
[{
...invocation(),
result: { mode: 'strict', typeSymbol: '@fixture#Broken', schema: {} as z.ZodType },
}, 'has no parse'],
]
for (const [index, [descriptor, message]] of malformed.entries()) {
expect(() => ctx.typert.remotes.register({
package: `@fixture/malformed-${String(index)}`,
descriptors: [descriptor],
})).toThrow(message)
}
expect(() => ctx.typert.lookups.register('bad#key' as 'fixture', {
parameter: 'agent',
wire: 'agent/id',
hostTypeSymbol: '',
wireTypeSymbol: '',
resolve: () => undefined,
})).toThrow('lookup key')
expect(() => ctx.typert.lookups.register('fixture', {
parameter: 'agent',
wire: 'agent/id',
hostTypeSymbol: '@fixture#Agent',
wireTypeSymbol: '@fixture#AgentId',
resolve: () => undefined,
})).toThrow('lookup wire field')
})
it('installs the registry through the Client entry without importing the Host entry', async () => {
const ctx = new Context()
await ctx.plugin({ inject: clientRegistryInject, apply: applyClientRegistry })
expect(ctx.typert.list()).toEqual([])
})
it('contains change-listener failures and still notifies later listeners', async () => {
const ctx = await makeCtx()
const warnings: unknown[] = []

View File

@@ -26,9 +26,7 @@
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {

View File

@@ -107,6 +107,80 @@ describe('type-meta Remote declarations', () => {
expect(remoteMethods(first)).toEqual([{ method: 'run', invocation: { kind: 'direct' } }])
})
it('supports explicit export names without exposing marker storage', () => {
class Service {
run(value: string): string {
return value
}
scoped(value: string): string {
return value
}
}
const initializers: Array<(this: Service) => void> = []
Remote('execute')(
Reflect.get(Service.prototype, 'run') as (this: Service, ...args: unknown[]) => unknown,
methodContext('run', initializers),
)
RemoteContext('metaFixture', 'inspect')(
Reflect.get(Service.prototype, 'scoped') as (this: Service, ...args: unknown[]) => unknown,
methodContext('scoped', initializers),
)
const service = new Service()
for (const initialize of initializers) initialize.call(service)
expect(remoteMethods(service)).toEqual([
{ method: 'run', exportName: 'execute', invocation: { kind: 'direct' } },
{ method: 'scoped', exportName: 'inspect', invocation: { kind: 'context', context: 'metaFixture' } },
])
expect(remoteMethods({})).toEqual([])
const prototypeLess: object = {}
Reflect.setPrototypeOf(prototypeLess, null)
expect(remoteMethods(prototypeLess)).toEqual([])
})
it('rejects malformed decorator calls and targets', () => {
const method: (this: object) => void = function (this: object): void {}
expect(() => { (Remote as unknown as (value: typeof method) => void)(method) }).toThrow('context is missing')
expect(() => Remote('bad/name')).toThrow('export name')
expect(() => RemoteContext('' as 'metaFixture')).toThrow('Context key')
expect(() => RemoteContext('metaFixture', 'bad/name')).toThrow('export name')
for (const context of [
{ ...methodContext('run', []), private: true },
{ ...methodContext('run', []), static: true },
{ ...methodContext('run', []), name: Symbol('run') },
]) {
expect(() => { Remote(method, context) })
.toThrow('public instance method')
}
})
it('rejects prototype-less initialization and conflicting markers', () => {
const method: (this: object) => void = function (this: object): void {}
const direct: Array<(this: object) => void> = []
Remote(method, methodContext('run', direct))
const prototypeLess: object = {}
Reflect.setPrototypeOf(prototypeLess, null)
expect(() => { direct[0]!.call(prototypeLess) }).toThrow('without a prototype')
class Service {
run(): void {}
}
const conflicting: Array<(this: Service) => void> = []
Remote(
Reflect.get(Service.prototype, 'run'),
methodContext('run', conflicting),
)
RemoteContext('metaFixture')(
Reflect.get(Service.prototype, 'run'),
methodContext('run', conflicting),
)
const service = new Service()
conflicting[0]!.call(service)
expect(() => { conflicting[1]!.call(service) }).toThrow('conflicting invocation markers')
})
it('rejects ambiguous binding names', () => {
expect(() => bindTypeRTGateway({}, '')).toThrow('service key')
expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace')