From 0367506471a8ac2b2dde247bbbba72bf8316e0f1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:59:23 +0800 Subject: [PATCH] feat: slot system + entries/priority/errorreport + typert generator --- .../src/client/contract/views.ts | 4 +- .../skeleton/ConversationRoot.module.css | 2 +- .../client/skeleton/ConversationSession.tsx | 18 +- .../tests/skeleton.client.spec.tsx | 50 ++-- .../ui-layout/src/client/AppFrame.module.css | 11 + .../client/ui-layout/src/client/AppFrame.tsx | 5 +- .../src/client/GeneralSection.module.css | 2 +- .../src/client/SidebarRoot.module.css | 34 ++- .../ui-sidebar/src/client/SidebarRoot.tsx | 11 +- .../ui-sidebar/src/client/contract/slots.ts | 9 +- .../client/ui-sidebar/src/client/index.ts | 6 +- .../ui-sidebar/tests/apply.client.spec.tsx | 5 +- .../tests/sidebar-root.client.spec.tsx | 21 +- packages/client/ui-slots/src/index.ts | 248 ++++++++++++++++-- packages/client/ui-slots/src/renderer.ts | 21 ++ .../client/web-react/src/scoped-slots.tsx | 146 +++++++++-- .../scoped-slots-real-core.client.spec.tsx | 2 + .../tests/scoped-slots.client.spec.tsx | 34 ++- .../tests/session-provider.client.spec.tsx | 4 + .../tests/stale-authorization.client.spec.tsx | 4 + .../tests/use-projection.client.spec.tsx | 4 + .../core/tools/tests/gen-tool-catalog.spec.ts | 27 +- packages/host/apiproxy/src/api-proxy.ts | 7 +- packages/host/apiproxy/src/api/index.ts | 6 +- .../test-support/client-runtime/src/index.ts | 22 +- packages/typert/generator/src/analyzer.ts | 41 ++- .../typert/generator/src/cordis-catalog.ts | 171 ++++++++---- .../generator/tests/cordis-catalog.spec.ts | 27 +- 28 files changed, 777 insertions(+), 165 deletions(-) diff --git a/packages/client/ui-conversation/src/client/contract/views.ts b/packages/client/ui-conversation/src/client/contract/views.ts index a8da4121b9..1680e0322d 100644 --- a/packages/client/ui-conversation/src/client/contract/views.ts +++ b/packages/client/ui-conversation/src/client/contract/views.ts @@ -14,14 +14,14 @@ export interface ViewTab { id: string; label: string } /** * Per-session state shared by conversation, chat-view, and details slots. - * Unknown persisted view ids fall back to the first registered view. + * Unknown persisted view ids fall back to the stable Chat view. */ export interface ChatStoreState { /** Details-linkage channel (conversation writes, details reads). */ selection: SelectionTarget | null /** Composer draft (persisted; survives session switches and reloads). */ draft: string - /** Active conversation view id ('conversation.view' entry id); null falls back to the first view. */ + /** Active conversation view id ('conversation.view' entry id); null falls back to Chat. */ view: string | null /** * One-shot inspect handoff: chat writes the call to reveal, the trajectory diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 1b72470feb..6a8eb15559 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -280,7 +280,7 @@ overflow-y: auto; } -.scrollBody:has([data-conversation-composer-overlay]) > .viewArea { +.scrollBody:has([data-conversation-composer-overlay]) > :global([data-slot='conversation.session']) > .viewArea { flex: 1 1 0; min-height: 0; overflow: hidden; diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 5c05660601..d576aa175d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -6,6 +6,7 @@ import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/d import type { ConversationSessionHeaderSlotProps, ConversationSessionSlotProps, } from '../contract/slots.ts' +import type { ViewTab } from '../contract/views.ts' import css from './ConversationRoot.module.css' /** Full props composed from the strict session body contract. */ @@ -19,6 +20,15 @@ interface Breadcrumb { readonly displayTitle: string } +const DEFAULT_VIEW_ID = 'chat' + +/** Resolve by id and keep stale persisted selections on the stable Chat fallback. */ +function resolveActiveView(tabs: readonly ViewTab[], selectedId: string | null): ViewTab | undefined { + const requestedId = selectedId ?? DEFAULT_VIEW_ID + return tabs.find(view => view.id === requestedId) + ?? tabs.find(view => view.id === DEFAULT_VIEW_ID) +} + function deriveAncestry(list: SessionListState, id: SessionId): readonly Breadcrumb[] { const chain: Breadcrumb[] = [] const seen = new Set() @@ -54,8 +64,8 @@ export function ConversationSessionHeader({ }: ConversationSessionHeaderProps) { useSyncExternalStore(views.subscribe, views.version) const tabs = views.list() - const activeId = useStore(s => s.view) ?? 'chat' - const active = tabs.find(view => view.id === activeId) ?? tabs[0] + const selectedId = useStore(s => s.view) + const active = resolveActiveView(tabs, selectedId) const ancestry = useSessions(s => deriveAncestry(s, sessionId), equalBreadcrumbs) const composerPhase = useSession(s => s.composerPhase) const blank = useSession(s => s.blank) @@ -131,8 +141,8 @@ export function ConversationSession({ }: ConversationSessionProps) { useSyncExternalStore(views.subscribe, views.version) const tabs = views.list() - const activeId = useStore(s => s.view) ?? 'chat' - const active = tabs.find(view => view.id === activeId) ?? tabs[0] + const selectedId = useStore(s => s.view) + const active = resolveActiveView(tabs, selectedId) const composerPhase = useSession(s => s.composerPhase) const blank = useSession(s => s.blank) const inputState = useInput(s => s) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index b6fb825b28..acf618fb2b 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -27,6 +27,7 @@ import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import type { ComposerBarOwnerProps, } from '../src/client/contract/slots.ts' +import type { ViewTab } from '../src/client/contract/views.ts' /** Machine-backed wiring over a sink spy. */ function fakeWiring() { @@ -96,6 +97,8 @@ function mount( summaryOrigin?: 'subagent' /** A composer block another plugin raised for this session. */ composerBlock?: { reason: string } + /** Mutable view ledger used by registration-order regressions. */ + viewTabs?: ViewTab[] } = {}, ) { const root = sid('root') @@ -123,6 +126,15 @@ function mount( const stop = vi.fn() const open = vi.fn() const slotCalls: string[] = [] + const viewTabs = options.viewTabs ?? [ + { id: 'chat', label: 'Chat' }, + { id: 'trajectory', label: 'Trajectory' }, + ] + const views = { + list: () => viewTabs, + subscribe: () => () => {}, + version: () => 1, + } /** Owner share handed to the two composer tool-row seats, per render. */ const seatOwners: { key: string; owner: unknown }[] = [] let pickerOwner: unknown @@ -146,14 +158,7 @@ function mount( useStore={bindSnapshotSelector(chat)} actions={chat.actions} renderSlot={renderSlot as never} - views={{ - list: () => [ - { id: 'chat', label: 'Chat' }, - { id: 'trajectory', label: 'Trajectory' }, - ], - subscribe: () => () => {}, - version: () => 1, - }} + views={views} open={open} t={t} /> @@ -173,14 +178,7 @@ function mount( useStore={bindSnapshotSelector(chat)} actions={chat.actions} renderSlot={renderSlot as never} - views={{ - list: () => [ - { id: 'chat', label: 'Chat' }, - { id: 'trajectory', label: 'Trajectory' }, - ], - subscribe: () => () => {}, - version: () => 1, - }} + views={views} releaseSessionImages={vi.fn()} bindDraftMirror={write => wiring.bindMirror(write)} /> @@ -442,6 +440,26 @@ describe('ConversationRoot resident composer', () => { expect(b.view.getByRole('textbox')).toBeTruthy() }) + it('keeps the Chat fallback selected by id when a view is inserted before it', () => { + const viewTabs: ViewTab[] = [ + { id: 'chat', label: 'Chat' }, + { id: 'trajectory', label: 'Trajectory' }, + ] + const b = mount(conversationSnapshot(), undefined, undefined, { viewTabs }) + // A removed dynamic view leaves its persisted id behind. The visible + // fallback is Chat and must stay Chat when another lower-order view lands. + act(() => { b.chat.actions.setView('removed-view') }) + expect(b.view.getByTestId('view-chat')).toBeTruthy() + + viewTabs.unshift({ id: 'new-view', label: 'New view' }) + b.rerender() + + expect(b.view.getByTestId('view-chat')).toBeTruthy() + expect(b.view.queryByTestId('view-new-view')).toBeNull() + expect(b.view.getByRole('tab', { name: 'Chat' }).getAttribute('aria-selected')).toBe('true') + expect(b.view.getByRole('tab', { name: 'New view' }).getAttribute('aria-selected')).toBe('false') + }) + it('rolls the pending workspace label back when switching fails', async () => { const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') }) const b = mount( diff --git a/packages/client/ui-layout/src/client/AppFrame.module.css b/packages/client/ui-layout/src/client/AppFrame.module.css index ef6c70661d..ae56780133 100644 --- a/packages/client/ui-layout/src/client/AppFrame.module.css +++ b/packages/client/ui-layout/src/client/AppFrame.module.css @@ -106,3 +106,14 @@ background: var(--dsw-alias-button-floating-hover); border-color: var(--dsw-alias-border-l3); } + +.overlayLayer { + position: absolute; + inset: 0; + z-index: 20; + pointer-events: none; +} + +.overlayLayer > * { + pointer-events: auto; +} diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index 09386f5d47..2696dc91fa 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -20,7 +20,7 @@ import css from './AppFrame.module.css' /** Full composed props: runtime share + child-slot render share + store share. */ export type AppFrameProps = & PropsRuntime<'root'> - & PropsRenderSlots<'sidebar' | 'conversation' | 'details'> + & PropsRenderSlots<'sidebar' | 'conversation' | 'details' | 'shell.overlay'> & PropsStore> /** Center column grid item (session-body building block). */ @@ -190,6 +190,9 @@ export function AppFrame({ {renderSlot('conversation', {})} {renderSlot('details', {})} +
+ {renderSlot('shell.overlay', {})} +
{/* The collapsed rail is fixed-width: no resize handle while closed. */} {!sidebarCollapsed && } {cols.details > 0 && } diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.module.css b/packages/client/ui-settings-general/src/client/GeneralSection.module.css index efa367bc52..d9c83d8411 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.module.css +++ b/packages/client/ui-settings-general/src/client/GeneralSection.module.css @@ -7,6 +7,6 @@ width: 100%; } -.section > :last-child { +.section > :global([data-slot='settings.general.item']) > :last-child { border-bottom: none; } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 17333b5ccc..d689106707 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -227,11 +227,39 @@ padding-left: 0; } -/* Foot seat: a pure layout socket pinned under the region; the ui-settings - trigger row inside owns its own geometry (38px wide row / 36px rail - circle) and hover chrome. */ +/* Footer seats: Settings fills the left side and additive actions sit on the + right. Each occupant owns its button geometry and hover chrome. */ .footArea { flex: none; + display: flex; + align-items: flex-end; + gap: 8px; +} + +.settingsArea { + flex: 1; + min-width: 0; +} + +.footerActions { + flex: none; + display: flex; + align-items: flex-end; +} + +/* The 56px rail cannot hold two controls side by side. Keep both reachable in + the same footer, stacked in their original order. */ +.collapsed .footArea { + flex-direction: column; + align-items: center; + gap: 0; +} + +.collapsed .settingsArea, +.collapsed .footerActions { + flex: none; + display: flex; + justify-content: center; } @media (prefers-reduced-motion: reduce) { diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index f464066f56..5f171093d4 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -6,7 +6,7 @@ * snap to the 56px rail (one icon each, same top-down order) fading in as the * slide ends. The workspace/session browsing region between the New Session * button and the foot is the `sidebar.workspaces` registrant's, and the foot - * is the `sidebar.settings` registrant's; the shell hands them the wide flag + * holds `sidebar.settings` plus `sidebar.footer.action`; the shell hands them the wide flag * (plus an expand request callback for the browser). * * The column also owns whether the scroll regions nested in it draw a @@ -177,9 +177,14 @@ export function SidebarRoot({ })} - {/* Foot seat: ui-settings registers the trigger row + panel here. */} + {/* Footer: Settings stays on the left; optional actions sit beside it. */}
- {renderSlot('sidebar.settings', { wide })} +
+ {renderSlot('sidebar.settings', { wide })} +
+
+ {renderSlot('sidebar.footer.action', { wide })} +
) diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 4da4d14eed..a298ed7095 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -50,6 +50,12 @@ export interface SidebarSettingsOwnerProps { wide: boolean } +/** Owner share of an action rendered beside Settings at the sidebar foot. */ +export interface SidebarFooterActionOwnerProps { + /** Whether the sidebar renders wide content (false = 56px rail). */ + wide: boolean +} + /** * Registrant-private injected share (arrives via the register inject * factory). The shell keeps only its own controls: starting a Session from @@ -72,5 +78,6 @@ export type SidebarRootInjected = { * seat. No store is registered. */ export type SidebarRootComponentProps = - PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'> + PropsRuntime<'sidebar'> + & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings' | 'sidebar.footer.action'> & SidebarRootInjected & PropsLocale<'sidebar'> diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index a9706c3e99..0bd2c98295 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -6,7 +6,10 @@ import type { SidebarRootInjected } from './contract/slots.ts' import { SidebarRoot } from './SidebarRoot.tsx' import { en, zh, type SidebarKey } from './locales.ts' -export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from './contract/slots.ts' +export type { + SidebarFooterActionOwnerProps, SidebarRootComponentProps, SidebarRootInjected, + SidebarSectionOwnerProps, SidebarSettingsOwnerProps, +} from './contract/slots.ts' export type { SidebarKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -44,6 +47,7 @@ export function apply(ctx: ClientContext): void { children: { 'sidebar.workspaces': { kind: 'single', scope: 'root' }, 'sidebar.settings': { kind: 'single', scope: 'root' }, + 'sidebar.footer.action': { kind: 'list', scope: 'root' }, }, inject: injectProps, }, SidebarRoot), diff --git a/packages/client/ui-sidebar/tests/apply.client.spec.tsx b/packages/client/ui-sidebar/tests/apply.client.spec.tsx index 1849556cea..dc4fd3e27b 100644 --- a/packages/client/ui-sidebar/tests/apply.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.client.spec.tsx @@ -31,11 +31,13 @@ describe('ui-sidebar apply', () => { expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces', 'locale']) }) - it('registers the shell and declares the browsing-region hole', async () => { + it('registers the shell and declares its child seats', async () => { const b = await bench() await b.ctx.plugin({ inject: [...inject], apply }).await() expect(b.slots.entries('sidebar')).toHaveLength(1) expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' }) + expect(b.slots.spec('sidebar.settings')).toEqual({ kind: 'single', scope: 'root' }) + expect(b.slots.spec('sidebar.footer.action')).toEqual({ kind: 'list', scope: 'root' }) // Copy rides the standard locale seat, not the inject face. expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar') const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)() @@ -61,5 +63,6 @@ describe('ui-sidebar apply', () => { await fiber.dispose() expect(b.slots.entries('sidebar')).toHaveLength(0) expect(b.slots.spec('sidebar.workspaces')).toBeUndefined() + expect(b.slots.spec('sidebar.footer.action')).toBeUndefined() }) }) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index 925c18f8f3..8f53c92a83 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -1,7 +1,10 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import type { SidebarRootComponentProps, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from '../src/client/contract/slots.ts' +import type { + SidebarFooterActionOwnerProps, SidebarRootComponentProps, SidebarSectionOwnerProps, + SidebarSettingsOwnerProps, +} from '../src/client/contract/slots.ts' import { SidebarRoot } from '../src/client/SidebarRoot.tsx' import { en } from '../src/client/locales.ts' @@ -23,17 +26,25 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w const toggleSidebar = vi.fn() let regionOwner: SidebarSectionOwnerProps | undefined let settingsOwner: SidebarSettingsOwnerProps | undefined + let footerActionOwner: SidebarFooterActionOwnerProps | undefined let current = { collapsed, width } const root = () => ( { + renderSlot={(( + key: string, + owner: SidebarFooterActionOwnerProps | SidebarSectionOwnerProps | SidebarSettingsOwnerProps, + ) => { if (key === 'sidebar.settings') { settingsOwner = owner return
} + if (key === 'sidebar.footer.action') { + footerActionOwner = owner + return
+ } regionOwner = owner as SidebarSectionOwnerProps return
}) as SidebarRootComponentProps['renderSlot']} @@ -51,6 +62,10 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w if (settingsOwner === undefined) throw new Error('settings owner not rendered') return settingsOwner }, + footerActionOwner: () => { + if (footerActionOwner === undefined) throw new Error('footer action owner not rendered') + return footerActionOwner + }, rerender(next: Partial) { current = { ...current, ...next } view.rerender(root()) @@ -75,6 +90,7 @@ describe('SidebarRoot shell', () => { expect(b.regionOwner().wide).toBe(true) // The settings seat rides the same wide flag (ui-settings renders the row). expect(b.settingsOwner().wide).toBe(true) + expect(b.footerActionOwner().wide).toBe(true) // Expanded: the request is a no-op (no accidental collapse). b.regionOwner().expandSidebar() expect(b.toggleSidebar).not.toHaveBeenCalled() @@ -89,6 +105,7 @@ describe('SidebarRoot shell', () => { vi.advanceTimersByTime(200) b.rerender({}) expect(b.regionOwner().wide).toBe(false) + expect(b.footerActionOwner().wide).toBe(false) expect(screen.getByTestId('region')).toBeTruthy() b.regionOwner().expandSidebar() expect(b.toggleSidebar).toHaveBeenCalledOnce() diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index eae7837386..00e2eaaf7c 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -473,21 +473,40 @@ export type InjectParams = */ export type SlotLabel = string | (() => string) -/** Kind shape fields carried in register options (keyed dispatch key; list id/order/label; chain select/priority). */ +/** + * Kind shape fields carried in register options (keyed dispatch key; list + * id/order/label; chain select/priority; non-chain priority = cell shadowing rank). + */ export type KindOptions< K extends keyof SlotMap & string, EntryKey extends EntryKeyOf, M = never, > = - SlotMap[K]['kind'] extends 'keyed' ? { key: EntryKey } - : SlotMap[K]['kind'] extends 'list' ? { id: string; order?: number; label?: SlotLabel } + SlotMap[K]['kind'] extends 'keyed' ? { + key: EntryKey + /** Cell shadowing rank (ascending, default 0, lowest renders; same key + same priority throws — see {@link SlotCore.register}). */ + priority?: number + } + : SlotMap[K]['kind'] extends 'list' ? { + id: string + order?: number + label?: SlotLabel + /** Cell shadowing rank (ascending, default 0, lowest renders; same id + same priority throws — see {@link SlotCore.register}). */ + priority?: number + } : SlotMap[K]['kind'] extends 'chain' ? { /** Routing selector, mandatory on chain entries; `M` (the component's `matched` prop) infers from its return. */ select: ChainSelect /** Explicit chain position (ascending, default 0, lower tries first); ties keep registration = assembly order. */ priority?: number } - : object + : { + /** + * Cell shadowing rank (ascending, default 0, lowest renders; a + * same-priority second registration throws — see {@link SlotCore.register}). + */ + priority?: number + } /** * Compile-time presence check: an entry declaring children MUST consume @@ -596,6 +615,8 @@ interface SlotRecord { spec: SlotSpec | undefined /** Diagnostics: which slot's entry declared this key ('(built-in)' for root). */ declaredBy: string | undefined + /** Live parent declaration, absent for root slots. */ + parent: string | undefined /** Monotonic declaration lifetime, distinct from ordinary entry mutations. */ declarationEpoch: number entries: readonly StoredEntry[] @@ -606,6 +627,38 @@ interface SlotRecord { const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([]) +/** JSON-safe live occupant returned by slot inspection. */ +export interface LiveSlotOccupant { + /** Plugin or package that registered the entry, when known. */ + registrant?: string + /** Keyed-slot cell. */ + key?: string + /** List-slot cell. */ + id?: string + /** List display order. */ + order?: number + /** Shadowing or chain priority. */ + priority: number + /** Whether the renderer currently selects this entry. */ + active: boolean +} + +/** JSON-safe live slot declaration tree. */ +export interface LiveSlotNode { + /** Exact SlotMap key. */ + name: string + /** Slot cardinality. */ + kind: SlotKind + /** Runtime data scope. */ + scope: SlotScope + /** Diagnostic owner of this declaration. */ + declaredBy?: string + /** Current registrations in ledger order. */ + occupants: LiveSlotOccupant[] + /** Slots declared by entries mounted in this slot. */ + children: LiveSlotNode[] +} + /** * Pure slot registry (no cordis; event emission and the renderer installation contract * live in the runtime Service wrapper). @@ -618,7 +671,9 @@ const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([]) * fire); {@link SlotCore.subscribeDeclaration} fires synchronously for each * declaration lifetime boundary; {@link SlotCore.subscribe} notifications * batch per microtask, so N same-tick mutations produce one notification per - * touched key. + * touched key. Entry crash reports ({@link SlotCore.reportEntryError}) ride + * the same mutation channel when they abdicate, then notify + * {@link SlotCore.onEntryError} synchronously. */ export class SlotCore { private records = new Map() @@ -629,6 +684,16 @@ export class SlotCore { // reference skips a lookup (and an unreachable missing-record branch) at flush. private dirty = new Set() private flushScheduled = false + /** + * Entries retired by an abdicating crash report + * ({@link SlotCore.reportEntryError}): excluded from + * {@link SlotCore.entriesOfSlot} projections for the rest of their + * registration's life, while the registration itself stays on the ledger + * (disposal authority remains with the registrant). + */ + private abdicated = new WeakSet() + private entryErrorListeners + = new Set<(key: string, entry: StoredEntry, error: unknown, info: { abdicated: boolean }) => void>() constructor() { // The a-priori root hole. No markDirty: nothing can observe construction. @@ -646,11 +711,18 @@ export class SlotCore { * re-checks nothing): registering into an undeclared slot throws; declaring * an already-declared child key throws (one declarer per slot — the message * names the first declarer); mounting one shared store handle under slots - * of different scopes throws. Kind constraints: single — duplicate - * registration throws; keyed — missing/duplicate `key` throws; list — - * missing/duplicate `id` throws; chain — missing `select` throws (the + * of different scopes throws. Kind constraints: keyed — missing `key` + * throws; list — missing `id` throws; chain — missing `select` throws (the * selector is the entry's routing seat, see {@link ChainSelect}). * + * Shadowing (single/keyed/list): entries sharing one cell (single — the + * slot itself; keyed — same `key`; list — same `id`) coexist at distinct + * priorities, sorted ascending with ties keeping registration order; the + * cell's lowest live entry renders ({@link SlotCore.entriesOfSlot}). A + * second registration at an occupied cell's exact priority (default 0) + * throws naming the occupant, so priority-less composition keeps the + * historical one-occupant-per-cell fail-loud. + * * Lifecycle: the disposer removes the contribution AND collapses every * declared child slot (child entries clear recursively; their stale * disposers become no-ops) — one lifecycle axis, no dangling state. @@ -719,23 +791,33 @@ export class SlotCore { } const spec = rec.spec // Kind constraints stay runtime checks for dynamically-composed callers; - // typed callers already satisfied KindOptions statically. + // typed callers already satisfied KindOptions statically. Cell occupancy + // clashes only at the exact priority: a different priority shadows. + const priority = options.priority ?? 0 + const occupantHint = (occupant: StoredEntry) => + `at priority ${priority}${occupant.registrant !== undefined ? ` (registered by ${occupant.registrant})` : ''} — register at a different priority to shadow it (lowest renders)` switch (spec.kind) { - case 'single': - if (rec.entries.length > 0) throw new Error(`single slot "${options.name}" already has a registration`) + case 'single': { + const occupant = rec.entries.find(e => (e.options.priority ?? 0) === priority) + if (occupant) throw new Error(`single slot "${options.name}" already has a registration ${occupantHint(occupant)}`) break - case 'keyed': + } + case 'keyed': { if (options.key === undefined) throw new Error(`keyed slot "${options.name}" requires options.key`) - if (rec.entries.some(e => e.options.key === options.key)) { - throw new Error(`keyed slot "${options.name}" already has an entry for key "${options.key}"`) + const occupant = rec.entries.find(e => e.options.key === options.key && (e.options.priority ?? 0) === priority) + if (occupant) { + throw new Error(`keyed slot "${options.name}" already has an entry for key "${options.key}" ${occupantHint(occupant)}`) } break - case 'list': + } + case 'list': { if (options.id === undefined) throw new Error(`list slot "${options.name}" requires options.id`) - if (rec.entries.some(e => e.options.id === options.id)) { - throw new Error(`list slot "${options.name}" already has an entry with id "${options.id}"`) + const occupant = rec.entries.find(e => e.options.id === options.id && (e.options.priority ?? 0) === priority) + if (occupant) { + throw new Error(`list slot "${options.name}" already has an entry with id "${options.id}" ${occupantHint(occupant)}`) } break + } case 'chain': if (options.select === undefined) throw new Error(`chain slot "${options.name}" requires options.select`) break @@ -777,10 +859,13 @@ export class SlotCore { ...(options.registrant !== undefined ? { registrant: options.registrant } : {}), } const next = [...rec.entries, entry] - // Stable sorts: ascending, ties keep registration sequence (list rides - // `order`, chain rides `priority` — lower priority tries first). - if (spec.kind === 'list') next.sort((a, b) => (a.options.order ?? 0) - (b.options.order ?? 0)) - if (spec.kind === 'chain') next.sort((a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0)) + // Stable sorts: priority ascending for every kind, ties keep registration + // sequence — a cell's winner is its first occurrence, chain tries lower + // priority first. List refines equal priorities by explicit `order` so the + // raw ledger keeps its display sequence for priority-less compositions. + next.sort(spec.kind === 'list' + ? (a, b) => ((a.options.priority ?? 0) - (b.options.priority ?? 0)) || ((a.options.order ?? 0) - (b.options.order ?? 0)) + : (a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0)) rec.entries = next this.markDirty(options.name, rec) if (options.children) { @@ -789,6 +874,7 @@ export class SlotCore { const childRec = this.record(childKey) childRec.spec = childSpec childRec.declaredBy = `an entry in "${options.name}"${options.registrant ? ` (${options.registrant})` : ''}` + childRec.parent = options.name childRec.declarationEpoch += 1 declarations.push([childKey, childRec]) } @@ -835,6 +921,36 @@ export class SlotCore { return this.records.get(key)?.entries ?? NO_ENTRIES } + /** + * Project a key's entries to its shadowing winners: the first live + * (non-abdicated) entry of each cell in priority order — single: the slot + * is one cell; keyed: one cell per `key`; list: one cell per `id` (winners + * keep ledger sequence; list renderers still refine display by `order`). + * Chain keys return the raw entries unchanged: election consumes every + * entry, shadowing does not apply. The raw {@link SlotCore.entries} view + * stays the inspection surface. Builds a fresh array per call — a render + * body read, not a uSES getSnapshot source. + * @param key - slot key (dynamic: the render machinery holds keys as strings). + * @returns the winning entry per occupied cell (empty while undeclared). + */ + entriesOfSlot(key: string): readonly StoredEntry[] { + const rec = this.records.get(key) + if (!rec?.spec) return NO_ENTRIES + const kind = rec.spec.kind + if (kind === 'chain') return rec.entries + const heads: StoredEntry[] = [] + const seenCells = new Set() + for (const entry of rec.entries) { + if (this.abdicated.has(entry)) continue + // Single-kind entries all share the one undefined cell. + const cell = kind === 'keyed' ? entry.options.key : kind === 'list' ? entry.options.id : undefined + if (seenCells.has(cell)) continue + seenCells.add(cell) + heads.push(entry) + } + return heads + } + /** * Look up a slot's declared spec, narrowed by the SlotMap key. * @param key - SlotMap key. @@ -855,6 +971,53 @@ export class SlotCore { return this.records.get(key)?.spec } + /** + * Export the current declaration topology without components or executable hooks. + * @param root - exact Slot key to select; omitted returns every live root. + * @returns selected live Slot trees, or an empty array when `root` is unavailable. + */ + snapshot(root?: string): LiveSlotNode[] { + const build = (name: string, seen: Set): LiveSlotNode | undefined => { + const record = this.records.get(name) + if (record?.spec === undefined || seen.has(name)) return undefined + const branch = new Set(seen) + branch.add(name) + const active = new Set(this.entriesOfSlot(name)) + const children = [...this.records.entries()] + .filter(([, candidate]) => candidate.spec !== undefined && candidate.parent === name) + .flatMap(([child]) => { + const node = build(child, branch) + return node === undefined ? [] : [node] + }) + return { + name, + kind: record.spec.kind, + scope: record.spec.scope, + ...record.declaredBy === undefined ? {} : { declaredBy: record.declaredBy }, + occupants: record.entries.map(entry => ({ + ...entry.registrant === undefined ? {} : { registrant: entry.registrant }, + ...entry.options.key === undefined ? {} : { key: entry.options.key }, + ...entry.options.id === undefined ? {} : { id: entry.options.id }, + ...entry.options.order === undefined ? {} : { order: entry.options.order }, + priority: entry.options.priority ?? 0, + active: active.has(entry), + })), + children, + } + } + if (root !== undefined) { + const node = build(root, new Set()) + return node === undefined ? [] : [node] + } + return [...this.records.entries()] + .filter(([, record]) => record.spec !== undefined + && (record.parent === undefined || this.records.get(record.parent)?.spec === undefined)) + .flatMap(([name]) => { + const node = build(name, new Set()) + return node === undefined ? [] : [node] + }) + } + /** * Read the declaration lifetime of a key. Entry additions and removals do * not change it; declaration creation and collapse each advance it. @@ -916,6 +1079,47 @@ export class SlotCore { return () => { this.mutateListeners.delete(fn) } } + /** + * Renderer crash report from an entry boundary. Always notifies + * {@link SlotCore.onEntryError} listeners; with `info.abdicate` set (the + * shadowing kinds — single/keyed/list) it first retires the entry from its + * cell, one-shot: the record's version bumps through the ordinary mutation + * channel so outlets re-project onto the cell's next survivor, and a + * repeat abdicating report no-ops entirely. Chain crashes report with + * `abdicate: false` — election alternatives resolve at select time, so the + * entry keeps its cell and only the notification fires. The registration + * itself stays on the ledger either way — raw {@link SlotCore.entries} + * still lists the entry and its disposer keeps working. + * @param key - slot key the entry rendered under. + * @param entry - the crashed entry. + * @param error - the crash cause, forwarded to listeners verbatim. + * @param info - `abdicate`: whether the crash retires the entry from its cell. + */ + reportEntryError(key: string, entry: StoredEntry, error: unknown, info: { abdicate: boolean }): void { + if (info.abdicate) { + if (this.abdicated.has(entry)) return + this.abdicated.add(entry) + const rec = this.records.get(key) + if (rec !== undefined) this.markDirty(key, rec) + } + for (const fn of [...this.entryErrorListeners]) fn(key, entry, error, { abdicated: info.abdicate }) + } + + /** + * Observe entry boundary crashes (every render-time entry failure the + * boundaries contain, abdicating or not) — the supervision seam for hosts + * mirroring contribution health. Fires synchronously per report, after the + * registry mutated for abdicating crashes (same listener discipline as + * {@link SlotCore.onMutate}). + * @param fn - called with the slot key, the crashed entry, the crash + * cause, and `abdicated`: whether the crash retired the entry from its cell. + * @returns unsubscribe. + */ + onEntryError(fn: (key: string, entry: StoredEntry, error: unknown, info: { abdicated: boolean }) => void): () => void { + this.entryErrorListeners.add(fn) + return () => { this.entryErrorListeners.delete(fn) } + } + /** * Cascade for a removed entry: release its store mount and collapse every * child slot it declared — specs clear, contributions empty (their stale @@ -935,6 +1139,7 @@ export class SlotCore { const doomed = childRec.entries childRec.spec = undefined childRec.declaredBy = undefined + childRec.parent = undefined childRec.declarationEpoch += 1 childRec.entries = NO_ENTRIES this.markDirty(childKey, childRec) @@ -949,6 +1154,7 @@ export class SlotCore { rec = { spec: undefined, declaredBy: undefined, + parent: undefined, declarationEpoch: 0, entries: NO_ENTRIES, version: 0, diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index f04d003504..cdad4191ea 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/packages/client/ui-slots/src/renderer.ts @@ -118,6 +118,27 @@ export interface SlotRendererHost { * @returns entries in registration (list: order) sequence. */ entriesOf(key: string): readonly StoredEntry[] + /** + * Shadowing winners per cell for a key — the render read for single/keyed/ + * list dispatch: the first live (non-abdicated) entry of each cell in + * priority order; chain keys pass through unchanged (election consumes + * every entry). Fresh array per call — a render-body read, not a uSES + * getSnapshot source. + * @param key - slot key. + * @returns the winning entry per occupied cell. + */ + entriesOfSlot(key: string): readonly StoredEntry[] + /** + * Report an entry boundary crash. With `info.abdicate` (shadowing kinds) + * the entry retires from its cell, one-shot, so the next survivor renders; + * chain crashes report without abdicating. The registration stays on the + * ledger either way. + * @param key - slot key the entry rendered under. + * @param entry - the crashed entry. + * @param error - the crash cause. + * @param info - `abdicate`: whether the crash retires the entry from its cell. + */ + reportEntryError(key: string, entry: StoredEntry, error: unknown, info: { abdicate: boolean }): void /** * Declared runtime spec from the declarations ledger. * @param key - slot key. diff --git a/packages/client/web-react/src/scoped-slots.tsx b/packages/client/web-react/src/scoped-slots.tsx index cc23d17758..dea0511319 100644 --- a/packages/client/web-react/src/scoped-slots.tsx +++ b/packages/client/web-react/src/scoped-slots.tsx @@ -283,12 +283,14 @@ function useLocaleRevision(face: LocaleFace | undefined): number { } /** - * Entry-identity React keys for chain boundaries. A chain outlet renders ONE - * elected entry through an error boundary; without a key, a boundary that - * failed on entry A would survive a re-election and keep a healthy entry B - * blacked out. Keying by entry identity remounts the boundary fresh whenever - * the election changes (entries are identity-stable per registration, so the - * key is stable while the same entry stays elected). + * Entry-identity React keys for entry boundaries. An outlet renders one + * winner per position (single/keyed/list cell head, chain election) through + * an error boundary; without a key, a boundary that failed on entry A would + * survive a winner change (re-election, shadowing fallback after an + * abdication, HMR re-registration) and keep a healthy entry B blacked out. + * Keying by entry identity remounts the boundary fresh whenever the winner + * changes (entries are identity-stable per registration, so the key is + * stable while the same entry stays the winner). */ let nextEntryKey = 0 const entryKeys = new WeakMap() @@ -306,9 +308,14 @@ function entryKeyOf(entry: StoredEntry): number { * Per-entry isolation: one registrant crashing (component render or inject * factory) must not take down siblings. Assembly errors (missing providers) * rethrow — a miswired shell must fail loud, not degrade into fallbacks. + * Every catch reports through `onEntryError` (the ledger's supervision + * seam); for shadowing kinds the report abdicates the entry, the outlet + * re-renders onto the cell's next survivor, and this boundary's crash face + * only shows until that re-render lands (permanently once the cell is dry — + * the outlet then owns the crash face). */ class SlotErrorBoundary extends Component< - { slotKey: string; children: ReactNode }, { failed: boolean } + { slotKey: string; onEntryError: (error: unknown) => void; children: ReactNode }, { failed: boolean } > { override state = { failed: false } static getDerivedStateFromError(error: unknown): { failed: boolean } { @@ -317,6 +324,7 @@ class SlotErrorBoundary extends Component< } override componentDidCatch(error: unknown): void { console.error(`slot entry crashed in '${this.props.slotKey}':`, error) + this.props.onEntryError(error) } override render(): ReactNode { if (this.state.failed) return
@@ -607,18 +615,21 @@ function RootEntry({ entry, ownerProps, slotKey, slotInjected, hookContext, hasH return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext) } -function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookContext, hasHookContext }: { +function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookContext, hasHookContext, onEntryError }: { slotKey: string entry: StoredEntry ownerProps: object slotInjected: BoundSlotInject hookContext: unknown hasHookContext: boolean + onEntryError: (error: unknown) => void }) { const info = useSessionMaybeProvideInfo() if (info.sessionId === undefined) return null + // Per-session remount rides this key; per-entry remount rides the outer + // element's entry-identity key (the outlet's guarded() call). return ( - + + {renderOutletContent(host, slotKey, ownerProps, opts, sessionInfo)} +
+ ) +} + +/** Kind dispatch behind the outlet anchor (single/keyed/list/chain, fallbacks, crash faces). */ +function renderOutletContent( + host: SlotRendererHost, + slotKey: string, + ownerProps: object, + opts: (RenderOpts & ChainRenderOpts) | undefined, + sessionInfo: SessionMaybeProvideInfo, +): ReactNode { const spec = host.specOf(slotKey) // Undeclared (or no-longer-declared) keys render empty: a declaring entry's // unload returns the slot to the undeclared state while retained elements @@ -667,6 +707,13 @@ function SlotOutlet({ slotKey, ownerProps, opts }: { const guarded = (entry: StoredEntry, key?: string | number, owner: object = ownerProps) => { const hasHookContext = opts !== undefined && Object.hasOwn(opts, 'hookContext') const hookContext = opts?.hookContext + // Shadowing kinds abdicate on crash (the cell falls to its next + // survivor); chain reports without abdicating — election alternatives + // resolve at select time, and retiring a crashed elected entry would + // change the static crash face. + const onEntryError = (error: unknown) => { + host.reportEntryError(slotKey, entry, error, { abdicate: spec.kind !== 'chain' }) + } return spec.scope === 'session' ? ( ) : ( - + {spec.scope === 'session-maybe' ? ( ) } + // A cell whose every registration abdicated keeps the crash face: the + // shadowing collapse ran out of survivors, which is a failure state, not + // the owner's natural-empty fallback. + const deadCell = () =>
if (spec.kind === 'single') { - const entry = entries[0] - if (!entry) return <>{opts?.fallback ?? null} + const entry = host.entriesOfSlot(slotKey)[0] + if (!entry) return entries.length > 0 ? deadCell() : <>{opts?.fallback ?? null} return guarded(entry, entryKeyOf(entry)) } if (spec.kind === 'keyed') { - const entry = entries.find(e => e.options.key === opts?.entryKey) - if (!entry) return <>{opts?.fallback ?? null} + const entry = host.entriesOfSlot(slotKey).find(e => e.options.key === opts?.entryKey) + if (!entry) { + const occupied = entries.some(e => e.options.key === opts?.entryKey) + return occupied ? deadCell() : <>{opts?.fallback ?? null} + } return guarded(entry, entryKeyOf(entry)) } if (spec.kind === 'chain') { @@ -764,16 +819,35 @@ function SlotOutlet({ slotKey, ownerProps, opts }: { } return elected ?? <>{opts?.fallback ?? null} } - // list: registration order refined by explicit order, optional id filter. - const withListOptions = entries.map(entry => ({ + // list: one row per id cell — the cell's shadowing winner, or the crash + // face once every entry of the cell abdicated (a dry cell must not + // silently drop its row). Row sequence: registration order refined by + // explicit order, optional id filter, as before shadowing existed. + const winners = host.entriesOfSlot(slotKey) + const rows: { entry: StoredEntry | undefined; id: string | undefined; order: number }[] = winners.map(entry => ({ entry, id: entry.options.id, order: entry.options.order ?? 0, })) - let list = [...withListOptions].sort((a, b) => a.order - b.order) + const rowIds = new Set(rows.map(row => row.id)) + for (const entry of entries) { + if (rowIds.has(entry.options.id)) continue + rowIds.add(entry.options.id) + // Dry cells anchor their row at the cell head's declared order. + rows.push({ entry: undefined, id: entry.options.id, order: entry.options.order ?? 0 }) + } + let list = [...rows].sort((a, b) => a.order - b.order) if (opts?.only !== undefined) list = list.filter(item => item.id === opts.only) if (list.length === 0) return <>{opts?.fallback ?? null} - return <>{list.map(item => guarded(item.entry, entryKeyOf(item.entry)))} + // Winner rows key by entry identity (see entryKeyOf); dry-cell rows key by + // id — the disjoint prefixes keep the two namespaces from colliding. + return ( + <> + {list.map((item, i) => item.entry !== undefined + ? guarded(item.entry, `e${entryKeyOf(item.entry)}`) + :
)} + + ) } /** Root outlet: the shell's single ctx-level render entry — an unregistered 'root' is a boot-order failure, never a silent blank. */ @@ -784,19 +858,33 @@ function RootOutlet({ ownerProps }: { ownerProps: object }) { () => host.getVersion('root'), ) useLocaleRevision(host.locale) - const entry = host.entriesOf('root')[0] - if (!entry) throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)") + const entry = host.entriesOfSlot('root')[0] + if (!entry) { + // Registrations exist but every one abdicated: the shadowing collapse ran + // dry, so the crash face replaces the tree (registered-but-broken is a + // crash, not the boot-order assembly failure below). + if (host.entriesOf('root').length > 0) return
+ throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)") + } + // Same anchor contract as SlotOutlet: 'root' is a slot like any other, and + // display:contents keeps the wrapper out of the shell's layout. return ( - - + - + key={entryKeyOf(entry)} + onEntryError={(error) => { host.reportEntryError('root', entry, error, { abdicate: true }) }} + > + + +
) } diff --git a/packages/client/web-react/tests/scoped-slots-real-core.client.spec.tsx b/packages/client/web-react/tests/scoped-slots-real-core.client.spec.tsx index b7c211fe69..0e94030729 100644 --- a/packages/client/web-react/tests/scoped-slots-real-core.client.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots-real-core.client.spec.tsx @@ -31,6 +31,8 @@ function hostOver(core: SlotCore): SlotRendererHost { subscribe: (key, fn) => core.subscribe(key, fn), getVersion: key => core.getVersion(key), entriesOf: key => core.entries(key), + entriesOfSlot: key => core.entriesOfSlot(key), + reportEntryError: (key, entry, error, info) => { core.reportEntryError(key, entry, error, info) }, specOf: key => core.specDynamic(key), isLive: entry => core.isLive(entry), storeOf: () => undefined, diff --git a/packages/client/web-react/tests/scoped-slots.client.spec.tsx b/packages/client/web-react/tests/scoped-slots.client.spec.tsx index 171cc48ee1..b5d7e2d3b1 100644 --- a/packages/client/web-react/tests/scoped-slots.client.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots.client.spec.tsx @@ -83,6 +83,7 @@ function makeHost() { const versions = new Map() const subs = new Map void>>() const live = new Set() + const abdicated = new Set() const storeCache = new Map>() const list = observable<{ ids: string[] }>({ ids: [] }) const workspaces = observable<{ ids: string[] }>({ ids: [] }) @@ -105,6 +106,28 @@ function makeHost() { }, getVersion: key => versions.get(key) ?? 0, entriesOf: key => entries.get(key) ?? [], + entriesOfSlot: (key) => { + const all = entries.get(key) ?? [] + const kind = specs.get(key)?.kind + if (kind === 'chain') return all + // Mirror the ledger projection: first live (non-abdicated) entry per + // cell (single — one cell; keyed — per key; list — per id). + const heads: StoredEntry[] = [] + const seen = new Set() + for (const entry of all) { + if (abdicated.has(entry)) continue + const cell = kind === 'keyed' ? entry.options.key : kind === 'list' ? entry.options.id : undefined + if (seen.has(cell)) continue + seen.add(cell) + heads.push(entry) + } + return heads + }, + reportEntryError: (key, entry, _error, info) => { + if (!info.abdicate || abdicated.has(entry)) return + abdicated.add(entry) + bump(key) + }, specOf: key => specs.get(key), isLive: entry => live.has(entry), storeOf: (entry, scopeKey) => { @@ -147,11 +170,12 @@ function makeHost() { add: (key: string, partial: Omit & { options?: StoredEntry['options'] }) => { const entry = entryOf(partial) const next = [...(entries.get(key) ?? []), entry] - // Mirror the ledger contract: chain entries arrive priority-sorted - // (stable, ascending) — outlets iterate entries() order as-is. - if (specs.get(key)?.kind === 'chain') { - next.sort((a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0)) - } + // Mirror the ledger contract: entries arrive priority-sorted (stable, + // ascending; list refines equal priorities by order) — outlets iterate + // entries() order as-is. + next.sort(specs.get(key)?.kind === 'list' + ? (a, b) => ((a.options.priority ?? 0) - (b.options.priority ?? 0)) || ((a.options.order ?? 0) - (b.options.order ?? 0)) + : (a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0)) entries.set(key, next) live.add(entry) bump(key) diff --git a/packages/client/web-react/tests/session-provider.client.spec.tsx b/packages/client/web-react/tests/session-provider.client.spec.tsx index 248d4d17f5..af46f4138b 100644 --- a/packages/client/web-react/tests/session-provider.client.spec.tsx +++ b/packages/client/web-react/tests/session-provider.client.spec.tsx @@ -46,6 +46,10 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea subscribe: () => () => {}, getVersion: () => 0, entriesOf: key => key === 'root' ? [rootEntry] : sessionEntries, + // Single-kind everywhere and no crashes in this suite: the projection is + // the raw view and crash reports never fire. + entriesOfSlot: key => key === 'root' ? [rootEntry] : sessionEntries, + reportEntryError: () => {}, specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined, isLive: () => true, storeOf: () => undefined, diff --git a/packages/client/web-react/tests/stale-authorization.client.spec.tsx b/packages/client/web-react/tests/stale-authorization.client.spec.tsx index df3bc51d2b..e617ddecdd 100644 --- a/packages/client/web-react/tests/stale-authorization.client.spec.tsx +++ b/packages/client/web-react/tests/stale-authorization.client.spec.tsx @@ -35,6 +35,10 @@ function makeHost() { }, getVersion: key => versions.get(key) ?? 0, entriesOf: key => entries.get(key) ?? [], + // Single-kind everywhere and no crashes in this suite: the projection is + // the raw view and crash reports never fire. + entriesOfSlot: key => entries.get(key) ?? [], + reportEntryError: () => {}, specOf: () => ({ kind: 'single', scope: 'root' }), isLive: entry => live.has(entry), storeOf: () => undefined, diff --git a/packages/client/web-react/tests/use-projection.client.spec.tsx b/packages/client/web-react/tests/use-projection.client.spec.tsx index 15a63e9027..1af4f1abb1 100644 --- a/packages/client/web-react/tests/use-projection.client.spec.tsx +++ b/packages/client/web-react/tests/use-projection.client.spec.tsx @@ -49,6 +49,10 @@ function makeHost() { subscribe: () => () => {}, getVersion: () => 0, entriesOf: key => key === 'root' ? [rootEntry] : sessionEntries, + // Single-kind everywhere and no crashes in this suite: the projection is + // the raw view and crash reports never fire. + entriesOfSlot: key => key === 'root' ? [rootEntry] : sessionEntries, + reportEntryError: () => {}, specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined, isLive: () => true, storeOf: () => undefined, diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 8433671912..fb9f5f1f9f 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -5,9 +5,11 @@ import { describe, expect, it } from 'vitest' import { assertManifestComplete, + assertToolsHarvested, collectToolCatalog, render, type ToolCatalog, + type ToolPackage, } from '../../../../scripts/gen-tool-catalog.ts' /** JSON Schema shape enough to reach the values AST extraction can't. */ @@ -23,7 +25,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'read_image', 'report', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_define', 'cordis_package_inspect', 'cordis_run', 'cordis_runtime_inspect', 'cordis_stop', 'cordis_undefine', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'read_image', 'report', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { @@ -91,6 +93,29 @@ describe('gen-tool-catalog assertManifestComplete', () => { }) }) +describe('gen-tool-catalog assertToolsHarvested', () => { + const entry: ToolPackage = { + pkg: '@deepseek-ai/dsh-tool-demo', + dir: 'tool-demo', + source: 'packages/demo/tool-demo/src/index.ts', + requires: ['ctx.tools', 'ctx.somethingUnmounted'], + writes: ['tool/result'], + mount: () => Promise.resolve(), + } + + it('accepts a boot that registered at least one tool', () => { + expect(() => { assertToolsHarvested(entry, 1) }).not.toThrow() + }) + + it('throws, naming the package and its requirements, when a boot registers nothing', () => { + // The failure this guards is silent by construction: the package is in the + // manifest, its plugin merely stays PENDING on an unmounted service, and the + // catalog would ship without its tools while every gate stays green. + expect(() => { assertToolsHarvested(entry, 0) }).toThrow(/@deepseek-ai\/dsh-tool-demo booted without registering a single tool/) + expect(() => { assertToolsHarvested(entry, 0) }).toThrow(/ctx.somethingUnmounted/) + }) +}) + describe('gen-tool-catalog render', () => { it('emits a package heading, a tool heading, and a json schema fence', () => { const catalog: ToolCatalog = [ diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 3fe567b623..9ee0b35ef1 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -69,6 +69,11 @@ import { GoalError } from '@deepseek-ai/dsh-goal' import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal' // Type-only edges: resolve the command-change stream and `ctx.get('skills')`. import type {} from '@deepseek-ai/dsh-commands' +// Type-only: the dynamic-package runner's forwarded-event declarations. Its +// client-safe `./types` subpath deliberately, not the package root — the root +// merges `ctx.dynamicCordisRunner`, and a dependency on that package would +// rebuild the api-remotes cycle this direction exists to avoid. +import type {} from '@deepseek-ai/dsh-cordis-host-runner/types' import type {} from '@deepseek-ai/dsh-skill' // The settings/credentials seams: brand guards run at this wire boundary; the // service reads stay optional (`ctx.get`) so a composition without either @@ -1044,7 +1049,7 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie /** * Implement ApiProxy over a composed host context. * @param ctx - a context with the Host spine and Workspace registry mounted. - * @param defaults - Agent model and project-directory defaults. + * @param defaults - host routing and project-directory defaults. * @returns the ApiProxy implementation. */ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy { diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index d6434c4c85..ea632e89ee 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -33,7 +33,11 @@ export interface ApiProxy { llm: LlmApi /** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */ downloads: DownloadsApi - /** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */ + /** + * Response entry for server requests; not a domain method. + * @param message - Client response carrying the server request's rpcId. + * @returns Transport receipt for the response delivery. + */ respond(message: ClientResponse): Promise } diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 76bdd3e14e..dc513e388b 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -50,12 +50,13 @@ type ErasedRegister = (options: object, component: unknown) => () => void /** * One rendered slot's local view, from {@link SlotTestRuntime.renderSlot}: - * the `data-slot` wrapper is the snapshot root (`expect(view.container) - * .toMatchSnapshot()` captures exactly this slot's output), Testing Library - * queries are bound inside it, and `update` re-renders with new owner props. + * the renderer's own `[data-slot]` outlet anchor is the snapshot root + * (`expect(view.container).toMatchSnapshot()` captures exactly this slot's + * output), Testing Library queries are bound inside it, and `update` + * re-renders with new owner props. */ export interface SlotView { - /** The `
` wrapper around the slot's rendered output. */ + /** The renderer's `
` anchor around the slot's rendered output. */ readonly container: HTMLElement /** Testing Library queries scoped to {@link SlotView.container}. */ readonly view: BoundFunctions @@ -286,10 +287,10 @@ export class SlotTestRuntime { /** * Declare child slots under an auto-generated root frame — the single-slot * mounting path for local DOM snapshots. Each key later supplied through - * {@link SlotTestRuntime.renderSlot} renders inside its own - * `
` wrapper (the snapshot root). Mutually exclusive - * with {@link TestRoot.declare} ('root' is a single slot); one call per - * runtime. + * {@link SlotTestRuntime.renderSlot} renders inside the renderer's own + * `
` outlet anchor (the snapshot root — the frame + * adds no wrapper of its own). Mutually exclusive with + * {@link TestRoot.declare} ('root' is a single slot); one call per runtime. * @param children - child-slot declaration table (same contract as TestRoot.declare). * @returns completion of the act-wrapped registration. */ @@ -298,8 +299,11 @@ export class SlotTestRuntime { const cell = this.ownerCell const AutoFrame = (props: { renderSlot: (key: string, owner: object) => ReactNode }) => { useSyncExternalStore(cell.subscribe, cell.getVersion) + // Keyed Fragments only: the renderer's outlet anchor is the one + // `[data-slot]` element — the frame adding its own would nest + // duplicate anchors under the same key. return createElement(Fragment, null, cell.entries().map(([key, owner]) => - createElement('div', { 'data-slot': key, key }, props.renderSlot(key, owner)))) + createElement(Fragment, { key }, props.renderSlot(key, owner)))) } await this.root.declare(children as never, AutoFrame as never) } diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index b518cef338..0eee1ff61a 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -859,17 +859,39 @@ class FaceAnalyzer { const result: ServiceModel[] = [] for (const member of context.members) { if (!ts.isPropertySignature(member) || member.type === undefined) continue - const symbol = this.symbolAtType(member.type) - if (symbol === undefined) continue - const symbolId = this.symbolId(symbol) - const exported = bySymbol.get(symbolId)?.find(record => record.model.name === symbol.name) - ?? bySymbol.get(symbolId)?.find(record => record.model.name !== 'default') - ?? bySymbol.get(symbolId)?.[0] + // An OPTIONAL key is not a service: `X | undefined` and `key?: X` both mark + // a value the launcher or boot code installs before the tree mounts (a root + // accessor, an environment snapshot), which no plugin provides and no + // consumer can reach with `inject`. Describing one as a service would answer + // "add the plugin that provides it" for a key where no such plugin exists. + if (member.questionToken !== undefined + || (ts.isUnionTypeNode(member.type) + && member.type.types.some(node => node.kind === ts.SyntaxKind.UndefinedKeyword))) continue + const authoredSymbol = this.symbolAtType(member.type) + if (authoredSymbol === undefined) continue + const authoredSymbolId = this.symbolId(authoredSymbol) + const exported = bySymbol.get(authoredSymbolId)?.find(record => record.model.name === authoredSymbol.name) + ?? bySymbol.get(authoredSymbolId)?.find(record => record.model.name !== 'default') + ?? bySymbol.get(authoredSymbolId)?.[0] if (exported === undefined) continue - const declaration = preferredDeclaration(symbol) + let symbol = authoredSymbol + let declaration = preferredDeclaration(symbol) + const aliases = new Set() + while (declaration !== undefined && ts.isTypeAliasDeclaration(declaration)) { + if (aliases.has(symbol)) break + aliases.add(symbol) + const target = this.symbolAtType(declaration.type) + if (target === undefined) break + symbol = target + declaration = preferredDeclaration(symbol) + } if (declaration === undefined || (!ts.isClassDeclaration(declaration) && !ts.isInterfaceDeclaration(declaration))) { this.fail(member, `service ${memberName(member.name)} does not resolve to an exported class or interface`) } + const memberOwner = this.registrationForFile(member.getSourceFile().fileName) + const declarationOwner = this.registrationForFile(declaration.getSourceFile().fileName) + if (memberOwner?.name !== declarationOwner?.name) continue + const symbolId = this.symbolId(symbol) const model = this.ensureDeclaration(symbol, declaration) const exposed = model.members .filter(exposableMember) @@ -1990,6 +2012,11 @@ class FaceAnalyzer { && member.initializer !== undefined && ts.isCallExpression(member.initializer) && this.isTypeMetaSymbol(member.initializer.expression, 'bindTypertRemote')) continue + if (ts.isMethodDeclaration(member) && member.body !== undefined + && members.some(candidate => candidate !== member + && (ts.isMethodDeclaration(candidate) || ts.isMethodSignature(candidate)) + && memberName(candidate.name) === memberName(member.name) + && (!ts.isMethodDeclaration(candidate) || candidate.body === undefined))) continue const visibility = visibilityOf(member) const isStatic = hasModifier(member, ts.SyntaxKind.StaticKeyword) if (visibility !== 'public' || isStatic || ts.isConstructorDeclaration(member)) continue diff --git a/packages/typert/generator/src/cordis-catalog.ts b/packages/typert/generator/src/cordis-catalog.ts index 6de24f022c..855a908159 100644 --- a/packages/typert/generator/src/cordis-catalog.ts +++ b/packages/typert/generator/src/cordis-catalog.ts @@ -12,13 +12,15 @@ import type { FaceModel, MemberModel, ParameterModel, + ServiceModel, SignatureModel, SourceDeclarationModel, SourceLocation, + TypertFace, TypeNodeId, } from './model.ts' -type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial' +type Mode = 'emit' | 'bail' | 'waterfall' | 'parallel' | 'serial' /** The fenced-block info string for generated signature blocks (skipped by * doc-typecheck, since a bare signature fragment is not standalone-compilable). */ @@ -112,6 +114,10 @@ export interface CordisCatalogPolicy { readonly foundationTypeNames: ReadonlySet /** Repository types deliberately documented outside the linked data catalog. */ readonly typeLinkExemptions: Readonly> + /** Framework Services included in the model-facing runtime catalog but not the harness documentation partition. */ + readonly runtimeServices?: readonly ServiceEntry[] + /** Harness Services omitted from the model-facing runtime catalog because dynamic Plugins must not call them. */ + readonly runtimeServiceExclusions?: ReadonlySet /** Manually curated framework events inherited by every plugin. */ readonly inheritedEvents: readonly InheritedEntry[] /** Manually curated framework context members inherited by every plugin. */ @@ -129,7 +135,7 @@ export class CordisCatalogProjector { private readonly renderer: TypeGraphRenderer /** - * @param face - analyzed host face containing package business semantics. + * @param face - analyzed Host or Client face containing package business semantics. * @param sourceDeclarations - exported declarations available to the runtime type closure. * @param policy - caller-owned type classifications and inherited Cordis data. */ @@ -138,7 +144,6 @@ export class CordisCatalogProjector { private readonly sourceDeclarations: readonly SourceDeclarationModel[], private readonly policy: CordisCatalogPolicy, ) { - if (face.face !== 'host') throw new Error(`cordis catalog requires the host face, received ${face.face}`) this.renderer = new TypeGraphRenderer(face.graph) } @@ -159,10 +164,13 @@ export class CordisCatalogProjector { * @returns the model-facing TypeScript catalog source. */ renderRuntimeApi(model: CordisCatalogModel): string { + const services = [...model.services, ...(this.policy.runtimeServices ?? [])] + .filter(service => !this.policy.runtimeServiceExclusions?.has(service.key)) + .sort((left, right) => left.key.localeCompare(right.key)) return renderRuntimeApi( - model.services, + services, model.events, - this.runtimeTypes(model.services), + this.runtimeTypes(services), this.policy.inheritedServices, ) } @@ -173,6 +181,8 @@ export class CordisCatalogProjector { const typeLinkViolations: string[] = [] for (const packageModel of this.face.packages) { for (const event of packageModel.events) { + const parsed = parseJsDoc(event.jsDoc ?? '') + if (parsed.deprecated) continue const source = pointer(event.location) const where = `event '${event.name}' (${source})` const node = this.renderer.node(event.signature) @@ -180,11 +190,12 @@ export class CordisCatalogProjector { violations.push(`${where} is not represented by a callable type.`) continue } - checkTypeLinks(where, signatureTypeNames(this.renderer, node.signature), this.policy, typeLinkViolations) - const parsed = parseJsDoc(event.jsDoc ?? '') + if (this.face.face === 'host') { + checkTypeLinks(where, signatureTypeNames(this.renderer, node.signature), this.policy, typeLinkViolations) + } const mode = event.mode if (!isMode(mode)) { - violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`) + violations.push(`${where} is missing an @mode tag. Add '@mode emit|bail|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`) } const last = node.signature.parameters.at(-1) const hasNext = last?.name === 'next' @@ -223,47 +234,91 @@ export class CordisCatalogProjector { return entries } + /** + * The services this projection describes, one per `ctx.`: those whose + * Context merge sits one level under a package's `src` and whose declaration + * belongs to that same package. + * + * Interfaces qualify beside classes, because an interface-typed key + * (`lsp: LspService`) has its Service Definition — and, by repository + * convention, its member documentation — on the interface; requiring a class + * would drop a real injectable service from every catalog. The declaration may + * live in any file of the package (`types.ts` is the usual home), while a + * declaration from ANOTHER package is not this package's surface to document. + * + * One key can have both kinds of candidate across packages: `ctx.typert` is + * typed by a merge-extensible interface in `type-meta` and implemented by a + * class in `registry`. The CLASS wins — it carries the documentation and is the + * object a caller meets — and picking before validating is what keeps a + * discarded candidate's missing JSDoc from failing the gate. + */ + private renderableServices(): ServiceModel[] { + const chosen = new Map() + for (const packageModel of this.face.packages) { + for (const service of packageModel.services) { + const declaration = this.renderer.declaration(service.symbol) + const owner = /^packages\/[^/]+\/[^/]+\/src\//.exec(service.location.file)?.[0] + if ((declaration.kind !== 'class' && declaration.kind !== 'interface') + || owner === undefined + || (this.face.face === 'host' + ? !/^packages\/[^/]+\/[^/]+\/src\/[^/]+\.ts$/.test(service.location.file) + : !/^packages\/[^/]+\/[^/]+\/src\/client\/.+\.tsx?$/.test(service.location.file)) + || !declaration.location.file.startsWith(owner)) continue + const current = chosen.get(service.key) + if (current !== undefined && this.renderer.declaration(current.symbol).kind === 'class') continue + chosen.set(service.key, service) + } + } + return [...chosen.values()] + } + private collectServices(): ServiceEntry[] { const entries: ServiceEntry[] = [] const violations: string[] = [] const typeLinkViolations: string[] = [] - for (const packageModel of this.face.packages) { - for (const service of packageModel.services) { - const declaration = this.renderer.declaration(service.symbol) - if (declaration.kind !== 'class' - || !/^packages\/[^/]+\/[^/]+\/src\/[^/]+\.ts$/.test(service.location.file) - || declaration.location.file !== service.location.file) continue - const doc = parseJsDoc(declaration.jsDoc ?? '').doc - const source = pointer(declaration.location) - if (doc === '') { - violations.push(`service ctx.${service.key} (${source}): class ${declaration.name} has no JSDoc.`) - } - const methods: ServiceMethodEntry[] = [] - for (const memberId of service.members) { - const member = this.renderer.member(memberId) - if (member.kind !== 'method' || member.name.startsWith('[')) continue - const where = `service method ctx.${service.key}.${member.name} (${pointer(member.location)})` - checkTypeLinks(where, signatureTypeNames(this.renderer, member.signature), this.policy, typeLinkViolations) - methods.push({ signature: member.text, jsDoc: member.jsDoc ?? '' }) - if (member.jsDoc === undefined) { - violations.push(`${where} has no JSDoc.`) - continue - } - const parsed = parseJsDoc(member.jsDoc) - if (parsed.doc === '') violations.push(`${where} has no description prose above its block tags.`) - checkParams(where, 'service', member.signature.parameters, parsed.params, - parameter => parameter.receiver, violations) - checkReturns(where, member.signature, parsed.returns, this.renderer, violations) - } - entries.push({ - key: service.key, - type: declaration.name, - abstract: declaration.abstract, - doc, - methods, - source, - }) + for (const service of this.renderableServices()) { + const declaration = this.renderer.declaration(service.symbol) + const parsedDeclaration = parseJsDoc(declaration.jsDoc ?? '') + if (parsedDeclaration.deprecated) continue + const doc = parsedDeclaration.doc + const source = pointer(declaration.location) + if (doc === '') { + violations.push(`service ctx.${service.key} (${source}): ${declaration.kind} ${declaration.name} has no JSDoc.`) } + const methods: ServiceMethodEntry[] = [] + for (const memberId of service.members) { + const member = this.renderer.member(memberId) + if (member.name.startsWith('[')) continue + const parsed = parseJsDoc(member.jsDoc ?? '') + if (parsed.deprecated) continue + if (member.kind === 'property') { + if (member.jsDoc === undefined) continue + methods.push({ signature: member.text, jsDoc: member.jsDoc }) + continue + } + if (member.kind !== 'method') continue + const where = `service method ctx.${service.key}.${member.name} (${pointer(member.location)})` + if (this.face.face === 'host') { + checkTypeLinks(where, signatureTypeNames(this.renderer, member.signature), this.policy, typeLinkViolations) + } + methods.push({ signature: member.text, jsDoc: member.jsDoc ?? '' }) + if (member.jsDoc === undefined) { + violations.push(`${where} has no JSDoc.`) + continue + } + if (parsed.doc === '') violations.push(`${where} has no description prose above its block tags.`) + checkParams(where, 'service', member.signature.parameters, parsed.params, + parameter => parameter.receiver, violations) + checkReturns(where, member.signature, parsed.returns, this.renderer, violations) + } + entries.push({ + key: service.key, + type: declaration.name, + abstract: declaration.abstract, + doc, + methods, + source, + }) } reportViolations('gen-cordis-catalog', violations) reportTypeLinkViolations('gen-cordis-catalog', typeLinkViolations) @@ -274,8 +329,8 @@ export class CordisCatalogProjector { const declarations = new Map() const ambiguous = new Set() for (const declaration of this.sourceDeclarations) { - if (declaration.face !== 'host' || declaration.kind === 'enum' - || !/^packages\/[^/]+\/[^/]+\/src\/[^/]+\.ts$/.test(declaration.location.file)) continue + if (declaration.face !== this.face.face || declaration.kind === 'enum' + || !/^packages\/[^/]+\/[^/]+\/src\/.+\.tsx?$/.test(declaration.location.file)) continue if (declarations.has(declaration.name)) { ambiguous.add(declaration.name) continue @@ -298,31 +353,31 @@ export class CordisCatalogProjector { * @param policy - caller-owned type classifications and inherited Cordis data. * @returns the configured projector and its validated catalog model. */ -export function projectCordisCatalog(scanRoot: string, policy: CordisCatalogPolicy): { +export function projectCordisCatalog(scanRoot: string, policy: CordisCatalogPolicy, targetFace: TypertFace = 'host'): { readonly projector: CordisCatalogProjector readonly model: CordisCatalogModel } { const caches = new WorkspaceCaches() const discovery = new WorkspaceAnalyzer({ root: scanRoot, - faces: ['host'], + faces: [targetFace], checkDiagnostics: false, caches, }).discoverPackages() - const packages = discovery.filter(candidate => candidate.faces.includes('host')) + const packages = discovery.filter(candidate => candidate.faces.includes(targetFace)) .map(candidate => candidate.package) const workspace = new WorkspaceAnalyzer({ root: scanRoot, - faces: ['host'], + faces: [targetFace], packages, checkDiagnostics: false, caches, }).analyzeInBatches() - const face = workspace.faces.find(candidate => candidate.face === 'host') - if (face === undefined) throw new Error('gen-cordis-catalog: Typert produced no host face') + const face = workspace.faces.find(candidate => candidate.face === targetFace) + if (face === undefined) throw new Error(`gen-cordis-catalog: Typert produced no ${targetFace} face`) const sourceDeclarations = new WorkspaceAnalyzer({ root: scanRoot, - faces: ['host'], + faces: [targetFace], checkDiagnostics: false, caches, }).indexSourceDeclarations() @@ -354,6 +409,7 @@ interface ParsedJsDoc { readonly doc: string readonly params: ReadonlyMap readonly returns: string | null + readonly deprecated: boolean } function parseJsDoc(raw: string): ParsedJsDoc { @@ -410,8 +466,14 @@ function parseJsDoc(raw: string): ParsedJsDoc { const params = new Map() let returns: string | null = null + let deprecated = false let sink: ((text: string) => void) | undefined for (const line of lines) { + if (/^@deprecated(?:\s|$)/.test(line)) { + deprecated = true + sink = undefined + continue + } const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line) if (param !== null) { const name = (param[1] ?? '').replace(/^\[|\]$/g, '') @@ -440,6 +502,7 @@ function parseJsDoc(raw: string): ParsedJsDoc { doc: blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim(), params, returns, + deprecated, } } @@ -494,7 +557,7 @@ function pointer(location: SourceLocation): string { } function isMode(mode: string | undefined): mode is Mode { - return mode === 'emit' || mode === 'waterfall' || mode === 'parallel' || mode === 'serial' + return mode === 'emit' || mode === 'bail' || mode === 'waterfall' || mode === 'parallel' || mode === 'serial' } function signatureTypeNames(renderer: TypeGraphRenderer, signature: SignatureModel): string[] { diff --git a/packages/typert/generator/tests/cordis-catalog.spec.ts b/packages/typert/generator/tests/cordis-catalog.spec.ts index 7b9ce35c84..5cb9351999 100644 --- a/packages/typert/generator/tests/cordis-catalog.spec.ts +++ b/packages/typert/generator/tests/cordis-catalog.spec.ts @@ -10,9 +10,14 @@ import { CORDIS_CATALOG_POLICY, EVENT_SCOPE_PAGE, REGION_BEGIN, REGION_END, SERV const workspaceRoot = resolve(import.meta.dirname, '../../../..') +/** One workspace projection shared by both cases: analyzing it twice doubles a multi-minute run. */ +let cached: ReturnType | undefined +const projection = (): ReturnType => + (cached ??= projectCordisCatalog(workspaceRoot, CORDIS_CATALOG_POLICY)) + 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 { projector, model } = projection() const expected = (path: string): string => readFileSync(join(workspaceRoot, path), 'utf8') expect(renderInheritedPage(CORDIS_CATALOG_POLICY)).toBe(expected('docs/cordis-api/inherited.md')) @@ -35,4 +40,24 @@ describe('Typert-backed Cordis catalog', () => { expected('packages/extensions/tool-cordis/src/api-catalog.ts'), ) }) + + it('resolves each key to the declaration a caller meets, and drops keys no plugin provides', { timeout: 480_000 }, () => { + const byKey = new Map(projection().model.services.map(service => [service.key, service])) + // An interface-typed key is described by its Service Definition: that is where + // the contract and, by repository convention, the member JSDoc live. + expect(byKey.get('lsp')?.type).toBe('LspService') + // The Service Definition may sit anywhere in the package, including a nested + // contract directory (`src/api/`), while the Context merge stays in `src`. + expect(byKey.get('apiProxy')?.type).toBe('ApiProxy') + // Two packages describe `ctx.typert` — a merge-extensible interface in + // type-meta and the implementing class in registry. The class wins: it is the + // object a caller meets and it carries the documentation. + expect(byKey.get('typert')?.type).toBe('TypertRegistry') + // Optional keys are values a launcher installs before the tree mounts. No + // plugin provides them, so describing one as a service would answer "add the + // plugin that provides it" for a key where no such plugin exists. + expect(byKey.has('headlessIo')).toBe(false) + expect(byKey.has('dshHomePath')).toBe(false) + expect(byKey.has('launcherEnvironment')).toBe(false) + }) })