mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat: slot system + entries/priority/errorreport + typert generator
This commit is contained in:
@@ -14,14 +14,14 @@ export interface ViewTab { id: string; label: string }
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-session state shared by conversation, chat-view, and details slots.
|
* 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 {
|
export interface ChatStoreState {
|
||||||
/** Details-linkage channel (conversation writes, details reads). */
|
/** Details-linkage channel (conversation writes, details reads). */
|
||||||
selection: SelectionTarget | null
|
selection: SelectionTarget | null
|
||||||
/** Composer draft (persisted; survives session switches and reloads). */
|
/** Composer draft (persisted; survives session switches and reloads). */
|
||||||
draft: string
|
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
|
view: string | null
|
||||||
/**
|
/**
|
||||||
* One-shot inspect handoff: chat writes the call to reveal, the trajectory
|
* One-shot inspect handoff: chat writes the call to reveal, the trajectory
|
||||||
|
|||||||
@@ -280,7 +280,7 @@
|
|||||||
overflow-y: auto;
|
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;
|
flex: 1 1 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/d
|
|||||||
import type {
|
import type {
|
||||||
ConversationSessionHeaderSlotProps, ConversationSessionSlotProps,
|
ConversationSessionHeaderSlotProps, ConversationSessionSlotProps,
|
||||||
} from '../contract/slots.ts'
|
} from '../contract/slots.ts'
|
||||||
|
import type { ViewTab } from '../contract/views.ts'
|
||||||
import css from './ConversationRoot.module.css'
|
import css from './ConversationRoot.module.css'
|
||||||
|
|
||||||
/** Full props composed from the strict session body contract. */
|
/** Full props composed from the strict session body contract. */
|
||||||
@@ -19,6 +20,15 @@ interface Breadcrumb {
|
|||||||
readonly displayTitle: string
|
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[] {
|
function deriveAncestry(list: SessionListState, id: SessionId): readonly Breadcrumb[] {
|
||||||
const chain: Breadcrumb[] = []
|
const chain: Breadcrumb[] = []
|
||||||
const seen = new Set<SessionId>()
|
const seen = new Set<SessionId>()
|
||||||
@@ -54,8 +64,8 @@ export function ConversationSessionHeader({
|
|||||||
}: ConversationSessionHeaderProps) {
|
}: ConversationSessionHeaderProps) {
|
||||||
useSyncExternalStore(views.subscribe, views.version)
|
useSyncExternalStore(views.subscribe, views.version)
|
||||||
const tabs = views.list()
|
const tabs = views.list()
|
||||||
const activeId = useStore(s => s.view) ?? 'chat'
|
const selectedId = useStore(s => s.view)
|
||||||
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
|
const active = resolveActiveView(tabs, selectedId)
|
||||||
const ancestry = useSessions(s => deriveAncestry(s, sessionId), equalBreadcrumbs)
|
const ancestry = useSessions(s => deriveAncestry(s, sessionId), equalBreadcrumbs)
|
||||||
const composerPhase = useSession(s => s.composerPhase)
|
const composerPhase = useSession(s => s.composerPhase)
|
||||||
const blank = useSession(s => s.blank)
|
const blank = useSession(s => s.blank)
|
||||||
@@ -131,8 +141,8 @@ export function ConversationSession({
|
|||||||
}: ConversationSessionProps) {
|
}: ConversationSessionProps) {
|
||||||
useSyncExternalStore(views.subscribe, views.version)
|
useSyncExternalStore(views.subscribe, views.version)
|
||||||
const tabs = views.list()
|
const tabs = views.list()
|
||||||
const activeId = useStore(s => s.view) ?? 'chat'
|
const selectedId = useStore(s => s.view)
|
||||||
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
|
const active = resolveActiveView(tabs, selectedId)
|
||||||
const composerPhase = useSession(s => s.composerPhase)
|
const composerPhase = useSession(s => s.composerPhase)
|
||||||
const blank = useSession(s => s.blank)
|
const blank = useSession(s => s.blank)
|
||||||
const inputState = useInput(s => s)
|
const inputState = useInput(s => s)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
|||||||
import type {
|
import type {
|
||||||
ComposerBarOwnerProps,
|
ComposerBarOwnerProps,
|
||||||
} from '../src/client/contract/slots.ts'
|
} from '../src/client/contract/slots.ts'
|
||||||
|
import type { ViewTab } from '../src/client/contract/views.ts'
|
||||||
|
|
||||||
/** Machine-backed wiring over a sink spy. */
|
/** Machine-backed wiring over a sink spy. */
|
||||||
function fakeWiring() {
|
function fakeWiring() {
|
||||||
@@ -96,6 +97,8 @@ function mount(
|
|||||||
summaryOrigin?: 'subagent'
|
summaryOrigin?: 'subagent'
|
||||||
/** A composer block another plugin raised for this session. */
|
/** A composer block another plugin raised for this session. */
|
||||||
composerBlock?: { reason: string }
|
composerBlock?: { reason: string }
|
||||||
|
/** Mutable view ledger used by registration-order regressions. */
|
||||||
|
viewTabs?: ViewTab[]
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const root = sid('root')
|
const root = sid('root')
|
||||||
@@ -123,6 +126,15 @@ function mount(
|
|||||||
const stop = vi.fn()
|
const stop = vi.fn()
|
||||||
const open = vi.fn()
|
const open = vi.fn()
|
||||||
const slotCalls: string[] = []
|
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. */
|
/** Owner share handed to the two composer tool-row seats, per render. */
|
||||||
const seatOwners: { key: string; owner: unknown }[] = []
|
const seatOwners: { key: string; owner: unknown }[] = []
|
||||||
let pickerOwner: unknown
|
let pickerOwner: unknown
|
||||||
@@ -146,14 +158,7 @@ function mount(
|
|||||||
useStore={bindSnapshotSelector(chat)}
|
useStore={bindSnapshotSelector(chat)}
|
||||||
actions={chat.actions}
|
actions={chat.actions}
|
||||||
renderSlot={renderSlot as never}
|
renderSlot={renderSlot as never}
|
||||||
views={{
|
views={views}
|
||||||
list: () => [
|
|
||||||
{ id: 'chat', label: 'Chat' },
|
|
||||||
{ id: 'trajectory', label: 'Trajectory' },
|
|
||||||
],
|
|
||||||
subscribe: () => () => {},
|
|
||||||
version: () => 1,
|
|
||||||
}}
|
|
||||||
open={open}
|
open={open}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
@@ -173,14 +178,7 @@ function mount(
|
|||||||
useStore={bindSnapshotSelector(chat)}
|
useStore={bindSnapshotSelector(chat)}
|
||||||
actions={chat.actions}
|
actions={chat.actions}
|
||||||
renderSlot={renderSlot as never}
|
renderSlot={renderSlot as never}
|
||||||
views={{
|
views={views}
|
||||||
list: () => [
|
|
||||||
{ id: 'chat', label: 'Chat' },
|
|
||||||
{ id: 'trajectory', label: 'Trajectory' },
|
|
||||||
],
|
|
||||||
subscribe: () => () => {},
|
|
||||||
version: () => 1,
|
|
||||||
}}
|
|
||||||
releaseSessionImages={vi.fn()}
|
releaseSessionImages={vi.fn()}
|
||||||
bindDraftMirror={write => wiring.bindMirror(write)}
|
bindDraftMirror={write => wiring.bindMirror(write)}
|
||||||
/>
|
/>
|
||||||
@@ -442,6 +440,26 @@ describe('ConversationRoot resident composer', () => {
|
|||||||
expect(b.view.getByRole('textbox')).toBeTruthy()
|
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 () => {
|
it('rolls the pending workspace label back when switching fails', async () => {
|
||||||
const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') })
|
const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') })
|
||||||
const b = mount(
|
const b = mount(
|
||||||
|
|||||||
@@ -106,3 +106,14 @@
|
|||||||
background: var(--dsw-alias-button-floating-hover);
|
background: var(--dsw-alias-button-floating-hover);
|
||||||
border-color: var(--dsw-alias-border-l3);
|
border-color: var(--dsw-alias-border-l3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overlayLayer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlayLayer > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import css from './AppFrame.module.css'
|
|||||||
/** Full composed props: runtime share + child-slot render share + store share. */
|
/** Full composed props: runtime share + child-slot render share + store share. */
|
||||||
export type AppFrameProps =
|
export type AppFrameProps =
|
||||||
& PropsRuntime<'root'>
|
& PropsRuntime<'root'>
|
||||||
& PropsRenderSlots<'sidebar' | 'conversation' | 'details'>
|
& PropsRenderSlots<'sidebar' | 'conversation' | 'details' | 'shell.overlay'>
|
||||||
& PropsStore<ReturnType<typeof createLayoutStore>>
|
& PropsStore<ReturnType<typeof createLayoutStore>>
|
||||||
|
|
||||||
/** Center column grid item (session-body building block). */
|
/** Center column grid item (session-body building block). */
|
||||||
@@ -190,6 +190,9 @@ export function AppFrame({
|
|||||||
<CenterColumn>{renderSlot('conversation', {})}</CenterColumn>
|
<CenterColumn>{renderSlot('conversation', {})}</CenterColumn>
|
||||||
<DetailsColumn>{renderSlot('details', {})}</DetailsColumn>
|
<DetailsColumn>{renderSlot('details', {})}</DetailsColumn>
|
||||||
</>
|
</>
|
||||||
|
<div className={css.overlayLayer} data-shell-overlay>
|
||||||
|
{renderSlot('shell.overlay', {})}
|
||||||
|
</div>
|
||||||
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
|
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
|
||||||
{!sidebarCollapsed && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
|
{!sidebarCollapsed && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
|
||||||
{cols.details > 0 && <DragHandle side="details" left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}
|
{cols.details > 0 && <DragHandle side="details" left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}
|
||||||
|
|||||||
@@ -7,6 +7,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section > :last-child {
|
.section > :global([data-slot='settings.general.item']) > :last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,11 +227,39 @@
|
|||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Foot seat: a pure layout socket pinned under the region; the ui-settings
|
/* Footer seats: Settings fills the left side and additive actions sit on the
|
||||||
trigger row inside owns its own geometry (38px wide row / 36px rail
|
right. Each occupant owns its button geometry and hover chrome. */
|
||||||
circle) and hover chrome. */
|
|
||||||
.footArea {
|
.footArea {
|
||||||
flex: none;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* snap to the 56px rail (one icon each, same top-down order) fading in as the
|
* 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
|
* slide ends. The workspace/session browsing region between the New Session
|
||||||
* button and the foot is the `sidebar.workspaces` registrant's, and the foot
|
* 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).
|
* (plus an expand request callback for the browser).
|
||||||
*
|
*
|
||||||
* The column also owns whether the scroll regions nested in it draw a
|
* The column also owns whether the scroll regions nested in it draw a
|
||||||
@@ -177,9 +177,14 @@ export function SidebarRoot({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Foot seat: ui-settings registers the trigger row + panel here. */}
|
{/* Footer: Settings stays on the left; optional actions sit beside it. */}
|
||||||
<div className={css.footArea}>
|
<div className={css.footArea}>
|
||||||
{renderSlot('sidebar.settings', { wide })}
|
<div className={css.settingsArea}>
|
||||||
|
{renderSlot('sidebar.settings', { wide })}
|
||||||
|
</div>
|
||||||
|
<div className={css.footerActions}>
|
||||||
|
{renderSlot('sidebar.footer.action', { wide })}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ export interface SidebarSettingsOwnerProps {
|
|||||||
wide: boolean
|
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
|
* Registrant-private injected share (arrives via the register inject
|
||||||
* factory). The shell keeps only its own controls: starting a Session from
|
* factory). The shell keeps only its own controls: starting a Session from
|
||||||
@@ -72,5 +78,6 @@ export type SidebarRootInjected = {
|
|||||||
* seat. No store is registered.
|
* seat. No store is registered.
|
||||||
*/
|
*/
|
||||||
export type SidebarRootComponentProps =
|
export type SidebarRootComponentProps =
|
||||||
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'>
|
PropsRuntime<'sidebar'>
|
||||||
|
& PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings' | 'sidebar.footer.action'>
|
||||||
& SidebarRootInjected & PropsLocale<'sidebar'>
|
& SidebarRootInjected & PropsLocale<'sidebar'>
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import type { SidebarRootInjected } from './contract/slots.ts'
|
|||||||
import { SidebarRoot } from './SidebarRoot.tsx'
|
import { SidebarRoot } from './SidebarRoot.tsx'
|
||||||
import { en, zh, type SidebarKey } from './locales.ts'
|
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'
|
export type { SidebarKey } from './locales.ts'
|
||||||
|
|
||||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||||
@@ -44,6 +47,7 @@ export function apply(ctx: ClientContext): void {
|
|||||||
children: {
|
children: {
|
||||||
'sidebar.workspaces': { kind: 'single', scope: 'root' },
|
'sidebar.workspaces': { kind: 'single', scope: 'root' },
|
||||||
'sidebar.settings': { kind: 'single', scope: 'root' },
|
'sidebar.settings': { kind: 'single', scope: 'root' },
|
||||||
|
'sidebar.footer.action': { kind: 'list', scope: 'root' },
|
||||||
},
|
},
|
||||||
inject: injectProps,
|
inject: injectProps,
|
||||||
}, SidebarRoot),
|
}, SidebarRoot),
|
||||||
|
|||||||
@@ -31,11 +31,13 @@ describe('ui-sidebar apply', () => {
|
|||||||
expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces', 'locale'])
|
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()
|
const b = await bench()
|
||||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||||
expect(b.slots.entries('sidebar')).toHaveLength(1)
|
expect(b.slots.entries('sidebar')).toHaveLength(1)
|
||||||
expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' })
|
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.
|
// Copy rides the standard locale seat, not the inject face.
|
||||||
expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar')
|
expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar')
|
||||||
const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
|
const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
|
||||||
@@ -61,5 +63,6 @@ describe('ui-sidebar apply', () => {
|
|||||||
await fiber.dispose()
|
await fiber.dispose()
|
||||||
expect(b.slots.entries('sidebar')).toHaveLength(0)
|
expect(b.slots.entries('sidebar')).toHaveLength(0)
|
||||||
expect(b.slots.spec('sidebar.workspaces')).toBeUndefined()
|
expect(b.slots.spec('sidebar.workspaces')).toBeUndefined()
|
||||||
|
expect(b.slots.spec('sidebar.footer.action')).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
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 { SidebarRoot } from '../src/client/SidebarRoot.tsx'
|
||||||
import { en } from '../src/client/locales.ts'
|
import { en } from '../src/client/locales.ts'
|
||||||
|
|
||||||
@@ -23,17 +26,25 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
|
|||||||
const toggleSidebar = vi.fn()
|
const toggleSidebar = vi.fn()
|
||||||
let regionOwner: SidebarSectionOwnerProps | undefined
|
let regionOwner: SidebarSectionOwnerProps | undefined
|
||||||
let settingsOwner: SidebarSettingsOwnerProps | undefined
|
let settingsOwner: SidebarSettingsOwnerProps | undefined
|
||||||
|
let footerActionOwner: SidebarFooterActionOwnerProps | undefined
|
||||||
let current = { collapsed, width }
|
let current = { collapsed, width }
|
||||||
const root = () => (
|
const root = () => (
|
||||||
<SidebarRoot
|
<SidebarRoot
|
||||||
collapsed={current.collapsed} width={current.width}
|
collapsed={current.collapsed} width={current.width}
|
||||||
useSessions={neverHook} useWorkspaces={neverHook}
|
useSessions={neverHook} useWorkspaces={neverHook}
|
||||||
startSession={startSession} toggleSidebar={toggleSidebar} t={t}
|
startSession={startSession} toggleSidebar={toggleSidebar} t={t}
|
||||||
renderSlot={((key: string, owner: SidebarSectionOwnerProps | SidebarSettingsOwnerProps) => {
|
renderSlot={((
|
||||||
|
key: string,
|
||||||
|
owner: SidebarFooterActionOwnerProps | SidebarSectionOwnerProps | SidebarSettingsOwnerProps,
|
||||||
|
) => {
|
||||||
if (key === 'sidebar.settings') {
|
if (key === 'sidebar.settings') {
|
||||||
settingsOwner = owner
|
settingsOwner = owner
|
||||||
return <div data-testid="settings-seat" data-wide={owner.wide} />
|
return <div data-testid="settings-seat" data-wide={owner.wide} />
|
||||||
}
|
}
|
||||||
|
if (key === 'sidebar.footer.action') {
|
||||||
|
footerActionOwner = owner
|
||||||
|
return <div data-testid="footer-action-seat" data-wide={owner.wide} />
|
||||||
|
}
|
||||||
regionOwner = owner as SidebarSectionOwnerProps
|
regionOwner = owner as SidebarSectionOwnerProps
|
||||||
return <div data-testid="region" data-wide={owner.wide} />
|
return <div data-testid="region" data-wide={owner.wide} />
|
||||||
}) as SidebarRootComponentProps['renderSlot']}
|
}) 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')
|
if (settingsOwner === undefined) throw new Error('settings owner not rendered')
|
||||||
return settingsOwner
|
return settingsOwner
|
||||||
},
|
},
|
||||||
|
footerActionOwner: () => {
|
||||||
|
if (footerActionOwner === undefined) throw new Error('footer action owner not rendered')
|
||||||
|
return footerActionOwner
|
||||||
|
},
|
||||||
rerender(next: Partial<typeof current>) {
|
rerender(next: Partial<typeof current>) {
|
||||||
current = { ...current, ...next }
|
current = { ...current, ...next }
|
||||||
view.rerender(root())
|
view.rerender(root())
|
||||||
@@ -75,6 +90,7 @@ describe('SidebarRoot shell', () => {
|
|||||||
expect(b.regionOwner().wide).toBe(true)
|
expect(b.regionOwner().wide).toBe(true)
|
||||||
// The settings seat rides the same wide flag (ui-settings renders the row).
|
// The settings seat rides the same wide flag (ui-settings renders the row).
|
||||||
expect(b.settingsOwner().wide).toBe(true)
|
expect(b.settingsOwner().wide).toBe(true)
|
||||||
|
expect(b.footerActionOwner().wide).toBe(true)
|
||||||
// Expanded: the request is a no-op (no accidental collapse).
|
// Expanded: the request is a no-op (no accidental collapse).
|
||||||
b.regionOwner().expandSidebar()
|
b.regionOwner().expandSidebar()
|
||||||
expect(b.toggleSidebar).not.toHaveBeenCalled()
|
expect(b.toggleSidebar).not.toHaveBeenCalled()
|
||||||
@@ -89,6 +105,7 @@ describe('SidebarRoot shell', () => {
|
|||||||
vi.advanceTimersByTime(200)
|
vi.advanceTimersByTime(200)
|
||||||
b.rerender({})
|
b.rerender({})
|
||||||
expect(b.regionOwner().wide).toBe(false)
|
expect(b.regionOwner().wide).toBe(false)
|
||||||
|
expect(b.footerActionOwner().wide).toBe(false)
|
||||||
expect(screen.getByTestId('region')).toBeTruthy()
|
expect(screen.getByTestId('region')).toBeTruthy()
|
||||||
b.regionOwner().expandSidebar()
|
b.regionOwner().expandSidebar()
|
||||||
expect(b.toggleSidebar).toHaveBeenCalledOnce()
|
expect(b.toggleSidebar).toHaveBeenCalledOnce()
|
||||||
|
|||||||
@@ -473,21 +473,40 @@ export type InjectParams<K extends keyof SlotMap & string, H> =
|
|||||||
*/
|
*/
|
||||||
export type SlotLabel = string | (() => string)
|
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<
|
export type KindOptions<
|
||||||
K extends keyof SlotMap & string,
|
K extends keyof SlotMap & string,
|
||||||
EntryKey extends EntryKeyOf<K>,
|
EntryKey extends EntryKeyOf<K>,
|
||||||
M = never,
|
M = never,
|
||||||
> =
|
> =
|
||||||
SlotMap[K]['kind'] extends 'keyed' ? { key: EntryKey }
|
SlotMap[K]['kind'] extends 'keyed' ? {
|
||||||
: SlotMap[K]['kind'] extends 'list' ? { id: string; order?: number; label?: SlotLabel }
|
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' ? {
|
: SlotMap[K]['kind'] extends 'chain' ? {
|
||||||
/** Routing selector, mandatory on chain entries; `M` (the component's `matched` prop) infers from its return. */
|
/** Routing selector, mandatory on chain entries; `M` (the component's `matched` prop) infers from its return. */
|
||||||
select: ChainSelect<SlotMap[K] extends { owner: infer O extends object } ? O : object, M>
|
select: ChainSelect<SlotMap[K] extends { owner: infer O extends object } ? O : object, M>
|
||||||
/** Explicit chain position (ascending, default 0, lower tries first); ties keep registration = assembly order. */
|
/** Explicit chain position (ascending, default 0, lower tries first); ties keep registration = assembly order. */
|
||||||
priority?: number
|
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
|
* Compile-time presence check: an entry declaring children MUST consume
|
||||||
@@ -596,6 +615,8 @@ interface SlotRecord {
|
|||||||
spec: SlotSpec<SlotEntryDef> | undefined
|
spec: SlotSpec<SlotEntryDef> | undefined
|
||||||
/** Diagnostics: which slot's entry declared this key ('(built-in)' for root). */
|
/** Diagnostics: which slot's entry declared this key ('(built-in)' for root). */
|
||||||
declaredBy: string | undefined
|
declaredBy: string | undefined
|
||||||
|
/** Live parent declaration, absent for root slots. */
|
||||||
|
parent: string | undefined
|
||||||
/** Monotonic declaration lifetime, distinct from ordinary entry mutations. */
|
/** Monotonic declaration lifetime, distinct from ordinary entry mutations. */
|
||||||
declarationEpoch: number
|
declarationEpoch: number
|
||||||
entries: readonly StoredEntry[]
|
entries: readonly StoredEntry[]
|
||||||
@@ -606,6 +627,38 @@ interface SlotRecord {
|
|||||||
|
|
||||||
const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([])
|
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
|
* Pure slot registry (no cordis; event emission and the renderer installation contract
|
||||||
* live in the runtime Service wrapper).
|
* 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
|
* fire); {@link SlotCore.subscribeDeclaration} fires synchronously for each
|
||||||
* declaration lifetime boundary; {@link SlotCore.subscribe} notifications
|
* declaration lifetime boundary; {@link SlotCore.subscribe} notifications
|
||||||
* batch per microtask, so N same-tick mutations produce one notification per
|
* 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 {
|
export class SlotCore {
|
||||||
private records = new Map<string, SlotRecord>()
|
private records = new Map<string, SlotRecord>()
|
||||||
@@ -629,6 +684,16 @@ export class SlotCore {
|
|||||||
// reference skips a lookup (and an unreachable missing-record branch) at flush.
|
// reference skips a lookup (and an unreachable missing-record branch) at flush.
|
||||||
private dirty = new Set<SlotRecord>()
|
private dirty = new Set<SlotRecord>()
|
||||||
private flushScheduled = false
|
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<StoredEntry>()
|
||||||
|
private entryErrorListeners
|
||||||
|
= new Set<(key: string, entry: StoredEntry, error: unknown, info: { abdicated: boolean }) => void>()
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// The a-priori root hole. No markDirty: nothing can observe construction.
|
// 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
|
* re-checks nothing): registering into an undeclared slot throws; declaring
|
||||||
* an already-declared child key throws (one declarer per slot — the message
|
* an already-declared child key throws (one declarer per slot — the message
|
||||||
* names the first declarer); mounting one shared store handle under slots
|
* names the first declarer); mounting one shared store handle under slots
|
||||||
* of different scopes throws. Kind constraints: single — duplicate
|
* of different scopes throws. Kind constraints: keyed — missing `key`
|
||||||
* registration throws; keyed — missing/duplicate `key` throws; list —
|
* throws; list — missing `id` throws; chain — missing `select` throws (the
|
||||||
* missing/duplicate `id` throws; chain — missing `select` throws (the
|
|
||||||
* selector is the entry's routing seat, see {@link ChainSelect}).
|
* 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
|
* Lifecycle: the disposer removes the contribution AND collapses every
|
||||||
* declared child slot (child entries clear recursively; their stale
|
* declared child slot (child entries clear recursively; their stale
|
||||||
* disposers become no-ops) — one lifecycle axis, no dangling state.
|
* disposers become no-ops) — one lifecycle axis, no dangling state.
|
||||||
@@ -719,23 +791,33 @@ export class SlotCore {
|
|||||||
}
|
}
|
||||||
const spec = rec.spec
|
const spec = rec.spec
|
||||||
// Kind constraints stay runtime checks for dynamically-composed callers;
|
// 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) {
|
switch (spec.kind) {
|
||||||
case 'single':
|
case 'single': {
|
||||||
if (rec.entries.length > 0) throw new Error(`single slot "${options.name}" already has a registration`)
|
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
|
break
|
||||||
case 'keyed':
|
}
|
||||||
|
case 'keyed': {
|
||||||
if (options.key === undefined) throw new Error(`keyed slot "${options.name}" requires options.key`)
|
if (options.key === undefined) throw new Error(`keyed slot "${options.name}" requires options.key`)
|
||||||
if (rec.entries.some(e => e.options.key === options.key)) {
|
const occupant = rec.entries.find(e => e.options.key === options.key && (e.options.priority ?? 0) === priority)
|
||||||
throw new Error(`keyed slot "${options.name}" already has an entry for key "${options.key}"`)
|
if (occupant) {
|
||||||
|
throw new Error(`keyed slot "${options.name}" already has an entry for key "${options.key}" ${occupantHint(occupant)}`)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'list':
|
}
|
||||||
|
case 'list': {
|
||||||
if (options.id === undefined) throw new Error(`list slot "${options.name}" requires options.id`)
|
if (options.id === undefined) throw new Error(`list slot "${options.name}" requires options.id`)
|
||||||
if (rec.entries.some(e => e.options.id === options.id)) {
|
const occupant = rec.entries.find(e => e.options.id === options.id && (e.options.priority ?? 0) === priority)
|
||||||
throw new Error(`list slot "${options.name}" already has an entry with id "${options.id}"`)
|
if (occupant) {
|
||||||
|
throw new Error(`list slot "${options.name}" already has an entry with id "${options.id}" ${occupantHint(occupant)}`)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case 'chain':
|
case 'chain':
|
||||||
if (options.select === undefined) throw new Error(`chain slot "${options.name}" requires options.select`)
|
if (options.select === undefined) throw new Error(`chain slot "${options.name}" requires options.select`)
|
||||||
break
|
break
|
||||||
@@ -777,10 +859,13 @@ export class SlotCore {
|
|||||||
...(options.registrant !== undefined ? { registrant: options.registrant } : {}),
|
...(options.registrant !== undefined ? { registrant: options.registrant } : {}),
|
||||||
}
|
}
|
||||||
const next = [...rec.entries, entry]
|
const next = [...rec.entries, entry]
|
||||||
// Stable sorts: ascending, ties keep registration sequence (list rides
|
// Stable sorts: priority ascending for every kind, ties keep registration
|
||||||
// `order`, chain rides `priority` — lower priority tries first).
|
// sequence — a cell's winner is its first occurrence, chain tries lower
|
||||||
if (spec.kind === 'list') next.sort((a, b) => (a.options.order ?? 0) - (b.options.order ?? 0))
|
// priority first. List refines equal priorities by explicit `order` so the
|
||||||
if (spec.kind === 'chain') next.sort((a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0))
|
// 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
|
rec.entries = next
|
||||||
this.markDirty(options.name, rec)
|
this.markDirty(options.name, rec)
|
||||||
if (options.children) {
|
if (options.children) {
|
||||||
@@ -789,6 +874,7 @@ export class SlotCore {
|
|||||||
const childRec = this.record(childKey)
|
const childRec = this.record(childKey)
|
||||||
childRec.spec = childSpec
|
childRec.spec = childSpec
|
||||||
childRec.declaredBy = `an entry in "${options.name}"${options.registrant ? ` (${options.registrant})` : ''}`
|
childRec.declaredBy = `an entry in "${options.name}"${options.registrant ? ` (${options.registrant})` : ''}`
|
||||||
|
childRec.parent = options.name
|
||||||
childRec.declarationEpoch += 1
|
childRec.declarationEpoch += 1
|
||||||
declarations.push([childKey, childRec])
|
declarations.push([childKey, childRec])
|
||||||
}
|
}
|
||||||
@@ -835,6 +921,36 @@ export class SlotCore {
|
|||||||
return this.records.get(key)?.entries ?? NO_ENTRIES
|
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<string | undefined>()
|
||||||
|
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.
|
* Look up a slot's declared spec, narrowed by the SlotMap key.
|
||||||
* @param key - SlotMap key.
|
* @param key - SlotMap key.
|
||||||
@@ -855,6 +971,53 @@ export class SlotCore {
|
|||||||
return this.records.get(key)?.spec
|
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<string>): 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
|
* Read the declaration lifetime of a key. Entry additions and removals do
|
||||||
* not change it; declaration creation and collapse each advance it.
|
* not change it; declaration creation and collapse each advance it.
|
||||||
@@ -916,6 +1079,47 @@ export class SlotCore {
|
|||||||
return () => { this.mutateListeners.delete(fn) }
|
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
|
* Cascade for a removed entry: release its store mount and collapse every
|
||||||
* child slot it declared — specs clear, contributions empty (their stale
|
* child slot it declared — specs clear, contributions empty (their stale
|
||||||
@@ -935,6 +1139,7 @@ export class SlotCore {
|
|||||||
const doomed = childRec.entries
|
const doomed = childRec.entries
|
||||||
childRec.spec = undefined
|
childRec.spec = undefined
|
||||||
childRec.declaredBy = undefined
|
childRec.declaredBy = undefined
|
||||||
|
childRec.parent = undefined
|
||||||
childRec.declarationEpoch += 1
|
childRec.declarationEpoch += 1
|
||||||
childRec.entries = NO_ENTRIES
|
childRec.entries = NO_ENTRIES
|
||||||
this.markDirty(childKey, childRec)
|
this.markDirty(childKey, childRec)
|
||||||
@@ -949,6 +1154,7 @@ export class SlotCore {
|
|||||||
rec = {
|
rec = {
|
||||||
spec: undefined,
|
spec: undefined,
|
||||||
declaredBy: undefined,
|
declaredBy: undefined,
|
||||||
|
parent: undefined,
|
||||||
declarationEpoch: 0,
|
declarationEpoch: 0,
|
||||||
entries: NO_ENTRIES,
|
entries: NO_ENTRIES,
|
||||||
version: 0,
|
version: 0,
|
||||||
|
|||||||
@@ -118,6 +118,27 @@ export interface SlotRendererHost {
|
|||||||
* @returns entries in registration (list: order) sequence.
|
* @returns entries in registration (list: order) sequence.
|
||||||
*/
|
*/
|
||||||
entriesOf(key: string): readonly StoredEntry[]
|
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.
|
* Declared runtime spec from the declarations ledger.
|
||||||
* @param key - slot key.
|
* @param key - slot key.
|
||||||
|
|||||||
@@ -283,12 +283,14 @@ function useLocaleRevision(face: LocaleFace | undefined): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entry-identity React keys for chain boundaries. A chain outlet renders ONE
|
* Entry-identity React keys for entry boundaries. An outlet renders one
|
||||||
* elected entry through an error boundary; without a key, a boundary that
|
* winner per position (single/keyed/list cell head, chain election) through
|
||||||
* failed on entry A would survive a re-election and keep a healthy entry B
|
* an error boundary; without a key, a boundary that failed on entry A would
|
||||||
* blacked out. Keying by entry identity remounts the boundary fresh whenever
|
* survive a winner change (re-election, shadowing fallback after an
|
||||||
* the election changes (entries are identity-stable per registration, so the
|
* abdication, HMR re-registration) and keep a healthy entry B blacked out.
|
||||||
* key is stable while the same entry stays elected).
|
* 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
|
let nextEntryKey = 0
|
||||||
const entryKeys = new WeakMap<StoredEntry, number>()
|
const entryKeys = new WeakMap<StoredEntry, number>()
|
||||||
@@ -306,9 +308,14 @@ function entryKeyOf(entry: StoredEntry): number {
|
|||||||
* Per-entry isolation: one registrant crashing (component render or inject
|
* Per-entry isolation: one registrant crashing (component render or inject
|
||||||
* factory) must not take down siblings. Assembly errors (missing providers)
|
* factory) must not take down siblings. Assembly errors (missing providers)
|
||||||
* rethrow — a miswired shell must fail loud, not degrade into fallbacks.
|
* 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<
|
class SlotErrorBoundary extends Component<
|
||||||
{ slotKey: string; children: ReactNode }, { failed: boolean }
|
{ slotKey: string; onEntryError: (error: unknown) => void; children: ReactNode }, { failed: boolean }
|
||||||
> {
|
> {
|
||||||
override state = { failed: false }
|
override state = { failed: false }
|
||||||
static getDerivedStateFromError(error: unknown): { failed: boolean } {
|
static getDerivedStateFromError(error: unknown): { failed: boolean } {
|
||||||
@@ -317,6 +324,7 @@ class SlotErrorBoundary extends Component<
|
|||||||
}
|
}
|
||||||
override componentDidCatch(error: unknown): void {
|
override componentDidCatch(error: unknown): void {
|
||||||
console.error(`slot entry crashed in '${this.props.slotKey}':`, error)
|
console.error(`slot entry crashed in '${this.props.slotKey}':`, error)
|
||||||
|
this.props.onEntryError(error)
|
||||||
}
|
}
|
||||||
override render(): ReactNode {
|
override render(): ReactNode {
|
||||||
if (this.state.failed) return <div data-slot-error={this.props.slotKey} />
|
if (this.state.failed) return <div data-slot-error={this.props.slotKey} />
|
||||||
@@ -607,18 +615,21 @@ function RootEntry({ entry, ownerProps, slotKey, slotInjected, hookContext, hasH
|
|||||||
return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext)
|
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
|
slotKey: string
|
||||||
entry: StoredEntry
|
entry: StoredEntry
|
||||||
ownerProps: object
|
ownerProps: object
|
||||||
slotInjected: BoundSlotInject
|
slotInjected: BoundSlotInject
|
||||||
hookContext: unknown
|
hookContext: unknown
|
||||||
hasHookContext: boolean
|
hasHookContext: boolean
|
||||||
|
onEntryError: (error: unknown) => void
|
||||||
}) {
|
}) {
|
||||||
const info = useSessionMaybeProvideInfo()
|
const info = useSessionMaybeProvideInfo()
|
||||||
if (info.sessionId === undefined) return null
|
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 (
|
return (
|
||||||
<SlotErrorBoundary slotKey={slotKey} key={info.sessionId}>
|
<SlotErrorBoundary slotKey={slotKey} key={info.sessionId} onEntryError={onEntryError}>
|
||||||
<SessionEntry
|
<SessionEntry
|
||||||
entry={entry}
|
entry={entry}
|
||||||
ownerProps={ownerProps}
|
ownerProps={ownerProps}
|
||||||
@@ -632,6 +643,14 @@ function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookCont
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anchor style shared by every outlet wrapper: `display:contents` keeps the
|
||||||
|
* wrapper out of layout (grid/flex parents see the slot's own children), so
|
||||||
|
* the anchor is purely addressable surface. Module-level constant — a stable
|
||||||
|
* reference so the wrapper never diffs its style prop.
|
||||||
|
*/
|
||||||
|
const ANCHOR_STYLE = { display: 'contents' } as const
|
||||||
|
|
||||||
function SlotOutlet({ slotKey, ownerProps, opts }: {
|
function SlotOutlet({ slotKey, ownerProps, opts }: {
|
||||||
slotKey: string
|
slotKey: string
|
||||||
ownerProps: object
|
ownerProps: object
|
||||||
@@ -647,6 +666,27 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
|||||||
// bodies re-derive their `t` seat at the new revision (fresh identity).
|
// bodies re-derive their `t` seat at the new revision (fresh identity).
|
||||||
useLocaleRevision(host.locale)
|
useLocaleRevision(host.locale)
|
||||||
const sessionInfo = useSessionMaybeProvideInfo()
|
const sessionInfo = useSessionMaybeProvideInfo()
|
||||||
|
// Anchor contract: every slot render site exposes a stable
|
||||||
|
// `[data-slot="<key>"]` wrapper — the addressable seam dynamic styles
|
||||||
|
// target — and `display:contents` keeps it layout-neutral. The wrapper
|
||||||
|
// rides the outlet, not the dispatch outcome: fallback, crash-face, and
|
||||||
|
// undeclared-empty states all render inside it, so the anchor's presence
|
||||||
|
// never flickers with registration churn.
|
||||||
|
return (
|
||||||
|
<div data-slot={slotKey} style={ANCHOR_STYLE}>
|
||||||
|
{renderOutletContent(host, slotKey, ownerProps, opts, sessionInfo)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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)
|
const spec = host.specOf(slotKey)
|
||||||
// Undeclared (or no-longer-declared) keys render empty: a declaring entry's
|
// Undeclared (or no-longer-declared) keys render empty: a declaring entry's
|
||||||
// unload returns the slot to the undeclared state while retained elements
|
// 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 guarded = (entry: StoredEntry, key?: string | number, owner: object = ownerProps) => {
|
||||||
const hasHookContext = opts !== undefined && Object.hasOwn(opts, 'hookContext')
|
const hasHookContext = opts !== undefined && Object.hasOwn(opts, 'hookContext')
|
||||||
const hookContext = 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'
|
return spec.scope === 'session'
|
||||||
? (
|
? (
|
||||||
<StrictSessionEntry
|
<StrictSessionEntry
|
||||||
@@ -676,11 +723,12 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
|||||||
slotInjected={slotInjected}
|
slotInjected={slotInjected}
|
||||||
hookContext={hookContext}
|
hookContext={hookContext}
|
||||||
hasHookContext={hasHookContext}
|
hasHookContext={hasHookContext}
|
||||||
|
onEntryError={onEntryError}
|
||||||
key={key}
|
key={key}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<SlotErrorBoundary slotKey={slotKey} key={key}>
|
<SlotErrorBoundary slotKey={slotKey} key={key} onEntryError={onEntryError}>
|
||||||
{spec.scope === 'session-maybe'
|
{spec.scope === 'session-maybe'
|
||||||
? (
|
? (
|
||||||
<SessionMaybeEntry
|
<SessionMaybeEntry
|
||||||
@@ -705,15 +753,22 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
|||||||
</SlotErrorBoundary>
|
</SlotErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// 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 = () => <div data-slot-error={slotKey} />
|
||||||
|
|
||||||
if (spec.kind === 'single') {
|
if (spec.kind === 'single') {
|
||||||
const entry = entries[0]
|
const entry = host.entriesOfSlot(slotKey)[0]
|
||||||
if (!entry) return <>{opts?.fallback ?? null}</>
|
if (!entry) return entries.length > 0 ? deadCell() : <>{opts?.fallback ?? null}</>
|
||||||
return guarded(entry, entryKeyOf(entry))
|
return guarded(entry, entryKeyOf(entry))
|
||||||
}
|
}
|
||||||
if (spec.kind === 'keyed') {
|
if (spec.kind === 'keyed') {
|
||||||
const entry = entries.find(e => e.options.key === opts?.entryKey)
|
const entry = host.entriesOfSlot(slotKey).find(e => e.options.key === opts?.entryKey)
|
||||||
if (!entry) return <>{opts?.fallback ?? null}</>
|
if (!entry) {
|
||||||
|
const occupied = entries.some(e => e.options.key === opts?.entryKey)
|
||||||
|
return occupied ? deadCell() : <>{opts?.fallback ?? null}</>
|
||||||
|
}
|
||||||
return guarded(entry, entryKeyOf(entry))
|
return guarded(entry, entryKeyOf(entry))
|
||||||
}
|
}
|
||||||
if (spec.kind === 'chain') {
|
if (spec.kind === 'chain') {
|
||||||
@@ -764,16 +819,35 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
|
|||||||
}
|
}
|
||||||
return elected ?? <>{opts?.fallback ?? null}</>
|
return elected ?? <>{opts?.fallback ?? null}</>
|
||||||
}
|
}
|
||||||
// list: registration order refined by explicit order, optional id filter.
|
// list: one row per id cell — the cell's shadowing winner, or the crash
|
||||||
const withListOptions = entries.map(entry => ({
|
// 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,
|
entry,
|
||||||
id: entry.options.id,
|
id: entry.options.id,
|
||||||
order: entry.options.order ?? 0,
|
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 (opts?.only !== undefined) list = list.filter(item => item.id === opts.only)
|
||||||
if (list.length === 0) return <>{opts?.fallback ?? null}</>
|
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)}`)
|
||||||
|
: <div data-slot-error={slotKey} key={`x${item.id ?? i}`} />)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Root outlet: the shell's single ctx-level render entry — an unregistered 'root' is a boot-order failure, never a silent blank. */
|
/** 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'),
|
() => host.getVersion('root'),
|
||||||
)
|
)
|
||||||
useLocaleRevision(host.locale)
|
useLocaleRevision(host.locale)
|
||||||
const entry = host.entriesOf('root')[0]
|
const entry = host.entriesOfSlot('root')[0]
|
||||||
if (!entry) throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)")
|
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 <div data-slot-error="root" />
|
||||||
|
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 (
|
return (
|
||||||
<SlotErrorBoundary slotKey="root" key={entryKeyOf(entry)}>
|
<div data-slot="root" style={ANCHOR_STYLE}>
|
||||||
<RootEntry
|
<SlotErrorBoundary
|
||||||
entry={entry}
|
|
||||||
ownerProps={ownerProps}
|
|
||||||
slotKey="root"
|
slotKey="root"
|
||||||
slotInjected={EMPTY_SLOT_INJECT}
|
key={entryKeyOf(entry)}
|
||||||
hookContext={undefined}
|
onEntryError={(error) => { host.reportEntryError('root', entry, error, { abdicate: true }) }}
|
||||||
hasHookContext={false}
|
>
|
||||||
/>
|
<RootEntry
|
||||||
</SlotErrorBoundary>
|
entry={entry}
|
||||||
|
ownerProps={ownerProps}
|
||||||
|
slotKey="root"
|
||||||
|
slotInjected={EMPTY_SLOT_INJECT}
|
||||||
|
hookContext={undefined}
|
||||||
|
hasHookContext={false}
|
||||||
|
/>
|
||||||
|
</SlotErrorBoundary>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ function hostOver(core: SlotCore): SlotRendererHost {
|
|||||||
subscribe: (key, fn) => core.subscribe(key, fn),
|
subscribe: (key, fn) => core.subscribe(key, fn),
|
||||||
getVersion: key => core.getVersion(key),
|
getVersion: key => core.getVersion(key),
|
||||||
entriesOf: key => core.entries(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),
|
specOf: key => core.specDynamic(key),
|
||||||
isLive: entry => core.isLive(entry),
|
isLive: entry => core.isLive(entry),
|
||||||
storeOf: () => undefined,
|
storeOf: () => undefined,
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ function makeHost() {
|
|||||||
const versions = new Map<string, number>()
|
const versions = new Map<string, number>()
|
||||||
const subs = new Map<string, Set<() => void>>()
|
const subs = new Map<string, Set<() => void>>()
|
||||||
const live = new Set<StoredEntry>()
|
const live = new Set<StoredEntry>()
|
||||||
|
const abdicated = new Set<StoredEntry>()
|
||||||
const storeCache = new Map<StoredEntry, Map<string, StoreInstanceLike>>()
|
const storeCache = new Map<StoredEntry, Map<string, StoreInstanceLike>>()
|
||||||
const list = observable<{ ids: string[] }>({ ids: [] })
|
const list = observable<{ ids: string[] }>({ ids: [] })
|
||||||
const workspaces = observable<{ ids: string[] }>({ ids: [] })
|
const workspaces = observable<{ ids: string[] }>({ ids: [] })
|
||||||
@@ -105,6 +106,28 @@ function makeHost() {
|
|||||||
},
|
},
|
||||||
getVersion: key => versions.get(key) ?? 0,
|
getVersion: key => versions.get(key) ?? 0,
|
||||||
entriesOf: key => entries.get(key) ?? [],
|
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<string | undefined>()
|
||||||
|
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),
|
specOf: key => specs.get(key),
|
||||||
isLive: entry => live.has(entry),
|
isLive: entry => live.has(entry),
|
||||||
storeOf: (entry, scopeKey) => {
|
storeOf: (entry, scopeKey) => {
|
||||||
@@ -147,11 +170,12 @@ function makeHost() {
|
|||||||
add: (key: string, partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }) => {
|
add: (key: string, partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }) => {
|
||||||
const entry = entryOf(partial)
|
const entry = entryOf(partial)
|
||||||
const next = [...(entries.get(key) ?? []), entry]
|
const next = [...(entries.get(key) ?? []), entry]
|
||||||
// Mirror the ledger contract: chain entries arrive priority-sorted
|
// Mirror the ledger contract: entries arrive priority-sorted (stable,
|
||||||
// (stable, ascending) — outlets iterate entries() order as-is.
|
// ascending; list refines equal priorities by order) — outlets iterate
|
||||||
if (specs.get(key)?.kind === 'chain') {
|
// entries() order as-is.
|
||||||
next.sort((a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0))
|
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)
|
entries.set(key, next)
|
||||||
live.add(entry)
|
live.add(entry)
|
||||||
bump(key)
|
bump(key)
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
|
|||||||
subscribe: () => () => {},
|
subscribe: () => () => {},
|
||||||
getVersion: () => 0,
|
getVersion: () => 0,
|
||||||
entriesOf: key => key === 'root' ? [rootEntry] : sessionEntries,
|
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,
|
specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
|
||||||
isLive: () => true,
|
isLive: () => true,
|
||||||
storeOf: () => undefined,
|
storeOf: () => undefined,
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ function makeHost() {
|
|||||||
},
|
},
|
||||||
getVersion: key => versions.get(key) ?? 0,
|
getVersion: key => versions.get(key) ?? 0,
|
||||||
entriesOf: key => entries.get(key) ?? [],
|
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' }),
|
specOf: () => ({ kind: 'single', scope: 'root' }),
|
||||||
isLive: entry => live.has(entry),
|
isLive: entry => live.has(entry),
|
||||||
storeOf: () => undefined,
|
storeOf: () => undefined,
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ function makeHost() {
|
|||||||
subscribe: () => () => {},
|
subscribe: () => () => {},
|
||||||
getVersion: () => 0,
|
getVersion: () => 0,
|
||||||
entriesOf: key => key === 'root' ? [rootEntry] : sessionEntries,
|
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,
|
specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
|
||||||
isLive: () => true,
|
isLive: () => true,
|
||||||
storeOf: () => undefined,
|
storeOf: () => undefined,
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
assertManifestComplete,
|
assertManifestComplete,
|
||||||
|
assertToolsHarvested,
|
||||||
collectToolCatalog,
|
collectToolCatalog,
|
||||||
render,
|
render,
|
||||||
type ToolCatalog,
|
type ToolCatalog,
|
||||||
|
type ToolPackage,
|
||||||
} from '../../../../scripts/gen-tool-catalog.ts'
|
} from '../../../../scripts/gen-tool-catalog.ts'
|
||||||
|
|
||||||
/** JSON Schema shape enough to reach the values AST extraction can't. */
|
/** 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 () => {
|
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
|
||||||
const catalog = await collectToolCatalog()
|
const catalog = await collectToolCatalog()
|
||||||
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
|
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).
|
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||||
for (const entry of catalog) {
|
for (const entry of catalog) {
|
||||||
for (const schema of entry.schemas) {
|
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', () => {
|
describe('gen-tool-catalog render', () => {
|
||||||
it('emits a package heading, a tool heading, and a json schema fence', () => {
|
it('emits a package heading, a tool heading, and a json schema fence', () => {
|
||||||
const catalog: ToolCatalog = [
|
const catalog: ToolCatalog = [
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ import { GoalError } from '@deepseek-ai/dsh-goal'
|
|||||||
import type { GoalRef as CoreGoalRef } 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')`.
|
// Type-only edges: resolve the command-change stream and `ctx.get('skills')`.
|
||||||
import type {} from '@deepseek-ai/dsh-commands'
|
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'
|
import type {} from '@deepseek-ai/dsh-skill'
|
||||||
// The settings/credentials seams: brand guards run at this wire boundary; the
|
// The settings/credentials seams: brand guards run at this wire boundary; the
|
||||||
// service reads stay optional (`ctx.get`) so a composition without either
|
// 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.
|
* Implement ApiProxy over a composed host context.
|
||||||
* @param ctx - a context with the Host spine and Workspace registry mounted.
|
* @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.
|
* @returns the ApiProxy implementation.
|
||||||
*/
|
*/
|
||||||
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
|
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ export interface ApiProxy {
|
|||||||
llm: LlmApi
|
llm: LlmApi
|
||||||
/** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */
|
/** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */
|
||||||
downloads: DownloadsApi
|
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<RpcReceipt>
|
respond(message: ClientResponse): Promise<RpcReceipt>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,12 +50,13 @@ type ErasedRegister = (options: object, component: unknown) => () => void
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One rendered slot's local view, from {@link SlotTestRuntime.renderSlot}:
|
* One rendered slot's local view, from {@link SlotTestRuntime.renderSlot}:
|
||||||
* the `data-slot` wrapper is the snapshot root (`expect(view.container)
|
* the renderer's own `[data-slot]` outlet anchor is the snapshot root
|
||||||
* .toMatchSnapshot()` captures exactly this slot's output), Testing Library
|
* (`expect(view.container).toMatchSnapshot()` captures exactly this slot's
|
||||||
* queries are bound inside it, and `update` re-renders with new owner props.
|
* output), Testing Library queries are bound inside it, and `update`
|
||||||
|
* re-renders with new owner props.
|
||||||
*/
|
*/
|
||||||
export interface SlotView<K extends keyof SlotMap & string> {
|
export interface SlotView<K extends keyof SlotMap & string> {
|
||||||
/** The `<div data-slot="<key>">` wrapper around the slot's rendered output. */
|
/** The renderer's `<div data-slot="<key>">` anchor around the slot's rendered output. */
|
||||||
readonly container: HTMLElement
|
readonly container: HTMLElement
|
||||||
/** Testing Library queries scoped to {@link SlotView.container}. */
|
/** Testing Library queries scoped to {@link SlotView.container}. */
|
||||||
readonly view: BoundFunctions<typeof queries>
|
readonly view: BoundFunctions<typeof queries>
|
||||||
@@ -286,10 +287,10 @@ export class SlotTestRuntime {
|
|||||||
/**
|
/**
|
||||||
* Declare child slots under an auto-generated root frame — the single-slot
|
* Declare child slots under an auto-generated root frame — the single-slot
|
||||||
* mounting path for local DOM snapshots. Each key later supplied through
|
* mounting path for local DOM snapshots. Each key later supplied through
|
||||||
* {@link SlotTestRuntime.renderSlot} renders inside its own
|
* {@link SlotTestRuntime.renderSlot} renders inside the renderer's own
|
||||||
* `<div data-slot="<key>">` wrapper (the snapshot root). Mutually exclusive
|
* `<div data-slot="<key>">` outlet anchor (the snapshot root — the frame
|
||||||
* with {@link TestRoot.declare} ('root' is a single slot); one call per
|
* adds no wrapper of its own). Mutually exclusive with
|
||||||
* runtime.
|
* {@link TestRoot.declare} ('root' is a single slot); one call per runtime.
|
||||||
* @param children - child-slot declaration table (same contract as TestRoot.declare).
|
* @param children - child-slot declaration table (same contract as TestRoot.declare).
|
||||||
* @returns completion of the act-wrapped registration.
|
* @returns completion of the act-wrapped registration.
|
||||||
*/
|
*/
|
||||||
@@ -298,8 +299,11 @@ export class SlotTestRuntime {
|
|||||||
const cell = this.ownerCell
|
const cell = this.ownerCell
|
||||||
const AutoFrame = (props: { renderSlot: (key: string, owner: object) => ReactNode }) => {
|
const AutoFrame = (props: { renderSlot: (key: string, owner: object) => ReactNode }) => {
|
||||||
useSyncExternalStore(cell.subscribe, cell.getVersion)
|
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]) =>
|
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)
|
await this.root.declare(children as never, AutoFrame as never)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -859,17 +859,39 @@ class FaceAnalyzer {
|
|||||||
const result: ServiceModel[] = []
|
const result: ServiceModel[] = []
|
||||||
for (const member of context.members) {
|
for (const member of context.members) {
|
||||||
if (!ts.isPropertySignature(member) || member.type === undefined) continue
|
if (!ts.isPropertySignature(member) || member.type === undefined) continue
|
||||||
const symbol = this.symbolAtType(member.type)
|
// An OPTIONAL key is not a service: `X | undefined` and `key?: X` both mark
|
||||||
if (symbol === undefined) continue
|
// a value the launcher or boot code installs before the tree mounts (a root
|
||||||
const symbolId = this.symbolId(symbol)
|
// accessor, an environment snapshot), which no plugin provides and no
|
||||||
const exported = bySymbol.get(symbolId)?.find(record => record.model.name === symbol.name)
|
// consumer can reach with `inject`. Describing one as a service would answer
|
||||||
?? bySymbol.get(symbolId)?.find(record => record.model.name !== 'default')
|
// "add the plugin that provides it" for a key where no such plugin exists.
|
||||||
?? bySymbol.get(symbolId)?.[0]
|
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
|
if (exported === undefined) continue
|
||||||
const declaration = preferredDeclaration(symbol)
|
let symbol = authoredSymbol
|
||||||
|
let declaration = preferredDeclaration(symbol)
|
||||||
|
const aliases = new Set<ts.Symbol>()
|
||||||
|
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))) {
|
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`)
|
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 model = this.ensureDeclaration(symbol, declaration)
|
||||||
const exposed = model.members
|
const exposed = model.members
|
||||||
.filter(exposableMember)
|
.filter(exposableMember)
|
||||||
@@ -1990,6 +2012,11 @@ class FaceAnalyzer {
|
|||||||
&& member.initializer !== undefined
|
&& member.initializer !== undefined
|
||||||
&& ts.isCallExpression(member.initializer)
|
&& ts.isCallExpression(member.initializer)
|
||||||
&& this.isTypeMetaSymbol(member.initializer.expression, 'bindTypertRemote')) continue
|
&& 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 visibility = visibilityOf(member)
|
||||||
const isStatic = hasModifier(member, ts.SyntaxKind.StaticKeyword)
|
const isStatic = hasModifier(member, ts.SyntaxKind.StaticKeyword)
|
||||||
if (visibility !== 'public' || isStatic || ts.isConstructorDeclaration(member)) continue
|
if (visibility !== 'public' || isStatic || ts.isConstructorDeclaration(member)) continue
|
||||||
|
|||||||
@@ -12,13 +12,15 @@ import type {
|
|||||||
FaceModel,
|
FaceModel,
|
||||||
MemberModel,
|
MemberModel,
|
||||||
ParameterModel,
|
ParameterModel,
|
||||||
|
ServiceModel,
|
||||||
SignatureModel,
|
SignatureModel,
|
||||||
SourceDeclarationModel,
|
SourceDeclarationModel,
|
||||||
SourceLocation,
|
SourceLocation,
|
||||||
|
TypertFace,
|
||||||
TypeNodeId,
|
TypeNodeId,
|
||||||
} from './model.ts'
|
} 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
|
/** The fenced-block info string for generated signature blocks (skipped by
|
||||||
* doc-typecheck, since a bare signature fragment is not standalone-compilable). */
|
* doc-typecheck, since a bare signature fragment is not standalone-compilable). */
|
||||||
@@ -112,6 +114,10 @@ export interface CordisCatalogPolicy {
|
|||||||
readonly foundationTypeNames: ReadonlySet<string>
|
readonly foundationTypeNames: ReadonlySet<string>
|
||||||
/** Repository types deliberately documented outside the linked data catalog. */
|
/** Repository types deliberately documented outside the linked data catalog. */
|
||||||
readonly typeLinkExemptions: Readonly<Record<string, string>>
|
readonly typeLinkExemptions: Readonly<Record<string, string>>
|
||||||
|
/** 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<string>
|
||||||
/** Manually curated framework events inherited by every plugin. */
|
/** Manually curated framework events inherited by every plugin. */
|
||||||
readonly inheritedEvents: readonly InheritedEntry[]
|
readonly inheritedEvents: readonly InheritedEntry[]
|
||||||
/** Manually curated framework context members inherited by every plugin. */
|
/** Manually curated framework context members inherited by every plugin. */
|
||||||
@@ -129,7 +135,7 @@ export class CordisCatalogProjector {
|
|||||||
private readonly renderer: TypeGraphRenderer
|
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 sourceDeclarations - exported declarations available to the runtime type closure.
|
||||||
* @param policy - caller-owned type classifications and inherited Cordis data.
|
* @param policy - caller-owned type classifications and inherited Cordis data.
|
||||||
*/
|
*/
|
||||||
@@ -138,7 +144,6 @@ export class CordisCatalogProjector {
|
|||||||
private readonly sourceDeclarations: readonly SourceDeclarationModel[],
|
private readonly sourceDeclarations: readonly SourceDeclarationModel[],
|
||||||
private readonly policy: CordisCatalogPolicy,
|
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)
|
this.renderer = new TypeGraphRenderer(face.graph)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,10 +164,13 @@ export class CordisCatalogProjector {
|
|||||||
* @returns the model-facing TypeScript catalog source.
|
* @returns the model-facing TypeScript catalog source.
|
||||||
*/
|
*/
|
||||||
renderRuntimeApi(model: CordisCatalogModel): string {
|
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(
|
return renderRuntimeApi(
|
||||||
model.services,
|
services,
|
||||||
model.events,
|
model.events,
|
||||||
this.runtimeTypes(model.services),
|
this.runtimeTypes(services),
|
||||||
this.policy.inheritedServices,
|
this.policy.inheritedServices,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -173,6 +181,8 @@ export class CordisCatalogProjector {
|
|||||||
const typeLinkViolations: string[] = []
|
const typeLinkViolations: string[] = []
|
||||||
for (const packageModel of this.face.packages) {
|
for (const packageModel of this.face.packages) {
|
||||||
for (const event of packageModel.events) {
|
for (const event of packageModel.events) {
|
||||||
|
const parsed = parseJsDoc(event.jsDoc ?? '')
|
||||||
|
if (parsed.deprecated) continue
|
||||||
const source = pointer(event.location)
|
const source = pointer(event.location)
|
||||||
const where = `event '${event.name}' (${source})`
|
const where = `event '${event.name}' (${source})`
|
||||||
const node = this.renderer.node(event.signature)
|
const node = this.renderer.node(event.signature)
|
||||||
@@ -180,11 +190,12 @@ export class CordisCatalogProjector {
|
|||||||
violations.push(`${where} is not represented by a callable type.`)
|
violations.push(`${where} is not represented by a callable type.`)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
checkTypeLinks(where, signatureTypeNames(this.renderer, node.signature), this.policy, typeLinkViolations)
|
if (this.face.face === 'host') {
|
||||||
const parsed = parseJsDoc(event.jsDoc ?? '')
|
checkTypeLinks(where, signatureTypeNames(this.renderer, node.signature), this.policy, typeLinkViolations)
|
||||||
|
}
|
||||||
const mode = event.mode
|
const mode = event.mode
|
||||||
if (!isMode(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 last = node.signature.parameters.at(-1)
|
||||||
const hasNext = last?.name === 'next'
|
const hasNext = last?.name === 'next'
|
||||||
@@ -223,47 +234,91 @@ export class CordisCatalogProjector {
|
|||||||
return entries
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The services this projection describes, one per `ctx.<key>`: 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<string, ServiceModel>()
|
||||||
|
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[] {
|
private collectServices(): ServiceEntry[] {
|
||||||
const entries: ServiceEntry[] = []
|
const entries: ServiceEntry[] = []
|
||||||
const violations: string[] = []
|
const violations: string[] = []
|
||||||
const typeLinkViolations: string[] = []
|
const typeLinkViolations: string[] = []
|
||||||
for (const packageModel of this.face.packages) {
|
for (const service of this.renderableServices()) {
|
||||||
for (const service of packageModel.services) {
|
const declaration = this.renderer.declaration(service.symbol)
|
||||||
const declaration = this.renderer.declaration(service.symbol)
|
const parsedDeclaration = parseJsDoc(declaration.jsDoc ?? '')
|
||||||
if (declaration.kind !== 'class'
|
if (parsedDeclaration.deprecated) continue
|
||||||
|| !/^packages\/[^/]+\/[^/]+\/src\/[^/]+\.ts$/.test(service.location.file)
|
const doc = parsedDeclaration.doc
|
||||||
|| declaration.location.file !== service.location.file) continue
|
const source = pointer(declaration.location)
|
||||||
const doc = parseJsDoc(declaration.jsDoc ?? '').doc
|
if (doc === '') {
|
||||||
const source = pointer(declaration.location)
|
violations.push(`service ctx.${service.key} (${source}): ${declaration.kind} ${declaration.name} has no JSDoc.`)
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
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)
|
reportViolations('gen-cordis-catalog', violations)
|
||||||
reportTypeLinkViolations('gen-cordis-catalog', typeLinkViolations)
|
reportTypeLinkViolations('gen-cordis-catalog', typeLinkViolations)
|
||||||
@@ -274,8 +329,8 @@ export class CordisCatalogProjector {
|
|||||||
const declarations = new Map<string, string>()
|
const declarations = new Map<string, string>()
|
||||||
const ambiguous = new Set<string>()
|
const ambiguous = new Set<string>()
|
||||||
for (const declaration of this.sourceDeclarations) {
|
for (const declaration of this.sourceDeclarations) {
|
||||||
if (declaration.face !== 'host' || declaration.kind === 'enum'
|
if (declaration.face !== this.face.face || declaration.kind === 'enum'
|
||||||
|| !/^packages\/[^/]+\/[^/]+\/src\/[^/]+\.ts$/.test(declaration.location.file)) continue
|
|| !/^packages\/[^/]+\/[^/]+\/src\/.+\.tsx?$/.test(declaration.location.file)) continue
|
||||||
if (declarations.has(declaration.name)) {
|
if (declarations.has(declaration.name)) {
|
||||||
ambiguous.add(declaration.name)
|
ambiguous.add(declaration.name)
|
||||||
continue
|
continue
|
||||||
@@ -298,31 +353,31 @@ export class CordisCatalogProjector {
|
|||||||
* @param policy - caller-owned type classifications and inherited Cordis data.
|
* @param policy - caller-owned type classifications and inherited Cordis data.
|
||||||
* @returns the configured projector and its validated catalog model.
|
* @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 projector: CordisCatalogProjector
|
||||||
readonly model: CordisCatalogModel
|
readonly model: CordisCatalogModel
|
||||||
} {
|
} {
|
||||||
const caches = new WorkspaceCaches()
|
const caches = new WorkspaceCaches()
|
||||||
const discovery = new WorkspaceAnalyzer({
|
const discovery = new WorkspaceAnalyzer({
|
||||||
root: scanRoot,
|
root: scanRoot,
|
||||||
faces: ['host'],
|
faces: [targetFace],
|
||||||
checkDiagnostics: false,
|
checkDiagnostics: false,
|
||||||
caches,
|
caches,
|
||||||
}).discoverPackages()
|
}).discoverPackages()
|
||||||
const packages = discovery.filter(candidate => candidate.faces.includes('host'))
|
const packages = discovery.filter(candidate => candidate.faces.includes(targetFace))
|
||||||
.map(candidate => candidate.package)
|
.map(candidate => candidate.package)
|
||||||
const workspace = new WorkspaceAnalyzer({
|
const workspace = new WorkspaceAnalyzer({
|
||||||
root: scanRoot,
|
root: scanRoot,
|
||||||
faces: ['host'],
|
faces: [targetFace],
|
||||||
packages,
|
packages,
|
||||||
checkDiagnostics: false,
|
checkDiagnostics: false,
|
||||||
caches,
|
caches,
|
||||||
}).analyzeInBatches()
|
}).analyzeInBatches()
|
||||||
const face = workspace.faces.find(candidate => candidate.face === 'host')
|
const face = workspace.faces.find(candidate => candidate.face === targetFace)
|
||||||
if (face === undefined) throw new Error('gen-cordis-catalog: Typert produced no host face')
|
if (face === undefined) throw new Error(`gen-cordis-catalog: Typert produced no ${targetFace} face`)
|
||||||
const sourceDeclarations = new WorkspaceAnalyzer({
|
const sourceDeclarations = new WorkspaceAnalyzer({
|
||||||
root: scanRoot,
|
root: scanRoot,
|
||||||
faces: ['host'],
|
faces: [targetFace],
|
||||||
checkDiagnostics: false,
|
checkDiagnostics: false,
|
||||||
caches,
|
caches,
|
||||||
}).indexSourceDeclarations()
|
}).indexSourceDeclarations()
|
||||||
@@ -354,6 +409,7 @@ interface ParsedJsDoc {
|
|||||||
readonly doc: string
|
readonly doc: string
|
||||||
readonly params: ReadonlyMap<string, string>
|
readonly params: ReadonlyMap<string, string>
|
||||||
readonly returns: string | null
|
readonly returns: string | null
|
||||||
|
readonly deprecated: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJsDoc(raw: string): ParsedJsDoc {
|
function parseJsDoc(raw: string): ParsedJsDoc {
|
||||||
@@ -410,8 +466,14 @@ function parseJsDoc(raw: string): ParsedJsDoc {
|
|||||||
|
|
||||||
const params = new Map<string, string>()
|
const params = new Map<string, string>()
|
||||||
let returns: string | null = null
|
let returns: string | null = null
|
||||||
|
let deprecated = false
|
||||||
let sink: ((text: string) => void) | undefined
|
let sink: ((text: string) => void) | undefined
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
if (/^@deprecated(?:\s|$)/.test(line)) {
|
||||||
|
deprecated = true
|
||||||
|
sink = undefined
|
||||||
|
continue
|
||||||
|
}
|
||||||
const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line)
|
const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line)
|
||||||
if (param !== null) {
|
if (param !== null) {
|
||||||
const name = (param[1] ?? '').replace(/^\[|\]$/g, '')
|
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(),
|
doc: blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim(),
|
||||||
params,
|
params,
|
||||||
returns,
|
returns,
|
||||||
|
deprecated,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,7 +557,7 @@ function pointer(location: SourceLocation): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isMode(mode: string | undefined): mode is Mode {
|
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[] {
|
function signatureTypeNames(renderer: TypeGraphRenderer, signature: SignatureModel): string[] {
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ import { CORDIS_CATALOG_POLICY, EVENT_SCOPE_PAGE, REGION_BEGIN, REGION_END, SERV
|
|||||||
|
|
||||||
const workspaceRoot = resolve(import.meta.dirname, '../../../..')
|
const workspaceRoot = resolve(import.meta.dirname, '../../../..')
|
||||||
|
|
||||||
|
/** One workspace projection shared by both cases: analyzing it twice doubles a multi-minute run. */
|
||||||
|
let cached: ReturnType<typeof projectCordisCatalog> | undefined
|
||||||
|
const projection = (): ReturnType<typeof projectCordisCatalog> =>
|
||||||
|
(cached ??= projectCordisCatalog(workspaceRoot, CORDIS_CATALOG_POLICY))
|
||||||
|
|
||||||
describe('Typert-backed Cordis catalog', () => {
|
describe('Typert-backed Cordis catalog', () => {
|
||||||
it('reproduces every committed catalog artifact byte for byte', { timeout: 480_000 }, () => {
|
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')
|
const expected = (path: string): string => readFileSync(join(workspaceRoot, path), 'utf8')
|
||||||
|
|
||||||
expect(renderInheritedPage(CORDIS_CATALOG_POLICY)).toBe(expected('docs/cordis-api/inherited.md'))
|
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'),
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user