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.
Files changed (50) hide show
  1. package/README.md +116 -0
  2. package/bin/docutrack.js +67 -0
  3. package/package.json +38 -0
  4. package/src/analyzer/complexity.js +145 -0
  5. package/src/analyzer/detect.js +124 -0
  6. package/src/analyzer/index.js +121 -0
  7. package/src/analyzer/parsers/express.js +110 -0
  8. package/src/analyzer/parsers/fastapi.js +89 -0
  9. package/src/commands/analyze.js +47 -0
  10. package/src/commands/badge.js +79 -0
  11. package/src/commands/check.js +187 -0
  12. package/src/commands/clear.js +17 -0
  13. package/src/commands/export.js +182 -0
  14. package/src/commands/init.js +182 -0
  15. package/src/commands/onboard.js +288 -0
  16. package/src/commands/scan.js +121 -0
  17. package/src/commands/serve.js +48 -0
  18. package/src/commands/status.js +94 -0
  19. package/src/utils/drift.js +167 -0
  20. package/src/utils/queue.js +62 -0
  21. package/src/utils/settings.js +69 -0
  22. package/src/utils/stale.js +80 -0
  23. package/src/viewer/index.html +1411 -0
  24. package/src/viewer/server.js +652 -0
  25. package/templates/ARCHITECTURE.md +51 -0
  26. package/templates/agents/documentalista.md +113 -0
  27. package/templates/claude-snippet.md +39 -0
  28. package/templates/commands/adr-new.md +58 -0
  29. package/templates/commands/arch-review.md +59 -0
  30. package/templates/commands/ask-docs.md +26 -0
  31. package/templates/commands/doc-map.md +50 -0
  32. package/templates/docs/api/.gitkeep +0 -0
  33. package/templates/docs/decisions/.gitkeep +0 -0
  34. package/templates/docs/modules/.gitkeep +0 -0
  35. package/templates/docutrack.config.json +13 -0
  36. package/templates/github/workflows/docutrack-docs.yml +42 -0
  37. package/templates/github/workflows/docutrack-gate.yml +31 -0
  38. package/templates/github/workflows/docutrack-pr.yml +93 -0
  39. package/templates/hooks/on-stop.js +39 -0
  40. package/templates/hooks/post-tool-use.js +52 -0
  41. package/templates/stacks/express/ARCHITECTURE.md +67 -0
  42. package/templates/stacks/express/documentalista.md +63 -0
  43. package/templates/stacks/fastapi/ARCHITECTURE.md +68 -0
  44. package/templates/stacks/fastapi/documentalista.md +88 -0
  45. package/templates/stacks/go/ARCHITECTURE.md +68 -0
  46. package/templates/stacks/go/documentalista.md +89 -0
  47. package/templates/stacks/monorepo/ARCHITECTURE.md +60 -0
  48. package/templates/stacks/monorepo/documentalista.md +59 -0
  49. package/templates/stacks/nextjs/ARCHITECTURE.md +76 -0
  50. 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)
@@ -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 }