docutrack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -0
- package/bin/docutrack.js +67 -0
- package/package.json +38 -0
- package/src/analyzer/complexity.js +145 -0
- package/src/analyzer/detect.js +124 -0
- package/src/analyzer/index.js +121 -0
- package/src/analyzer/parsers/express.js +110 -0
- package/src/analyzer/parsers/fastapi.js +89 -0
- package/src/commands/analyze.js +47 -0
- package/src/commands/badge.js +79 -0
- package/src/commands/check.js +187 -0
- package/src/commands/clear.js +17 -0
- package/src/commands/export.js +182 -0
- package/src/commands/init.js +182 -0
- package/src/commands/onboard.js +288 -0
- package/src/commands/scan.js +121 -0
- package/src/commands/serve.js +48 -0
- package/src/commands/status.js +94 -0
- package/src/utils/drift.js +167 -0
- package/src/utils/queue.js +62 -0
- package/src/utils/settings.js +69 -0
- package/src/utils/stale.js +80 -0
- package/src/viewer/index.html +1411 -0
- package/src/viewer/server.js +652 -0
- package/templates/ARCHITECTURE.md +51 -0
- package/templates/agents/documentalista.md +113 -0
- package/templates/claude-snippet.md +39 -0
- package/templates/commands/adr-new.md +58 -0
- package/templates/commands/arch-review.md +59 -0
- package/templates/commands/ask-docs.md +26 -0
- package/templates/commands/doc-map.md +50 -0
- package/templates/docs/api/.gitkeep +0 -0
- package/templates/docs/decisions/.gitkeep +0 -0
- package/templates/docs/modules/.gitkeep +0 -0
- package/templates/docutrack.config.json +13 -0
- package/templates/github/workflows/docutrack-docs.yml +42 -0
- package/templates/github/workflows/docutrack-gate.yml +31 -0
- package/templates/github/workflows/docutrack-pr.yml +93 -0
- package/templates/hooks/on-stop.js +39 -0
- package/templates/hooks/post-tool-use.js +52 -0
- package/templates/stacks/express/ARCHITECTURE.md +67 -0
- package/templates/stacks/express/documentalista.md +63 -0
- package/templates/stacks/fastapi/ARCHITECTURE.md +68 -0
- package/templates/stacks/fastapi/documentalista.md +88 -0
- package/templates/stacks/go/ARCHITECTURE.md +68 -0
- package/templates/stacks/go/documentalista.md +89 -0
- package/templates/stacks/monorepo/ARCHITECTURE.md +60 -0
- package/templates/stacks/monorepo/documentalista.md +59 -0
- package/templates/stacks/nextjs/ARCHITECTURE.md +76 -0
- package/templates/stacks/nextjs/documentalista.md +93 -0
package/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# DocuTrack
|
|
2
|
+
|
|
3
|
+
**Plugin de Claude Code que documenta automáticamente lo que construyes.**
|
|
4
|
+
|
|
5
|
+
DocuTrack engancha los lifecycle hooks de Claude Code para registrar cada archivo modificado y generar documentación técnica en tiempo real — sin interrumpir tu flujo de trabajo.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Instalación
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx docutrack init
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Detecta tu stack automáticamente (Next.js, FastAPI, Express, Go, monorepo) y configura todo en segundos.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## ¿Qué hace?
|
|
20
|
+
|
|
21
|
+
- **Hook PostToolUse** — cada vez que Claude edita un archivo, lo encola automáticamente
|
|
22
|
+
- **Hook Stop** — al terminar la sesión, el subagente `documentalista` genera los docs de todo lo pendiente
|
|
23
|
+
- **Visor web** — interfaz tipo Notion en `localhost:4242` con sidebar, renderizado Markdown, API Explorer interactivo y Health Check
|
|
24
|
+
- **Generación con IA** — escanea proyectos existentes y genera toda la documentación con un clic desde el visor
|
|
25
|
+
- **Sin dependencias** — llama a la API de Anthropic directo via `https` nativo de Node.js
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Uso rápido
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Inicializar en tu proyecto
|
|
33
|
+
npx docutrack init
|
|
34
|
+
|
|
35
|
+
# Abrir el visor de documentación
|
|
36
|
+
docutrack serve
|
|
37
|
+
|
|
38
|
+
# Escanear un proyecto existente y generar docs
|
|
39
|
+
# → Usar el botón "Regenerar docs" en el visor
|
|
40
|
+
|
|
41
|
+
# Ver estado de cobertura
|
|
42
|
+
docutrack status
|
|
43
|
+
|
|
44
|
+
# Health check completo (drift, complejidad, stale)
|
|
45
|
+
docutrack check
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Comandos
|
|
51
|
+
|
|
52
|
+
| Comando | Descripción |
|
|
53
|
+
|---------|-------------|
|
|
54
|
+
| `docutrack init` | Inicializa DocuTrack en el proyecto actual |
|
|
55
|
+
| `docutrack serve` | Abre el visor web en el puerto 4242 |
|
|
56
|
+
| `docutrack scan` | Encola todos los archivos fuente existentes |
|
|
57
|
+
| `docutrack status` | Muestra cobertura, pendientes y docs desactualizados |
|
|
58
|
+
| `docutrack check` | Health check: drift, complejidad, stale |
|
|
59
|
+
| `docutrack analyze` | Detecta rutas y genera `docs/api/openapi.json` |
|
|
60
|
+
| `docutrack onboard` | Genera `docs/ONBOARDING.md` |
|
|
61
|
+
| `docutrack export` | Exporta a Mintlify o Docusaurus |
|
|
62
|
+
| `docutrack badge` | Genera badge SVG de cobertura |
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Templates soportados
|
|
67
|
+
|
|
68
|
+
Detección automática o manual con `--template`:
|
|
69
|
+
|
|
70
|
+
- `nextjs` — Next.js App Router
|
|
71
|
+
- `fastapi` — Python FastAPI
|
|
72
|
+
- `express` — Node.js Express / Fastify
|
|
73
|
+
- `monorepo` — Turborepo / pnpm workspaces
|
|
74
|
+
- `go` — Go modules
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Estructura generada
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
docs/
|
|
82
|
+
├── modules/ ← un .md por módulo/componente
|
|
83
|
+
├── api/ ← docs de rutas API + openapi.json
|
|
84
|
+
└── decisions/ ← Architecture Decision Records (ADRs)
|
|
85
|
+
ARCHITECTURE.md ← vista general del proyecto (auto-generada con IA)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Visor web
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
docutrack serve
|
|
94
|
+
# → http://localhost:4242
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Incluye:
|
|
98
|
+
- Sidebar con todos los módulos, decisiones y rutas API
|
|
99
|
+
- **API Explorer** interactivo estilo Swagger
|
|
100
|
+
- **Health Check**: drift de código vs docs, mapa de complejidad
|
|
101
|
+
- **Generación desde la UI**: escanea y documenta sin abrir la terminal
|
|
102
|
+
- Toggle de idioma Español / English
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Requisitos
|
|
107
|
+
|
|
108
|
+
- Node.js 18+
|
|
109
|
+
- Claude Code CLI
|
|
110
|
+
- `ANTHROPIC_API_KEY` en el entorno o en `.env.local` (solo para generación con IA)
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Licencia
|
|
115
|
+
|
|
116
|
+
MIT — [mnovoaq](https://github.com/mnovoaq)
|
package/bin/docutrack.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
'use strict'
|
|
4
|
+
|
|
5
|
+
const [, , command, ...args] = process.argv
|
|
6
|
+
|
|
7
|
+
const commands = {
|
|
8
|
+
init: () => require('../src/commands/init'),
|
|
9
|
+
serve: () => require('../src/commands/serve'),
|
|
10
|
+
analyze: () => require('../src/commands/analyze'),
|
|
11
|
+
status: () => require('../src/commands/status'),
|
|
12
|
+
clear: () => require('../src/commands/clear'),
|
|
13
|
+
badge: () => require('../src/commands/badge'),
|
|
14
|
+
export: () => require('../src/commands/export'),
|
|
15
|
+
check: () => require('../src/commands/check'),
|
|
16
|
+
onboard: () => require('../src/commands/onboard'),
|
|
17
|
+
scan: () => require('../src/commands/scan'),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!command || command === '--help' || command === '-h') {
|
|
21
|
+
console.log(`
|
|
22
|
+
docutrack — Claude Code documentation plugin
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
npx docutrack init Initialize DocuTrack in the current project
|
|
26
|
+
docutrack init --template <name> Init with a specific stack template
|
|
27
|
+
docutrack serve Start the documentation web viewer (port 4242)
|
|
28
|
+
docutrack analyze Scan routes and generate docs/api/openapi.json
|
|
29
|
+
docutrack status Show coverage, pending files, and stale docs
|
|
30
|
+
docutrack status --json Machine-readable output for CI
|
|
31
|
+
docutrack badge Generate docs/badge.svg for your README
|
|
32
|
+
docutrack clear Clear the documentation queue
|
|
33
|
+
docutrack export --format mintlify Export docs to Mintlify format
|
|
34
|
+
docutrack export --format docusaurus Export docs to Docusaurus format
|
|
35
|
+
docutrack export --format <f> --out <dir> Export to a custom output directory
|
|
36
|
+
docutrack check Full health: drift, complexity, stale docs
|
|
37
|
+
docutrack check --json Machine-readable health report for CI
|
|
38
|
+
docutrack onboard Generate docs/ONBOARDING.md
|
|
39
|
+
docutrack onboard --force Regenerate ONBOARDING.md
|
|
40
|
+
docutrack scan Queue ALL existing source files for initial documentation
|
|
41
|
+
docutrack scan --dry-run Preview what would be queued
|
|
42
|
+
|
|
43
|
+
Templates (auto-detected from project files):
|
|
44
|
+
nextjs, fastapi, express, monorepo, go
|
|
45
|
+
|
|
46
|
+
Options:
|
|
47
|
+
--help, -h Show this help message
|
|
48
|
+
--version, -v Show version
|
|
49
|
+
`)
|
|
50
|
+
process.exit(0)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (command === '--version' || command === '-v') {
|
|
54
|
+
console.log(require('../package.json').version)
|
|
55
|
+
process.exit(0)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const handler = commands[command]
|
|
59
|
+
if (!handler) {
|
|
60
|
+
console.error(`Unknown command: ${command}\nRun "docutrack --help" for usage.`)
|
|
61
|
+
process.exit(1)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
handler().run(args).catch(err => {
|
|
65
|
+
console.error(`\nError: ${err.message}`)
|
|
66
|
+
process.exit(1)
|
|
67
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "docutrack",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code plugin that forces AI agents to document what they build — automatically.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"claude",
|
|
8
|
+
"documentation",
|
|
9
|
+
"ai-agents",
|
|
10
|
+
"architecture",
|
|
11
|
+
"developer-tools"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"type": "commonjs",
|
|
15
|
+
"main": "./bin/docutrack.js",
|
|
16
|
+
"bin": {
|
|
17
|
+
"docutrack": "bin/docutrack.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"bin/",
|
|
21
|
+
"src/",
|
|
22
|
+
"templates/"
|
|
23
|
+
],
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/mnovoaq/docutrack.git"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/mnovoaq/docutrack#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/mnovoaq/docutrack/issues"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18.0.0"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "node --test src/**/*.test.js"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
const THRESHOLDS = {
|
|
7
|
+
lines: { warn: 200, critical: 400 },
|
|
8
|
+
exports: { warn: 10, critical: 20 },
|
|
9
|
+
complexity: { warn: 15, critical: 30 },
|
|
10
|
+
maxNesting: { warn: 4, critical: 6 },
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function analyzeFile(filePath) {
|
|
14
|
+
let content
|
|
15
|
+
try { content = fs.readFileSync(filePath, 'utf8') } catch { return null }
|
|
16
|
+
|
|
17
|
+
const ext = path.extname(filePath).slice(1)
|
|
18
|
+
const lines = content.split('\n').filter(l => l.trim()).length // non-blank lines
|
|
19
|
+
|
|
20
|
+
const metrics = {
|
|
21
|
+
file: filePath,
|
|
22
|
+
lines,
|
|
23
|
+
exports: countExports(content, ext),
|
|
24
|
+
complexity: countCyclomaticApprox(content),
|
|
25
|
+
maxNesting: countMaxNesting(content, ext),
|
|
26
|
+
warnings: [],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Flag violations
|
|
30
|
+
if (metrics.lines >= THRESHOLDS.lines.critical) metrics.warnings.push({ level: 'critical', message: `${metrics.lines} lines — consider splitting this module` })
|
|
31
|
+
else if (metrics.lines >= THRESHOLDS.lines.warn) metrics.warnings.push({ level: 'warn', message: `${metrics.lines} lines — getting large` })
|
|
32
|
+
|
|
33
|
+
if (metrics.exports >= THRESHOLDS.exports.critical) metrics.warnings.push({ level: 'critical', message: `${metrics.exports} exports — wide public API` })
|
|
34
|
+
else if (metrics.exports >= THRESHOLDS.exports.warn) metrics.warnings.push({ level: 'warn', message: `${metrics.exports} exports` })
|
|
35
|
+
|
|
36
|
+
if (metrics.complexity >= THRESHOLDS.complexity.critical) metrics.warnings.push({ level: 'critical', message: `complexity ${metrics.complexity} — high branching` })
|
|
37
|
+
else if (metrics.complexity >= THRESHOLDS.complexity.warn) metrics.warnings.push({ level: 'warn', message: `complexity ${metrics.complexity}` })
|
|
38
|
+
|
|
39
|
+
if (metrics.maxNesting >= THRESHOLDS.maxNesting.critical) metrics.warnings.push({ level: 'critical', message: `nesting depth ${metrics.maxNesting} — deeply nested code` })
|
|
40
|
+
else if (metrics.maxNesting >= THRESHOLDS.maxNesting.warn) metrics.warnings.push({ level: 'warn', message: `nesting depth ${metrics.maxNesting}` })
|
|
41
|
+
|
|
42
|
+
metrics.score = computeScore(metrics)
|
|
43
|
+
return metrics
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function countExports(content, ext) {
|
|
47
|
+
if (ext === 'py') {
|
|
48
|
+
return (content.match(/^(?:def|class)\s+[A-Z][A-Za-z0-9_]*/gm) || []).length
|
|
49
|
+
}
|
|
50
|
+
if (ext === 'go') {
|
|
51
|
+
return (content.match(/^(?:func|type|var|const)\s+[A-Z]/gm) || []).length
|
|
52
|
+
}
|
|
53
|
+
// JS/TS
|
|
54
|
+
const patterns = [
|
|
55
|
+
/export\s+(?:async\s+)?function\s+/g,
|
|
56
|
+
/export\s+(?:const|let|var)\s+/g,
|
|
57
|
+
/export\s+class\s+/g,
|
|
58
|
+
/exports\.[A-Za-z_$]/g,
|
|
59
|
+
]
|
|
60
|
+
const moduleExports = content.match(/module\.exports\s*=\s*\{([^}]+)\}/)?.[1]
|
|
61
|
+
const fromModule = moduleExports
|
|
62
|
+
? moduleExports.split(',').filter(s => /[A-Za-z_$]/.test(s.trim())).length
|
|
63
|
+
: 0
|
|
64
|
+
return patterns.reduce((acc, re) => acc + (content.match(re) || []).length, 0) + fromModule
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function countCyclomaticApprox(content) {
|
|
68
|
+
// Count decision points: if, else if, for, while, case, catch, ternary, &&, ||
|
|
69
|
+
const DECISION_RE = /\b(if|else\s+if|for|while|case|catch)\b|\?(?!:)|&&|\|\|/g
|
|
70
|
+
return (content.match(DECISION_RE) || []).length
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function countMaxNesting(content, ext) {
|
|
74
|
+
if (ext === 'py') return countPythonNesting(content)
|
|
75
|
+
// Brace-based languages
|
|
76
|
+
let depth = 0
|
|
77
|
+
let max = 0
|
|
78
|
+
for (const ch of content) {
|
|
79
|
+
if (ch === '{') { depth++; if (depth > max) max = depth }
|
|
80
|
+
else if (ch === '}') depth = Math.max(0, depth - 1)
|
|
81
|
+
}
|
|
82
|
+
return max
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function countPythonNesting(content) {
|
|
86
|
+
let max = 0
|
|
87
|
+
for (const line of content.split('\n')) {
|
|
88
|
+
const indent = line.match(/^(\s*)/)?.[1].length || 0
|
|
89
|
+
const depth = Math.floor(indent / 4)
|
|
90
|
+
if (depth > max) max = depth
|
|
91
|
+
}
|
|
92
|
+
return max
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function computeScore(m) {
|
|
96
|
+
// 0–100, lower = worse
|
|
97
|
+
let score = 100
|
|
98
|
+
score -= Math.max(0, m.lines - THRESHOLDS.lines.warn) * 0.1
|
|
99
|
+
score -= Math.max(0, m.exports - THRESHOLDS.exports.warn) * 2
|
|
100
|
+
score -= Math.max(0, m.complexity - THRESHOLDS.complexity.warn) * 1.5
|
|
101
|
+
score -= Math.max(0, m.maxNesting - THRESHOLDS.maxNesting.warn) * 5
|
|
102
|
+
return Math.round(Math.max(0, Math.min(100, score)))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function analyzeComplexity(projectRoot) {
|
|
106
|
+
const results = []
|
|
107
|
+
const SCAN_DIRS = ['src', 'lib', 'app', 'pkg', 'internal']
|
|
108
|
+
const EXTS = new Set(['.js', '.ts', '.mjs', '.py', '.go', '.jsx', '.tsx'])
|
|
109
|
+
const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', '__pycache__', '.docutrack', 'docs'])
|
|
110
|
+
|
|
111
|
+
for (const dir of SCAN_DIRS) {
|
|
112
|
+
const full = path.join(projectRoot, dir)
|
|
113
|
+
if (!fs.existsSync(full)) continue
|
|
114
|
+
walk(full, results, EXTS, IGNORE)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Sort by score ascending (worst first)
|
|
118
|
+
results.sort((a, b) => a.score - b.score)
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
files: results,
|
|
122
|
+
summary: {
|
|
123
|
+
total: results.length,
|
|
124
|
+
critical: results.filter(f => f.warnings.some(w => w.level === 'critical')).length,
|
|
125
|
+
warnings: results.filter(f => f.warnings.some(w => w.level === 'warn') && !f.warnings.some(w => w.level === 'critical')).length,
|
|
126
|
+
healthy: results.filter(f => f.warnings.length === 0).length,
|
|
127
|
+
},
|
|
128
|
+
thresholds: THRESHOLDS,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function walk(dir, acc, exts, ignore, depth = 0) {
|
|
133
|
+
if (depth > 8) return
|
|
134
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
135
|
+
if (ignore.has(entry.name)) continue
|
|
136
|
+
const full = path.join(dir, entry.name)
|
|
137
|
+
if (entry.isDirectory()) walk(full, acc, exts, ignore, depth + 1)
|
|
138
|
+
else if (entry.isFile() && exts.has(path.extname(entry.name))) {
|
|
139
|
+
const m = analyzeFile(full)
|
|
140
|
+
if (m) acc.push(m)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { analyzeComplexity, analyzeFile, THRESHOLDS }
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
const JS_EXTS = ['.js', '.ts', '.mjs', '.cjs']
|
|
7
|
+
const PY_EXTS = ['.py']
|
|
8
|
+
const GO_EXTS = ['.go']
|
|
9
|
+
|
|
10
|
+
const JS_ROUTE_DIRS = [
|
|
11
|
+
'routes', 'src/routes',
|
|
12
|
+
'api', 'src/api',
|
|
13
|
+
'controllers', 'src/controllers',
|
|
14
|
+
'handlers', 'src/handlers',
|
|
15
|
+
'routers', 'src/routers',
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const PY_ROUTE_DIRS = ['routers', 'app/routers', 'api', 'app/api', 'routes', 'app/routes']
|
|
19
|
+
const GO_ROUTE_DIRS = ['internal/handlers', 'handlers', 'api', 'cmd']
|
|
20
|
+
|
|
21
|
+
function detectFramework(root) {
|
|
22
|
+
// Python project?
|
|
23
|
+
const isPython = fs.existsSync(path.join(root, 'requirements.txt'))
|
|
24
|
+
|| fs.existsSync(path.join(root, 'pyproject.toml'))
|
|
25
|
+
|| fs.existsSync(path.join(root, 'setup.py'))
|
|
26
|
+
if (isPython) {
|
|
27
|
+
const isFastAPI = checkFileContent(root, ['requirements.txt', 'pyproject.toml', 'Pipfile'], 'fastapi')
|
|
28
|
+
const framework = isFastAPI ? 'fastapi' : 'python'
|
|
29
|
+
return { framework, name: path.basename(root), version: '0.0.0', routeFiles: findFiles(root, PY_ROUTE_DIRS, PY_EXTS), lang: 'python' }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Go project?
|
|
33
|
+
if (fs.existsSync(path.join(root, 'go.mod'))) {
|
|
34
|
+
const modContent = fs.readFileSync(path.join(root, 'go.mod'), 'utf8')
|
|
35
|
+
const modMatch = modContent.match(/^module\s+(\S+)/m)
|
|
36
|
+
return { framework: 'go', name: modMatch?.[1]?.split('/').pop() || path.basename(root), version: '0.0.0', routeFiles: findFiles(root, GO_ROUTE_DIRS, GO_EXTS), lang: 'go' }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Node.js project
|
|
40
|
+
let pkg = {}
|
|
41
|
+
try { pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) } catch { /* ok */ }
|
|
42
|
+
|
|
43
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }
|
|
44
|
+
const name = pkg.name || path.basename(root)
|
|
45
|
+
const version = pkg.version || '0.0.0'
|
|
46
|
+
|
|
47
|
+
// Next.js — detect API routes in app/ or pages/api/
|
|
48
|
+
if (deps.next) {
|
|
49
|
+
return { framework: 'nextjs', name, version, routeFiles: findNextJsRoutes(root), lang: 'js' }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let framework = 'generic'
|
|
53
|
+
if (deps.express || deps['@types/express']) framework = 'express'
|
|
54
|
+
else if (deps.fastify) framework = 'fastify'
|
|
55
|
+
else if (deps.koa || deps['koa-router']) framework = 'koa'
|
|
56
|
+
else if (deps.hapi || deps['@hapi/hapi']) framework = 'hapi'
|
|
57
|
+
|
|
58
|
+
const routeFiles = findFiles(root, JS_ROUTE_DIRS, JS_EXTS)
|
|
59
|
+
if (routeFiles.length === 0) {
|
|
60
|
+
// Shallow scan src/
|
|
61
|
+
const srcDir = path.join(root, 'src')
|
|
62
|
+
if (fs.existsSync(srcDir)) walkFiles(srcDir, routeFiles, JS_EXTS, 2)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { framework, name, version, routeFiles, lang: 'js' }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findNextJsRoutes(root) {
|
|
69
|
+
const files = []
|
|
70
|
+
// App router: app/**/route.{js,ts}
|
|
71
|
+
const appDir = path.join(root, 'app')
|
|
72
|
+
if (fs.existsSync(appDir)) {
|
|
73
|
+
walkFilesWhere(appDir, files, JS_EXTS, f => path.basename(f, path.extname(f)) === 'route')
|
|
74
|
+
}
|
|
75
|
+
// Pages router: pages/api/**/*.{js,ts}
|
|
76
|
+
const pagesApi = path.join(root, 'pages', 'api')
|
|
77
|
+
if (fs.existsSync(pagesApi)) walkFiles(pagesApi, files, JS_EXTS, 10)
|
|
78
|
+
|
|
79
|
+
return files
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function findFiles(root, dirs, exts) {
|
|
83
|
+
const found = []
|
|
84
|
+
for (const dir of dirs) {
|
|
85
|
+
const full = path.join(root, dir)
|
|
86
|
+
if (fs.existsSync(full)) walkFiles(full, found, exts)
|
|
87
|
+
}
|
|
88
|
+
return found
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function walkFiles(dir, acc, exts, maxDepth = 10, depth = 0) {
|
|
92
|
+
if (depth > maxDepth) return
|
|
93
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
94
|
+
const full = path.join(dir, entry.name)
|
|
95
|
+
if (entry.isDirectory() && entry.name !== 'node_modules' && !entry.name.startsWith('.')) {
|
|
96
|
+
walkFiles(full, acc, exts, maxDepth, depth + 1)
|
|
97
|
+
} else if (entry.isFile() && exts.includes(path.extname(entry.name))) {
|
|
98
|
+
acc.push(full)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function walkFilesWhere(dir, acc, exts, predicate, maxDepth = 10, depth = 0) {
|
|
104
|
+
if (depth > maxDepth) return
|
|
105
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
106
|
+
const full = path.join(dir, entry.name)
|
|
107
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
108
|
+
walkFilesWhere(full, acc, exts, predicate, maxDepth, depth + 1)
|
|
109
|
+
} else if (entry.isFile() && exts.includes(path.extname(entry.name)) && predicate(full)) {
|
|
110
|
+
acc.push(full)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function checkFileContent(root, files, keyword) {
|
|
116
|
+
for (const f of files) {
|
|
117
|
+
const full = path.join(root, f)
|
|
118
|
+
if (!fs.existsSync(full)) continue
|
|
119
|
+
if (fs.readFileSync(full, 'utf8').toLowerCase().includes(keyword)) return true
|
|
120
|
+
}
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { detectFramework }
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const { detectFramework } = require('./detect')
|
|
6
|
+
const expressParser = require('./parsers/express')
|
|
7
|
+
const fastapiParser = require('./parsers/fastapi')
|
|
8
|
+
|
|
9
|
+
function getParser(framework) {
|
|
10
|
+
if (framework === 'fastapi' || framework === 'python') return fastapiParser
|
|
11
|
+
// Express, Fastify, Koa, Next.js, Go, generic — all use JS-style regex for now
|
|
12
|
+
return expressParser
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function analyze(projectRoot) {
|
|
16
|
+
const { framework, name, version, routeFiles, lang } = detectFramework(projectRoot)
|
|
17
|
+
const parser = getParser(framework)
|
|
18
|
+
const allRoutes = []
|
|
19
|
+
|
|
20
|
+
for (const file of routeFiles) {
|
|
21
|
+
let content
|
|
22
|
+
try { content = fs.readFileSync(file, 'utf8') } catch { continue }
|
|
23
|
+
|
|
24
|
+
const routes = parser.parse(file, content)
|
|
25
|
+
|
|
26
|
+
// For Next.js App Router route files, extract HTTP exports
|
|
27
|
+
if (framework === 'nextjs') {
|
|
28
|
+
const nextRoutes = parseNextJsRoute(file, content, projectRoot)
|
|
29
|
+
allRoutes.push(...nextRoutes)
|
|
30
|
+
} else {
|
|
31
|
+
allRoutes.push(...routes)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return buildSpec(name, version, allRoutes, framework)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseNextJsRoute(filePath, content, root) {
|
|
39
|
+
const routes = []
|
|
40
|
+
const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']
|
|
41
|
+
|
|
42
|
+
// Detect exported HTTP methods: export async function GET(...) or export { GET }
|
|
43
|
+
const exportRe = /export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s*\(/g
|
|
44
|
+
const exportConst = /export\s+const\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s*=/g
|
|
45
|
+
|
|
46
|
+
const foundMethods = new Set()
|
|
47
|
+
let m
|
|
48
|
+
while ((m = exportRe.exec(content)) !== null) foundMethods.add(m[1])
|
|
49
|
+
while ((m = exportConst.exec(content)) !== null) foundMethods.add(m[1])
|
|
50
|
+
|
|
51
|
+
if (foundMethods.size === 0) return routes
|
|
52
|
+
|
|
53
|
+
// Derive the API path from the file path
|
|
54
|
+
const rel = path.relative(root, filePath).replace(/\\/g, '/')
|
|
55
|
+
let apiPath = '/'
|
|
56
|
+
|
|
57
|
+
if (rel.startsWith('app/')) {
|
|
58
|
+
apiPath = '/' + rel
|
|
59
|
+
.replace(/^app\//, '')
|
|
60
|
+
.replace(/\/route\.[jt]s$/, '')
|
|
61
|
+
.replace(/\(([^)]+)\)\//g, '') // remove route groups like (auth)/
|
|
62
|
+
|| '/'
|
|
63
|
+
// Convert Next.js [param] to OpenAPI {param}
|
|
64
|
+
apiPath = '/' + apiPath.replace(/\[([^\]]+)\]/g, '{$1}').replace(/^\/+/, '')
|
|
65
|
+
} else if (rel.startsWith('pages/api/')) {
|
|
66
|
+
apiPath = '/' + rel.replace(/^pages\//, '').replace(/\.[jt]sx?$/, '')
|
|
67
|
+
.replace(/\[([^\]]+)\]/g, '{$1}')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const tag = apiPath.split('/').filter(Boolean)[1] || 'api'
|
|
71
|
+
|
|
72
|
+
for (const method of foundMethods) {
|
|
73
|
+
const params = (apiPath.match(/\{([^}]+)\}/g) || []).map(p => ({
|
|
74
|
+
name: p.slice(1, -1), in: 'path', required: true, schema: { type: 'string' },
|
|
75
|
+
}))
|
|
76
|
+
const route = {
|
|
77
|
+
method,
|
|
78
|
+
path: apiPath,
|
|
79
|
+
tag,
|
|
80
|
+
summary: '',
|
|
81
|
+
operationId: method.toLowerCase() + apiPath.replace(/[^a-zA-Z0-9]/g, '_').replace(/_+/g, '_'),
|
|
82
|
+
parameters: params,
|
|
83
|
+
responses: { '200': { description: 'OK' } },
|
|
84
|
+
}
|
|
85
|
+
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
86
|
+
route.requestBody = { content: { 'application/json': { schema: { type: 'object' } } } }
|
|
87
|
+
}
|
|
88
|
+
routes.push(route)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return routes
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildSpec(name, version, routes, framework) {
|
|
95
|
+
const paths = {}
|
|
96
|
+
|
|
97
|
+
for (const route of routes) {
|
|
98
|
+
if (!paths[route.path]) paths[route.path] = {}
|
|
99
|
+
const method = route.method.toLowerCase()
|
|
100
|
+
if (paths[route.path][method]) continue
|
|
101
|
+
|
|
102
|
+
const operation = { operationId: route.operationId, summary: route.summary, tags: [route.tag] }
|
|
103
|
+
if (route.parameters?.length) operation.parameters = route.parameters
|
|
104
|
+
if (route.requestBody) operation.requestBody = route.requestBody
|
|
105
|
+
operation.responses = route.responses
|
|
106
|
+
|
|
107
|
+
paths[route.path][method] = operation
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
openapi: '3.0.3',
|
|
112
|
+
info: {
|
|
113
|
+
title: name,
|
|
114
|
+
version,
|
|
115
|
+
description: `API documentation auto-generated by DocuTrack. Framework: ${framework}.`,
|
|
116
|
+
},
|
|
117
|
+
paths,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { analyze }
|