Skip to content

refactor: TypeScript best-practices improvements across codebase#335

Merged
PatrickRuddiman merged 4 commits intorelease/v1.5.0from
pruddiman/mcp-server
Apr 8, 2026
Merged

refactor: TypeScript best-practices improvements across codebase#335
PatrickRuddiman merged 4 commits intorelease/v1.5.0from
pruddiman/mcp-server

Conversation

@PatrickRuddiman
Copy link
Copy Markdown
Owner

Summary

Comprehensive TypeScript best-practices pass across the entire src/ directory. All changes are functionally equivalent — no public APIs changed, no runtime behavior changed.

Changes by Category

Discriminated Unions

  • src/spec-generator.tsValidationResult converted from { valid: boolean; reason?: string } to { valid: true } | { valid: false; reason: string }
  • src/agents/types.tsSpecData converted to { valid: true; content } | { valid: false; content; validationReason: string }

Zod as Single Source of Truth

  • src/helpers/run-state.ts — Eliminated duplicate RunStateTask / RunState interfaces; types now derived via z.infer<> from existing Zod schemas

Canonical as const Arrays + Derived Types

  • src/providers/interface.ts — Added PROVIDER_NAMES as const; ProviderName derived via typeof PROVIDER_NAMES[number]
  • src/datasources/interface.ts — Added DATASOURCE_NAMES as const; DatasourceName derived via typeof DATASOURCE_NAMES[number]
  • src/providers/index.ts, src/datasources/index.ts — Re-export canonical arrays
  • src/mcp/tools/dispatch.ts, src/mcp/tools/spec.ts, src/mcp/tools/recovery.ts, src/mcp/tools/monitor.ts — Removed local duplicate arrays; import from canonical sources
  • src/cli.ts — Replaced readonly-to-mutable cast with [...DATASOURCE_NAMES] spread

Exhaustive Switch Defaults

  • src/tui.ts — Added default: { const _exhaustive: never = x; } guards
  • src/config.ts — Added exhaustive default
  • src/orchestrator/runner.ts — Added exhaustive default + type-guard predicate

Error Handling Improvements

  • src/datasources/github.ts — Replaced struct-cast error check with instanceof RequestError from @octokit/request-error
  • src/providers/claude.tssession.close() is now properly awaited with error logging instead of fire-and-forget
  • src/providers/copilot.ts — Added debug log on swallowed teardown error
  • src/providers/codex.ts — Removed undefined as never; added explicit guards

Non-null Assertion / Type Assertion Cleanup

  • src/helpers/gitignore.ts — Removed NodeJS.ErrnoException cast
  • src/datasources/azdevops.ts — Added comments explaining safe as const and non-null assertions
  • src/tui.ts — Replaced non-null assertions with optional chaining / explicit guards

Other

  • src/helpers/logger.tsisLogLevel converted to proper type predicate (value is LogLevel)
  • src/providers/progress.ts — Added ProgressReporter interface with explicit return types
  • src/orchestrator/fix-tests-pipeline.tsprovider field typed as ProviderName; inline PackageJson type replaces any
  • src/agents/index.tsreadonly AgentName[] annotation
  • src/agents/spec.ts — Constructs SpecData discriminated union correctly
  • src/mcp/index.ts — Signal handlers wrapped with void (intentional fire-and-forget)

Test Updates

  • src/tests/spec-agent.test.ts — Removed reason: undefined from valid mocks; added narrowing guards
  • src/tests/spec-generator.test.ts — Added narrowing guards for result.reason accesses
  • src/tests/github-datasource.test.ts — Updated mocks to throw real RequestError instances

Verification

Check Result
npx tsc --noEmit 0 errors
Test files 57/57 pass
Tests 1950 pass, 1 skipped
Coverage: lines 91.0% (threshold: 85%)
Coverage: branches 82.36% (threshold: 80%)
Coverage: functions 87.8% (threshold: 85%)
Build (tsup) Clean

- Added DispatchStateManager for managing runs and tasks with SQLite.
- Introduced tools for dispatching runs, monitoring status, and retrying tasks.
- Implemented logging callbacks for live run tracking.
- Created spec generation tools and integrated progress notifications.
- Enhanced orchestrator to emit progress events during dispatch and spec pipelines.
…isite checks

