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