docutrack 0.1.1 → 0.1.6

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.
@@ -1,694 +1,383 @@
1
- 'use strict'
2
-
3
- const http = require('http')
4
- const https = require('https')
5
- const fs = require('fs')
6
- const path = require('path')
7
- const { findStale } = require('../utils/stale')
8
- const { analyzeComplexity } = require('../analyzer/complexity')
9
- const { analyzeDrift } = require('../utils/drift')
10
-
11
- const HTML_PATH = path.join(__dirname, 'index.html')
12
-
13
- class DocuTrackServer {
14
- constructor(projectRoot, port = 4242) {
15
- this.root = projectRoot
16
- this.port = port
17
- this.sseClients = []
18
- this.generating = false
19
- }
20
-
21
- start() {
22
- this.server = http.createServer((req, res) => this.route(req, res))
23
- this.server.listen(this.port, '127.0.0.1', () => {
24
- console.log(`\n DocuTrack docs → http://localhost:${this.port}\n`)
25
- console.log(' Press Ctrl+C to stop.\n')
26
- })
27
- this.watchDocs()
28
- return this
29
- }
30
-
31
- route(req, res) {
32
- const reqUrl = new URL(req.url, `http://127.0.0.1:${this.port}`)
33
- const p = reqUrl.pathname
34
-
35
- res.setHeader('Access-Control-Allow-Origin', '*')
36
-
37
- if (p === '/' || p === '/index.html') return this.serveShell(res)
38
- if (p === '/api/tree') return this.serveTree(res)
39
- if (p === '/api/content') return this.serveContent(res, reqUrl.searchParams.get('path'))
40
- if (p === '/api/status') return this.serveStatus(res)
41
- if (p === '/api/openapi') return this.serveOpenAPI(res)
42
- if (p === '/api/check') return this.serveCheck(res)
43
- if (p === '/api/complexity') return this.serveComplexity(res)
44
- if (p === '/api/scan' && req.method === 'POST') return this.serveScan(res)
45
- if (p === '/api/generate' && req.method === 'POST') return this.serveGenerate(res, req)
46
- if (p === '/api/generate-arch' && req.method === 'POST') return this.serveGenerateArch(res, req)
47
- if (p === '/api/search') return this.serveSearch(res, reqUrl.searchParams.get('q'))
48
- if (p === '/events') return this.serveSSE(req, res)
49
-
50
- res.writeHead(404, { 'Content-Type': 'text/plain' })
51
- res.end('Not found')
52
- }
53
-
54
- serveShell(res) {
55
- const html = fs.readFileSync(HTML_PATH, 'utf8')
56
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
57
- res.end(html)
58
- }
59
-
60
- serveTree(res) {
61
- res.writeHead(200, { 'Content-Type': 'application/json' })
62
- res.end(JSON.stringify(this.buildTree()))
63
- }
64
-
65
- serveContent(res, filePath) {
66
- if (!filePath) {
67
- res.writeHead(400, { 'Content-Type': 'text/plain' })
68
- return res.end('Missing path parameter')
69
- }
70
-
71
- // Allow only docs/, ARCHITECTURE.md, and a few safe root files
72
- const normalized = filePath.replace(/\\/g, '/').replace(/^\//, '')
73
- const SAFE_ROOT = new Set(['ARCHITECTURE.md', 'package.json', 'README.md'])
74
- const allowed = SAFE_ROOT.has(normalized) || normalized.startsWith('docs/')
75
- if (!allowed) {
76
- res.writeHead(403, { 'Content-Type': 'text/plain' })
77
- return res.end('Forbidden')
78
- }
79
-
80
- const fullPath = path.join(this.root, normalized)
81
- if (!fs.existsSync(fullPath)) {
82
- res.writeHead(404, { 'Content-Type': 'text/plain' })
83
- return res.end('File not found')
84
- }
85
-
86
- const content = fs.readFileSync(fullPath, 'utf8')
87
- res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
88
- res.end(content)
89
- }
90
-
91
- serveOpenAPI(res) {
92
- const specPath = path.join(this.root, 'docs', 'api', 'openapi.json')
93
- if (!fs.existsSync(specPath)) {
94
- res.writeHead(404, { 'Content-Type': 'application/json' })
95
- return res.end(JSON.stringify({ error: 'No spec found. Run: npx docutrack analyze' }))
96
- }
97
- const spec = fs.readFileSync(specPath, 'utf8')
98
- res.writeHead(200, { 'Content-Type': 'application/json' })
99
- res.end(spec)
100
- }
101
-
102
- serveStatus(res) {
103
- const queuePath = path.join(this.root, '.docutrack', 'queue.json')
104
- let queue = { pending: [], lastClear: null }
105
- try {
106
- if (fs.existsSync(queuePath)) queue = JSON.parse(fs.readFileSync(queuePath, 'utf8'))
107
- } catch { /* ignore */ }
108
-
109
- const docCount = this.countDocs()
110
- const stale = findStale(this.root)
111
- const coverage = docCount + queue.pending.length > 0
112
- ? Math.round((docCount / (docCount + queue.pending.length)) * 100)
113
- : 100
114
-
115
- res.writeHead(200, { 'Content-Type': 'application/json' })
116
- res.end(JSON.stringify({
117
- pending: queue.pending.length,
118
- pendingFiles: queue.pending,
119
- docCount,
120
- coverage,
121
- stale: stale.map(s => ({ doc: s.doc, source: s.source, staleSinceMs: s.staleSinceMs })),
122
- }))
123
- }
124
-
125
- serveCheck(res) {
126
- const queuePath = path.join(this.root, '.docutrack', 'queue.json')
127
- let queue = { pending: [] }
128
- try { queue = JSON.parse(fs.readFileSync(queuePath, 'utf8')) } catch { /* ok */ }
129
-
130
- let drift = []
131
- try { drift = analyzeDrift(this.root) } catch { /* ok */ }
132
-
133
- let complexity = { summary: { total: 0, critical: 0, warnings: 0, healthy: 0 }, files: [] }
134
- try { complexity = analyzeComplexity(this.root) } catch { /* ok */ }
135
-
136
- const stale = findStale(this.root)
137
-
138
- const critical = complexity.files.filter(f => f.warnings.some(w => w.level === 'critical'))
139
- .map(f => ({ file: path.relative(this.root, f.file), score: f.score, warnings: f.warnings }))
140
- .slice(0, 10)
141
-
142
- res.writeHead(200, { 'Content-Type': 'application/json' })
143
- res.end(JSON.stringify({
144
- pending: queue.pending.length,
145
- stale: stale.length,
146
- drift: drift.map(d => ({ module: d.module, severity: d.severity, undocumented: d.undocumented.slice(0, 5), orphaned: d.orphaned.slice(0, 5) })),
147
- complexity: { summary: complexity.summary, critical },
148
- ok: queue.pending.length === 0 && stale.length === 0 && drift.filter(d => d.severity === 'high').length === 0 && critical.length === 0,
149
- }))
150
- }
151
-
152
- serveComplexity(res) {
153
- let report = { files: [], summary: { total: 0, critical: 0, warnings: 0, healthy: 0 } }
154
- try { report = analyzeComplexity(this.root) } catch { /* ok */ }
155
-
156
- const top = report.files.filter(f => f.warnings.length > 0).slice(0, 20).map(f => ({
157
- file: path.relative(this.root, f.file),
158
- score: f.score,
159
- lines: f.lines,
160
- exports: f.exports,
161
- complexity: f.complexity,
162
- maxNesting: f.maxNesting,
163
- warnings: f.warnings,
164
- }))
165
-
166
- res.writeHead(200, { 'Content-Type': 'application/json' })
167
- res.end(JSON.stringify({ summary: report.summary, files: top }))
168
- }
169
-
170
- serveScan(res) {
171
- const SOURCE_DIRS = ['src', 'lib', 'app', 'pkg', 'internal', 'api', 'routes', 'controllers', 'handlers']
172
- const SOURCE_EXTS = new Set(['.js', '.ts', '.mjs', '.jsx', '.tsx', '.py', '.go'])
173
- const IGNORE_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'build', '__pycache__', '.docutrack', 'docs', '.worktrees', 'coverage', '.turbo'])
174
- const IGNORE_RE = [/\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /\.d\.ts$/, /\.min\.js$/]
175
-
176
- const found = []
177
- const walk = (dir, depth = 0) => {
178
- if (depth > 6) return
179
- let entries
180
- try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
181
- for (const e of entries) {
182
- if (e.isDirectory()) {
183
- if (!IGNORE_DIRS.has(e.name) && !e.name.startsWith('.')) walk(path.join(dir, e.name), depth + 1)
184
- } else if (e.isFile() && SOURCE_EXTS.has(path.extname(e.name))) {
185
- if (!IGNORE_RE.some(re => re.test(e.name))) {
186
- found.push(path.relative(this.root, path.join(dir, e.name)).replace(/\\/g, '/'))
187
- }
188
- }
189
- }
190
- }
191
-
192
- for (const dir of SOURCE_DIRS) {
193
- const full = path.join(this.root, dir)
194
- if (fs.existsSync(full)) walk(full)
195
- }
196
-
197
- // Load existing queue and add new files
198
- const queuePath = path.join(this.root, '.docutrack', 'queue.json')
199
- let queue = { pending: [], lastClear: null }
200
- try { queue = JSON.parse(fs.readFileSync(queuePath, 'utf8')) } catch { /* ok */ }
201
-
202
- const alreadyQueued = new Set(queue.pending.map(e => e.file))
203
- const newFiles = found.filter(f => !alreadyQueued.has(f))
204
- const now = new Date().toISOString()
205
- for (const f of newFiles) queue.pending.push({ file: f, addedAt: now })
206
-
207
- try {
208
- fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2))
209
- } catch (err) {
210
- res.writeHead(500, { 'Content-Type': 'application/json' })
211
- return res.end(JSON.stringify({ error: err.message }))
212
- }
213
-
214
- // Notify SSE clients so sidebar updates immediately
215
- this.broadcast('reload')
216
-
217
- res.writeHead(200, { 'Content-Type': 'application/json' })
218
- res.end(JSON.stringify({
219
- queued: newFiles.length,
220
- skipped: found.length - newFiles.length,
221
- total: found.length,
222
- files: newFiles.slice(0, 5),
223
- hasMore: newFiles.length > 5,
224
- }))
225
- }
226
-
227
- serveGenerate(res, req) {
228
- if (this.generating) {
229
- res.writeHead(409, { 'Content-Type': 'application/json' })
230
- return res.end(JSON.stringify({ error: 'Generation already in progress' }))
231
- }
232
-
233
- const apiKey = this.readApiKey()
234
- if (!apiKey) {
235
- res.writeHead(400, { 'Content-Type': 'application/json' })
236
- return res.end(JSON.stringify({
237
- error: 'no_api_key',
238
- message: 'ANTHROPIC_API_KEY not found. Set it in your environment or .env.local file.',
239
- }))
240
- }
241
-
242
- // Read request body for options (lang, force)
243
- let body = ''
244
- req.on('data', chunk => { body += chunk })
245
- req.on('end', () => {
246
- let opts = {}
247
- try { opts = JSON.parse(body) } catch { /* ok, use defaults */ }
248
-
249
- const lang = opts.lang || 'es'
250
- const force = !!opts.force
251
-
252
- const queuePath = path.join(this.root, '.docutrack', 'queue.json')
253
- let queue = { pending: [] }
254
- try { queue = JSON.parse(fs.readFileSync(queuePath, 'utf8')) } catch { /* ok */ }
255
-
256
- let files = queue.pending.map(e => e.file)
257
-
258
- // --force: re-queue all source files regardless of existing docs
259
- if (force || files.length === 0) {
260
- files = this.scanSourceFiles()
261
- if (files.length === 0) {
262
- res.writeHead(400, { 'Content-Type': 'application/json' })
263
- return res.end(JSON.stringify({ error: 'No source files found.' }))
264
- }
265
- }
266
-
267
- res.writeHead(200, { 'Content-Type': 'application/json' })
268
- res.end(JSON.stringify({ started: true, total: files.length, lang }))
269
-
270
- this.runGeneration(apiKey, files, queuePath, lang, force).catch(err => {
271
- this.generating = false
272
- this.broadcast(`error:${err.message}`)
273
- })
274
- })
275
- }
276
-
277
- async runGeneration(apiKey, files, queuePath, lang = 'es', force = false) {
278
- this.generating = true
279
- fs.mkdirSync(path.join(this.root, 'docs', 'modules'), { recursive: true })
280
-
281
- let done = 0
282
- for (const file of files) {
283
- const fullPath = path.join(this.root, file)
284
- if (!fs.existsSync(fullPath)) { done++; continue }
285
-
286
- const isRoute = (file.includes('/api/') && (file.endsWith('route.ts') || file.endsWith('route.js')))
287
- const docName = isRoute ? this.routeDocName(file) : this.moduleDocName(file)
288
- const docPath = isRoute
289
- ? path.join(this.root, 'docs', 'api', docName + '.md')
290
- : path.join(this.root, 'docs', 'modules', docName + '.md')
291
-
292
- // Skip existing docs unless force=true
293
- if (!force && fs.existsSync(docPath) && fs.readFileSync(docPath, 'utf8').length > 200) {
294
- done++
295
- this.broadcast(`progress:${done}/${files.length}:skip:${file}`)
296
- continue
297
- }
298
-
299
- this.broadcast(`progress:${done}/${files.length}:working:${file}`)
300
-
301
- let content
302
- try { content = fs.readFileSync(fullPath, 'utf8') } catch { done++; continue }
303
-
304
- if (content.length > 8000) content = content.slice(0, 8000) + '\n// ... (truncated)'
305
-
306
- try {
307
- const doc = await this.generateDoc(apiKey, file, content, isRoute, lang)
308
- fs.mkdirSync(path.dirname(docPath), { recursive: true })
309
- fs.writeFileSync(docPath, doc)
310
- done++
311
- this.broadcast(`progress:${done}/${files.length}:done:${file}`)
312
- // NO per-file reload — sidebar updates in one shot at the end
313
- } catch (err) {
314
- done++
315
- this.broadcast(`progress:${done}/${files.length}:error:${file}`)
316
- }
317
- }
318
-
319
- // Clear the queue
320
- try { fs.writeFileSync(queuePath, JSON.stringify({ pending: [], lastClear: new Date().toISOString() }, null, 2)) } catch { /* ok */ }
321
-
322
- this.generating = false
323
- this.broadcast('reload')
324
- this.broadcast(`done:${done}`)
325
- }
326
-
327
- async generateDoc(apiKey, file, content, isRoute, lang = 'es') {
328
- const name = path.basename(file, path.extname(file))
329
- const ext = path.extname(file).slice(1)
330
-
331
- const langInstruction = lang === 'es'
332
- ? 'Escribe toda la documentación en español. Los títulos de sección también en español.'
333
- : 'Write all documentation in English.'
334
-
335
- const systemPrompt = `You are a technical writer generating concise module documentation for a software project.
336
- Output ONLY the markdown document, no preamble or explanation.
337
- ${langInstruction}`
338
-
339
- const userPrompt = isRoute
340
- ? `Document this API route file. File: ${file}
341
-
342
- \`\`\`${ext}
343
- ${content}
344
- \`\`\`
345
-
346
- Write a markdown doc with:
347
- # ${name} API
348
-
349
- **Route**: \`${this.fileToApiPath(file)}\`
350
-
351
- ## Endpoints
352
- [List each exported HTTP method (GET/POST/etc), what it does, request params/body, response shape]
353
-
354
- ## Auth
355
- [Authentication/authorization requirements if visible]
356
-
357
- ## Notes
358
- [Anything non-obvious about this route]
359
-
360
- Keep it concise and technical.`
361
- : `Document this source file. File: ${file}
362
-
363
- \`\`\`${ext}
364
- ${content}
365
- \`\`\`
366
-
367
- Write a markdown doc with:
368
- # ${name}
369
-
370
- **Responsibility**: [one sentence — what this module does]
371
-
372
- ## Public API
373
- [exported functions/classes with brief description of params and return value]
374
-
375
- ## Dependencies
376
- [what it imports from — internal and external]
377
-
378
- ## Data Shapes
379
- [key types, interfaces, schemas, Prisma models if relevant]
380
-
381
- ## Notes
382
- [constraints, gotchas, non-obvious decisions — omit if nothing notable]
383
-
384
- Keep it concise. Skip sections that don't apply.`
385
-
386
- const response = await this.callClaude(apiKey, systemPrompt, userPrompt)
387
- return response
388
- }
389
-
390
- callClaude(apiKey, system, user) {
391
- return new Promise((resolve, reject) => {
392
- const body = JSON.stringify({
393
- model: 'claude-haiku-4-5-20251001',
394
- max_tokens: 1024,
395
- system,
396
- messages: [{ role: 'user', content: user }],
397
- })
398
-
399
- const req = https.request({
400
- hostname: 'api.anthropic.com',
401
- path: '/v1/messages',
402
- method: 'POST',
403
- headers: {
404
- 'Content-Type': 'application/json',
405
- 'x-api-key': apiKey,
406
- 'anthropic-version': '2023-06-01',
407
- 'Content-Length': Buffer.byteLength(body),
408
- },
409
- }, (res) => {
410
- let data = ''
411
- res.on('data', chunk => { data += chunk })
412
- res.on('end', () => {
413
- try {
414
- const r = JSON.parse(data)
415
- if (r.content?.[0]?.text) resolve(r.content[0].text)
416
- else reject(new Error(r.error?.message || `API error ${res.statusCode}`))
417
- } catch (e) { reject(e) }
418
- })
419
- })
420
- req.on('error', reject)
421
- req.write(body)
422
- req.end()
423
- })
424
- }
425
-
426
- readApiKey() {
427
- if (process.env.ANTHROPIC_API_KEY) return process.env.ANTHROPIC_API_KEY
428
- for (const envFile of ['.env.local', '.env']) {
429
- const p = path.join(this.root, envFile)
430
- if (!fs.existsSync(p)) continue
431
- for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
432
- const m = line.match(/^ANTHROPIC_API_KEY\s*=\s*(.+)/)
433
- if (m) return m[1].trim().replace(/^["']|["']$/g, '')
434
- }
435
- }
436
- return null
437
- }
438
-
439
- serveGenerateArch(res, req) {
440
- const apiKey = this.readApiKey()
441
- if (!apiKey) {
442
- res.writeHead(400, { 'Content-Type': 'application/json' })
443
- return res.end(JSON.stringify({ error: 'no_api_key' }))
444
- }
445
-
446
- let body = ''
447
- req.on('data', chunk => { body += chunk })
448
- req.on('end', async () => {
449
- let opts = {}
450
- try { opts = JSON.parse(body) } catch { /* ok */ }
451
- const lang = opts.lang || 'es'
452
-
453
- // Gather project context
454
- let pkg = {}
455
- try { pkg = JSON.parse(fs.readFileSync(path.join(this.root, 'package.json'), 'utf8')) } catch { /* ok */ }
456
-
457
- const files = this.scanSourceFiles().slice(0, 80)
458
- const fileTree = files.join('\n')
459
-
460
- // Read a few key files for context
461
- const contextFiles = []
462
- for (const f of files.slice(0, 6)) {
463
- try {
464
- const content = fs.readFileSync(path.join(this.root, f), 'utf8').slice(0, 1500)
465
- contextFiles.push(`### ${f}\n\`\`\`\n${content}\n\`\`\``)
466
- } catch { /* ok */ }
467
- }
468
-
469
- // Read existing ARCHITECTURE.md (the template)
470
- let existingArch = ''
471
- try { existingArch = fs.readFileSync(path.join(this.root, 'ARCHITECTURE.md'), 'utf8') } catch { /* ok */ }
472
-
473
- const langInstruction = lang === 'es'
474
- ? 'Escribe toda la documentación en español. Los títulos de sección también en español.'
475
- : 'Write all documentation in English.'
476
-
477
- const system = `You are a senior software architect writing project documentation.
478
- Output ONLY the markdown document. No preamble, no explanation.
479
- ${langInstruction}`
480
-
481
- const user = `Fill in this ARCHITECTURE.md for a real project. Replace ALL placeholder content with real information derived from the project files below.
482
-
483
- Package.json:
484
- \`\`\`json
485
- ${JSON.stringify({ name: pkg.name, description: pkg.description, dependencies: pkg.dependencies, devDependencies: pkg.devDependencies }, null, 2).slice(0, 2000)}
486
- \`\`\`
487
-
488
- Source file list (${files.length} files):
489
- \`\`\`
490
- ${fileTree}
491
- \`\`\`
492
-
493
- Sample source files:
494
- ${contextFiles.join('\n\n')}
495
-
496
- Current ARCHITECTURE.md template to fill in:
497
- ${existingArch}
498
-
499
- Instructions:
500
- - Fill every empty table cell and placeholder comment with real content derived from the project
501
- - For the Tech Stack table: detect framework, styling, auth, database, ORM from package.json dependencies
502
- - For Module Map: list the most important modules from the file list with their actual responsibilities
503
- - For App Structure: show the real directory tree
504
- - For Data Flow: describe the actual flow based on the code
505
- - Keep the same markdown structure and headers
506
- - Remove placeholder comments like <!-- Describe... -->
507
- - If a section truly doesn't apply, write "N/A" rather than leaving it blank`
508
-
509
- try {
510
- const arch = await this.callClaude(apiKey, system, user)
511
- fs.writeFileSync(path.join(this.root, 'ARCHITECTURE.md'), arch)
512
- this.broadcast('reload')
513
- res.writeHead(200, { 'Content-Type': 'application/json' })
514
- res.end(JSON.stringify({ ok: true }))
515
- } catch (err) {
516
- res.writeHead(500, { 'Content-Type': 'application/json' })
517
- res.end(JSON.stringify({ error: err.message }))
518
- }
519
- })
520
- }
521
-
522
- scanSourceFiles() {
523
- const SOURCE_DIRS = ['src', 'lib', 'app', 'pkg', 'internal', 'api', 'routes', 'controllers', 'handlers']
524
- const SOURCE_EXTS = new Set(['.js', '.ts', '.mjs', '.jsx', '.tsx', '.py', '.go'])
525
- const IGNORE_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'build', '__pycache__', '.docutrack', 'docs', '.worktrees', 'coverage', '.turbo'])
526
- const IGNORE_RE = [/\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /\.d\.ts$/, /\.min\.js$/]
527
- const found = []
528
- const walk = (dir, depth = 0) => {
529
- if (depth > 6) return
530
- let entries
531
- try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
532
- for (const e of entries) {
533
- if (e.isDirectory()) {
534
- if (!IGNORE_DIRS.has(e.name) && !e.name.startsWith('.')) walk(path.join(dir, e.name), depth + 1)
535
- } else if (e.isFile() && SOURCE_EXTS.has(path.extname(e.name))) {
536
- if (!IGNORE_RE.some(re => re.test(e.name))) {
537
- found.push(path.relative(this.root, path.join(dir, e.name)).replace(/\\/g, '/'))
538
- }
539
- }
540
- }
541
- }
542
- for (const dir of SOURCE_DIRS) {
543
- const full = path.join(this.root, dir)
544
- if (fs.existsSync(full)) walk(full)
545
- }
546
- return found
547
- }
548
-
549
- moduleDocName(file) {
550
- // app/dashboard/SearchBar.tsx → dashboard-SearchBar
551
- // lib/rules-engine.ts → rules-engine
552
- // src/utils/queue.js → utils-queue
553
- const noExt = file.replace(/\.[^.]+$/, '')
554
- const parts = noExt.replace(/\\/g, '/').split('/')
555
- // Drop leading src/lib/app if only one level below
556
- if (['src', 'lib', 'app'].includes(parts[0]) && parts.length === 2) return parts[1]
557
- // For deeper paths, join last 2 segments with dash
558
- return parts.slice(-2).join('-')
559
- }
560
-
561
- routeDocName(file) {
562
- return file
563
- .replace(/^app\/api\//, '')
564
- .replace(/\/route\.[jt]s$/, '')
565
- .replace(/\[([^\]]+)\]/g, '$1')
566
- .replace(/\//g, '-') || 'api'
567
- }
568
-
569
- fileToApiPath(file) {
570
- return '/' + file
571
- .replace(/^app\//, '')
572
- .replace(/\/route\.[jt]s$/, '')
573
- .replace(/\[([^\]]+)\]/g, '{$1}')
574
- }
575
-
576
- serveSearch(res, q) {
577
- res.writeHead(200, { 'Content-Type': 'application/json' })
578
- if (!q || q.length < 2) return res.end(JSON.stringify([]))
579
-
580
- const lq = q.toLowerCase()
581
- const results = []
582
-
583
- const searchFile = (fullPath, relPath) => {
584
- if (results.length >= 8) return
585
- try {
586
- const content = fs.readFileSync(fullPath, 'utf8')
587
- const lc = content.toLowerCase()
588
- const idx = lc.indexOf(lq)
589
- if (idx === -1) return
590
- const start = Math.max(0, idx - 50)
591
- const end = Math.min(content.length, idx + lq.length + 100)
592
- const snippet = (start > 0 ? '…' : '') + content.slice(start, end).replace(/[#*`\n]/g, ' ').replace(/\s+/g, ' ').trim() + (end < content.length ? '…' : '')
593
- const titleMatch = content.match(/^#\s+(.+)/m)
594
- const title = titleMatch ? titleMatch[1].trim() : path.basename(relPath, '.md')
595
- results.push({ path: relPath.replace(/\\/g, '/'), title, snippet })
596
- } catch { /* skip */ }
597
- }
598
-
599
- const archPath = path.join(this.root, 'ARCHITECTURE.md')
600
- if (fs.existsSync(archPath)) searchFile(archPath, 'ARCHITECTURE.md')
601
-
602
- const walk = (dir) => {
603
- if (results.length >= 8 || !fs.existsSync(dir)) return
604
- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
605
- if (e.isDirectory()) walk(path.join(dir, e.name))
606
- else if (e.name.endsWith('.md') && e.name !== '.gitkeep') {
607
- const full = path.join(dir, e.name)
608
- searchFile(full, path.relative(this.root, full))
609
- }
610
- }
611
- }
612
- walk(path.join(this.root, 'docs'))
613
-
614
- res.end(JSON.stringify(results))
615
- }
616
-
617
- serveSSE(req, res) {
618
- res.writeHead(200, {
619
- 'Content-Type': 'text/event-stream',
620
- 'Cache-Control': 'no-cache',
621
- 'Connection': 'keep-alive',
622
- })
623
- res.write('data: connected\n\n')
624
-
625
- this.sseClients.push(res)
626
- req.on('close', () => {
627
- this.sseClients = this.sseClients.filter(c => c !== res)
628
- })
629
- }
630
-
631
- broadcast(event) {
632
- for (const client of this.sseClients) {
633
- client.write(`data: ${event}\n\n`)
634
- }
635
- }
636
-
637
- buildTree() {
638
- const tree = { architecture: null, modules: [], decisions: [], api: [] }
639
-
640
- const arch = path.join(this.root, 'ARCHITECTURE.md')
641
- if (fs.existsSync(arch)) tree.architecture = 'ARCHITECTURE.md'
642
-
643
- const readDir = (rel, key) => {
644
- const full = path.join(this.root, rel)
645
- if (!fs.existsSync(full)) return
646
- for (const e of fs.readdirSync(full, { withFileTypes: true })) {
647
- if (e.isFile() && e.name.endsWith('.md') && e.name !== '.gitkeep') {
648
- tree[key].push({ path: `${rel}/${e.name}`, name: e.name.replace('.md', '') })
649
- }
650
- }
651
- tree[key].sort((a, b) => a.name.localeCompare(b.name))
652
- }
653
-
654
- readDir('docs/modules', 'modules')
655
- readDir('docs/decisions', 'decisions')
656
- readDir('docs/api', 'api')
657
-
658
- return tree
659
- }
660
-
661
- countDocs() {
662
- let n = 0
663
- const walk = (dir) => {
664
- if (!fs.existsSync(dir)) return
665
- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
666
- if (e.isDirectory()) walk(path.join(dir, e.name))
667
- else if (e.name.endsWith('.md') && e.name !== '.gitkeep') n++
668
- }
669
- }
670
- walk(path.join(this.root, 'docs'))
671
- return n
672
- }
673
-
674
- watchDocs() {
675
- const debounce = (fn, ms) => {
676
- let t
677
- return () => { clearTimeout(t); t = setTimeout(fn, ms) }
678
- }
679
- const reload = debounce(() => this.broadcast('reload'), 300)
680
-
681
- const targets = [
682
- path.join(this.root, 'docs'),
683
- path.join(this.root, 'ARCHITECTURE.md'),
684
- path.join(this.root, '.docutrack', 'queue.json'),
685
- ]
686
- for (const t of targets) {
687
- if (fs.existsSync(t)) {
688
- try { fs.watch(t, { recursive: true }, reload) } catch { /* ignore */ }
689
- }
690
- }
691
- }
692
- }
693
-
694
- module.exports = DocuTrackServer
1
+ 'use strict'
2
+
3
+ const http = require('http')
4
+ const fs = require('fs')
5
+ const path = require('path')
6
+ const { findStale } = require('../utils/stale')
7
+ const { analyzeComplexity } = require('../analyzer/complexity')
8
+ const { analyzeDrift } = require('../utils/drift')
9
+
10
+ const HTML_PATH = path.join(__dirname, 'index.html')
11
+
12
+ class DocuTrackServer {
13
+ constructor(projectRoot, port = 4242) {
14
+ this.root = projectRoot
15
+ this.port = port
16
+ this.sseClients = []
17
+ }
18
+
19
+ start() {
20
+ this.server = http.createServer((req, res) => this.route(req, res))
21
+ this.server.listen(this.port, '127.0.0.1', () => {
22
+ console.log(`\n DocuTrack docs http://localhost:${this.port}\n`)
23
+ console.log(' Press Ctrl+C to stop.\n')
24
+ })
25
+ this.watchDocs()
26
+ return this
27
+ }
28
+
29
+ route(req, res) {
30
+ const reqUrl = new URL(req.url, `http://127.0.0.1:${this.port}`)
31
+ const p = reqUrl.pathname
32
+
33
+ res.setHeader('Access-Control-Allow-Origin', '*')
34
+
35
+ if (p === '/' || p === '/index.html') return this.serveShell(res)
36
+ if (p === '/api/tree') return this.serveTree(res)
37
+ if (p === '/api/content') return this.serveContent(res, reqUrl.searchParams.get('path'))
38
+ if (p === '/api/status') return this.serveStatus(res)
39
+ if (p === '/api/openapi') return this.serveOpenAPI(res)
40
+ if (p === '/api/check') return this.serveCheck(res)
41
+ if (p === '/api/complexity') return this.serveComplexity(res)
42
+ if (p === '/api/scan' && req.method === 'POST') return this.serveScan(res)
43
+ if (p === '/api/generate' && req.method === 'POST') return this.serveGenerate(res, req)
44
+ if (p === '/api/search') return this.serveSearch(res, reqUrl.searchParams.get('q'))
45
+ if (p === '/events') return this.serveSSE(req, res)
46
+
47
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
48
+ res.end('Not found')
49
+ }
50
+
51
+ serveShell(res) {
52
+ const html = fs.readFileSync(HTML_PATH, 'utf8')
53
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
54
+ res.end(html)
55
+ }
56
+
57
+ serveTree(res) {
58
+ res.writeHead(200, { 'Content-Type': 'application/json' })
59
+ res.end(JSON.stringify(this.buildTree()))
60
+ }
61
+
62
+ serveContent(res, filePath) {
63
+ if (!filePath) {
64
+ res.writeHead(400, { 'Content-Type': 'text/plain' })
65
+ return res.end('Missing path parameter')
66
+ }
67
+
68
+ // Allow only docs/, ARCHITECTURE.md, and a few safe root files
69
+ const normalized = filePath.replace(/\\/g, '/').replace(/^\//, '')
70
+ const SAFE_ROOT = new Set(['ARCHITECTURE.md', 'package.json', 'README.md'])
71
+ const allowed = SAFE_ROOT.has(normalized) || normalized.startsWith('docs/')
72
+ if (!allowed) {
73
+ res.writeHead(403, { 'Content-Type': 'text/plain' })
74
+ return res.end('Forbidden')
75
+ }
76
+
77
+ const fullPath = path.join(this.root, normalized)
78
+ if (!fs.existsSync(fullPath)) {
79
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
80
+ return res.end('File not found')
81
+ }
82
+
83
+ const content = fs.readFileSync(fullPath, 'utf8')
84
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
85
+ res.end(content)
86
+ }
87
+
88
+ serveOpenAPI(res) {
89
+ const specPath = path.join(this.root, 'docs', 'api', 'openapi.json')
90
+ if (!fs.existsSync(specPath)) {
91
+ res.writeHead(404, { 'Content-Type': 'application/json' })
92
+ return res.end(JSON.stringify({ error: 'No spec found. Run: npx docutrack analyze' }))
93
+ }
94
+ const spec = fs.readFileSync(specPath, 'utf8')
95
+ res.writeHead(200, { 'Content-Type': 'application/json' })
96
+ res.end(spec)
97
+ }
98
+
99
+ serveStatus(res) {
100
+ const queuePath = path.join(this.root, '.docutrack', 'queue.json')
101
+ let queue = { pending: [], lastClear: null }
102
+ try {
103
+ if (fs.existsSync(queuePath)) queue = JSON.parse(fs.readFileSync(queuePath, 'utf8'))
104
+ } catch { /* ignore */ }
105
+
106
+ const docCount = this.countDocs()
107
+ const stale = findStale(this.root)
108
+ const coverage = docCount + queue.pending.length > 0
109
+ ? Math.round((docCount / (docCount + queue.pending.length)) * 100)
110
+ : 100
111
+
112
+ res.writeHead(200, { 'Content-Type': 'application/json' })
113
+ res.end(JSON.stringify({
114
+ pending: queue.pending.length,
115
+ pendingFiles: queue.pending,
116
+ docCount,
117
+ coverage,
118
+ stale: stale.map(s => ({ doc: s.doc, source: s.source, staleSinceMs: s.staleSinceMs })),
119
+ }))
120
+ }
121
+
122
+ serveCheck(res) {
123
+ const queuePath = path.join(this.root, '.docutrack', 'queue.json')
124
+ let queue = { pending: [] }
125
+ try { queue = JSON.parse(fs.readFileSync(queuePath, 'utf8')) } catch { /* ok */ }
126
+
127
+ let drift = []
128
+ try { drift = analyzeDrift(this.root) } catch { /* ok */ }
129
+
130
+ let complexity = { summary: { total: 0, critical: 0, warnings: 0, healthy: 0 }, files: [] }
131
+ try { complexity = analyzeComplexity(this.root) } catch { /* ok */ }
132
+
133
+ const stale = findStale(this.root)
134
+
135
+ const critical = complexity.files.filter(f => f.warnings.some(w => w.level === 'critical'))
136
+ .map(f => ({ file: path.relative(this.root, f.file), score: f.score, warnings: f.warnings }))
137
+ .slice(0, 10)
138
+
139
+ res.writeHead(200, { 'Content-Type': 'application/json' })
140
+ res.end(JSON.stringify({
141
+ pending: queue.pending.length,
142
+ stale: stale.length,
143
+ drift: drift.map(d => ({ module: d.module, severity: d.severity, undocumented: d.undocumented.slice(0, 5), orphaned: d.orphaned.slice(0, 5) })),
144
+ complexity: { summary: complexity.summary, critical },
145
+ ok: queue.pending.length === 0 && stale.length === 0 && drift.filter(d => d.severity === 'high').length === 0 && critical.length === 0,
146
+ }))
147
+ }
148
+
149
+ serveComplexity(res) {
150
+ let report = { files: [], summary: { total: 0, critical: 0, warnings: 0, healthy: 0 } }
151
+ try { report = analyzeComplexity(this.root) } catch { /* ok */ }
152
+
153
+ const top = report.files.filter(f => f.warnings.length > 0).slice(0, 20).map(f => ({
154
+ file: path.relative(this.root, f.file),
155
+ score: f.score,
156
+ lines: f.lines,
157
+ exports: f.exports,
158
+ complexity: f.complexity,
159
+ maxNesting: f.maxNesting,
160
+ warnings: f.warnings,
161
+ }))
162
+
163
+ res.writeHead(200, { 'Content-Type': 'application/json' })
164
+ res.end(JSON.stringify({ summary: report.summary, files: top }))
165
+ }
166
+
167
+ serveScan(res) {
168
+ const SOURCE_DIRS = ['src', 'lib', 'app', 'pkg', 'internal', 'api', 'routes', 'controllers', 'handlers', 'packages']
169
+ const SOURCE_EXTS = new Set(['.js', '.ts', '.mjs', '.jsx', '.tsx', '.py', '.go'])
170
+ const IGNORE_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'build', '__pycache__', '.docutrack', 'docs', '.worktrees', 'coverage', '.turbo'])
171
+ const IGNORE_RE = [/\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /\.d\.ts$/, /\.min\.js$/]
172
+
173
+ const found = []
174
+ const walk = (dir, depth = 0) => {
175
+ if (depth > 6) return
176
+ let entries
177
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
178
+ for (const e of entries) {
179
+ if (e.isDirectory()) {
180
+ if (!IGNORE_DIRS.has(e.name) && !e.name.startsWith('.')) walk(path.join(dir, e.name), depth + 1)
181
+ } else if (e.isFile() && SOURCE_EXTS.has(path.extname(e.name))) {
182
+ if (!IGNORE_RE.some(re => re.test(e.name))) {
183
+ found.push(path.relative(this.root, path.join(dir, e.name)).replace(/\\/g, '/'))
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ for (const dir of SOURCE_DIRS) {
190
+ const full = path.join(this.root, dir)
191
+ if (fs.existsSync(full)) walk(full)
192
+ }
193
+
194
+ // Load existing queue and add new files
195
+ const queuePath = path.join(this.root, '.docutrack', 'queue.json')
196
+ let queue = { pending: [], lastClear: null }
197
+ try { queue = JSON.parse(fs.readFileSync(queuePath, 'utf8')) } catch { /* ok */ }
198
+
199
+ const alreadyQueued = new Set(queue.pending.map(e => e.file))
200
+ const newFiles = found.filter(f => !alreadyQueued.has(f))
201
+ const now = new Date().toISOString()
202
+ for (const f of newFiles) queue.pending.push({ file: f, addedAt: now })
203
+
204
+ try {
205
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2))
206
+ } catch (err) {
207
+ res.writeHead(500, { 'Content-Type': 'application/json' })
208
+ return res.end(JSON.stringify({ error: err.message }))
209
+ }
210
+
211
+ // Notify SSE clients so sidebar updates immediately
212
+ this.broadcast('reload')
213
+
214
+ res.writeHead(200, { 'Content-Type': 'application/json' })
215
+ res.end(JSON.stringify({
216
+ queued: newFiles.length,
217
+ skipped: found.length - newFiles.length,
218
+ total: found.length,
219
+ files: newFiles.slice(0, 5),
220
+ hasMore: newFiles.length > 5,
221
+ }))
222
+ }
223
+
224
+ serveGenerate(res, req) {
225
+ let body = ''
226
+ req.on('data', c => { body += c })
227
+ req.on('end', () => {
228
+ try {
229
+ const { lang = 'es', force = false } = JSON.parse(body || '{}')
230
+ const triggerPath = path.join(this.root, '.docutrack', 'generate.trigger')
231
+ fs.writeFileSync(triggerPath, JSON.stringify({ lang, force, requestedAt: new Date().toISOString() }))
232
+ } catch { /* ok */ }
233
+ res.writeHead(200, { 'Content-Type': 'application/json' })
234
+ res.end(JSON.stringify({ triggered: true }))
235
+ })
236
+ }
237
+
238
+ moduleDocName(file) {
239
+ // app/dashboard/SearchBar.tsx → dashboard-SearchBar
240
+ // lib/rules-engine.ts → rules-engine
241
+ // src/utils/queue.js → utils-queue
242
+ const noExt = file.replace(/\.[^.]+$/, '')
243
+ const parts = noExt.replace(/\\/g, '/').split('/')
244
+ // Drop leading src/lib/app if only one level below
245
+ if (['src', 'lib', 'app'].includes(parts[0]) && parts.length === 2) return parts[1]
246
+ // For deeper paths, join last 2 segments with dash
247
+ return parts.slice(-2).join('-')
248
+ }
249
+
250
+ routeDocName(file) {
251
+ return file
252
+ .replace(/^app\/api\//, '')
253
+ .replace(/\/route\.[jt]s$/, '')
254
+ .replace(/\[([^\]]+)\]/g, '$1')
255
+ .replace(/\//g, '-') || 'api'
256
+ }
257
+
258
+ fileToApiPath(file) {
259
+ return '/' + file
260
+ .replace(/^app\//, '')
261
+ .replace(/\/route\.[jt]s$/, '')
262
+ .replace(/\[([^\]]+)\]/g, '{$1}')
263
+ }
264
+
265
+ serveSearch(res, q) {
266
+ res.writeHead(200, { 'Content-Type': 'application/json' })
267
+ if (!q || q.length < 2) return res.end(JSON.stringify([]))
268
+
269
+ const lq = q.toLowerCase()
270
+ const results = []
271
+
272
+ const searchFile = (fullPath, relPath) => {
273
+ if (results.length >= 8) return
274
+ try {
275
+ const content = fs.readFileSync(fullPath, 'utf8')
276
+ const lc = content.toLowerCase()
277
+ const idx = lc.indexOf(lq)
278
+ if (idx === -1) return
279
+ const start = Math.max(0, idx - 50)
280
+ const end = Math.min(content.length, idx + lq.length + 100)
281
+ const snippet = (start > 0 ? '…' : '') + content.slice(start, end).replace(/[#*`\n]/g, ' ').replace(/\s+/g, ' ').trim() + (end < content.length ? '…' : '')
282
+ const titleMatch = content.match(/^#\s+(.+)/m)
283
+ const title = titleMatch ? titleMatch[1].trim() : path.basename(relPath, '.md')
284
+ results.push({ path: relPath.replace(/\\/g, '/'), title, snippet })
285
+ } catch { /* skip */ }
286
+ }
287
+
288
+ const archPath = path.join(this.root, 'ARCHITECTURE.md')
289
+ if (fs.existsSync(archPath)) searchFile(archPath, 'ARCHITECTURE.md')
290
+
291
+ const walk = (dir) => {
292
+ if (results.length >= 8 || !fs.existsSync(dir)) return
293
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
294
+ if (e.isDirectory()) walk(path.join(dir, e.name))
295
+ else if (e.name.endsWith('.md') && e.name !== '.gitkeep') {
296
+ const full = path.join(dir, e.name)
297
+ searchFile(full, path.relative(this.root, full))
298
+ }
299
+ }
300
+ }
301
+ walk(path.join(this.root, 'docs'))
302
+
303
+ res.end(JSON.stringify(results))
304
+ }
305
+
306
+ serveSSE(req, res) {
307
+ res.writeHead(200, {
308
+ 'Content-Type': 'text/event-stream',
309
+ 'Cache-Control': 'no-cache',
310
+ 'Connection': 'keep-alive',
311
+ })
312
+ res.write('data: connected\n\n')
313
+
314
+ this.sseClients.push(res)
315
+ req.on('close', () => {
316
+ this.sseClients = this.sseClients.filter(c => c !== res)
317
+ })
318
+ }
319
+
320
+ broadcast(event) {
321
+ for (const client of this.sseClients) {
322
+ client.write(`data: ${event}\n\n`)
323
+ }
324
+ }
325
+
326
+ buildTree() {
327
+ const tree = { architecture: null, modules: [], decisions: [], api: [] }
328
+
329
+ const arch = path.join(this.root, 'ARCHITECTURE.md')
330
+ if (fs.existsSync(arch)) tree.architecture = 'ARCHITECTURE.md'
331
+
332
+ const readDir = (rel, key) => {
333
+ const full = path.join(this.root, rel)
334
+ if (!fs.existsSync(full)) return
335
+ for (const e of fs.readdirSync(full, { withFileTypes: true })) {
336
+ if (e.isFile() && e.name.endsWith('.md') && e.name !== '.gitkeep') {
337
+ tree[key].push({ path: `${rel}/${e.name}`, name: e.name.replace('.md', '') })
338
+ }
339
+ }
340
+ tree[key].sort((a, b) => a.name.localeCompare(b.name))
341
+ }
342
+
343
+ readDir('docs/modules', 'modules')
344
+ readDir('docs/decisions', 'decisions')
345
+ readDir('docs/api', 'api')
346
+
347
+ return tree
348
+ }
349
+
350
+ countDocs() {
351
+ let n = 0
352
+ const walk = (dir) => {
353
+ if (!fs.existsSync(dir)) return
354
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
355
+ if (e.isDirectory()) walk(path.join(dir, e.name))
356
+ else if (e.name.endsWith('.md') && e.name !== '.gitkeep') n++
357
+ }
358
+ }
359
+ walk(path.join(this.root, 'docs'))
360
+ return n
361
+ }
362
+
363
+ watchDocs() {
364
+ const debounce = (fn, ms) => {
365
+ let t
366
+ return () => { clearTimeout(t); t = setTimeout(fn, ms) }
367
+ }
368
+ const reload = debounce(() => this.broadcast('reload'), 300)
369
+
370
+ const targets = [
371
+ path.join(this.root, 'docs'),
372
+ path.join(this.root, 'ARCHITECTURE.md'),
373
+ path.join(this.root, '.docutrack', 'queue.json'),
374
+ ]
375
+ for (const t of targets) {
376
+ if (fs.existsSync(t)) {
377
+ try { fs.watch(t, { recursive: true }, reload) } catch { /* ignore */ }
378
+ }
379
+ }
380
+ }
381
+ }
382
+
383
+ module.exports = DocuTrackServer