Просмотр исходного кода

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.
Tianyi Cui 4 месяцев назад
Родитель
Сommit
370b5d3aab

+ 7 - 0
AGENTS.md

@@ -68,6 +68,13 @@ only needed for publishing/consumption outside the repo.
 - **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)`
   and MUST call `next()` to delegate; returning without it short-circuits.
   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
   documented extension seams (see the plugin sanity checklist in
   docs/architecture.md). Changing `agent-loop` requires updating that doc.

+ 2 - 0
packages/llm/src/assembler.ts

@@ -6,6 +6,7 @@
  */
 
 import { CallId } from './brand.ts'
+import { assertNever } from './never.ts'
 import type { ContentBlock, FinishReason, GenerateResult, Message, StreamChunk, TokenUsage } from './types.ts'
 
 interface PartialBlock {
@@ -82,6 +83,7 @@ export class BlockAssembler {
         this._finish = chunk.reason
         return
       }
+      default: return assertNever(chunk, 'BlockAssembler.push')
     }
   }
 

+ 1 - 0
packages/llm/src/index.ts

@@ -11,6 +11,7 @@ import type { ContentBlock, GenerateOptions, GenerateResult, StreamChunk } from
 import { BlockAssembler } from './assembler.ts'
 
 export * from './brand.ts'
+export * from './never.ts'
 export * from './types.ts'
 export { BlockAssembler } from './assembler.ts'
 

+ 35 - 0
packages/llm/src/never.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}`)
+}

+ 15 - 0
packages/llm/tests/assembler.spec.ts

@@ -151,3 +151,18 @@ describe('BlockAssembler', () => {
     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')
+  })
+})