Jelajahi Sumber

Add ESLint: typescript-eslint strict-type-checked + stylistic formatting

Flat config with two layers. Correctness (type-checked): the headline
rules for this codebase are no-floating-promises / no-misused-promises
(a lost promise in the agent loop is our primary bug class),
switch-exhaustiveness-check (we switch over merge-extensible unions
everywhere), no-unnecessary-condition, require-await, and
no-explicit-any. Style (@stylistic): 2-space, no semicolons, single
quotes, trailing commas, max-len 140 — the existing house style, now
enforced instead of drifting between agents. vendor/ is excluded
(vendored source keeps upstream style); tests relax the rules that
fight test ergonomics (non-null assertions after expects, async mock
signatures, non-Error throws).

Code adjusted to pass: registry disposers wrap ctx.effect's
promise-returning disposer behind a sync () => void (our public API),
BlockAssembler gains an invariant-checking mustGet instead of non-null
assertions, lastTurnNumber uses findLast, waterfall tails return
Promise.resolve instead of async-without-await arrows, and the two
deliberate suppressions (non-exhaustive derivation switch, unbound
execute pass-through) carry justification comments.

yarn lint / yarn lint:fix added.
Tianyi Cui 3 bulan lalu
induk
melakukan
cb6bee3d03

+ 133 - 0
eslint.config.mjs

