npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

spectopus

v0.2.0

Published

The OpenAPI 3.1 spec builder that LLMs actually love — fluent, TypeScript-first, with first-class LLM output adapters

Readme

🐙 spectopus

The OpenAPI 3.1 spec builder where docs live with the code that proves them true.

npm version TypeScript OpenAPI License: MIT Zero deps Tests


The Problem with API Docs

Every API has two definitions of itself. The real one — the code — and the documentary one: Swagger YAML, JSDoc annotations, a Notion page, a Postman collection. The two diverge the moment they're created.

You rename a field. The code is updated. The docs? Maybe. Eventually. If someone remembers.

You add a query parameter. You write the logic. Then, separately, you open the Swagger YAML and write it again. Two sources of truth means one of them is lying.

spectopus is built on a different premise: documentation that is derived from the code can never drift from the code.


Write Once: Code and Docs

import { z } from 'zod';
import { SpecBuilder, OperationBuilder } from 'spectopus';

// This schema validates incoming requests at runtime.
// spectopus reads it to generate your docs.
// One definition. Both jobs.
const CreateUserSchema = z.object({
  name:  z.string().min(1).max(100),
  email: z.string().email(),
  role:  z.enum(['admin', 'user', 'guest']).optional(),
});

const spec = new SpecBuilder()
  .title('User API').version('1.0.0')
  .bearerAuth()
  .add('/users', 'post',
    new OperationBuilder()
      .summary('Create a user')
      .body(CreateUserSchema)          // ← same schema you parse with at runtime
      .response(201, UserSchema)
      .response(400, ErrorSchema)
  )
  .build();

Change CreateUserSchema and your documentation changes with it — automatically, correctly, immediately. No separate annotation to update. No drift possible.


Features

| Feature | Details | |---|---| | OpenAPI 3.1 | Full spec support including JSON Schema 2020-12 | | Fluent builder API | Chainable, immutable, TypeScript-native | | Zod integration | Your validation schemas become your API docs | | Joi integration | First-class support for Joi 17+ schemas | | LLM adapters | Export as OpenAI tools, Anthropic tools, or compact context | | Decorator API | Class-based controllers with colocated docs | | Zero runtime deps | Pure TypeScript. Nothing to audit. Nothing to break. | | Framework-agnostic | Works with Express, Fastify, Hono, Koa, bare Node, anything | | Type-safe | Full TypeScript types for the entire OpenAPI 3.1 spec |


Installation

npm install spectopus
# If using Zod integration:
npm install zod
# If using Joi integration:
npm install joi

spectopus has zero required runtime dependencies. Both Zod and Joi are optional peer dependencies — install only what you use.


Quick Start

import { z } from 'zod';
import { SpecBuilder, OperationBuilder, toLLM } from 'spectopus';

// Your schemas. Used for validation already.
// Now they're your documentation too.
const UserSchema = z.object({
  id:        z.string().uuid(),
  name:      z.string().min(1).max(100),
  email:     z.string().email(),
  role:      z.enum(['admin', 'user', 'guest']),
  createdAt: z.string().datetime(),
});

const ErrorSchema = z.object({
  error: z.string(),
  code:  z.string().optional(),
});

const spec = new SpecBuilder()
  .title('My API')
  .version('1.0.0')
  .server('https://api.example.com', 'Production')
  .server('http://localhost:3000', 'Development')
  .bearerAuth()
  .tag('Users', 'User management')
  .component('User', UserSchema)
  .component('Error', ErrorSchema)

  .add('/users', 'get',
    new OperationBuilder()
      .summary('List users')
      .tag('Users')
      .query('page',  z.number().int().min(1).default(1),   'Page number')
      .query('limit', z.number().int().min(1).max(100).default(20), 'Per page')
      .response(200, z.object({ users: z.array(UserSchema), total: z.number() }))
      .response(401, ErrorSchema, 'Unauthorized')
  )
  .add('/users/:id', 'get',
    new OperationBuilder()
      .summary('Get user by ID')
      .tag('Users')
      .pathParam('id', z.string().uuid(), 'User UUID')
      .response(200, '#/components/schemas/User', 'User found')
      .response(404, ErrorSchema, 'Not found')
  )
  .add('/users', 'post',
    new OperationBuilder()
      .summary('Create user')
      .tag('Users')
      .body(z.object({ name: z.string(), email: z.string().email() }))
      .response(201, '#/components/schemas/User', 'Created')
      .response(400, ErrorSchema, 'Validation error')
  )
  .build();

// Serve as OpenAPI JSON
console.log(JSON.stringify(spec, null, 2));

// One line to LLM tools — always accurate because they come from your real spec
const openaiTools     = toLLM.openai(spec);
const anthropicTools  = toLLM.anthropic(spec);
const compactContext  = toLLM.compact(spec);   // paste into system prompts

LLM Integration

This is where spectopus earns its name.

OpenAI Function Calling

import { toLLM } from 'spectopus';
import OpenAI from 'openai';

const tools = toLLM.openai(spec);
// [{ type: 'function', function: { name: 'listUsers', description: '...', parameters: {...} } }]

const openai = new OpenAI();
const response = await openai.chat.completions.create({
  model: 'gpt-4o',
  messages: [{ role: 'user', content: 'Show me all admin users' }],
  tools,
});

Anthropic Tool Use

import Anthropic from '@anthropic-ai/sdk';

const tools = toLLM.anthropic(spec);
// [{ name: 'listUsers', description: '...', input_schema: {...} }]

const anthropic = new Anthropic();
const response = await anthropic.messages.create({
  model: 'claude-opus-4-5',
  messages: [{ role: 'user', content: 'Create a guest account for [email protected]' }],
  tools,
  max_tokens: 1024,
});

Compact Context (Token-Efficient)

For pasting into system prompts — maximum information density, minimum tokens:

const context = toLLM.compact(spec);

Output:

API: My API v1.0.0
Base: https://api.example.com
Auth: Bearer JWT (Authorization header)

### Users
GET    /users — List users
  Query: ?page(int,default:1), ?limit(int,1-100,default:20)
  → 200: { users: User[], total: int }
  → 401: { error: string }

GET    /users/{id} — Get user by ID
  Path: id(uuid)
  → 200: User
  → 404: { error: string }

POST   /users — Create user
  Body: { name: string, email: email }
  → 201: User
  → 400: { error: string }

Schemas:
  User: { id: uuid, name: string, email: email, role: "admin" | "user" | "guest", createdAt: datetime }
  Error: { error: string, code?: string }

Filter and Customize LLM Output

// Only expose certain tags to the LLM
const tools = toLLM.openai(spec, { includeTags: ['Users', 'Orders'] });

// Exclude sensitive endpoints
const tools = toLLM.openai(spec, { excludeOperations: ['deleteUser', 'adminPanel'] });

// OpenAI strict mode (structured outputs)
const tools = toLLM.openai(spec, { strict: true });

// Limit to N tools (LLMs have limits)
const tools = toLLM.openai(spec, { limit: 20 });

Zod Integration

spectopus treats Zod schemas as first-class citizens throughout the entire API. Pass Zod schemas anywhere a schema is expected:

import { z } from 'zod';
import { zodToOpenAPI } from 'spectopus';

// Manual conversion if you need the raw schema object
const openAPISchema = zodToOpenAPI(z.object({
  id:       z.string().uuid(),
  name:     z.string().min(1).max(100),
  age:      z.number().int().min(0).max(150).optional(),
  role:     z.enum(['admin', 'user']).default('user'),
  tags:     z.array(z.string()),
  metadata: z.record(z.unknown()).optional(),
}));

Supported Zod Types

| Zod Type | OpenAPI Output | |---|---| | z.string() | { type: "string" } | | z.string().email() | { type: "string", format: "email" } | | z.string().uuid() | { type: "string", format: "uuid" } | | z.string().url() | { type: "string", format: "uri" } | | z.string().datetime() | { type: "string", format: "date-time" } | | z.string().min(n).max(m) | { minLength: n, maxLength: m } | | z.string().regex(/.../) | { pattern: "..." } | | z.number() | { type: "number" } | | z.number().int() | { type: "integer" } | | z.number().min(n).max(m) | { minimum: n, maximum: m } | | z.number().gt(n).lt(m) | { exclusiveMinimum: n, exclusiveMaximum: m } | | z.boolean() | { type: "boolean" } | | z.null() | { type: "null" } | | z.literal(x) | { const: x } | | z.enum([...]) | { type: "string", enum: [...] } | | z.object({...}) | Full object schema with required[] | | z.object({...}).strict() | additionalProperties: false | | z.array(T) | { type: "array", items: T } | | z.array(T).min(n).max(m) | { minItems: n, maxItems: m } | | z.tuple([A, B]) | { prefixItems: [A, B], items: false } | | z.union([A, B]) | { anyOf: [A, B] } | | z.intersection(A, B) | { allOf: [A, B] } | | z.discriminatedUnion(...) | { anyOf: [...] } | | z.optional(T) | Inner type (marked not-required in parent) | | z.nullable(T) | { type: ["T", "null"] } (JSON Schema 2020-12) | | z.default(v) | Inner type + { default: v } | | z.readonly(T) | Inner type + { readOnly: true } | | z.record(T) | { type: "object", additionalProperties: T } | | z.set(T) | { type: "array", uniqueItems: true, items: T } | | z.branded(T) | Inner type (brand erased) | | z.describe("...") | { description: "..." } |


Joi Integration

spectopus supports Joi 17+ as a first-class schema source alongside Zod. Pass Joi schemas anywhere a schema is expected — in OperationBuilder, response definitions, or directly via joiToOpenAPI.

Joi uses its stable public .describe() API under the hood, so spectopus works across all Joi 17+ releases without coupling to internal properties.

import Joi from 'joi';
import { joiToOpenAPI, SpecBuilder, OperationBuilder } from 'spectopus';

// Manual conversion
const openAPISchema = joiToOpenAPI(Joi.object({
  id:    Joi.string().uuid().required(),
  name:  Joi.string().min(1).max(100).required(),
  age:   Joi.number().integer().min(0).optional(),
  email: Joi.string().email().required(),
  role:  Joi.string().valid('admin', 'user', 'guest').default('user'),
}));

// Or pass Joi schemas directly into the builder API
const spec = new SpecBuilder()
  .title('My API').version('1.0.0')
  .add('/users', 'post',
    new OperationBuilder()
      .summary('Create a user')
      .body(Joi.object({ name: Joi.string().required() }))  // ← Joi schema
      .response(201, Joi.object({ id: Joi.string().uuid() }))
  )
  .build();

Supported Joi Types

| Joi Schema | OpenAPI Output | |---|---| | Joi.string() | { type: "string" } | | Joi.string().email() | { type: "string", format: "email" } | | Joi.string().uri() | { type: "string", format: "uri" } | | Joi.string().guid() / .uuid() | { type: "string", format: "uuid" } | | Joi.string().isoDate() | { type: "string", format: "date-time" } | | Joi.string().hostname() | { type: "string", format: "hostname" } | | Joi.string().min(n).max(m) | { minLength: n, maxLength: m } | | Joi.string().length(n) | { minLength: n, maxLength: n } | | Joi.string().pattern(/.../) | { pattern: "..." } | | Joi.string().alphanum() | { pattern: "^[a-zA-Z0-9]*$" } | | Joi.string().hex() | { pattern: "^[a-fA-F0-9]*$" } | | Joi.string().base64() | { contentEncoding: "base64" } | | Joi.number() | { type: "number" } | | Joi.number().integer() | { type: "integer" } | | Joi.number().min(n).max(m) | { minimum: n, maximum: m } | | Joi.number().greater(n).less(m) | { exclusiveMinimum: n, exclusiveMaximum: m } | | Joi.number().multiple(n) | { multipleOf: n } | | Joi.boolean() | { type: "boolean" } | | Joi.date() | { type: "string", format: "date-time" } | | Joi.binary() | { type: "string", contentEncoding: "base64" } | | Joi.object({...}) | Full object schema with required[] | | Joi.array().items(T) | { type: "array", items: T } | | Joi.array().items(A, B) | { type: "array", items: { anyOf: [A, B] } } | | Joi.array().ordered(A, B) | { prefixItems: [A, B], items: false } (tuple) | | Joi.array().min(n).max(m) | { minItems: n, maxItems: m } | | Joi.array().unique() | { uniqueItems: true } | | Joi.alternatives().try(A, B) | { anyOf: [A, B] } | | .valid('a', 'b') | { enum: ['a', 'b'] } | | .allow(null) | { type: ["T", "null"] } (JSON Schema 2020-12) | | .default(v) | { default: v } | | .description("...") | { description: "..." } | | .required() / .optional() | Tracked in parent object's required[] | | Joi.any() | {} (accepts anything) |

Direct Description Conversion

If you already have Joi's describe output (e.g. from serialization), use joiDescriptionToOpenAPI:

import { joiDescriptionToOpenAPI } from 'spectopus';

const desc = Joi.string().email().describe();
// { type: 'string', rules: [{ name: 'email' }] }

const schema = joiDescriptionToOpenAPI(desc);
// { type: 'string', format: 'email' }

Decorator API

For class-based controller patterns. Put your docs where your handlers live.

import { z } from 'zod';
import {
  Controller, Get, Post, Patch, Delete,
  Query, Path, Body,
  Response,
  Tag, Summary, Description,
} from 'spectopus/decorators';

const UserSchema = z.object({
  id:    z.string().uuid(),
  name:  z.string(),
  email: z.string().email(),
});

const ErrorSchema = z.object({ error: z.string() });

@Controller('/users')
@Tag('Users')
class UserController {

  @Get()
  @Summary('List all users')
  @Query('page',  z.number().int().default(1),   'Page number')
  @Query('limit', z.number().int().max(100).default(20), 'Per page')
  @Response(200, z.object({ users: z.array(UserSchema) }), 'User list')
  async listUsers(req, res) {
    const { page, limit } = UserListQuerySchema.parse(req.query);
    // ... implementation
  }

  @Get('/:id')
  @Summary('Get user by ID')
  @Path('id', z.string().uuid(), 'User ID')
  @Response(200, UserSchema, 'User found')
  @Response(404, ErrorSchema, 'Not found')
  async getUser(req, res) {
    const { id } = req.params;
    // ... implementation
  }

  @Post()
  @Summary('Create a user')
  @Body(z.object({ name: z.string(), email: z.string().email() }))
  @Response(201, UserSchema, 'Created')
  @Response(400, ErrorSchema, 'Validation error')
  async createUser(req, res) {
    // ... implementation
  }

  @Delete('/:id')
  @Summary('Delete user')
  @Path('id', z.string().uuid())
  @Response(204, undefined, 'Deleted')
  async deleteUser(req, res) {
    // ... implementation
  }
}

// Extract and build
import { extractControllerOperations } from 'spectopus/decorators';
import { SpecBuilder } from 'spectopus';

const ops = extractControllerOperations(UserController);

let builder = new SpecBuilder().title('My API').version('1.0.0').bearerAuth();
for (const { path, method, operation } of ops) {
  builder = builder.add(path, method, operation);
}
const spec = builder.build();

Schema Components

Register reusable schemas to $ref them throughout your spec:

const spec = new SpecBuilder()
  .title('My API').version('1.0.0')
  // Register once
  .component('User', UserSchema)
  .component('Error', ErrorSchema)
  // Reference anywhere with '#/components/schemas/Name'
  .add('/users/:id', 'get',
    new OperationBuilder()
      .response(200, '#/components/schemas/User')
      .response(404, '#/components/schemas/Error')
  )
  .build();

Security Schemes

const spec = new SpecBuilder()
  .title('Secure API').version('1.0.0')

  // Bearer JWT — applied globally
  .bearerAuth()

  // API key — applied globally
  .apiKeyAuth('apiKey', 'X-API-Key')

  // Custom scheme
  .securityScheme('oauth2', {
    type: 'oauth2',
    flows: {
      authorizationCode: {
        authorizationUrl: 'https://auth.example.com/oauth/authorize',
        tokenUrl: 'https://auth.example.com/oauth/token',
        scopes: { 'read:users': 'Read users', 'write:users': 'Write users' },
      },
    },
  })
  .add('/users', 'get',
    new OperationBuilder()
      .noAuth()               // Override: this endpoint is public
      .response(200, UserListSchema)
  )
  .add('/users', 'post',
    new OperationBuilder()
      .oauth2('oauth2', 'write:users')  // Require specific scope
      .body(CreateUserSchema)
      .response(201, UserSchema)
  )
  .build();

Full API Reference

SpecBuilder

new SpecBuilder(options?)
  // Info
  .title(string)
  .version(string)
  .description(string)
  .summary(string)
  .contact(name, email?, url?)
  .license(name, url?)
  .termsOfService(url)

  // Servers
  .server(url, description?)

  // Operations
  .add(path, method, operation)
  .addRoutes([[path, method, operation], ...])
  .paths(PathsObject)

  // Tags
  .tag(name, description?)

  // Security
  .bearerAuth(schemeName?, applyGlobally?)
  .apiKeyAuth(schemeName?, header?, applyGlobally?)
  .security(SecurityRequirementObject)
  .securityScheme(name, SecuritySchemeObject)

  // Components
  .component(name, schema)
  .components(ComponentsBuilder)

  // Output
  .build()                   // → OpenAPIDocument
  .toJSON(indent?)           // → string (JSON)
  .toYAML()                  // → Promise<string> (requires 'yaml' package)

OperationBuilder

new OperationBuilder()
  // Identity
  .operationId(string)
  .summary(string)
  .description(string)
  .tag(string)
  .tags(...strings)
  .deprecated(boolean?)

  // Parameters
  .query(name, schema, description?, required?)
  .requiredQuery(name, schema, description?)
  .pathParam(name, schema, description?)
  .header(name, schema, description?, required?)
  .cookie(name, schema, description?, required?)
  .param(ParameterObject)

  // Body
  .body(schema, description?, required?)
  .bodyContent(mediaType, schema, description?, required?)
  .formBody(schema, description?, required?)
  .requestBody(RequestBodyObject)

  // Responses
  .response(statusCode, schema, description?)
  .noContent(statusCode?, description?)
  .rawResponse(statusCode, ResponseObject)

  // Security
  .bearerAuth(schemeName?)
  .apiKeyAuth(schemeName?)
  .oauth2(schemeName?, ...scopes)
  .noAuth()
  .security(SecurityRequirementObject)

  // Output
  .build()                   // → OperationObject

toLLM Adapters

import { toLLM } from 'spectopus';

// OpenAI function calling
toLLM.openai(spec, {
  strict?: boolean,            // Enable structured outputs mode
  includeTags?: string[],      // Filter by tag
  excludeOperations?: string[], // Exclude by operationId
  limit?: number,              // Max tools to return
})

// Anthropic tool use
toLLM.anthropic(spec, {
  includeTags?: string[],
  excludeOperations?: string[],
  limit?: number,
  includePathInDescription?: boolean,
})

// Compact text for system prompts
toLLM.compact(spec, {
  includeSchemas?: boolean,
  includeServers?: boolean,
  includeSecurity?: boolean,
  includeParamDescriptions?: boolean,
  includeTags?: string[],
  maxSchemaDepth?: number,
})

zodToOpenAPI

import { zodToOpenAPI } from 'spectopus';

zodToOpenAPI(zodSchema, {
  includeDescriptions?: boolean,  // Include .describe() text
  includeDefaults?: boolean,      // Include .default() values
  maxDepth?: number,              // Max recursion depth (default: 20)
})

joiToOpenAPI

import { joiToOpenAPI, joiDescriptionToOpenAPI } from 'spectopus';

// Convert a Joi schema instance
joiToOpenAPI(joiSchema, {
  includeDescriptions?: boolean,  // Include .description() text
  includeDefaults?: boolean,      // Include .default() values
  maxDepth?: number,              // Max recursion depth (default: 20)
})

// Convert raw describe() output (plain object — no Joi instance required)
joiDescriptionToOpenAPI(joiSchema.describe(), options?)

Also available as a named sub-path import:

import { joiToOpenAPI } from 'spectopus/adapters/joi';

Zero Dependencies

spectopus's runtime has no required dependencies. Zero. The package is pure TypeScript compiled to JavaScript.

  • Zod is an optional peer dependency. Install it only if you want the Zod adapter. Without it, you can still use plain SchemaObject definitions.
  • Joi is an optional peer dependency. Install it only if you want the Joi adapter. spectopus uses Joi's public .describe() API — no internal coupling.
  • YAML libraries (yaml or js-yaml) are only needed if you call .toYAML(). Neither is required.
  • LLM SDKs are not required — spectopus just produces plain JavaScript objects that match the tool definition formats.

