
Ts Morph Analyzer
- 45 installs
- 49 repo stars
- Updated February 11, 2026
- ratacat/claude-skills
Helps with ai & agent building tasks.
About
ts-morph-analyzer is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- ts-morph-analyzer
- AI & Agent Building
- AI-coding skill
Ts Morph Analyzer by the numbers
- 45 all-time installs (skills.sh)
- Ranked #7,643 of 16,556 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ratacat/claude-skills --skill ts-morph-analyzerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 45 |
|---|---|
| repo stars | ★ 49 |
| Last updated | February 11, 2026 |
| Repository | ratacat/claude-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
TypeScript Codebase Analyzer
Overview
Lightweight codebase analysis using ts-morph. Extract signatures, JSDoc, and call chains without flooding context with full file reads.
Core principle: Get maximum architectural insight with minimum token usage.
When to Use
| Situation | Script to Use |
|---|---|
| Understand a codebase's public API quickly | extract-signatures.ts |
| Trace a bug through function calls | trace-calls.ts |
| Map what a module exports | analyze-exports.ts |
| Detect architectural issues before diving in | code-smells.ts |
| Understand import/dependency structure | analyze-exports.ts --deps |
Setup
# In the skill directory
cd ~/.claude/skills/ts-morph-analyzer
npm installOr run the setup script:
~/.claude/skills/ts-morph-analyzer/setup.shQuick Reference
Extract Signatures (Most Common)
Get function/method signatures + JSDoc without reading full files:
# All signatures in a file
npx ts-node scripts/extract-signatures.ts src/api/users.ts
# All signatures in a directory (recursive)
npx ts-node scripts/extract-signatures.ts src/
# Filter to exported only
npx ts-node scripts/extract-signatures.ts src/ --exported
# Include types and interfaces
npx ts-node scripts/extract-signatures.ts src/ --types
# Output as JSON for further processing
npx ts-node scripts/extract-signatures.ts src/ --jsonOutput example:
// src/api/users.ts
/**
* Fetches user by ID from the database
* @param id - User's unique identifier
* @returns User object or null if not found
*/
export async function getUser(id: string): Promise<User | null>
/**
* Creates a new user account
* @throws ValidationError if email is invalid
*/
export async function createUser(data: CreateUserInput): Promise<User>Trace Call Hierarchy
Follow function calls up (who calls this?) or down (what does this call?):
# Who calls this function?
npx ts-node scripts/trace-calls.ts src/api/users.ts:getUser --up
# What does this function call?
npx ts-node scripts/trace-calls.ts src/api/users.ts:getUser --down
# Full call chain (both directions, limited depth)
npx ts-node scripts/trace-calls.ts src/api/users.ts:getUser --depth 3
# Output as tree
npx ts-node scripts/trace-calls.ts src/api/users.ts:getUser --treeOutput example (--up):
getUser (src/api/users.ts:15)
├── called by: handleGetUser (src/routes/users.ts:23)
│ └── called by: router.get('/users/:id') (src/routes/users.ts:8)
├── called by: validateSession (src/middleware/auth.ts:45)
└── called by: getUserProfile (src/services/profile.ts:12)Analyze Exports
Map a module's public API surface:
# What does this module export?
npx ts-node scripts/analyze-exports.ts src/api/
# Include re-exports
npx ts-node scripts/analyze-exports.ts src/ --follow-reexports
# Show dependency graph
npx ts-node scripts/analyze-exports.ts src/ --depsDetect Code Smells
Quick architectural assessment:
# Full analysis
npx ts-node scripts/code-smells.ts src/
# Specific checks
npx ts-node scripts/code-smells.ts src/ --check circular-deps
npx ts-node scripts/code-smells.ts src/ --check large-functions
npx ts-node scripts/code-smells.ts src/ --check missing-jsdoc
npx ts-node scripts/code-smells.ts src/ --check many-paramsArchitectural Assessment Patterns
When analyzing a new codebase for potential issues:
1. Public API Surface First
# Get the big picture: what's exported?
npx ts-node scripts/extract-signatures.ts src/ --exported --json > api-surface.jsonLook for: Overly complex interfaces, inconsistent naming, missing JSDoc on public APIs
2. Dependency Structure
# Map imports - circular deps are red flags
npx ts-node scripts/code-smells.ts src/ --check circular-depsLook for: Circular dependencies, deep import chains, unclear module boundaries
3. Function Complexity
# Find complex functions that may need refactoring
npx ts-node scripts/code-smells.ts src/ --check large-functions --check many-paramsLook for: Functions >50 lines, >5 parameters, deep nesting
4. Documentation Coverage
# Public APIs should be documented
npx ts-node scripts/code-smells.ts src/ --check missing-jsdoc --exportedFollowing the Data Trail
When debugging, trace data flow without reading full files:
Pattern: "Where does this value come from?"
# 1. Find who calls the function with the bad value
npx ts-node scripts/trace-calls.ts src/service.ts:processData --up --depth 5
# 2. Get signatures of callers to understand parameter flow
npx ts-node scripts/extract-signatures.ts src/caller.tsPattern: "Where does this return value go?"
# 1. Find what uses this function's return
npx ts-node scripts/trace-calls.ts src/api.ts:fetchUser --down
# 2. Check how return values are consumed
npx ts-node scripts/trace-calls.ts src/api.ts:fetchUser --down --show-usagePattern: "Full call chain for a bug"
# Get complete path from entry point to problem area
npx ts-node scripts/trace-calls.ts src/broken.ts:problematicFn --up --treeScript Locations
All scripts in: ~/.claude/skills/ts-morph-analyzer/scripts/
| Script | Purpose |
|---|---|
extract-signatures.ts | Extract function/method/class signatures with JSDoc |
trace-calls.ts | Trace call hierarchies up/down |
analyze-exports.ts | Map module exports and dependencies |
code-smells.ts | Detect architectural issues |
Common Issues
| Problem | Solution |
|---|---|
| "Cannot find module 'ts-morph'" | Run npm install in skill directory |
| Slow on large codebases | Add --include "src/**/*.ts" to limit scope |
| Missing type info | Ensure tsconfig.json is in project root |
| Memory issues | Use --exclude "node_modules" (default) |
{
"name": "ts-morph-analyzer",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ts-morph-analyzer",
"version": "1.0.0",
"dependencies": {
"ts-morph": "^24.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.7.0"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@ts-morph/common": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.25.0.tgz",
"integrity": "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==",
"license": "MIT",
"dependencies": {
"minimatch": "^9.0.4",
"path-browserify": "^1.0.1",
"tinyglobby": "^0.2.9"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/code-block-writer": {
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
"license": "MIT"
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"license": "ISC"
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"license": "MIT"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/ts-morph": {
"version": "24.0.0",
"resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-24.0.0.tgz",
"integrity": "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==",
"license": "MIT",
"dependencies": {
"@ts-morph/common": "~0.25.0",
"code-block-writer": "^13.0.3"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
}
}
}
{
"name": "ts-morph-analyzer",
"version": "1.0.0",
"description": "TypeScript codebase analyzer using ts-morph for signature extraction, call tracing, and architectural analysis",
"type": "module",
"scripts": {
"extract-signatures": "npx ts-node scripts/extract-signatures.ts",
"trace-calls": "npx ts-node scripts/trace-calls.ts",
"analyze-exports": "npx ts-node scripts/analyze-exports.ts",
"code-smells": "npx ts-node scripts/code-smells.ts"
},
"dependencies": {
"ts-morph": "^24.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.7.0"
}
}
#!/usr/bin/env npx ts-node
// ABOUTME: Analyzes module exports and import dependencies for understanding codebase structure.
// ABOUTME: Maps public API surface and shows dependency graphs between modules.
import { Project, SourceFile, ExportDeclaration, ExportAssignment, Node } from "ts-morph";
import * as path from "path";
interface ExportInfo {
name: string;
kind: "function" | "class" | "interface" | "type" | "variable" | "const" | "namespace" | "reexport" | "default";
file: string;
line: number;
fromModule?: string;
}
interface ModuleInfo {
file: string;
exports: ExportInfo[];
imports: { module: string; names: string[] }[];
}
interface AnalyzeOptions {
followReexports: boolean;
showDeps: boolean;
jsonOutput: boolean;
}
function getExportsFromSourceFile(sourceFile: SourceFile): ExportInfo[] {
const exports: ExportInfo[] = [];
const filePath = path.relative(process.cwd(), sourceFile.getFilePath());
// Direct exports (export function, export class, etc.)
for (const fn of sourceFile.getFunctions()) {
if (fn.isExported()) {
exports.push({
name: fn.getName() || "(anonymous)",
kind: "function",
file: filePath,
line: fn.getStartLineNumber(),
});
}
}
for (const cls of sourceFile.getClasses()) {
if (cls.isExported()) {
exports.push({
name: cls.getName() || "(anonymous)",
kind: "class",
file: filePath,
line: cls.getStartLineNumber(),
});
}
}
for (const iface of sourceFile.getInterfaces()) {
if (iface.isExported()) {
exports.push({
name: iface.getName(),
kind: "interface",
file: filePath,
line: iface.getStartLineNumber(),
});
}
}
for (const typeAlias of sourceFile.getTypeAliases()) {
if (typeAlias.isExported()) {
exports.push({
name: typeAlias.getName(),
kind: "type",
file: filePath,
line: typeAlias.getStartLineNumber(),
});
}
}
for (const varDecl of sourceFile.getVariableDeclarations()) {
const varStmt = varDecl.getVariableStatement();
if (varStmt?.isExported()) {
const isConst = varStmt.getDeclarationKind() === 0; // VariableDeclarationKind.Const
exports.push({
name: varDecl.getName(),
kind: isConst ? "const" : "variable",
file: filePath,
line: varDecl.getStartLineNumber(),
});
}
}
// Re-exports (export { x } from './module')
for (const exportDecl of sourceFile.getExportDeclarations()) {
const moduleSpecifier = exportDecl.getModuleSpecifierValue();
if (exportDecl.isNamespaceExport()) {
exports.push({
name: "*",
kind: "reexport",
file: filePath,
line: exportDecl.getStartLineNumber(),
fromModule: moduleSpecifier,
});
} else {
for (const namedExport of exportDecl.getNamedExports()) {
const exportedName = namedExport.getAliasNode()?.getText() || namedExport.getName();
exports.push({
name: exportedName,
kind: "reexport",
file: filePath,
line: exportDecl.getStartLineNumber(),
fromModule: moduleSpecifier,
});
}
}
}
// Default export
const defaultExport = sourceFile.getDefaultExportSymbol();
if (defaultExport) {
const decl = defaultExport.getDeclarations()[0];
if (decl) {
exports.push({
name: "default",
kind: "default",
file: filePath,
line: decl.getStartLineNumber(),
});
}
}
return exports;
}
function getImportsFromSourceFile(sourceFile: SourceFile): { module: string; names: string[] }[] {
const imports: { module: string; names: string[] }[] = [];
for (const importDecl of sourceFile.getImportDeclarations()) {
const moduleSpecifier = importDecl.getModuleSpecifierValue();
const names: string[] = [];
// Default import
const defaultImport = importDecl.getDefaultImport();
if (defaultImport) {
names.push(`default as ${defaultImport.getText()}`);
}
// Namespace import
const namespaceImport = importDecl.getNamespaceImport();
if (namespaceImport) {
names.push(`* as ${namespaceImport.getText()}`);
}
// Named imports
for (const namedImport of importDecl.getNamedImports()) {
const alias = namedImport.getAliasNode();
if (alias) {
names.push(`${namedImport.getName()} as ${alias.getText()}`);
} else {
names.push(namedImport.getName());
}
}
if (names.length > 0) {
imports.push({ module: moduleSpecifier, names });
}
}
return imports;
}
function buildDependencyGraph(modules: ModuleInfo[]): Map<string, Set<string>> {
const graph = new Map<string, Set<string>>();
for (const mod of modules) {
const deps = new Set<string>();
for (const imp of mod.imports) {
// Only include local imports (not from node_modules)
if (imp.module.startsWith(".") || imp.module.startsWith("/")) {
// Normalize the path
const basePath = path.dirname(mod.file);
const resolvedPath = path.normalize(path.join(basePath, imp.module));
deps.add(resolvedPath);
}
}
graph.set(mod.file, deps);
}
return graph;
}
function findCircularDeps(graph: Map<string, Set<string>>): string[][] {
const cycles: string[][] = [];
const visited = new Set<string>();
const recursionStack = new Set<string>();
const path: string[] = [];
function dfs(node: string): void {
visited.add(node);
recursionStack.add(node);
path.push(node);
const deps = graph.get(node) || new Set();
for (const dep of deps) {
if (!visited.has(dep)) {
dfs(dep);
} else if (recursionStack.has(dep)) {
// Found a cycle
const cycleStart = path.indexOf(dep);
if (cycleStart !== -1) {
cycles.push([...path.slice(cycleStart), dep]);
}
}
}
path.pop();
recursionStack.delete(node);
}
for (const node of graph.keys()) {
if (!visited.has(node)) {
dfs(node);
}
}
return cycles;
}
function printDependencyGraph(graph: Map<string, Set<string>>): void {
console.log("\n=== Dependency Graph ===\n");
for (const [file, deps] of graph) {
if (deps.size > 0) {
console.log(`${file}`);
for (const dep of deps) {
console.log(` → ${dep}`);
}
console.log("");
}
}
const cycles = findCircularDeps(graph);
if (cycles.length > 0) {
console.log("⚠️ Circular Dependencies Detected:\n");
for (const cycle of cycles) {
console.log(` ${cycle.join(" → ")}`);
}
console.log("");
}
}
function printUsage(): void {
console.log(`
Usage: analyze-exports.ts <path> [options]
Arguments:
path File or directory to analyze
Options:
--follow-reexports Follow and resolve re-exports to their source
--deps Show dependency graph between modules
--json Output as JSON
--help Show this help message
Examples:
analyze-exports.ts src/api/
analyze-exports.ts src/ --deps
analyze-exports.ts src/ --follow-reexports --json
`);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes("--help") || args.length === 0) {
printUsage();
process.exit(0);
}
const targetPath = args.find(arg => !arg.startsWith("--"));
if (!targetPath) {
console.error("Error: No path provided");
printUsage();
process.exit(1);
}
const options: AnalyzeOptions = {
followReexports: args.includes("--follow-reexports"),
showDeps: args.includes("--deps"),
jsonOutput: args.includes("--json"),
};
// Create project
const project = new Project({
skipAddingFilesFromTsConfig: true,
});
// Add source files
const absolutePath = path.resolve(targetPath);
const fs = await import("fs");
const stats = await fs.promises.stat(absolutePath);
if (stats.isDirectory()) {
project.addSourceFilesAtPaths([
path.join(absolutePath, "**/*.ts"),
path.join(absolutePath, "**/*.tsx"),
path.join(absolutePath, "**/*.js"),
path.join(absolutePath, "**/*.jsx"),
`!${path.join(absolutePath, "**/node_modules/**")}`,
`!${path.join(absolutePath, "**/*.d.ts")}`,
]);
} else {
project.addSourceFileAtPath(absolutePath);
}
const sourceFiles = project.getSourceFiles();
if (sourceFiles.length === 0) {
console.error("No source files found");
process.exit(1);
}
const modules: ModuleInfo[] = [];
for (const sourceFile of sourceFiles) {
const filePath = path.relative(process.cwd(), sourceFile.getFilePath());
modules.push({
file: filePath,
exports: getExportsFromSourceFile(sourceFile),
imports: getImportsFromSourceFile(sourceFile),
});
}
if (options.jsonOutput) {
console.log(JSON.stringify(modules, null, 2));
return;
}
// Print exports by file
console.log("\n=== Module Exports ===\n");
for (const mod of modules) {
if (mod.exports.length === 0) continue;
console.log(`${mod.file}:`);
for (const exp of mod.exports) {
const fromStr = exp.fromModule ? ` (from '${exp.fromModule}')` : "";
console.log(` [${exp.kind}] ${exp.name}${fromStr}`);
}
console.log("");
}
// Show dependency graph if requested
if (options.showDeps) {
const graph = buildDependencyGraph(modules);
printDependencyGraph(graph);
}
}
main().catch(err => {
console.error("Error:", err.message);
process.exit(1);
});
#!/usr/bin/env npx ts-node
// ABOUTME: Detects architectural code smells like circular deps, large functions, missing JSDoc.
// ABOUTME: Quick assessment tool for identifying potential issues before deep code review.
import { Project, SourceFile, FunctionDeclaration, MethodDeclaration, ClassDeclaration, Node, SyntaxKind } from "ts-morph";
import * as path from "path";
interface SmellReport {
type: string;
severity: "warning" | "error";
file: string;
line?: number;
message: string;
details?: string;
}
interface SmellOptions {
checks: Set<string>;
exportedOnly: boolean;
thresholds: {
maxParams: number;
maxFunctionLines: number;
maxFileLines: number;
maxNestingDepth: number;
maxComplexity: number;
};
}
const DEFAULT_THRESHOLDS = {
maxParams: 5,
maxFunctionLines: 50,
maxFileLines: 500,
maxNestingDepth: 4,
maxComplexity: 10,
};
const ALL_CHECKS = [
"circular-deps",
"large-functions",
"many-params",
"missing-jsdoc",
"deep-nesting",
"large-files",
"god-classes",
];
function getJsDoc(node: Node): string | null {
const jsDocs = (node as any).getJsDocs?.();
if (!jsDocs || jsDocs.length === 0) return null;
return jsDocs.map((doc: any) => doc.getText()).join("\n");
}
function isExported(node: Node): boolean {
if (Node.isExportable(node)) {
return node.isExported();
}
return false;
}
function countLines(node: Node): number {
const startLine = node.getStartLineNumber();
const endLine = node.getEndLineNumber();
return endLine - startLine + 1;
}
function getMaxNestingDepth(node: Node): number {
let maxDepth = 0;
function traverse(n: Node, depth: number): void {
// Control flow statements increase nesting
if (
Node.isIfStatement(n) ||
Node.isForStatement(n) ||
Node.isForInStatement(n) ||
Node.isForOfStatement(n) ||
Node.isWhileStatement(n) ||
Node.isDoStatement(n) ||
Node.isTryStatement(n) ||
Node.isSwitchStatement(n)
) {
maxDepth = Math.max(maxDepth, depth + 1);
n.forEachChild(child => traverse(child, depth + 1));
} else {
n.forEachChild(child => traverse(child, depth));
}
}
traverse(node, 0);
return maxDepth;
}
function getCyclomaticComplexity(node: FunctionDeclaration | MethodDeclaration): number {
let complexity = 1; // Base complexity
node.forEachDescendant(descendant => {
if (
Node.isIfStatement(descendant) ||
Node.isConditionalExpression(descendant) ||
Node.isForStatement(descendant) ||
Node.isForInStatement(descendant) ||
Node.isForOfStatement(descendant) ||
Node.isWhileStatement(descendant) ||
Node.isDoStatement(descendant) ||
Node.isCaseClause(descendant) ||
Node.isCatchClause(descendant)
) {
complexity++;
}
// Count logical operators
if (Node.isBinaryExpression(descendant)) {
const op = descendant.getOperatorToken().getKind();
if (op === SyntaxKind.AmpersandAmpersandToken || op === SyntaxKind.BarBarToken) {
complexity++;
}
}
});
return complexity;
}
function checkLargeFunctions(sourceFile: SourceFile, options: SmellOptions): SmellReport[] {
const reports: SmellReport[] = [];
const filePath = path.relative(process.cwd(), sourceFile.getFilePath());
const checkFn = (fn: FunctionDeclaration | MethodDeclaration, className?: string) => {
if (options.exportedOnly && !isExported(fn)) return;
const lines = countLines(fn);
const name = fn.getName() || "(anonymous)";
const fullName = className ? `${className}.${name}` : name;
if (lines > options.thresholds.maxFunctionLines) {
reports.push({
type: "large-function",
severity: lines > options.thresholds.maxFunctionLines * 2 ? "error" : "warning",
file: filePath,
line: fn.getStartLineNumber(),
message: `${fullName} is ${lines} lines (max: ${options.thresholds.maxFunctionLines})`,
});
}
// Also check complexity
const complexity = getCyclomaticComplexity(fn);
if (complexity > options.thresholds.maxComplexity) {
reports.push({
type: "high-complexity",
severity: complexity > options.thresholds.maxComplexity * 2 ? "error" : "warning",
file: filePath,
line: fn.getStartLineNumber(),
message: `${fullName} has cyclomatic complexity of ${complexity} (max: ${options.thresholds.maxComplexity})`,
});
}
};
for (const fn of sourceFile.getFunctions()) {
checkFn(fn);
}
for (const cls of sourceFile.getClasses()) {
for (const method of cls.getMethods()) {
checkFn(method, cls.getName());
}
}
return reports;
}
function checkManyParams(sourceFile: SourceFile, options: SmellOptions): SmellReport[] {
const reports: SmellReport[] = [];
const filePath = path.relative(process.cwd(), sourceFile.getFilePath());
const checkFn = (fn: FunctionDeclaration | MethodDeclaration, className?: string) => {
if (options.exportedOnly && !isExported(fn)) return;
const params = fn.getParameters();
const name = fn.getName() || "(anonymous)";
const fullName = className ? `${className}.${name}` : name;
if (params.length > options.thresholds.maxParams) {
reports.push({
type: "many-params",
severity: params.length > options.thresholds.maxParams + 3 ? "error" : "warning",
file: filePath,
line: fn.getStartLineNumber(),
message: `${fullName} has ${params.length} parameters (max: ${options.thresholds.maxParams})`,
details: `Consider using an options object instead`,
});
}
};
for (const fn of sourceFile.getFunctions()) {
checkFn(fn);
}
for (const cls of sourceFile.getClasses()) {
for (const method of cls.getMethods()) {
checkFn(method, cls.getName());
}
}
return reports;
}
function checkMissingJsDoc(sourceFile: SourceFile, options: SmellOptions): SmellReport[] {
const reports: SmellReport[] = [];
const filePath = path.relative(process.cwd(), sourceFile.getFilePath());
// Only check exported items (public API)
for (const fn of sourceFile.getFunctions()) {
if (!isExported(fn)) continue;
if (!getJsDoc(fn)) {
reports.push({
type: "missing-jsdoc",
severity: "warning",
file: filePath,
line: fn.getStartLineNumber(),
message: `Exported function '${fn.getName() || "(anonymous)"}' lacks JSDoc`,
});
}
}
for (const cls of sourceFile.getClasses()) {
if (!isExported(cls)) continue;
if (!getJsDoc(cls)) {
reports.push({
type: "missing-jsdoc",
severity: "warning",
file: filePath,
line: cls.getStartLineNumber(),
message: `Exported class '${cls.getName() || "(anonymous)"}' lacks JSDoc`,
});
}
// Check public methods
for (const method of cls.getMethods()) {
if (method.getScope() === "private") continue;
if (!getJsDoc(method)) {
reports.push({
type: "missing-jsdoc",
severity: "warning",
file: filePath,
line: method.getStartLineNumber(),
message: `Public method '${cls.getName()}.${method.getName()}' lacks JSDoc`,
});
}
}
}
return reports;
}
function checkDeepNesting(sourceFile: SourceFile, options: SmellOptions): SmellReport[] {
const reports: SmellReport[] = [];
const filePath = path.relative(process.cwd(), sourceFile.getFilePath());
const checkFn = (fn: FunctionDeclaration | MethodDeclaration, className?: string) => {
const depth = getMaxNestingDepth(fn);
const name = fn.getName() || "(anonymous)";
const fullName = className ? `${className}.${name}` : name;
if (depth > options.thresholds.maxNestingDepth) {
reports.push({
type: "deep-nesting",
severity: depth > options.thresholds.maxNestingDepth + 2 ? "error" : "warning",
file: filePath,
line: fn.getStartLineNumber(),
message: `${fullName} has nesting depth of ${depth} (max: ${options.thresholds.maxNestingDepth})`,
details: "Consider extracting nested logic into separate functions",
});
}
};
for (const fn of sourceFile.getFunctions()) {
checkFn(fn);
}
for (const cls of sourceFile.getClasses()) {
for (const method of cls.getMethods()) {
checkFn(method, cls.getName());
}
}
return reports;
}
function checkLargeFiles(sourceFile: SourceFile, options: SmellOptions): SmellReport[] {
const reports: SmellReport[] = [];
const filePath = path.relative(process.cwd(), sourceFile.getFilePath());
const lines = sourceFile.getEndLineNumber();
if (lines > options.thresholds.maxFileLines) {
reports.push({
type: "large-file",
severity: lines > options.thresholds.maxFileLines * 2 ? "error" : "warning",
file: filePath,
message: `File is ${lines} lines (max: ${options.thresholds.maxFileLines})`,
details: "Consider splitting into multiple modules",
});
}
return reports;
}
function checkGodClasses(sourceFile: SourceFile, options: SmellOptions): SmellReport[] {
const reports: SmellReport[] = [];
const filePath = path.relative(process.cwd(), sourceFile.getFilePath());
for (const cls of sourceFile.getClasses()) {
const methods = cls.getMethods();
const properties = cls.getProperties();
if (methods.length > 20) {
reports.push({
type: "god-class",
severity: methods.length > 30 ? "error" : "warning",
file: filePath,
line: cls.getStartLineNumber(),
message: `Class '${cls.getName()}' has ${methods.length} methods`,
details: "Consider splitting responsibilities into multiple classes",
});
}
if (properties.length > 15) {
reports.push({
type: "god-class",
severity: "warning",
file: filePath,
line: cls.getStartLineNumber(),
message: `Class '${cls.getName()}' has ${properties.length} properties`,
});
}
}
return reports;
}
function checkCircularDeps(project: Project): SmellReport[] {
const reports: SmellReport[] = [];
const graph = new Map<string, Set<string>>();
for (const sourceFile of project.getSourceFiles()) {
const filePath = path.relative(process.cwd(), sourceFile.getFilePath());
const deps = new Set<string>();
for (const importDecl of sourceFile.getImportDeclarations()) {
const moduleSpecifier = importDecl.getModuleSpecifierValue();
if (moduleSpecifier.startsWith(".") || moduleSpecifier.startsWith("/")) {
const basePath = path.dirname(filePath);
let resolvedPath = path.normalize(path.join(basePath, moduleSpecifier));
// Try to resolve to actual file
for (const ext of [".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx", "/index.js"]) {
const tryPath = resolvedPath + ext;
if (project.getSourceFile(path.resolve(tryPath))) {
resolvedPath = tryPath;
break;
}
}
deps.add(resolvedPath);
}
}
graph.set(filePath, deps);
}
// Find cycles using DFS
const visited = new Set<string>();
const recursionStack = new Set<string>();
const pathStack: string[] = [];
function dfs(node: string): void {
visited.add(node);
recursionStack.add(node);
pathStack.push(node);
const deps = graph.get(node) || new Set();
for (const dep of deps) {
if (!visited.has(dep)) {
dfs(dep);
} else if (recursionStack.has(dep)) {
const cycleStart = pathStack.indexOf(dep);
if (cycleStart !== -1) {
const cycle = [...pathStack.slice(cycleStart), dep];
reports.push({
type: "circular-dependency",
severity: "error",
file: cycle[0],
message: `Circular dependency detected`,
details: cycle.join(" → "),
});
}
}
}
pathStack.pop();
recursionStack.delete(node);
}
for (const node of graph.keys()) {
if (!visited.has(node)) {
dfs(node);
}
}
return reports;
}
function printReports(reports: SmellReport[]): void {
if (reports.length === 0) {
console.log("\n✅ No code smells detected!\n");
return;
}
// Group by type
const byType = new Map<string, SmellReport[]>();
for (const report of reports) {
const existing = byType.get(report.type) || [];
existing.push(report);
byType.set(report.type, existing);
}
const errorCount = reports.filter(r => r.severity === "error").length;
const warningCount = reports.filter(r => r.severity === "warning").length;
console.log(`\n=== Code Smell Report ===`);
console.log(`Found ${errorCount} errors, ${warningCount} warnings\n`);
for (const [type, typeReports] of byType) {
console.log(`\n## ${type} (${typeReports.length})\n`);
for (const report of typeReports) {
const icon = report.severity === "error" ? "❌" : "⚠️";
const location = report.line ? `${report.file}:${report.line}` : report.file;
console.log(`${icon} ${location}`);
console.log(` ${report.message}`);
if (report.details) {
console.log(` ${report.details}`);
}
}
}
console.log("");
}
function printUsage(): void {
console.log(`
Usage: code-smells.ts <path> [options]
Arguments:
path File or directory to analyze
Options:
--check <type> Run specific check only (can specify multiple)
Types: ${ALL_CHECKS.join(", ")}
--exported Only check exported items
--json Output as JSON
--help Show this help message
Thresholds (can override):
--max-params N Max parameters per function (default: ${DEFAULT_THRESHOLDS.maxParams})
--max-fn-lines N Max lines per function (default: ${DEFAULT_THRESHOLDS.maxFunctionLines})
--max-file-lines N Max lines per file (default: ${DEFAULT_THRESHOLDS.maxFileLines})
--max-nesting N Max nesting depth (default: ${DEFAULT_THRESHOLDS.maxNestingDepth})
Examples:
code-smells.ts src/
code-smells.ts src/ --check circular-deps
code-smells.ts src/ --check large-functions --check many-params
code-smells.ts src/ --exported --max-params 3
`);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes("--help") || args.length === 0) {
printUsage();
process.exit(0);
}
const targetPath = args.find(arg => !arg.startsWith("--"));
if (!targetPath) {
console.error("Error: No path provided");
printUsage();
process.exit(1);
}
// Parse checks
const checks = new Set<string>();
let i = 0;
while (i < args.length) {
if (args[i] === "--check" && args[i + 1]) {
checks.add(args[i + 1]);
i += 2;
} else {
i++;
}
}
// If no specific checks, run all
if (checks.size === 0) {
ALL_CHECKS.forEach(c => checks.add(c));
}
// Parse thresholds
const thresholds = { ...DEFAULT_THRESHOLDS };
const maxParamsIdx = args.indexOf("--max-params");
if (maxParamsIdx !== -1 && args[maxParamsIdx + 1]) {
thresholds.maxParams = parseInt(args[maxParamsIdx + 1], 10);
}
const maxFnLinesIdx = args.indexOf("--max-fn-lines");
if (maxFnLinesIdx !== -1 && args[maxFnLinesIdx + 1]) {
thresholds.maxFunctionLines = parseInt(args[maxFnLinesIdx + 1], 10);
}
const maxFileLinesIdx = args.indexOf("--max-file-lines");
if (maxFileLinesIdx !== -1 && args[maxFileLinesIdx + 1]) {
thresholds.maxFileLines = parseInt(args[maxFileLinesIdx + 1], 10);
}
const maxNestingIdx = args.indexOf("--max-nesting");
if (maxNestingIdx !== -1 && args[maxNestingIdx + 1]) {
thresholds.maxNestingDepth = parseInt(args[maxNestingIdx + 1], 10);
}
const options: SmellOptions = {
checks,
exportedOnly: args.includes("--exported"),
thresholds,
};
const jsonOutput = args.includes("--json");
// Create project
const project = new Project({
skipAddingFilesFromTsConfig: true,
});
// Add source files
const absolutePath = path.resolve(targetPath);
const fs = await import("fs");
const stats = await fs.promises.stat(absolutePath);
if (stats.isDirectory()) {
project.addSourceFilesAtPaths([
path.join(absolutePath, "**/*.ts"),
path.join(absolutePath, "**/*.tsx"),
path.join(absolutePath, "**/*.js"),
path.join(absolutePath, "**/*.jsx"),
`!${path.join(absolutePath, "**/node_modules/**")}`,
`!${path.join(absolutePath, "**/*.d.ts")}`,
]);
} else {
project.addSourceFileAtPath(absolutePath);
}
const sourceFiles = project.getSourceFiles();
if (sourceFiles.length === 0) {
console.error("No source files found");
process.exit(1);
}
const allReports: SmellReport[] = [];
// Run checks
if (checks.has("circular-deps")) {
allReports.push(...checkCircularDeps(project));
}
for (const sourceFile of sourceFiles) {
if (checks.has("large-functions")) {
allReports.push(...checkLargeFunctions(sourceFile, options));
}
if (checks.has("many-params")) {
allReports.push(...checkManyParams(sourceFile, options));
}
if (checks.has("missing-jsdoc")) {
allReports.push(...checkMissingJsDoc(sourceFile, options));
}
if (checks.has("deep-nesting")) {
allReports.push(...checkDeepNesting(sourceFile, options));
}
if (checks.has("large-files")) {
allReports.push(...checkLargeFiles(sourceFile, options));
}
if (checks.has("god-classes")) {
allReports.push(...checkGodClasses(sourceFile, options));
}
}
if (jsonOutput) {
console.log(JSON.stringify(allReports, null, 2));
} else {
printReports(allReports);
}
// Exit with error code if errors found
const hasErrors = allReports.some(r => r.severity === "error");
if (hasErrors) {
process.exit(1);
}
}
main().catch(err => {
console.error("Error:", err.message);
process.exit(1);
});
#!/usr/bin/env npx ts-node
// ABOUTME: Extracts function, method, and class signatures with JSDoc comments from TypeScript/JavaScript files.
// ABOUTME: Outputs lightweight API surface for architectural analysis without full file reads.
import { Project, SourceFile, FunctionDeclaration, MethodDeclaration, ClassDeclaration, InterfaceDeclaration, TypeAliasDeclaration, Node, SyntaxKind, JSDoc } from "ts-morph";
import * as path from "path";
interface ExtractOptions {
exportedOnly: boolean;
includeTypes: boolean;
jsonOutput: boolean;
includePrivate: boolean;
}
interface SignatureInfo {
file: string;
name: string;
kind: "function" | "method" | "class" | "interface" | "type";
signature: string;
jsdoc: string | null;
exported: boolean;
line: number;
className?: string;
}
function getJsDoc(node: Node): string | null {
const jsDocs = (node as any).getJsDocs?.() as JSDoc[] | undefined;
if (!jsDocs || jsDocs.length === 0) return null;
return jsDocs.map(doc => doc.getText()).join("\n");
}
function getFunctionSignature(fn: FunctionDeclaration | MethodDeclaration): string {
const name = fn.getName() || "(anonymous)";
const typeParams = fn.getTypeParameters().map(tp => tp.getText()).join(", ");
const params = fn.getParameters().map(p => {
const optional = p.isOptional() ? "?" : "";
const type = p.getType().getText();
return `${p.getName()}${optional}: ${type}`;
}).join(", ");
const returnType = fn.getReturnType().getText();
const async = fn.isAsync() ? "async " : "";
const typeParamStr = typeParams ? `<${typeParams}>` : "";
return `${async}function ${name}${typeParamStr}(${params}): ${returnType}`;
}
function getMethodSignature(method: MethodDeclaration, className: string): string {
const name = method.getName();
const typeParams = method.getTypeParameters().map(tp => tp.getText()).join(", ");
const params = method.getParameters().map(p => {
const optional = p.isOptional() ? "?" : "";
const type = p.getType().getText();
return `${p.getName()}${optional}: ${type}`;
}).join(", ");
const returnType = method.getReturnType().getText();
const async = method.isAsync() ? "async " : "";
const staticMod = method.isStatic() ? "static " : "";
const typeParamStr = typeParams ? `<${typeParams}>` : "";
return `${staticMod}${async}${name}${typeParamStr}(${params}): ${returnType}`;
}
function getClassSignature(cls: ClassDeclaration): string {
const name = cls.getName() || "(anonymous)";
const typeParams = cls.getTypeParameters().map(tp => tp.getText()).join(", ");
const typeParamStr = typeParams ? `<${typeParams}>` : "";
const extendsClause = cls.getExtends()?.getText();
const implementsClauses = cls.getImplements().map(i => i.getText()).join(", ");
let sig = `class ${name}${typeParamStr}`;
if (extendsClause) sig += ` extends ${extendsClause}`;
if (implementsClauses) sig += ` implements ${implementsClauses}`;
return sig;
}
function getInterfaceSignature(iface: InterfaceDeclaration): string {
const name = iface.getName();
const typeParams = iface.getTypeParameters().map(tp => tp.getText()).join(", ");
const typeParamStr = typeParams ? `<${typeParams}>` : "";
const extendsClause = iface.getExtends().map(e => e.getText()).join(", ");
let sig = `interface ${name}${typeParamStr}`;
if (extendsClause) sig += ` extends ${extendsClause}`;
return sig;
}
function getTypeSignature(typeAlias: TypeAliasDeclaration): string {
const name = typeAlias.getName();
const typeParams = typeAlias.getTypeParameters().map(tp => tp.getText()).join(", ");
const typeParamStr = typeParams ? `<${typeParams}>` : "";
const typeText = typeAlias.getType().getText();
return `type ${name}${typeParamStr} = ${typeText}`;
}
function isExported(node: Node): boolean {
if (Node.isExportable(node)) {
return node.isExported();
}
return false;
}
function extractFromSourceFile(sourceFile: SourceFile, options: ExtractOptions): SignatureInfo[] {
const signatures: SignatureInfo[] = [];
const filePath = sourceFile.getFilePath();
const relativePath = path.relative(process.cwd(), filePath);
// Extract functions
for (const fn of sourceFile.getFunctions()) {
const exported = isExported(fn);
if (options.exportedOnly && !exported) continue;
signatures.push({
file: relativePath,
name: fn.getName() || "(anonymous)",
kind: "function",
signature: getFunctionSignature(fn),
jsdoc: getJsDoc(fn),
exported,
line: fn.getStartLineNumber(),
});
}
// Extract classes and their methods
for (const cls of sourceFile.getClasses()) {
const classExported = isExported(cls);
if (options.exportedOnly && !classExported) continue;
const className = cls.getName() || "(anonymous)";
signatures.push({
file: relativePath,
name: className,
kind: "class",
signature: getClassSignature(cls),
jsdoc: getJsDoc(cls),
exported: classExported,
line: cls.getStartLineNumber(),
});
// Extract methods
for (const method of cls.getMethods()) {
const isPrivate = method.getScope() === "private";
if (!options.includePrivate && isPrivate) continue;
signatures.push({
file: relativePath,
name: method.getName(),
kind: "method",
signature: getMethodSignature(method, className),
jsdoc: getJsDoc(method),
exported: classExported,
line: method.getStartLineNumber(),
className,
});
}
}
// Extract interfaces and types if requested
if (options.includeTypes) {
for (const iface of sourceFile.getInterfaces()) {
const exported = isExported(iface);
if (options.exportedOnly && !exported) continue;
signatures.push({
file: relativePath,
name: iface.getName(),
kind: "interface",
signature: getInterfaceSignature(iface),
jsdoc: getJsDoc(iface),
exported,
line: iface.getStartLineNumber(),
});
}
for (const typeAlias of sourceFile.getTypeAliases()) {
const exported = isExported(typeAlias);
if (options.exportedOnly && !exported) continue;
signatures.push({
file: relativePath,
name: typeAlias.getName(),
kind: "type",
signature: getTypeSignature(typeAlias),
jsdoc: getJsDoc(typeAlias),
exported,
line: typeAlias.getStartLineNumber(),
});
}
}
return signatures;
}
function formatSignature(sig: SignatureInfo): string {
const lines: string[] = [];
if (sig.jsdoc) {
lines.push(sig.jsdoc);
}
const exportPrefix = sig.exported ? "export " : "";
if (sig.kind === "method" && sig.className) {
lines.push(` ${sig.signature}`);
} else {
lines.push(`${exportPrefix}${sig.signature}`);
}
return lines.join("\n");
}
function groupByFile(signatures: SignatureInfo[]): Map<string, SignatureInfo[]> {
const grouped = new Map<string, SignatureInfo[]>();
for (const sig of signatures) {
const existing = grouped.get(sig.file) || [];
existing.push(sig);
grouped.set(sig.file, existing);
}
return grouped;
}
function printUsage(): void {
console.log(`
Usage: extract-signatures.ts <path> [options]
Arguments:
path File or directory to analyze
Options:
--exported Only include exported items
--types Include interfaces and type aliases
--private Include private methods
--json Output as JSON
--help Show this help message
Examples:
extract-signatures.ts src/api/users.ts
extract-signatures.ts src/ --exported
extract-signatures.ts src/ --types --json
`);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes("--help") || args.length === 0) {
printUsage();
process.exit(0);
}
const targetPath = args.find(arg => !arg.startsWith("--"));
if (!targetPath) {
console.error("Error: No path provided");
printUsage();
process.exit(1);
}
const options: ExtractOptions = {
exportedOnly: args.includes("--exported"),
includeTypes: args.includes("--types"),
jsonOutput: args.includes("--json"),
includePrivate: args.includes("--private"),
};
// Create project - try to find tsconfig.json
const project = new Project({
skipAddingFilesFromTsConfig: true,
});
// Add source files
const absolutePath = path.resolve(targetPath);
const stats = await import("fs").then(fs => fs.promises.stat(absolutePath));
if (stats.isDirectory()) {
project.addSourceFilesAtPaths([
path.join(absolutePath, "**/*.ts"),
path.join(absolutePath, "**/*.tsx"),
path.join(absolutePath, "**/*.js"),
path.join(absolutePath, "**/*.jsx"),
`!${path.join(absolutePath, "**/node_modules/**")}`,
`!${path.join(absolutePath, "**/*.d.ts")}`,
]);
} else {
project.addSourceFileAtPath(absolutePath);
}
const sourceFiles = project.getSourceFiles();
if (sourceFiles.length === 0) {
console.error("No source files found");
process.exit(1);
}
const allSignatures: SignatureInfo[] = [];
for (const sourceFile of sourceFiles) {
const sigs = extractFromSourceFile(sourceFile, options);
allSignatures.push(...sigs);
}
if (options.jsonOutput) {
console.log(JSON.stringify(allSignatures, null, 2));
} else {
const grouped = groupByFile(allSignatures);
for (const [file, sigs] of grouped) {
console.log(`\n// ${file}\n`);
// Group by class for methods
let currentClass: string | null = null;
for (const sig of sigs) {
if (sig.kind === "class") {
currentClass = sig.name;
console.log(formatSignature(sig) + " {");
} else if (sig.kind === "method" && sig.className === currentClass) {
console.log(formatSignature(sig));
} else {
if (currentClass !== null && sig.className !== currentClass) {
console.log("}\n");
currentClass = null;
}
console.log(formatSignature(sig));
}
}
if (currentClass !== null) {
console.log("}");
}
}
}
}
main().catch(err => {
console.error("Error:", err.message);
process.exit(1);
});
#!/usr/bin/env npx ts-node
// ABOUTME: Traces call hierarchies up (callers) or down (callees) for a function/method.
// ABOUTME: Helps follow data flow and debug issues by showing the full call chain.
import { Project, SourceFile, Node, SyntaxKind, FunctionDeclaration, MethodDeclaration, CallExpression, Identifier } from "ts-morph";
import * as path from "path";
interface CallNode {
name: string;
file: string;
line: number;
signature?: string;
children: CallNode[];
}
interface TraceOptions {
direction: "up" | "down" | "both";
maxDepth: number;
showTree: boolean;
showUsage: boolean;
}
function findFunctionByName(project: Project, filePath: string, functionName: string): FunctionDeclaration | MethodDeclaration | null {
const sourceFile = project.getSourceFile(filePath);
if (!sourceFile) {
console.error(`File not found: ${filePath}`);
return null;
}
// Try function declarations first
const fn = sourceFile.getFunction(functionName);
if (fn) return fn;
// Try method declarations in classes
for (const cls of sourceFile.getClasses()) {
const method = cls.getMethod(functionName);
if (method) return method;
}
// Try to find by traversing and matching name
let found: FunctionDeclaration | MethodDeclaration | null = null;
sourceFile.forEachDescendant(node => {
if (Node.isFunctionDeclaration(node) && node.getName() === functionName) {
found = node;
return true;
}
if (Node.isMethodDeclaration(node) && node.getName() === functionName) {
found = node;
return true;
}
return false;
});
return found;
}
function getCallers(node: FunctionDeclaration | MethodDeclaration, project: Project, depth: number, maxDepth: number, visited: Set<string>): CallNode[] {
if (depth >= maxDepth) return [];
const callers: CallNode[] = [];
const name = node.getName() || "(anonymous)";
const nodeKey = `${node.getSourceFile().getFilePath()}:${name}`;
if (visited.has(nodeKey)) return [];
visited.add(nodeKey);
const references = node.findReferencesAsNodes();
for (const ref of references) {
// Skip the definition itself
if (ref === node || ref === node.getNameNode()) continue;
// Find the containing function/method
const containingFn = ref.getFirstAncestor(ancestor =>
Node.isFunctionDeclaration(ancestor) || Node.isMethodDeclaration(ancestor) || Node.isArrowFunction(ancestor)
);
if (containingFn && (Node.isFunctionDeclaration(containingFn) || Node.isMethodDeclaration(containingFn))) {
const callerName = containingFn.getName() || "(anonymous)";
const callerFile = path.relative(process.cwd(), containingFn.getSourceFile().getFilePath());
const callerLine = containingFn.getStartLineNumber();
const callerKey = `${containingFn.getSourceFile().getFilePath()}:${callerName}`;
if (!visited.has(callerKey)) {
const children = getCallers(containingFn, project, depth + 1, maxDepth, visited);
callers.push({
name: callerName,
file: callerFile,
line: callerLine,
children,
});
}
}
}
return callers;
}
function getCallees(node: FunctionDeclaration | MethodDeclaration, project: Project, depth: number, maxDepth: number, visited: Set<string>): CallNode[] {
if (depth >= maxDepth) return [];
const callees: CallNode[] = [];
const name = node.getName() || "(anonymous)";
const nodeKey = `${node.getSourceFile().getFilePath()}:${name}`;
if (visited.has(nodeKey)) return [];
visited.add(nodeKey);
// Find all call expressions within this function
const body = node.getBody();
if (!body) return [];
body.forEachDescendant(descendant => {
if (Node.isCallExpression(descendant)) {
const expression = descendant.getExpression();
let calledName = "";
if (Node.isIdentifier(expression)) {
calledName = expression.getText();
} else if (Node.isPropertyAccessExpression(expression)) {
calledName = expression.getName();
}
if (calledName) {
// Try to find the definition
const definitions = expression.getType().getSymbol()?.getDeclarations() || [];
for (const def of definitions) {
if (Node.isFunctionDeclaration(def) || Node.isMethodDeclaration(def)) {
const calleeFile = path.relative(process.cwd(), def.getSourceFile().getFilePath());
const calleeLine = def.getStartLineNumber();
const calleeKey = `${def.getSourceFile().getFilePath()}:${calledName}`;
if (!visited.has(calleeKey)) {
const children = getCallees(def, project, depth + 1, maxDepth, new Set(visited));
callees.push({
name: calledName,
file: calleeFile,
line: calleeLine,
children,
});
}
}
}
}
}
});
return callees;
}
function printTree(nodes: CallNode[], prefix: string = "", isLast: boolean = true, direction: "up" | "down"): void {
const dirLabel = direction === "up" ? "called by" : "calls";
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const isLastNode = i === nodes.length - 1;
const connector = isLastNode ? "└── " : "├── ";
const childPrefix = isLastNode ? " " : "│ ";
console.log(`${prefix}${connector}${dirLabel}: ${node.name} (${node.file}:${node.line})`);
if (node.children.length > 0) {
printTree(node.children, prefix + childPrefix, isLastNode, direction);
}
}
}
function printFlat(nodes: CallNode[], indent: number = 0, direction: "up" | "down"): void {
const indentStr = " ".repeat(indent);
const dirLabel = direction === "up" ? "called by" : "calls";
for (const node of nodes) {
console.log(`${indentStr}${dirLabel}: ${node.name} (${node.file}:${node.line})`);
if (node.children.length > 0) {
printFlat(node.children, indent + 1, direction);
}
}
}
function printUsage(): void {
console.log(`
Usage: trace-calls.ts <file>:<function> [options]
Arguments:
file:function File path and function name (e.g., src/api.ts:getUser)
Options:
--up Show callers (who calls this function?)
--down Show callees (what does this function call?)
--depth N Maximum trace depth (default: 3)
--tree Output as ASCII tree
--show-usage Show how return values are used (with --down)
--help Show this help message
Examples:
trace-calls.ts src/api/users.ts:getUser --up
trace-calls.ts src/api/users.ts:getUser --down --depth 5
trace-calls.ts src/api/users.ts:getUser --up --tree
`);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes("--help") || args.length === 0) {
printUsage();
process.exit(0);
}
// Parse target (file:function)
const target = args.find(arg => !arg.startsWith("--") && arg.includes(":"));
if (!target) {
console.error("Error: Must specify file:function (e.g., src/api.ts:getUser)");
printUsage();
process.exit(1);
}
const [filePath, functionName] = target.split(":");
if (!filePath || !functionName) {
console.error("Error: Invalid format. Use file:function (e.g., src/api.ts:getUser)");
process.exit(1);
}
// Parse options
const options: TraceOptions = {
direction: args.includes("--down") ? (args.includes("--up") ? "both" : "down") : "up",
maxDepth: 3,
showTree: args.includes("--tree"),
showUsage: args.includes("--show-usage"),
};
const depthIndex = args.indexOf("--depth");
if (depthIndex !== -1 && args[depthIndex + 1]) {
options.maxDepth = parseInt(args[depthIndex + 1], 10);
}
// Create project
const project = new Project({
skipAddingFilesFromTsConfig: true,
});
// Add all source files in the directory
const absolutePath = path.resolve(filePath);
const dirPath = path.dirname(absolutePath);
project.addSourceFilesAtPaths([
path.join(dirPath, "**/*.ts"),
path.join(dirPath, "**/*.tsx"),
path.join(dirPath, "**/*.js"),
path.join(dirPath, "**/*.jsx"),
`!${path.join(dirPath, "**/node_modules/**")}`,
]);
// Also add the specific file
project.addSourceFileAtPath(absolutePath);
// Find the function
const fn = findFunctionByName(project, absolutePath, functionName);
if (!fn) {
console.error(`Function '${functionName}' not found in ${filePath}`);
process.exit(1);
}
const fnLine = fn.getStartLineNumber();
const relPath = path.relative(process.cwd(), absolutePath);
console.log(`\n${functionName} (${relPath}:${fnLine})`);
if (options.direction === "up" || options.direction === "both") {
const callers = getCallers(fn, project, 0, options.maxDepth, new Set());
if (callers.length === 0) {
console.log(" No callers found");
} else if (options.showTree) {
printTree(callers, "", true, "up");
} else {
printFlat(callers, 1, "up");
}
}
if (options.direction === "down" || options.direction === "both") {
if (options.direction === "both") console.log("");
const callees = getCallees(fn, project, 0, options.maxDepth, new Set());
if (callees.length === 0) {
console.log(" No callees found");
} else if (options.showTree) {
printTree(callees, "", true, "down");
} else {
printFlat(callees, 1, "down");
}
}
console.log("");
}
main().catch(err => {
console.error("Error:", err.message);
process.exit(1);
});
#!/bin/bash
# ABOUTME: Setup script for ts-morph-analyzer skill.
# ABOUTME: Installs ts-morph and TypeScript dependencies.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "Setting up ts-morph-analyzer..."
echo ""
cd "$SCRIPT_DIR"
# Check for npm
if ! command -v npm &> /dev/null; then
echo "Error: npm not found. Please install Node.js first."
exit 1
fi
# Install dependencies
echo "Installing dependencies..."
npm install
echo ""
echo "Setup complete!"
echo ""
echo "Usage:"
echo " npx ts-node scripts/extract-signatures.ts <path>"
echo " npx ts-node scripts/trace-calls.ts <file>:<function> --up"
echo " npx ts-node scripts/analyze-exports.ts <path>"
echo " npx ts-node scripts/code-smells.ts <path>"
echo ""
echo "Run any script with --help for more options."
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./scripts"
},
"include": ["scripts/**/*"],
"exclude": ["node_modules"]
}