Add assertNever with closed-vs-extensible exhaustiveness guidance
assertNever (dsh-llm) marks unreachable defaults on CLOSED unions: adding a StreamChunk variant now breaks compilation at BlockAssembler.push, and a value escaping its type at runtime throws with diagnostics. The module doc and a new AGENTS.md convention spell out the dividing line: merge-extensible unions (SessionEventMap, ContentBlockMap, …) must NOT use assertNever — plugin-added variants are valid unknown values there; handle known cases and fall through with a comment.
This commit is contained in:
@@ -68,6 +68,13 @@ only needed for publishing/consumption outside the repo.
|
|||||||
- **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)`
|
- **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)`
|
||||||
and MUST call `next()` to delegate; returning without it short-circuits.
|
and MUST call `next()` to delegate; returning without it short-circuits.
|
||||||
This is the veto mechanism — use deliberately.
|
This is the veto mechanism — use deliberately.
|
||||||
|
- **Switch exhaustiveness**: switches over CLOSED unions (e.g. `StreamChunk`)
|
||||||
|
end with `default: assertNever(value, 'context')` (from dsh-llm) so adding a
|
||||||
|
variant breaks compilation at every switch that must handle it. Switches
|
||||||
|
over MERGE-EXTENSIBLE unions (`SessionEventMap`, `ContentBlockMap`, …) must
|
||||||
|
NOT use assertNever — plugin-added variants are valid unknown values; handle
|
||||||
|
known cases and fall through with a comment (the lint rule
|
||||||
|
`switch-exhaustiveness-check` makes the choice explicit either way).
|
||||||
- **Plugins, not loop changes**: new behavior goes into a plugin on the
|
- **Plugins, not loop changes**: new behavior goes into a plugin on the
|
||||||
documented extension seams (see the plugin sanity checklist in
|
documented extension seams (see the plugin sanity checklist in
|
||||||
docs/architecture.md). Changing `agent-loop` requires updating that doc.
|
docs/architecture.md). Changing `agent-loop` requires updating that doc.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { CallId } from './brand.ts'
|
import { CallId } from './brand.ts'
|
||||||
|
import { assertNever } from './never.ts'
|
||||||
import type { ContentBlock, FinishReason, GenerateResult, Message, StreamChunk, TokenUsage } from './types.ts'
|
import type { ContentBlock, FinishReason, GenerateResult, Message, StreamChunk, TokenUsage } from './types.ts'
|
||||||
|
|
||||||
interface PartialBlock {
|
interface PartialBlock {
|
||||||
@@ -82,6 +83,7 @@ export class BlockAssembler {
|
|||||||
this._finish = chunk.reason
|
this._finish = chunk.reason
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
default: return assertNever(chunk, 'BlockAssembler.push')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { ContentBlock, GenerateOptions, GenerateResult, StreamChunk } from
|
|||||||
import { BlockAssembler } from './assembler.ts'
|
import { BlockAssembler } from './assembler.ts'
|
||||||
|
|
||||||
export * from './brand.ts'
|
export * from './brand.ts'
|
||||||
|
export * from './never.ts'
|
||||||
export * from './types.ts'
|
export * from './types.ts'
|
||||||
export { BlockAssembler } from './assembler.ts'
|
export { BlockAssembler } from './assembler.ts'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Exhaustiveness helper for switches over core unions.
|
||||||
|
*
|
||||||
|
* # When to use which pattern
|
||||||
|
*
|
||||||
|
* **Closed unions** (every variant is known at compile time in the consuming
|
||||||
|
* code — e.g. `StreamChunk` inside the assembler, `FiberState`-like enums):
|
||||||
|
* end the switch with `default: assertNever(value)`. Adding a variant then
|
||||||
|
* fails compilation at every switch that must handle it — the error appears
|
||||||
|
* exactly where work is needed.
|
||||||
|
*
|
||||||
|
* **Merge-extensible unions** (plugins add variants via declaration merging —
|
||||||
|
* `SessionEventMap`, `ContentBlockMap`, `MessageSourceMap`, …): do NOT use
|
||||||
|
* assertNever. From the core's view the union is open; plugin-added variants
|
||||||
|
* are valid values the core has never heard of. Handle the known cases and
|
||||||
|
* fall through intentionally, with a comment saying the switch is
|
||||||
|
* deliberately non-exhaustive (see `Session.deriveMessages`). The lint rule
|
||||||
|
* `switch-exhaustiveness-check` enforces that the choice is explicit either
|
||||||
|
* way.
|
||||||
|
*
|
||||||
|
* @module @deepseek-ai/dsh-llm/never
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks unreachable code on a closed union. If this is reachable, either a
|
||||||
|
* variant was added without updating the switch (compile error at the call
|
||||||
|
* site — the desired outcome) or a value escaped its type (runtime throw
|
||||||
|
* with diagnostics — the safety net).
|
||||||
|
*/
|
||||||
|
export function assertNever(value: never, context?: string): never {
|
||||||
|
// JSON.stringify is typed string but returns undefined for undefined input;
|
||||||
|
// String() covers that and other non-serializable escapes.
|
||||||
|
const rendered = (JSON.stringify(value) as string | undefined) ?? String(value)
|
||||||
|
throw new Error(`unreachable variant${context ? ` in ${context}` : ''}: ${rendered}`)
|
||||||
|
}
|
||||||
@@ -151,3 +151,18 @@ describe('BlockAssembler', () => {
|
|||||||
expect('usage' in result).toBe(true)
|
expect('usage' in result).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('assertNever', () => {
|
||||||
|
it('throws with diagnostics when a value escapes a closed union at runtime', async () => {
|
||||||
|
const { assertNever } = await import('@deepseek-ai/dsh-llm')
|
||||||
|
expect(() => assertNever({ type: 'rogue' } as never, 'test-context'))
|
||||||
|
.toThrow('unreachable variant in test-context: {"type":"rogue"}')
|
||||||
|
expect(() => assertNever(undefined as never)).toThrow('unreachable variant: undefined')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('BlockAssembler.push rejects chunks outside the closed StreamChunk union', () => {
|
||||||
|
const assembler = new BlockAssembler()
|
||||||
|
expect(() => assembler.push({ type: 'rogue-chunk' } as unknown as StreamChunk))
|
||||||
|
.toThrow('unreachable variant in BlockAssembler.push')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user