@@ -0,0 +1,133 @@
+import stylistic from '@stylistic/eslint-plugin'
+import tseslint from 'typescript-eslint'
+
+/**
+ * ESLint flat config. Two layers:
+ *
+ * 1. typescript-eslint strict-type-checked — correctness rules that need the
+ *    type checker. The headline rules for this codebase: no-floating-promises
+ *    and no-misused-promises (an un-awaited promise in the agent loop is our
+ *    primary bug class), switch-exhaustiveness-check (we switch over
+ *    merge-extensible unions everywhere).
+ * 2. @stylistic — formatting (2-space, no semicolons, single quotes, trailing
+ *    commas), so style is enforced rather than drifting between agents.
+ *
+ * vendor/ is linted lightly (style only stays OFF — vendored code keeps
+ * upstream style; only a few safety rules apply there) and examples/tests are
+ * linted with relaxed unsafe-* rules where mocks intentionally bend types.
+ */
+export default tseslint.config(
+  {
+    ignores: [
+      '**/lib/**',
+      '**/node_modules/**',
+      'vendor/**', // vendored source keeps upstream style and idioms
+      '**/*.js',
+      '**/*.mjs',
+    ],
+  },
+
+  // --- our packages: full strictness -------------------------------------
+  {
+    files: ['packages/*/src/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts'],
+    extends: [
+      ...tseslint.configs.strictTypeChecked,
+    ],
+    languageOptions: {
+      parserOptions: {
+        project: ['./tsconfig.typecheck.json'],
+        tsconfigRootDir: import.meta.dirname,
+      },
+    },
+    rules: {
+      // The bug class this repo cares most about: lost promises in the loop.
+      '@typescript-eslint/no-floating-promises': 'error',
+      '@typescript-eslint/no-misused-promises': 'error',
+      '@typescript-eslint/require-await': 'error',
+      '@typescript-eslint/switch-exhaustiveness-check': ['error', {
+        considerDefaultExhaustiveForUnions: true,
+      }],
+      '@typescript-eslint/no-unnecessary-condition': ['error', {
+        allowConstantLoopConditions: true,
+      }],
+      // `any` requires a justification comment — enforced as: no bare casts.
+      '@typescript-eslint/no-explicit-any': 'error',
+      // Style points where the codebase intentionally diverges from preset:
+      '@typescript-eslint/no-namespace': 'off', // Cordis Config-namespace idiom
+      '@typescript-eslint/no-empty-object-type': 'off', // merge-extensible maps
+      '@typescript-eslint/no-invalid-void-type': 'off', // event signatures
+      '@typescript-eslint/restrict-template-expressions': ['error', {
+        allowNumber: true,
+        allowBoolean: true,
+      }],
+      // `void foo()` in arrow listeners is our idiom for intentional fire-and-forget
+      'no-void': 'off',
+      '@typescript-eslint/no-unused-vars': ['error', {
+        argsIgnorePattern: '^_',
+        varsIgnorePattern: '^_',
+        caughtErrorsIgnorePattern: '^_',
+      }],
+    },
+  },
+
+  // --- examples: demo code conforms to async interfaces without awaiting ---
+  {
+    files: ['examples/**/*.ts'],
+    rules: {
+      '@typescript-eslint/require-await': 'off',
+    },
+  },
+
+  // --- tests: same rules, minus the friction that fights test ergonomics --
+  {
+    files: ['packages/*/tests/**/*.ts'],
+    extends: [
+      ...tseslint.configs.strictTypeChecked,
+    ],
+    languageOptions: {
+      parserOptions: {
+        project: ['./tsconfig.typecheck.json'],
+        tsconfigRootDir: import.meta.dirname,
+      },
+    },
+    rules: {
+      '@typescript-eslint/no-floating-promises': 'error',
+      '@typescript-eslint/no-misused-promises': 'error',
+      '@typescript-eslint/no-explicit-any': 'error',
+      '@typescript-eslint/no-non-null-assertion': 'off', // assertions follow expect()s
+      '@typescript-eslint/no-unnecessary-condition': 'off',
+      '@typescript-eslint/require-await': 'off', // mock execute() signatures
+      '@typescript-eslint/no-empty-function': 'off', // stub agents
+      '@typescript-eslint/only-throw-error': 'off', // testing non-Error throws
+      '@typescript-eslint/no-namespace': 'off',
+      '@typescript-eslint/no-empty-object-type': 'off',
+      '@typescript-eslint/restrict-template-expressions': 'off',
+      '@typescript-eslint/no-unused-vars': ['error', {
+        argsIgnorePattern: '^_',
+        varsIgnorePattern: '^_',
+        caughtErrorsIgnorePattern: '^_',
+      }],
+    },
+  },
+
+  // --- formatting (everything we own) -------------------------------------
+  {
+    files: ['packages/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'eslint.config.mjs'],
+    plugins: { '@stylistic': stylistic },
+    rules: {
+      '@stylistic/indent': ['error', 2],
+      '@stylistic/semi': ['error', 'never'],
+      '@stylistic/quotes': ['error', 'single', { avoidEscape: true }],
+      '@stylistic/comma-dangle': ['error', 'always-multiline'],
+      '@stylistic/eol-last': ['error', 'always'],
+      '@stylistic/no-trailing-spaces': 'error',
+      '@stylistic/object-curly-spacing': ['error', 'always'],
+      '@stylistic/arrow-parens': ['error', 'as-needed', { requireForBlockBody: true }],
+      '@stylistic/member-delimiter-style': ['error', {
+        multiline: { delimiter: 'none' },
+        singleline: { delimiter: 'semi', requireLast: false },
+      }],
+      '@stylistic/max-len': ['error', { code: 140, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true }],
+    },
+  },
+)

+ 1 - 1
examples/echo-agent/src/stdio-chat.ts

@@ -55,6 +55,6 @@ export function apply(ctx: Context) {
       setTimeout(() => process.exit(0), 200)
     })
     process.stdout.write('echo-agent ready. Type a message ("echo <text>" triggers the tool).\n> ')
-    return () => reader.close()
+    return () => { reader.close() }
   }, 'stdio-chat')
 }

+ 5 - 0
package.json

@@ -14,14 +14,19 @@
   "scripts": {
     "build": "tsc -b tsconfig.build.json && tsx scripts/build.ts",
     "typecheck": "tsc -b tsconfig.build.json && tsc -p tsconfig.typecheck.json",
+    "lint": "eslint .",
+    "lint:fix": "eslint . --fix",
     "test": "vitest run",
     "demo": "node --expose-internals --import tsx examples/echo-agent/start.ts"
   },
   "devDependencies": {
+    "@stylistic/eslint-plugin": "^5.10.0",
     "@types/node": "^25.3.5",
     "dumble": "^0.2.3",
+    "eslint": "^10.4.1",
     "tsx": "^4.22.4",
     "typescript": "^6.0.3",
+    "typescript-eslint": "^8.61.0",
     "vite-tsconfig-paths": "^6.1.1",
     "vitest": "^4.1.8"
   }

+ 2 - 2
packages/agent-loop/src/agent.ts