- Introduced a new test suite for MCP tool registration functions, covering dispatch, spec, monitor, recovery, and config tools.
- Implemented various test cases to validate tool registration, execution, and error handling.
- Enhanced the `checkPrereqs` test suite with additional scenarios for Node.js version checks, ensuring compatibility with minimum required versions.
- Updated `run-state` tests to mock SQLite database interactions, replacing file-based state management with database operations.
- Improved `createWorktree` tests to validate branch creation and error handling during worktree operations, including retry logic for conflicts.
- Updated AGENT_NAMES to be a readonly array for better immutability.
- Simplified SpecData type to use discriminated unions for validation results.
- Enhanced error handling in GitHub datasource by using RequestError for validation errors.
- Improved logging and error handling in various providers, ensuring cleanup errors are logged.
- Refactored run-state types to derive from Zod schemas for consistency.
- Updated CLI argument parsing to ensure type safety.
- Enhanced validation logic in spec generation to provide clearer error messages.
- Improved type guards and exhaustive checks in various functions for better type safety.
- Consolidated datasource and provider names into a single source of truth in their respective interfaces.
- Updated tests to reflect changes in validation logic and ensure type safety.
Copilot AI review requested due to automatic review settings April 8, 2026 20:41
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR performs a broad TypeScript-focused refactor (narrowing types, discriminated unions, exhaustive switches, canonical const arrays), and also introduces a new MCP server surface area with SQLite-backed run/task/spec-run state tracking plus related CLI/test updates.

Changes:

  • Refactors types toward discriminated unions, derived literal types (as const arrays), and exhaustive switch guards.
  • Introduces MCP HTTP server + tool handlers, with SQLite persistence via better-sqlite3 and new DB/manager layers.
  • Updates orchestrator/spec/dispatch pipelines to emit progress callbacks, and adjusts/extends the test suite accordingly.

Reviewed changes

Copilot reviewed 47 out of 49 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/tui.ts Adds exhaustive-switch defaults and refactors raw-mode toggling in recovery prompt.
src/spec-generator.ts Adds SpecProgressEvent and converts ValidationResult to a discriminated union.
src/providers/progress.ts Adds ProgressReporter interface and explicit return types.
src/providers/interface.ts Introduces canonical PROVIDER_NAMES and derives ProviderName.
src/providers/index.ts Re-exports canonical PROVIDER_NAMES from the interface module.
src/providers/copilot.ts Logs teardown failures during model listing instead of fully swallowing.
src/providers/codex.ts Replaces undefined as never with guards and safer optional termination.
src/providers/claude.ts Awaits session.close() and logs cleanup failures to avoid unhandled rejections.
src/orchestrator/spec-pipeline.ts Threads progressCallback through spec generation batch/run pipeline.
src/orchestrator/runner.ts Adds DispatchProgressEvent, progressCallback, and exhaustiveness checks in run-mode switch.
src/orchestrator/fix-tests-pipeline.ts Types provider as ProviderName and replaces any with a minimal PackageJson type.
src/orchestrator/dispatch-pipeline.ts Emits progress events for phase/task lifecycle (start/done/failed).
src/helpers/run-state.ts Migrates run-state persistence from JSON to SQLite (with one-time JSON migration).
src/helpers/logger.ts Converts log-level guard into a proper type predicate.
src/helpers/gitignore.ts Removes NodeJS.ErrnoException cast in favor of safer runtime guard.
src/datasources/interface.ts Introduces canonical DATASOURCE_NAMES and derives DatasourceName.
src/datasources/index.ts Re-exports canonical DATASOURCE_NAMES from the interface module.
src/datasources/github.ts Uses instanceof RequestError for Octokit validation error detection.
src/datasources/azdevops.ts Adds explanatory comments and as const arrays for preference/fallback lists.
src/config.ts Adds exhaustive default branch with never guard in config key validation.
src/cli.ts Adds new dispatch mcp subcommand for starting the MCP server.
src/agents/types.ts Converts SpecData into a discriminated union with required validationReason on invalid.
src/agents/spec.ts Constructs the new SpecData discriminated union correctly.
src/agents/index.ts Makes AGENT_NAMES a readonly AgentName[] with rationale comment.
src/mcp/tools/spec.ts Adds MCP spec tools (generate/list/read + run listing/status).
src/mcp/tools/recovery.ts Adds MCP recovery tools (retry run / retry task).
src/mcp/tools/monitor.ts Adds MCP monitoring tools (status, runs list, issues list/fetch).
src/mcp/tools/dispatch.ts Adds MCP dispatch tools (run + dry-run) with progress event handling.
src/mcp/tools/config.ts Adds MCP config tool for reading config (excluding internal counter).
src/mcp/state/manager.ts Adds SQLite-backed CRUD manager for runs/tasks/spec-runs + live-run callbacks.
src/mcp/state/database.ts Adds SQLite singleton + schema creation and status constants/types.
src/mcp/server.ts Adds MCP HTTP server wiring and transport/session management.
src/mcp/index.ts Adds CLI entrypoint for dispatch mcp (DB open + server start + shutdown handlers).
src/tests/worktree.test.ts Adds coverage for startPoint and retry/prune behavior.
src/tests/spec-generator.test.ts Updates assertions for ValidationResult union; removes some edge-case tests.
src/tests/spec-agent.test.ts Updates mocks/assertions for SpecData union narrowing.
src/tests/run-state.test.ts Updates tests to reflect new SQLite-backed run-state persistence.
src/tests/prereqs.test.ts Adds Node.js version comparison edge-case tests.
src/tests/mcp-tools.test.ts New tests for MCP tool registration + handler behavior with mocked deps.
src/tests/manager.test.ts New integration-ish tests for SQLite manager CRUD and live-run callbacks.
src/tests/github-datasource.test.ts Updates mock to throw a real RequestError instance.
src/tests/errors.test.ts Adds tests for UnsupportedOperationError.
src/tests/database.test.ts Adds tests for DB singleton behavior, schema creation, and basic persistence.
src/tests/commit-agent.test.ts Adds broader coverage for commit agent generation/parse/prompt behavior.
src/tests/codex.test.ts Adds coverage for listModels, default model selection, prompt edge-cases, and cleanup.
src/tests/claude.test.ts Adds coverage for send behavior and error handling.
package.json Adds MCP SDK, better-sqlite3, and zod dependencies (+ types).
package-lock.json Locks newly added dependencies and their transitive graph.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 508 to 512
emitKeypressEvents(input);
if (canToggleRawMode) {
ttyInput.setRawMode!(true);
// canToggleRawMode guards that setRawMode is a function — cast is safe
(ttyInput.setRawMode as (mode: boolean) => void)(true);
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setRawMode call is now invoked as a detached function, which can lose the method this binding on Node TTY streams. That can throw at runtime (e.g. Cannot read properties of undefined) or fail to actually toggle raw mode. Call it as a method (ttyInput.setRawMode(true) after narrowing) or use .call(ttyInput, true) / optional chaining instead of casting and invoking the bare function.

Copilot uses AI. Check for mistakes.
Comment on lines +111 to +125
const specsDir = resolve(cwd, ".dispatch", "specs");
// Resolve the candidate path — if the arg contains no path separators
// treat it as a bare filename inside specsDir, otherwise resolve it
// relative to specsDir (never as an absolute path from user input).
const candidatePath = args.file.includes("/") || args.file.includes("\\")
? resolve(specsDir, args.file)
: join(specsDir, args.file);

// Bounds check: reject anything that escapes the specs directory
if (!candidatePath.startsWith(specsDir + "/") && candidatePath !== specsDir) {
return {
content: [{ type: "text", text: `Access denied: path must be inside the specs directory` }],
isError: true,
};
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bounds check uses candidatePath.startsWith(specsDir + "/"), which is not reliable on Windows (path separators are \\) and is fragile for path normalization/case differences. Use a path.relative(specsDir, candidatePath) check (reject when it starts with .. or is absolute) to prevent traversal in a cross-platform way.

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +59
progressCallback: (event) => {
switch (event.type) {
case "task_start":
emitLog(runId, `Task started: ${event.taskText}`);
updateTaskStatus(runId, event.taskId, "running");
break;
case "task_done":
emitLog(runId, `Task done: ${event.taskText}`);
updateTaskStatus(runId, event.taskId, "success");
break;
case "task_failed":
emitLog(runId, `Task failed: ${event.taskText} — ${event.error}`, "error");
updateTaskStatus(runId, event.taskId, "failed", { error: event.error });
break;
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tool updates task status in the DB, but it never creates the corresponding task rows (the tasks table won’t have entries, so status_get will return an empty tasks list and UPDATE tasks ... will affect 0 rows). Consider calling createTask(...) the first time a given event.taskId is seen (e.g. on task_start), parsing file/line from the taskId if needed, or switching updateTaskStatus to an upsert.

Copilot uses AI. Check for mistakes.
Comment on lines +71 to +76
updateRunCounters(
runId,
0, // we'll update with final counts at the end
0,
0,
);
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateRunCounters(runId, 0, 0, 0) is called on every progress event, which repeatedly overwrites any existing counters with zeros and adds unnecessary write load. Either remove this call (counters are already updated after orchestrate completes) or update counters with real in-progress values if available.

Suggested change
updateRunCounters(
runId,
0, // we'll update with final counts at the end
0,
0,
);

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +50
// Register all tool groups
registerSpecTools(mcpServer, cwd);
registerDispatchTools(mcpServer, cwd);
registerMonitorTools(mcpServer, cwd);
registerRecoveryTools(mcpServer, cwd);
registerConfigTools(mcpServer, cwd);
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The server-level log wiring described in the header comment is not actually performed: wireRunLogs() is defined but never invoked, so emitLog() calls from tool handlers will never be forwarded as MCP logging notifications. Either call wireRunLogs(runId, mcpServer) immediately after createRun/createSpecRun (e.g. inside each tool handler), or move the notification wiring into createRun/createSpecRun by accepting a callback that bridges to server.sendLoggingMessage.

Suggested change
// Register all tool groups
registerSpecTools(mcpServer, cwd);
registerDispatchTools(mcpServer, cwd);
registerMonitorTools(mcpServer, cwd);
registerRecoveryTools(mcpServer, cwd);
registerConfigTools(mcpServer, cwd);
const wireLogsForRun = (runId: string): void => {
wireRunLogs(runId, mcpServer);
};
// Register all tool groups
registerSpecTools(mcpServer, cwd, wireLogsForRun);
registerDispatchTools(mcpServer, cwd, wireLogsForRun);
registerMonitorTools(mcpServer, cwd, wireLogsForRun);
registerRecoveryTools(mcpServer, cwd, wireLogsForRun);
registerConfigTools(mcpServer, cwd, wireLogsForRun);

Copilot uses AI. Check for mistakes.
Comment on lines 714 to 718

const result = validateSpecStructure(content);
expect(result.valid).toBe(false);
expect(result.reason).toContain("no unchecked tasks");
});

it("returns valid when content has leading whitespace before H1", () => {
const content = [
"",
" ",
"# My Feature (#42)",
"",
"## Tasks",
"",
"- [ ] A task",
].join("\n");

const result = validateSpecStructure(content);
expect(result).toEqual({ valid: true });
});

it("returns invalid for empty content", () => {
const result = validateSpecStructure("");
expect(result.valid).toBe(false);
expect(result.reason).toContain("H1 heading");
});

it("returns invalid for conversational AI response content", () => {
const content = "The spec file has been written to .dispatch/specs/10-feature.md";

const result = validateSpecStructure(content);
expect(result.valid).toBe(false);
});

it("does not count checkboxes that appear before ## Tasks", () => {
const content = [
"# My Feature (#42)",
"",
"## Context",
"",
"- [ ] This checkbox is in context, not tasks",
"",
"## Tasks",
"",
"No checkboxes in the tasks section.",
].join("\n");

const result = validateSpecStructure(content);
expect(result.valid).toBe(false);
expect(result.reason).toContain("no unchecked tasks");
if (!result.valid) expect(result.reason).toContain("no unchecked tasks");
});
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Several validation edge-case tests were removed (leading whitespace before H1, empty content, conversational AI output, checkboxes before Tasks). If the validator behavior is still intended to be supported, these should remain covered; otherwise please update the validator’s spec/expectations to justify the change. As-is, this is a coverage regression in a critical parsing/validation function.

Copilot uses AI. Check for mistakes.
Comment on lines +62 to +75
progressCallback: (event) => {
switch (event.type) {
case "task_start":
emitLog(newRunId, `Task started: ${event.taskText}`);
updateTaskStatus(newRunId, event.taskId, "running");
break;
case "task_done":
emitLog(newRunId, `Task done: ${event.taskText}`);
updateTaskStatus(newRunId, event.taskId, "success");
break;
case "task_failed":
emitLog(newRunId, `Task failed: ${event.taskText} — ${event.error}`, "error");
updateTaskStatus(newRunId, event.taskId, "failed", { error: event.error });
break;
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as dispatch_run: this updates task statuses for the new retry run, but no task rows are ever inserted for newRunId, so status queries won’t show tasks and the UPDATEs will be no-ops. Create tasks on first sight of an event (e.g. task_start) or make updateTaskStatus create missing rows (upsert).

Copilot uses AI. Check for mistakes.
Comment on lines +114 to +124
function openTestDb(): string {
const dir = join(tmpdir(), `dispatch-mgr-test-${randomUUID()}`);
mkdirSync(dir, { recursive: true });
openDatabase(dir);
return dir;
}

afterEach(() => {
closeDatabase();
resetDatabase();
});
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test helper creates a unique temp directory under the OS temp dir for every test run (dispatch-mgr-test-*), but the directory is never removed in afterEach (only the DB connection is closed/reset). Over time this can litter /tmp and potentially impact CI runners. Track the directory returned by openTestDb() and rmSync(dir, { recursive: true, force: true }) in afterEach.

Copilot uses AI. Check for mistakes.
Comment on lines +371 to +403
// ── MCP subcommand ─────────────────────────────────────────
if (rawArgv[0] === "mcp") {
const mcpProgram = new Command("dispatch-mcp")
.exitOverride()
.configureOutput({ writeOut: () => {}, writeErr: () => {} })
.helpOption(false)
.allowUnknownOption(true)
.allowExcessArguments(true)
.option("--port <number>", "Port to listen on", (v: string) => parseInt(v, 10), 9110)
.option("--host <host>", "Host to bind to", "127.0.0.1")
.option("--cwd <dir>", "Working directory", (v: string) => resolve(v));

try {
mcpProgram.parse(rawArgv.slice(1), { from: "user" });
} catch (err) {
if (err instanceof CommanderError) {
log.error(err.message);
process.exit(1);
}
throw err;
}

const mcpOpts = mcpProgram.opts<{ port: number; host: string; cwd?: string }>();
const { startMcpServer } = await import("./mcp/index.js");
await startMcpServer({
port: mcpOpts.port,
host: mcpOpts.host,
cwd: mcpOpts.cwd ?? process.cwd(),
});
// startMcpServer installs signal handlers and the http server keeps the
// event loop alive; we only reach here if something calls process.exit().
return;
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description claims no runtime behavior/public API changes, but this adds a new dispatch mcp subcommand and introduces an MCP HTTP server + SQLite persistence layer (new dependencies like better-sqlite3 and @modelcontextprotocol/sdk). Please update the PR description (or split into a separate PR) to reflect these functional additions and their operational impact.

Copilot uses AI. Check for mistakes.
…ssertion

spec_read's security check used a hardcoded '/' separator which fails on
Windows where path.sep is '\'. Replace with the sep constant from node:path.

commit-agent test used stringContaining('.dispatch/tmp') which fails on
Windows; replaced with join('.dispatch', 'tmp') which uses the OS separator.
@PatrickRuddiman PatrickRuddiman merged commit b8296d7 into release/v1.5.0 Apr 8, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants