fix(typert): satisfy workspace static gates
This commit is contained in:
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user