@@ -65,7 +65,7 @@ export class LoopAgent implements Agent {
 
   steer(content: ContentBlock[], options?: SendOptions): void {
     if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
-    if (this._status !== 'running') return this.send(content, options)
+    if (this._status !== 'running') { this.send(content, options); return }
     const source = this.resolveSource(options)
     this.inbox.steer({ content, source })
     this.ctx.emit('agent/queued', this, content, { source, steering: true })
@@ -88,7 +88,7 @@ export class LoopAgent implements Agent {
    */
   start(): () => void {
     this.done = runLoop(this.ctx, this, {
-      setStatus: status => this.setStatus(status),
+      setStatus: (status) => { this.setStatus(status) },
       setAbort: controller => void (this.currentAbort = controller),
       disposed: this.disposed,
       isDisposed: () => this._status === 'disposed',

+ 11 - 9
packages/agent-loop/src/loop.ts

@@ -113,7 +113,9 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
 
   // Drain queued messages into the session — they trigger this turn.
   const queued = agent.inbox.drainQueued()
-  const trigger: TurnTrigger = { kind: 'message', source: queued[0]!.source }
+  const first = queued[0]
+  if (!first) throw new Error('runTurn invariant violated: no queued message at turn start')
+  const trigger: TurnTrigger = { kind: 'message', source: first.source }
   for (const message of queued) {
     session.append('user/message', { content: message.content, source: message.source })
   }
@@ -177,7 +179,7 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
     try {
       shouldContinue = await ctx.waterfall(
         'agent/turn-continuation', agent, turn, defaultDecision,
-        async () => defaultDecision,
+        () => Promise.resolve(defaultDecision),
       )
     } catch (error: unknown) {
       // A broken continuation plugin ends the turn, not the loop.
@@ -246,7 +248,7 @@ async function runStep(
     ...assembly.tools.length > 0 ? { tools: assembly.tools } : {},
     signal,
   }
-  request = await ctx.waterfall('agent/request', agent, turn, step, request, async () => request)
+  request = await ctx.waterfall('agent/request', agent, turn, step, request, () => Promise.resolve(request))
   if (!request.model) {
     throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
   }
@@ -264,7 +266,7 @@ async function runStep(
   // source of truth for derived history and replay) records the message that
   // tool dispatch actually uses.
   let message: Message = assembler.message()
-  message = await ctx.waterfall('agent/step-result', agent, turn, step, message, async () => message)
+  message = await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message))
 
   session.append('assistant/message', { turn, step, content: message.content })
   if (assembler.usage) {
@@ -297,6 +299,9 @@ async function runStep(
       content: result.content,
       isError: result.isError,
     })
+    // signal CAN flip during the await above (abort() inside a tool);
+    // the analyzer can't see through the await boundary.
+    // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
     if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
   }
 
@@ -305,9 +310,6 @@ async function runStep(
 
 /** The last turn number in a (possibly seeded) session log, or 0. */
 function lastTurnNumber(session: Session): number {
-  for (let index = session.events.length - 1; index >= 0; index--) {
-    const event = session.events[index]!
-    if (event.type === 'turn/start') return event.data.turn
-  }
-  return 0
+  const lastStart = session.events.findLast(event => event.type === 'turn/start')
+  return lastStart?.data.turn ?? 0
 }

+ 5 - 5
packages/agent-loop/tests/loop.spec.ts

@@ -1,7 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import { Context } from 'cordis'
-import LlmService, { StreamChunk, ToolResultBlock } from '@deepseek-ai/dsh-llm'
-import SessionStore, { SessionEventType, TurnEndReason } from '@deepseek-ai/dsh-session'
+import LlmService, { StreamChunk } from '@deepseek-ai/dsh-llm'
+import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
@@ -99,7 +99,7 @@ describe('agent loop', () => {
     expect(toolResultMessage).toBeDefined()
     const block = toolResultMessage!.content.find(b => b.type === 'tool-result')!
     expect(block).toMatchObject({ toolCallId: 'c1', isError: false })
-    expect((block as ToolResultBlock).content).toEqual([{ type: 'text', text: 'echo: ping' }])
+    expect((block).content).toEqual([{ type: 'text', text: 'echo: ping' }])
 
     // session log records call + result
     const types = agent.session.events.map(e => e.type)
@@ -380,7 +380,7 @@ describe('agent loop', () => {
 
     expect(agent.status).toBe('disposed')
     expect(ctx.agents.get('scoped')).toBeUndefined()
-    expect(() => send(agent, 'too late')).toThrow('disposed')
+    expect(() => { send(agent, 'too late') }).toThrow('disposed')
   })
 
   it('replays a session log into an identical derived history', async () => {
@@ -405,6 +405,6 @@ describe('agent loop', () => {
     expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages())
     // event-by-event identity of types
     expect(replayed.events.map(e => e.type)).toEqual(
-      agent.session.events.map(e => e.type as SessionEventType))
+      agent.session.events.map(e => e.type))
   })
 })

+ 3 - 3
packages/agent-loop/tests/mock-adapter.ts

@@ -5,7 +5,7 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm'
 export function textResponse(text: string): StreamChunk[] {
   return [
     { type: 'block-start', index: 0, blockType: 'text' },
-    ...[...text].map((char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
+    ...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
     { type: 'block-end', index: 0, block: { type: 'text', text } },
     { type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } },
     { type: 'finish', reason: { kind: 'stop' } },
@@ -60,8 +60,8 @@ export class MockAdapter extends LlmAdapter {
       yield { type: 'block-start', index: 0, blockType: 'text' }
       yield { type: 'text-delta', index: 0, text: 'partial' }
       await new Promise<void>((_resolve, reject) => {
-        if (options.signal?.aborted) return reject(new Error('aborted'))
-        options.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
+        if (options.signal?.aborted) { reject(new Error('aborted')); return }
+        options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
       })
       return
     }

+ 4 - 1
packages/agent/src/index.ts

@@ -35,7 +35,7 @@ export class AgentRegistry extends Service {
    * when the calling fiber is disposed. Returns the disposer.
    */
   register(agent: Agent): () => void {
-    return this.ctx.effect(() => {
+    const dispose = this.ctx.effect(() => {
       if (this.store.has(agent.id)) {
         throw new Error(`agent "${agent.id}" is already registered`)
       }
@@ -46,6 +46,9 @@ export class AgentRegistry extends Service {
         this.ctx.emit('agent/disposed', agent)
       }
     }, 'agents.register()')
+    // ctx.effect's disposer returns Promise<void>; our disposer API is
+    // synchronous fire-and-forget — discard the (always-resolved) promise.
+    return () => void dispose()
   }
 
   get(id: string): Agent | undefined {

+ 14 - 5
packages/llm/src/assembler.ts

@@ -109,9 +109,16 @@ export class BlockAssembler {
     }
   }
 
+  /** Invariant accessor: every index in `order` has a partial. */
+  private mustGet(index: number): PartialBlock {
+    const partial = this.partials.get(index)
+    if (!partial) throw new Error(`BlockAssembler invariant violated: no partial for index ${index}`)
+    return partial
+  }
+
   /** Assemble all blocks seen so far, in stream order. */
   blocks(): ContentBlock[] {
-    return this.order.map(index => this.assemble(this.partials.get(index)!, index))
+    return this.order.map(index => this.assemble(this.mustGet(index), index))
   }
 
   /**
@@ -123,8 +130,9 @@ export class BlockAssembler {
   flushReady(): ContentBlock[] {
     const ready: ContentBlock[] = []
     while (this.flushed < this.order.length) {
-      const index = this.order[this.flushed]!
-      const partial = this.partials.get(index)!
+      const index = this.order[this.flushed]
+      if (index === undefined) break
+      const partial = this.mustGet(index)
       if (!partial.block) break
       ready.push(partial.block)
       this.flushed += 1
@@ -141,8 +149,9 @@ export class BlockAssembler {
   flushRemaining(): ContentBlock[] {
     const remaining: ContentBlock[] = []
     while (this.flushed < this.order.length) {
-      const index = this.order[this.flushed]!
-      remaining.push(this.assemble(this.partials.get(index)!, index))
+      const index = this.order[this.flushed]
+      if (index === undefined) break
+      remaining.push(this.assemble(this.mustGet(index), index))
       this.flushed += 1
     }
     return remaining

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

@@ -69,7 +69,7 @@ export class LlmService extends Service {
    * fiber.
    */
   registerAdapter(models: string[], adapter: LlmAdapter): () => void {
-    return this.ctx.effect(() => {
+    const dispose = this.ctx.effect(() => {
       for (const model of models) {
         if (this.adapters.has(model)) {
           throw new LlmError(`an adapter for model "${model}" is already registered`, 'DUPLICATE_ADAPTER')
@@ -82,6 +82,9 @@ export class LlmService extends Service {
         this.ctx.emit('llm/adapter-change')
       }
     }, 'llm.registerAdapter()')
+    // ctx.effect's disposer returns Promise<void>; our disposer API is
+    // synchronous fire-and-forget — discard the (always-resolved) promise.
+    return () => void dispose()
   }
 
   /** Model names with a registered adapter. */

+ 5 - 1
packages/session/src/index.ts

@@ -97,6 +97,10 @@ export class Session {
   deriveMessages(): Message[] {
     const messages: Message[] = []
     for (const event of this.log) {
+      // Intentionally non-exhaustive: only message-producing events derive
+      // history; turn/step boundaries, chunks, usage, and errors are
+      // trace/replay data.
+      // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
       switch (event.type) {
         case 'user/message': {
           messages.push({ role: 'user', content: event.data.content })
@@ -155,7 +159,7 @@ export class SessionStore extends Service {
     if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
     const session = new Session(id, seed)
     this.ctx.effect(() => {
-      session.onAppend = (event) => this.ctx.emit('session/event', session, event)
+      session.onAppend = (event) => { this.ctx.emit('session/event', session, event) }
       this.store.set(id, session)
       this.ctx.emit('session/created', session)
       return () => {

+ 9 - 3
packages/system-prompt/src/index.ts

@@ -73,7 +73,7 @@ export class SystemPrompt extends Service {
    * fiber is disposed. Emits `system-prompt/change` on register/unregister.
    */
   section(section: PromptSection): () => void {
-    return this.ctx.effect(() => {
+    const dispose = this.ctx.effect(() => {
       this.sections.push(section)
       this.ctx.emit('system-prompt/change')
       return () => {
@@ -82,6 +82,9 @@ export class SystemPrompt extends Service {
         this.ctx.emit('system-prompt/change')
       }
     }, 'systemPrompt.section()')
+    // ctx.effect's disposer returns Promise<void>; our disposer API is
+    // synchronous fire-and-forget — discard the (always-resolved) promise.
+    return () => void dispose()
   }
 
   /**
@@ -90,7 +93,7 @@ export class SystemPrompt extends Service {
    * removed when the calling fiber is disposed. Emits `system-prompt/change`.
    */
   tools(provider: () => ToolSchema[]): () => void {
-    return this.ctx.effect(() => {
+    const dispose = this.ctx.effect(() => {
       this.toolProviders.push(provider)
       this.ctx.emit('system-prompt/change')
       return () => {
@@ -99,6 +102,9 @@ export class SystemPrompt extends Service {
         this.ctx.emit('system-prompt/change')
       }
     }, 'systemPrompt.tools()')
+    // ctx.effect's disposer returns Promise<void>; our disposer API is
+    // synchronous fire-and-forget — discard the (always-resolved) promise.
+    return () => void dispose()
   }
 
   /**
@@ -113,7 +119,7 @@ export class SystemPrompt extends Service {
       sections: [...this.sections].sort((a, b) => a.order - b.order),
       tools: this.toolProviders.flatMap(provider => provider()),
     }
-    return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, async () => assembly)
+    return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, () => Promise.resolve(assembly))
   }
 }
 

+ 1 - 1
packages/system-prompt/tests/system-prompt.spec.ts

@@ -8,7 +8,7 @@ describe('SystemPrompt', () => {
     await ctx.plugin(SystemPrompt)
 
     ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'You are DeepSeek Code.' })
-    ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => `cwd: /tmp` })
+    ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => 'cwd: /tmp' })
     ctx.systemPrompt.section({ name: 'rules', order: 10, text: 'Be precise.' })
     ctx.systemPrompt.tools(() => [{ name: 'echo', description: 'echo back', parameters: {} }])
 

+ 6 - 1
packages/tools/src/index.ts

@@ -107,7 +107,7 @@ export class ToolRegistry extends Service {
    * with the calling fiber. Emits `tools/change` on register/unregister.
    */
   register(definition: ToolDefinition): () => void {
-    return this.ctx.effect(() => {
+    const dispose = this.ctx.effect(() => {
       if (this.store.has(definition.name)) {
         throw new Error(`tool "${definition.name}" is already registered`)
       }
@@ -118,6 +118,9 @@ export class ToolRegistry extends Service {
         this.ctx.emit('tools/change')
       }
     }, 'tools.register()')
+    // ctx.effect's disposer returns Promise<void>; our disposer API is
+    // synchronous fire-and-forget — discard the (always-resolved) promise.
+    return () => void dispose()
   }
 
   get(name: string): ToolDefinition | undefined {
@@ -130,6 +133,8 @@ export class ToolRegistry extends Service {
    * assembly.
    */
   schemas(): ToolSchema[] {
+    // Rest-destructure to drop `execute`; the unused binding is the idiom.
+    // eslint-disable-next-line @typescript-eslint/unbound-method, @typescript-eslint/no-unused-vars
     return [...this.store.values()].map(({ execute, ...schema }) => schema)
   }
 

+ 12 - 9
packages/tools/src/schema.ts

@@ -60,11 +60,11 @@ export type SchemaSpec = Record<string, SchemaProp>
 /** Map a {@link SchemaType} to its TS primitive type. */
 type TypeOf<T extends SchemaType> =
   T extends 'string' ? string :
-  T extends 'number' ? number :
-  T extends 'boolean' ? boolean :
-  T extends 'object' ? Record<string, unknown> :
-  T extends 'array' ? unknown[] :
-  never
+    T extends 'number' ? number :
+      T extends 'boolean' ? boolean :
+        T extends 'object' ? Record<string, unknown> :
+          T extends 'array' ? unknown[] :
+            never
 
 /** Flatten an intersection into one object type for readable hovers. */
 type Simplify<T> = { [K in keyof T]: T[K] } & {}
@@ -82,8 +82,8 @@ type RequiredKeys<S extends SchemaSpec> =
  */
 type InferPropValue<P extends SchemaProp> =
   P extends { type: 'object'; properties: infer Sub extends SchemaSpec } ? InferArgs<Sub> :
-  P extends { type: 'array'; items: infer Item extends SchemaProp } ? InferPropValue<Item>[] :
-  TypeOf<P['type']>
+    P extends { type: 'array'; items: infer Item extends SchemaProp } ? InferPropValue<Item>[] :
+      TypeOf<P['type']>
 
 /**
  * Infer the TS argument type for a complete {@link SchemaSpec}.
@@ -117,7 +117,7 @@ function propToJsonSchema(prop: SchemaProp): { schema: Record<string, unknown>;
   if (prop.enum) result.enum = prop.enum
   if (prop.default !== undefined) result.default = prop.default
 
-  let required = prop.required === true
+  const required = prop.required === true
 
   if (prop.type === 'object' && prop.properties) {
     const nested = schemaSpecToJsonSchema(prop.properties)
@@ -224,6 +224,9 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
     description: options.description,
     parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
     ...options.strict !== undefined ? { strict: options.strict } : {},
-    execute: options.execute as ToolDefinition['execute'],
+    // Object-literal execute methods don't use `this`; passing the reference
+    // through is safe.
+    // eslint-disable-next-line @typescript-eslint/unbound-method
+    execute: options.execute,
   }
 }

+ 2 - 2
packages/tools/tests/tools.spec.ts

@@ -18,7 +18,7 @@ const echoTool = defineTool({
   description: 'echo arguments back',
   parameters: { text: { type: 'string' } },
   async execute(args) {
-    return [{ type: 'text' as const, text: String(args.text ?? '') }]
+    return [{ type: 'text' as const, text: args.text ?? '' }]
   },
 })
 
@@ -372,7 +372,7 @@ describe('schema DSL regressions (Codex review round 2)', () => {
       ...echoTool,
       name: 'object-thrower',
       async execute() {
-        // eslint-disable-next-line no-throw-literal — testing non-Error throws
+        // testing non-Error throws on purpose
         throw { message: 'denied by object' }
       },
     })

File diff ditekan karena terlalu besar
+ 751 - 5
yarn.lock


Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini