refactor(cordis): generate catalogs from Typert models
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# 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/cordis/tool-cordis/README.md
|
||||
README.md: 5b58e665dae95aea0d0ad094238fef5d3dc0fb97
|
||||
README.zh.md: 237b72244be2336a82c8c48cb341d7f9796d08f4
|
||||
README.md: eda135d93e2912bbb4e111af40d176409b383b5b
|
||||
README.zh.md: 6eef10086142d56dd809e5114b4e0e712f726ecc
|
||||
|
||||
@@ -28,7 +28,7 @@ The sandbox isolates globals but is not a security boundary. Node globals are ab
|
||||
|
||||
## The generated API catalog
|
||||
|
||||
`src/api-catalog.ts` is generated by `scripts/gen-cordis-api.ts` from the same AST walk as [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) and freshness-gated by `pnpm run verify-cordis-api` (in `doc-sync`) — never edit it by hand. `cordis_inspect` intersects it with the live service store at call time. Broad `api` / `events` reports render summaries and signatures only; an exact `name` opts into the retained method/event JSDoc, and unknown or non-running service targets fail loud.
|
||||
`src/api-catalog.ts` is generated from the same Typert `FaceModel` projection as [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) and freshness-gated by `pnpm run verify-cordis-api` (in `doc-sync`) — never edit it by hand. `scripts/gen-cordis-api.ts` is a compatibility entry point for that unified projection, not a second collector. `cordis_inspect` intersects the committed catalog with the live service store at call time; it has no runtime Typert dependency. Broad `api` / `events` reports render summaries and signatures only; an exact `name` opts into the retained method/event JSDoc, and unknown or non-running service targets fail loud.
|
||||
|
||||
## Rendering
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
## 生成的 API 目录
|
||||
|
||||
`src/api-catalog.ts` 由 `scripts/gen-cordis-api.ts` 生成,使用与 [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) 相同的 AST 遍历,并由 `pnpm run verify-cordis-api`(位于 `doc-sync` 中)实施新鲜度门禁,绝不可手工编辑。`cordis_inspect` 在调用时把该目录与存活服务 store 取交集。宽泛的 `api`/`events` 报告只渲染摘要与签名;精确 `name` 会选择保留的方法/事件 JSDoc,未知或未运行的服务目标会明确报错。
|
||||
`src/api-catalog.ts` 与 [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) 由同一个 Typert `FaceModel` 投影生成,并由 `pnpm run verify-cordis-api`(位于 `doc-sync` 中)实施新鲜度门禁,绝不可手工编辑。`scripts/gen-cordis-api.ts` 是该统一投影的兼容入口,而非第二套收集器。`cordis_inspect` 在调用时把已提交的目录与存活服务 store 取交集;它在运行时不依赖 Typert。宽泛的 `api`/`events` 报告只渲染摘要与签名;精确 `name` 会选择保留的方法/事件 JSDoc,未知或未运行的服务目标会高声失败。
|
||||
|
||||
## 渲染
|
||||
|
||||
|
||||
@@ -489,7 +489,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
jsDoc: '/**\n * Deliver an allowed signal through an owned backend session.\n * @param owner - exact session owner.\n * @param id - target PTY identity.\n * @param signal - allowed POSIX signal name.\n * @returns delivered foreground process-group identity.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async kill(owner: Agent, id: PtySessionId, reason = \'model request\'): Promise<boolean>',
|
||||
signature: 'async kill(owner: Agent, id: PtySessionId, reason: string = \'model request\'): Promise<boolean>',
|
||||
jsDoc: '/**\n * Close one owned session and remove it only after quiescent backend cleanup.\n * @param owner - exact session owner.\n * @param id - target PTY identity.\n * @param reason - diagnostic cleanup reason.\n * @returns true for a newly closed session, false when the same close is already in flight.\n */',
|
||||
},
|
||||
{
|
||||
@@ -679,7 +679,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
summary: 'Exact-read consumer that prepares immutable cross-session message context.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'async listCandidates( agent: Agent, query = \'\', limit = this.config.candidateLimit, signal?: AbortSignal, ): Promise<SessionReferenceCandidate[]>',
|
||||
signature: 'async listCandidates( agent: Agent, query: string = \'\', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise<SessionReferenceCandidate[]>',
|
||||
jsDoc: '/**\n * List reference candidates, ranked by working-directory affinity.\n * @param agent - target agent; self is excluded and its cwd drives ranking.\n * @param query - optional case-insensitive session-id/cwd/title substring.\n * @param limit - optional positive result cap.\n * @param signal - optional cancellation boundary for host autocomplete teardown.\n * @returns candidates labeled by latest title or, when absent, session id.\n */',
|
||||
},
|
||||
{
|
||||
@@ -1002,6 +1002,40 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'typert',
|
||||
summary: 'Registry of generated schemas and package reflection.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'register(contribution: TypertContribution): () => void',
|
||||
jsDoc: '/**\n * Register one generated contribution atomically for the calling fiber.\n * Duplicate package-face identities or schema keys reject the whole batch.\n * @param contribution - generated schemas and package metadata.\n * @returns the exact effect disposer that removes this contribution.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'get(key: string): TypertSchemaRecord | undefined',
|
||||
jsDoc: '/**\n * Look up one schema by `<package>#<name>`.\n * @param key - global schema key.\n * @returns the live schema record, or `undefined` when absent.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'resolve(key: string): TypertSchemaRecord',
|
||||
jsDoc: '/**\n * Resolve one required schema.\n * @param key - global schema key.\n * @returns the live schema record.\n * @throws when the key is malformed, the package face is absent, or the schema is not contributed.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[]',
|
||||
jsDoc: '/**\n * Enumerate live schemas in registration order.\n * @param filter - optional package and face restriction.\n * @returns matching schema records.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'getPackage(packageName: string, face: TypertFace = \'host\'): TypertPackageRecord | undefined',
|
||||
jsDoc: '/**\n * Look up generated reflection for one package face.\n * @param packageName - exact npm package name.\n * @param face - face to query; defaults to the host runtime.\n * @returns the live package record, or `undefined` when absent.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[]',
|
||||
jsDoc: '/**\n * Enumerate generated package reflection in registration order.\n * @param filter - optional package and face restriction.\n * @returns matching package records.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema',
|
||||
jsDoc: '/**\n * Project a live Zod schema to JSON Schema without caching the result.\n * @param key - global schema key.\n * @param params - Zod projection parameters.\n * @returns a fresh JSON Schema document.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'userInteraction',
|
||||
summary: '`ctx.userInteraction`: one active UI provider plus an `ask()` surface.',
|
||||
@@ -1281,34 +1315,6 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
jsDoc: '/**\n * A skill provider, runtime contribution, or provider-backed catalog may\n * have changed. This is an unfiltered invalidation notification; consumers\n * refetch the catalog for their own lookup options. Listener failures are\n * contained and cannot veto the registry mutation.\n * @mode emit\n */',
|
||||
summary: 'A skill provider, runtime contribution, or provider-backed catalog may have changed.',
|
||||
},
|
||||
{
|
||||
name: 'slash/input-begin-command',
|
||||
mode: 'bail',
|
||||
signature: '\'slash/input-begin-command\'(request: BeginCommandRequest): true | undefined',
|
||||
jsDoc: '/**\n * Applies one command claim to the scoped Input. Dispatched with the\n * session\'s scope carrier; the owning session\'s input listener returns\n * `true` only after the phase and span CAS checks pass and the machine\n * actually mutated — producers treat anything else as "not applied".\n * @param request - Claim and menu-time span CAS.\n * @mode bail\n */',
|
||||
summary: 'Applies one command claim to the scoped Input.',
|
||||
},
|
||||
{
|
||||
name: 'slash/input-consume-token',
|
||||
mode: 'bail',
|
||||
signature: '\'slash/input-consume-token\'(request: ConsumeTokenRequest): true | undefined',
|
||||
jsDoc: '/**\n * Consumes one command token after business success (popup settle /\n * menu-pick execute). Same carrier routing and applied-truth contract.\n * @param request - Exact span or bare-token guard.\n * @mode bail\n */',
|
||||
summary: 'Consumes one command token after business success (popup settle / menu-pick execute).',
|
||||
},
|
||||
{
|
||||
name: 'slash/input-insert-reference',
|
||||
mode: 'bail',
|
||||
signature: '\'slash/input-insert-reference\'(request: InsertReferenceRequest): true | undefined',
|
||||
jsDoc: '/**\n * Inserts one reference into the scoped Input (same carrier routing and\n * applied-truth contract as begin-command).\n * @param request - Reference and menu-time span CAS.\n * @mode bail\n */',
|
||||
summary: 'Inserts one reference into the scoped Input (same carrier routing and applied-truth contract as begin-command).',
|
||||
},
|
||||
{
|
||||
name: 'slash/input-insert-text',
|
||||
mode: 'bail',
|
||||
signature: '\'slash/input-insert-text\'(request: InsertTextRequest): true | undefined',
|
||||
jsDoc: '/**\n * Replaces the trigger token span with literal text — the plain-text\n * reference path (decision 21). Same carrier routing and applied-truth\n * contract; the draft gains ordinary characters, no occurrence entry.\n * @param request - Replacement text and menu-time span CAS.\n * @mode bail\n */',
|
||||
summary: 'Replaces the trigger token span with literal text — the plain-text reference path (decision 21).',
|
||||
},
|
||||
{
|
||||
name: 'subagent/end',
|
||||
mode: 'emit',
|
||||
@@ -2694,6 +2700,62 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'TurnTriggerMap',
|
||||
declaration: 'export interface TurnTriggerMap {\n message: {\n kind: \'message\';\n source: MessageSource;\n };\n retry: {\n kind: \'retry\';\n };\n injection: {\n kind: \'injection\';\n source: MessageSource;\n };\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertContribution',
|
||||
declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertDocTag',
|
||||
declaration: 'export interface TypertDocTag {\n readonly name: string;\n readonly argument?: string;\n readonly comment?: string;\n readonly text: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertDocumentation',
|
||||
declaration: 'export interface TypertDocumentation {\n readonly description?: string;\n readonly summary?: string;\n readonly tags: readonly TypertDocTag[];\n readonly jsDoc?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertEventModel',
|
||||
declaration: 'export interface TypertEventModel extends TypertDocumentation {\n readonly name: string;\n readonly mode?: string;\n readonly signature: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertMemberModel',
|
||||
declaration: 'export interface TypertMemberModel {\n readonly kind: \'property\' | \'method\' | \'getter\' | \'setter\' | \'call\' | \'construct\' | \'index\';\n readonly name: string;\n readonly signature: string;\n readonly summary?: string;\n readonly jsDoc?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertObjectModel',
|
||||
declaration: 'export interface TypertObjectModel extends TypertDocumentation {\n readonly name: string;\n readonly exportName: string;\n readonly members: readonly TypertMemberModel[];\n readonly types: readonly TypertTypeModel[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertPackageFilter',
|
||||
declaration: 'export interface TypertPackageFilter {\n readonly package?: string;\n readonly face?: TypertFace;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertPackageModel',
|
||||
declaration: 'export interface TypertPackageModel {\n readonly services: readonly TypertServiceModel[];\n readonly events: readonly TypertEventModel[];\n readonly objects: readonly TypertObjectModel[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertPackageRecord',
|
||||
declaration: 'export interface TypertPackageRecord {\n readonly package: string;\n readonly face: TypertFace;\n readonly key: string;\n readonly model: TypertPackageModel;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertSchema',
|
||||
declaration: 'export interface TypertSchema {\n readonly name: string;\n readonly schema: z.ZodType;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertSchemaFilter',
|
||||
declaration: 'export interface TypertSchemaFilter {\n readonly package?: string;\n readonly face?: TypertFace;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertSchemaRecord',
|
||||
declaration: 'export interface TypertSchemaRecord extends TypertSchema {\n readonly package: string;\n readonly face: TypertFace;\n readonly key: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertServiceModel',
|
||||
declaration: 'export interface TypertServiceModel extends TypertDocumentation {\n readonly key: string;\n readonly exportName: string;\n readonly members: readonly TypertMemberModel[];\n readonly types: readonly TypertTypeModel[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertTypeModel',
|
||||
declaration: 'export interface TypertTypeModel {\n readonly name: string;\n readonly declaration: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'UserInteractionProvider',
|
||||
declaration: 'export interface UserInteractionProvider {\n ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>;\n}',
|
||||
|
||||
@@ -1207,7 +1207,7 @@ class FaceAnalyzer {
|
||||
const owner = this.registrationForFile(declaration.getSourceFile().fileName)
|
||||
if (owner !== undefined) {
|
||||
if (owner.name !== from.name) {
|
||||
if (module === undefined || module.package !== owner.name) {
|
||||
if (module === undefined) {
|
||||
this.fail(site, `reference to ${symbol.name} crosses a package without an explicit package import`)
|
||||
}
|
||||
const exportName = authoredExportName(site, moduleSpecifier as string)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Contract and negative-path tests for the cordis catalog generator
|
||||
* Model-extraction and negative-path contracts for the Cordis catalog generator
|
||||
* (`scripts/gen-cordis-catalog.ts`).
|
||||
*/
|
||||
|
||||
@@ -7,16 +7,91 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { collectEvents, collectServices, renderEvents, renderServices } from '../../../../scripts/gen-cordis-catalog.ts'
|
||||
import {
|
||||
collectEvents as collectEventsWithPolicy,
|
||||
collectServices as collectServicesWithPolicy,
|
||||
renderEvents as renderEventsWithPolicy,
|
||||
renderServices as renderServicesWithPolicy,
|
||||
} from '../src/cordis-catalog.ts'
|
||||
import type {
|
||||
CordisCatalogPolicy,
|
||||
EventEntry,
|
||||
ServiceEntry,
|
||||
} from '../src/cordis-catalog.ts'
|
||||
|
||||
const TEST_POLICY: CordisCatalogPolicy = {
|
||||
linkedTypePages: { SessionEvent: 'core.md' },
|
||||
foundationTypeNames: new Set(['AbortSignal', 'Promise', 'Readonly']),
|
||||
typeLinkExemptions: { PresetSpec: 'fixture deployment metadata' },
|
||||
inheritedEvents: [],
|
||||
inheritedServices: [],
|
||||
}
|
||||
|
||||
function collectEvents(root: string): EventEntry[] {
|
||||
return collectEventsWithPolicy(root, TEST_POLICY)
|
||||
}
|
||||
|
||||
function collectServices(root: string): ServiceEntry[] {
|
||||
return collectServicesWithPolicy(root, TEST_POLICY)
|
||||
}
|
||||
|
||||
function renderEvents(events: EventEntry[]): string {
|
||||
return renderEventsWithPolicy(events, TEST_POLICY)
|
||||
}
|
||||
|
||||
function renderServices(services: ServiceEntry[]): string {
|
||||
return renderServicesWithPolicy(services, TEST_POLICY)
|
||||
}
|
||||
|
||||
const TYPE_FIXTURES = [
|
||||
'export interface FixtureEntry {}',
|
||||
'interface SessionEvent {}',
|
||||
'interface PresetSpec {}',
|
||||
'interface MissingOne {}',
|
||||
'type missingTwo = string',
|
||||
'interface MissingServiceType {}',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
/** Materialize one independently compilable package and its host aggregate. */
|
||||
function writeProject(root: string, source: string): void {
|
||||
const packageRoot = join(root, 'packages', 'group', 'fix')
|
||||
const sourceRoot = join(packageRoot, 'src')
|
||||
mkdirSync(sourceRoot, { recursive: true })
|
||||
writeFileSync(join(root, 'tsconfig.host.json'), JSON.stringify({
|
||||
files: [],
|
||||
references: [{ path: './packages/group/fix' }],
|
||||
}))
|
||||
writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({
|
||||
name: '@fixture/fix',
|
||||
private: true,
|
||||
type: 'module',
|
||||
exports: {
|
||||
'.': {
|
||||
types: './lib/types/index.d.ts',
|
||||
default: './lib/index.js',
|
||||
},
|
||||
},
|
||||
}))
|
||||
writeFileSync(join(packageRoot, 'tsconfig.json'), JSON.stringify({
|
||||
compilerOptions: {
|
||||
composite: true,
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'Bundler',
|
||||
rootDir: 'src',
|
||||
target: 'ES2022',
|
||||
},
|
||||
include: ['src'],
|
||||
}))
|
||||
writeFileSync(join(sourceRoot, 'index.ts'), `${TYPE_FIXTURES}${source}`)
|
||||
}
|
||||
|
||||
/** Write a fixture package exposing one `interface Events` block and return the
|
||||
* scan root to hand `collectEvents`. */
|
||||
function fixtureRoot(eventsBlock: string): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-'))
|
||||
const dir = join(root, 'packages', 'group', 'fix', 'src')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(dir, 'index.ts'),
|
||||
writeProject(
|
||||
root,
|
||||
`declare module 'cordis' {\n interface Events {\n${eventsBlock}\n }\n}\n`,
|
||||
)
|
||||
return root
|
||||
@@ -27,10 +102,8 @@ function fixtureRoot(eventsBlock: string): string {
|
||||
* `collectServices`. */
|
||||
function serviceFixtureRoot(classSource: string): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-'))
|
||||
const dir = join(root, 'packages', 'group', 'fix', 'src')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(dir, 'index.ts'),
|
||||
writeProject(
|
||||
root,
|
||||
`declare module 'cordis' {\n interface Context {\n fix: FixService\n }\n}\n\n${classSource}\n`,
|
||||
)
|
||||
return root
|
||||
@@ -95,9 +168,9 @@ describe('gen-cordis-catalog collectEvents', () => {
|
||||
'fix/two',
|
||||
'packages/group/fix/src/index.ts',
|
||||
'missingTwo',
|
||||
'Add it to LINK_MAP',
|
||||
'FOUNDATION_TYPE_NAMES',
|
||||
'TYPE_LINK_EXEMPTIONS',
|
||||
'Add it to linkedTypePages',
|
||||
'foundationTypeNames',
|
||||
'typeLinkExemptions',
|
||||
].join('[\\s\\S]*'))
|
||||
expect(() => collectEvents(make(
|
||||
' /**\n * First.\n * @param value - first value.\n * @mode emit\n */\n \'fix/one\'(value: MissingOne): void\n /**\n * Second.\n * @param value - second value.\n * @mode emit\n */\n \'fix/two\'(value: missingTwo): void',
|
||||
@@ -222,7 +295,7 @@ export class FixService {
|
||||
it('hard-errors on an unannotated (inferred) return type', () => {
|
||||
expect(() => collectServices(makeService(
|
||||
'/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @param id - which thing.\n */\n run(id: string) { return id }\n}',
|
||||
))).toThrow(/no return type annotation/)
|
||||
))).toThrow(/missing an explicit type annotation/)
|
||||
})
|
||||
|
||||
it('hard-errors on a service class with no JSDoc', () => {
|
||||
24
packages/typert/generator/tests/cordis-catalog.spec.ts
Normal file
24
packages/typert/generator/tests/cordis-catalog.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
projectCordisCatalog,
|
||||
renderEvents,
|
||||
renderServices,
|
||||
} from '../src/cordis-catalog.ts'
|
||||
import { CORDIS_CATALOG_POLICY } from '../../../../scripts/gen-cordis-catalog.ts'
|
||||
|
||||
const workspaceRoot = resolve(import.meta.dirname, '../../../..')
|
||||
|
||||
describe('Typert-backed Cordis catalog', () => {
|
||||
it('reproduces every committed catalog artifact byte for byte', { timeout: 480_000 }, () => {
|
||||
const { projector, model } = projectCordisCatalog(workspaceRoot, CORDIS_CATALOG_POLICY)
|
||||
const expected = (path: string): string => readFileSync(join(workspaceRoot, path), 'utf8')
|
||||
|
||||
expect(renderEvents([...model.events], CORDIS_CATALOG_POLICY)).toBe(expected('docs/cordis-catalog/events.md'))
|
||||
expect(renderServices([...model.services], CORDIS_CATALOG_POLICY)).toBe(expected('docs/cordis-catalog/services.md'))
|
||||
expect(projector.renderRuntimeApi(model)).toBe(
|
||||
expected('packages/cordis/tool-cordis/src/api-catalog.ts'),
|
||||
)
|
||||
})
|
||||
})
|
||||
68
packages/typert/generator/tests/tools-catalog.spec.ts
Normal file
68
packages/typert/generator/tests/tools-catalog.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import type { TypertContribution } from '@deepseek-ai/dsh-typert-registry/types'
|
||||
import { EVENT_API, SERVICE_API, TYPE_API } from '@deepseek-ai/dsh-tool-cordis/src/api-catalog.ts'
|
||||
import { WorkspaceAnalyzer } from '../src/analyzer.ts'
|
||||
import { FaceModelEmitter } from '../src/emitter.ts'
|
||||
|
||||
const workspaceRoot = resolve(import.meta.dirname, '../../../..')
|
||||
const temporaryRoots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('model-driven dsh-tools generation', () => {
|
||||
it('round-trips the complete service and event structure through the runtime registry', { timeout: 30_000 }, async () => {
|
||||
const workspace = new WorkspaceAnalyzer({
|
||||
root: workspaceRoot,
|
||||
faces: ['host'],
|
||||
packages: ['@deepseek-ai/dsh-tools'],
|
||||
}).analyze()
|
||||
const host = workspace.faces.find(candidate => candidate.face === 'host')
|
||||
if (host === undefined) throw new Error('dsh-tools has no analyzed host face')
|
||||
const artifact = new FaceModelEmitter(host).emit('@deepseek-ai/dsh-tools')
|
||||
|
||||
const root = mkdtempSync(join(import.meta.dirname, '.generated-tools-'))
|
||||
temporaryRoots.push(root)
|
||||
const modulePath = join(root, 'host.mjs')
|
||||
writeFileSync(modulePath, artifact.js)
|
||||
const generated = await import(`${pathToFileURL(modulePath).href}?test=${Date.now()}`) as {
|
||||
TYPERT: TypertContribution
|
||||
}
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const dispose = ctx.typert.register(generated.TYPERT)
|
||||
const record = ctx.typert.getPackage('@deepseek-ai/dsh-tools', 'host')
|
||||
const service = record?.model.services.find(candidate => candidate.key === 'tools')
|
||||
expect(service).toBeDefined()
|
||||
expect({
|
||||
key: service?.key,
|
||||
summary: service?.summary,
|
||||
methods: service?.members
|
||||
.filter(member => member.kind === 'method' && !member.name.startsWith('['))
|
||||
.map(member => ({
|
||||
signature: member.signature,
|
||||
jsDoc: member.jsDoc ?? '',
|
||||
})),
|
||||
}).toEqual(SERVICE_API.find(candidate => candidate.key === 'tools'))
|
||||
expect(record?.model.events.filter(event => event.name.startsWith('tools/')).map(event => ({
|
||||
name: event.name,
|
||||
mode: event.mode,
|
||||
signature: event.signature,
|
||||
jsDoc: event.jsDoc ?? '',
|
||||
summary: event.summary,
|
||||
}))).toEqual(EVENT_API.filter(event => event.name.startsWith('tools/')))
|
||||
expect(service?.types.find(type => type.name === 'ToolDefinition')).toEqual(
|
||||
TYPE_API.find(type => type.name === 'ToolDefinition'),
|
||||
)
|
||||
|
||||
dispose()
|
||||
expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools', 'host')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -489,6 +489,59 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => {
|
||||
&& node.target.symbol === payload?.id)).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves explicit same-face package re-exports to their declaration owner', () => {
|
||||
const root = copyFixture('typert-same-face-reexport-')
|
||||
const packageRoot = join(root, 'packages/barrel')
|
||||
mkdirSync(join(packageRoot, 'src'), { recursive: true })
|
||||
writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({
|
||||
name: '@fixture/barrel',
|
||||
private: true,
|
||||
type: 'module',
|
||||
exports: {
|
||||
'.': {
|
||||
types: './lib/types/index.d.ts',
|
||||
default: './lib/index.js',
|
||||
},
|
||||
},
|
||||
}, null, 2))
|
||||
writeFileSync(join(packageRoot, 'tsconfig.json'), JSON.stringify({
|
||||
extends: '../../tsconfig.base.json',
|
||||
compilerOptions: { rootDir: 'src', outDir: 'lib/types' },
|
||||
include: ['src'],
|
||||
references: [{ path: '../host' }],
|
||||
}, null, 2))
|
||||
writeFileSync(
|
||||
join(packageRoot, 'src/index.ts'),
|
||||
"export type { Payload } from '@fixture/host/models'\n",
|
||||
)
|
||||
const basePath = join(root, 'tsconfig.base.json')
|
||||
const base = JSON.parse(readFileSync(basePath, 'utf8')) as {
|
||||
compilerOptions: { paths: Record<string, string[]> }
|
||||
}
|
||||
base.compilerOptions.paths['@fixture/barrel'] = ['./packages/barrel/src/index.ts']
|
||||
writeFileSync(basePath, `${JSON.stringify(base, null, 2)}\n`)
|
||||
const aggregatePath = join(root, 'tsconfig.host.json')
|
||||
const aggregate = JSON.parse(readFileSync(aggregatePath, 'utf8')) as { references: { path: string }[] }
|
||||
aggregate.references.push({ path: './packages/barrel' })
|
||||
writeFileSync(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`)
|
||||
addSameFacePackage(root, '@fixture/barrel', 'Payload')
|
||||
const consumerConfigPath = join(root, 'packages/consumer/tsconfig.json')
|
||||
const consumerConfig = JSON.parse(readFileSync(consumerConfigPath, 'utf8')) as {
|
||||
references: { path: string }[]
|
||||
}
|
||||
consumerConfig.references.push({ path: '../barrel' })
|
||||
writeFileSync(consumerConfigPath, `${JSON.stringify(consumerConfig, null, 2)}\n`)
|
||||
|
||||
const model = new WorkspaceAnalyzer({ root }).analyze()
|
||||
const host = model.faces.find(face => face.face === 'host')
|
||||
const payload = host?.graph.declarations.find(declaration => declaration.name === 'Payload')
|
||||
expect(host?.graph.nodes.some(node => node.id.includes('packages/consumer/src/index.ts')
|
||||
&& node.kind === 'reference'
|
||||
&& node.name === 'Payload'
|
||||
&& node.target.kind === 'declaration'
|
||||
&& node.target.symbol === payload?.id)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects same-face package imports outside package.json exports', () => {
|
||||
const root = copyFixture('typert-private-package-')
|
||||
writeFileSync(
|
||||
|
||||
Reference in New Issue
Block a user