This means spectopus is fast to install, easy to audit, and won't bloat your dependencies. The bundle is small and tree-shakeable.


Why not swagger-jsdoc? Why not tsoa?

| | spectopus | swagger-jsdoc | tsoa | typed-openapi | |---|---|---|---|---| | Colocation | ✅ Schema IS the doc | ❌ JSDoc is separate annotation | ⚠️ Decorator-only | ❌ Manual types | | Zod-native | ✅ First-class | ❌ | ❌ | ❌ | | Joi support | ✅ First-class | ❌ | ❌ | ❌ | | LLM adapters | ✅ Built-in | ❌ | ❌ | ❌ | | Runtime deps | ✅ Zero | ⚠️ Some | ⚠️ Some | ✅ Zero | | OpenAPI 3.1 | ✅ | ⚠️ Partial | ⚠️ 3.0 | ✅ | | Fluent API | ✅ | ❌ | ❌ | ❌ | | Drift possible | ❌ Never | ✅ Always | ⚠️ Sometimes | ✅ Always |

swagger-jsdoc requires you to write your API schema twice: once in code, once in JSDoc. Those two things will diverge.

tsoa is closer — decorators are colocated — but it doesn't support Zod, doesn't have LLM adapters, and its OpenAPI 3.1 support is incomplete.

spectopus is designed from the ground up for the principle that the schema that validates your runtime data is the schema that documents your API. No copies. No synchronization. One source of truth.


Framework Examples

Express

import express from 'express';
import { spec } from './spec.js';

const app = express();
app.use(express.json());

// Serve docs
app.get('/openapi.json', (req, res) => res.json(spec));
// Optional: serve Swagger UI
// app.use('/docs', swaggerUi.serve, swaggerUi.setup(spec));

// Your routes use the same Zod schemas for validation:
app.post('/users', async (req, res) => {
  const body = CreateUserSchema.parse(req.body);  // same schema as the doc
  const user = await db.createUser(body);
  res.status(201).json(user);
});

Fastify

import Fastify from 'fastify';
import { spec } from './spec.js';

const app = Fastify();

app.get('/openapi.json', async () => spec);

app.post('/users', {
  // Fastify can use JSON Schema for built-in validation
  schema: { body: zodToOpenAPI(CreateUserSchema) },
  handler: async (req, reply) => {
    const user = await db.createUser(req.body);
    reply.status(201).send(user);
  },
});

Hono

import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { spec } from './spec.js';

const app = new Hono();

app.get('/openapi.json', (c) => c.json(spec));

app.post('/users',
  zValidator('json', CreateUserSchema),  // same schema as the doc
  async (c) => {
    const body = c.req.valid('json');
    const user = await db.createUser(body);
    return c.json(user, 201);
  }
);

The Philosophy

The central insight behind spectopus:

Documentation that is generated from the source of truth can never drift from the source of truth.

When you use swagger-jsdoc, you write:

/**
 * @openapi
 * /users:
 *   post:
 *     requestBody:
 *       content:
 *         application/json:
 *           schema:
 *             type: object
 *             properties:
 *               name:
 *                 type: string    ← Is this still accurate?
 *               email:
 *                 type: string    ← Who checks?
 */
app.post('/users', (req, res) => {
  const { name, email } = CreateUserSchema.parse(req.body);
  // CreateUserSchema may have changed last week.
  // The JSDoc above hasn't.
});

When you use spectopus, you write:

.body(CreateUserSchema)  // ← If the schema changes, the doc changes. Always.

There is no annotation to forget. There is no second place to update. The documentation is a view over the code, not a parallel copy of it.

This is especially powerful for LLM tools, where stale function definitions cause silent failures: the LLM calls a function with parameters that no longer exist. With spectopus, that class of error is impossible.


Contributing

Contributions are very welcome! See CONTRIBUTING.md.

Areas we'd love help with:

  • More Zod type coverage edge cases
  • More Joi type coverage edge cases
  • Valibot adapter (spectopus/adapters/valibot)
  • Express/Fastify/Hono route extraction helpers
  • CLI tool (spectopus generate --output openapi.json)
  • OpenAPI spec validation (full schema validation, not just structural)

License

MIT © spectopus contributors