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,121 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { write: writeQueue, read: readQueue } = require('../utils/queue')
6
+
7
+ const SOURCE_DIRS = ['src', 'lib', 'app', 'pkg', 'internal', 'api', 'routes', 'controllers', 'handlers']
8
+ const SOURCE_EXTS = new Set(['.js', '.ts', '.mjs', '.jsx', '.tsx', '.py', '.go'])
9
+ const IGNORE_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'build', '__pycache__', '.docutrack', 'docs', '.worktrees', 'coverage', '.turbo'])
10
+ const IGNORE_PATTERNS = [/\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /\.d\.ts$/, /\.min\.js$/]
11
+
12
+ async function run(args) {
13
+ if (!fs.existsSync('.docutrack')) {
14
+ console.log('\nDocuTrack is not initialized. Run "npx docutrack init" first.\n')
15
+ process.exit(1)
16
+ }
17
+
18
+ const root = process.cwd()
19
+ const force = args.includes('--force')
20
+ const dryRun = args.includes('--dry-run')
21
+
22
+ console.log('\nDocuTrack — scanning existing project files\n')
23
+
24
+ const existing = readQueue(root)
25
+ const alreadyQueued = new Set(existing.pending.map(e => e.file))
26
+
27
+ // Find all source files
28
+ const files = []
29
+ for (const dir of SOURCE_DIRS) {
30
+ const full = path.join(root, dir)
31
+ if (fs.existsSync(full)) walk(full, files, root)
32
+ }
33
+
34
+ // Also scan root-level source files (index.js, server.js, main.go, etc.)
35
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
36
+ if (entry.isFile() && SOURCE_EXTS.has(path.extname(entry.name))) {
37
+ const rel = entry.name
38
+ if (!IGNORE_PATTERNS.some(re => re.test(rel))) files.push(rel)
39
+ }
40
+ }
41
+
42
+ if (files.length === 0) {
43
+ console.log(' No source files found to scan.\n')
44
+ console.log(' Checked: ' + SOURCE_DIRS.join(', ') + '\n')
45
+ return
46
+ }
47
+
48
+ // Separate new vs already-queued
49
+ const newFiles = files.filter(f => !alreadyQueued.has(f))
50
+ const skipped = files.length - newFiles.length
51
+
52
+ if (newFiles.length === 0 && !force) {
53
+ console.log(` All ${files.length} files already in queue.\n`)
54
+ console.log(' Run "docutrack status" to see the full queue.\n')
55
+ return
56
+ }
57
+
58
+ if (dryRun) {
59
+ console.log(` Would queue ${newFiles.length} files (${skipped} already queued):\n`)
60
+ for (const f of newFiles.slice(0, 20)) console.log(` ${f}`)
61
+ if (newFiles.length > 20) console.log(` …and ${newFiles.length - 20} more`)
62
+ console.log()
63
+ return
64
+ }
65
+
66
+ // Add all to queue
67
+ const queue = readQueue(root)
68
+ const now = new Date().toISOString()
69
+ for (const f of newFiles) {
70
+ queue.pending.push({ file: f, addedAt: now })
71
+ }
72
+ writeQueue(queue)
73
+
74
+ console.log(` Queued ${newFiles.length} file${newFiles.length !== 1 ? 's' : ''}`)
75
+ if (skipped > 0) console.log(` Skipped ${skipped} already in queue`)
76
+
77
+ // Group by dir for summary
78
+ const byDir = {}
79
+ for (const f of newFiles) {
80
+ const dir = f.split('/')[0]
81
+ byDir[dir] = (byDir[dir] || 0) + 1
82
+ }
83
+ console.log()
84
+ for (const [dir, count] of Object.entries(byDir).sort((a, b) => b[1] - a[1])) {
85
+ console.log(` ${dir}/ → ${count} file${count !== 1 ? 's' : ''}`)
86
+ }
87
+
88
+ console.log(`
89
+ Next step — run the documentalista subagent to generate docs for all queued files:
90
+
91
+ In your Claude Code session, say:
92
+ "Run the documentalista subagent to document all pending files"
93
+
94
+ Or use: /arch-review (to see what needs docs first)
95
+
96
+ The agent will read each file and write docs/modules/<name>.md for every module.
97
+ This may take a few minutes for large projects.
98
+ `)
99
+ }
100
+
101
+ function walk(dir, acc, root, depth = 0) {
102
+ if (depth > 6) return
103
+ let entries
104
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
105
+
106
+ for (const entry of entries) {
107
+ if (entry.isDirectory()) {
108
+ if (!IGNORE_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
109
+ walk(path.join(dir, entry.name), acc, root, depth + 1)
110
+ }
111
+ } else if (entry.isFile()) {
112
+ const ext = path.extname(entry.name)
113
+ if (!SOURCE_EXTS.has(ext)) continue
114
+ if (IGNORE_PATTERNS.some(re => re.test(entry.name))) continue
115
+ const rel = path.relative(root, path.join(dir, entry.name)).replace(/\\/g, '/')
116
+ acc.push(rel)
117
+ }
118
+ }
119
+ }
120
+
121
+ module.exports = { run }
@@ -0,0 +1,48 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const DocuTrackServer = require('../viewer/server')
6
+ const analyzeCmd = require('./analyze')
7
+
8
+ async function run(args) {
9
+ if (!fs.existsSync('.docutrack')) {
10
+ console.log('\nDocuTrack is not initialized. Run "npx docutrack init" first.\n')
11
+ process.exit(1)
12
+ }
13
+
14
+ const port = parsePort(args) || 4242
15
+ const projectRoot = process.cwd()
16
+
17
+ // Auto-analyze on serve startup (quiet mode)
18
+ await analyzeCmd.run(['--quiet'])
19
+
20
+ new DocuTrackServer(projectRoot, port).start()
21
+
22
+ // Open browser after a short delay
23
+ setTimeout(() => tryOpenBrowser(`http://localhost:${port}`), 800)
24
+
25
+ // Keep process alive
26
+ process.on('SIGINT', () => {
27
+ console.log('\n\n DocuTrack server stopped.\n')
28
+ process.exit(0)
29
+ })
30
+ }
31
+
32
+ function parsePort(args) {
33
+ const i = args.indexOf('--port')
34
+ if (i !== -1 && args[i + 1]) return parseInt(args[i + 1], 10)
35
+ const p = args.find(a => /^--port=\d+$/.test(a))
36
+ if (p) return parseInt(p.split('=')[1], 10)
37
+ return null
38
+ }
39
+
40
+ function tryOpenBrowser(url) {
41
+ const { exec } = require('child_process')
42
+ const cmd = process.platform === 'win32' ? `start "" "${url}"`
43
+ : process.platform === 'darwin' ? `open "${url}"`
44
+ : `xdg-open "${url}"`
45
+ exec(cmd, () => {}) // ignore errors — browser open is best-effort
46
+ }
47
+
48
+ module.exports = { run }
@@ -0,0 +1,94 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { read, QUEUE_PATH } = require('../utils/queue')
6
+ const { findStale } = require('../utils/stale')
7
+
8
+ function countDocFiles() {
9
+ let n = 0
10
+ const walk = (dir) => {
11
+ if (!fs.existsSync(dir)) return
12
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
13
+ if (e.isDirectory()) walk(path.join(dir, e.name))
14
+ else if (e.name.endsWith('.md') && e.name !== '.gitkeep') n++
15
+ }
16
+ }
17
+ walk('docs')
18
+ return n
19
+ }
20
+
21
+ function formatAge(ms) {
22
+ const secs = Math.floor(ms / 1000)
23
+ if (secs < 60) return `${secs}s ago`
24
+ const mins = Math.floor(secs / 60)
25
+ if (mins < 60) return `${mins}m ago`
26
+ const hours = Math.floor(mins / 60)
27
+ if (hours < 24) return `${hours}h ago`
28
+ return `${Math.floor(hours / 24)}d ago`
29
+ }
30
+
31
+ async function run(args) {
32
+ const asJson = args?.includes('--json')
33
+
34
+ if (!fs.existsSync('.docutrack')) {
35
+ if (asJson) { console.log(JSON.stringify({ error: 'not initialized' })); return }
36
+ console.log('\nDocuTrack is not initialized. Run "npx docutrack init" first.\n')
37
+ process.exit(1)
38
+ }
39
+
40
+ const queue = read()
41
+ const pending = queue.pending || []
42
+ const docCount = countDocFiles()
43
+ const lastClear = queue.lastClear ? new Date(queue.lastClear).toLocaleString() : 'never'
44
+ const stale = findStale(process.cwd())
45
+ const total = docCount + pending.length
46
+ const coverage = total > 0 ? Math.round((docCount / total) * 100) : 100
47
+
48
+ if (asJson) {
49
+ console.log(JSON.stringify({ pending: pending.length, docCount, staleCount: stale.length, coverage, lastClear }))
50
+ return
51
+ }
52
+
53
+ const bar = (() => {
54
+ const filled = Math.round(coverage / 10)
55
+ return '▓'.repeat(filled) + '░'.repeat(10 - filled)
56
+ })()
57
+
58
+ console.log(`
59
+ DocuTrack Status
60
+ ${'─'.repeat(48)}
61
+ Coverage : ${coverage}% [${bar}]
62
+ Doc files : ${docCount}
63
+ Pending : ${pending.length} (need documentation)
64
+ Stale : ${stale.length} (source changed, doc outdated)
65
+ Last clear : ${lastClear}
66
+ `)
67
+
68
+ if (pending.length > 0) {
69
+ console.log(' Files awaiting documentation:')
70
+ for (const e of pending) {
71
+ const age = formatAge(Date.now() - new Date(e.addedAt).getTime())
72
+ console.log(` ${e.file.padEnd(52)} ${age}`)
73
+ }
74
+ console.log()
75
+ }
76
+
77
+ if (stale.length > 0) {
78
+ console.log(' Stale documentation (source newer than doc):')
79
+ for (const s of stale) {
80
+ const age = formatAge(s.staleSinceMs)
81
+ console.log(` ${s.doc.padEnd(40)} source changed ${age}`)
82
+ }
83
+ console.log()
84
+ }
85
+
86
+ if (pending.length === 0 && stale.length === 0) {
87
+ console.log(' All good — documentation is up to date.\n')
88
+ } else {
89
+ console.log(` To update docs, tell the agent:`)
90
+ console.log(` "Review .docutrack/queue.json and update the documentation"\n`)
91
+ }
92
+ }
93
+
94
+ module.exports = { run }
@@ -0,0 +1,167 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ // Regex extractors per language — pull exported/public identifiers from source
7
+ const EXTRACTORS = {
8
+ js: extractJs,
9
+ ts: extractJs,
10
+ mjs: extractJs,
11
+ cjs: extractJs,
12
+ py: extractPython,
13
+ go: extractGo,
14
+ }
15
+
16
+ function extractJs(content) {
17
+ const names = new Set()
18
+ const patterns = [
19
+ /export\s+(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)/g,
20
+ /export\s+(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)/g,
21
+ /export\s+class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g,
22
+ /module\.exports\s*=\s*\{([^}]+)\}/g,
23
+ /exports\.([A-Za-z_$][A-Za-z0-9_$]*)\s*=/g,
24
+ ]
25
+ for (const re of patterns.slice(0, 4)) {
26
+ let m
27
+ while ((m = re.exec(content)) !== null) {
28
+ if (re === patterns[3]) {
29
+ // module.exports = { a, b, c }
30
+ for (const name of m[1].split(',').map(s => s.trim().split(':')[0].trim()).filter(Boolean)) {
31
+ if (/^[A-Za-z_$]/.test(name)) names.add(name)
32
+ }
33
+ } else {
34
+ names.add(m[1])
35
+ }
36
+ }
37
+ }
38
+ // exports.name =
39
+ let m
40
+ const exportsRe = /exports\.([A-Za-z_$][A-Za-z0-9_$]*)\s*=/g
41
+ while ((m = exportsRe.exec(content)) !== null) names.add(m[1])
42
+ return [...names]
43
+ }
44
+
45
+ function extractPython(content) {
46
+ const names = new Set()
47
+ // Public functions and classes (no leading underscore)
48
+ const fnRe = /^def\s+([A-Za-z][A-Za-z0-9_]*)\s*\(/gm
49
+ const classRe = /^class\s+([A-Za-z][A-Za-z0-9_]*)\s*[:(]/gm
50
+ let m
51
+ while ((m = fnRe.exec(content)) !== null) if (!m[1].startsWith('_')) names.add(m[1])
52
+ while ((m = classRe.exec(content)) !== null) if (!m[1].startsWith('_')) names.add(m[1])
53
+ return [...names]
54
+ }
55
+
56
+ function extractGo(content) {
57
+ const names = new Set()
58
+ // Exported functions and types start with uppercase
59
+ const fnRe = /^func\s+([A-Z][A-Za-z0-9_]*)\s*\(/gm
60
+ const typeRe = /^type\s+([A-Z][A-Za-z0-9_]*)\s+/gm
61
+ let m
62
+ while ((m = fnRe.exec(content)) !== null) names.add(m[1])
63
+ while ((m = typeRe.exec(content)) !== null) names.add(m[1])
64
+ return [...names]
65
+ }
66
+
67
+ function extractNamesFromDoc(docContent) {
68
+ // Only extract code-span references — avoids false positives from headers/bold prose
69
+ const names = new Set()
70
+ const patterns = [
71
+ /`([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g, // `functionName(` — most reliable
72
+ /`([A-Za-z_$][A-Za-z0-9_$]{2,})`/g, // `identifier` — short inline code
73
+ ]
74
+ for (const re of patterns) {
75
+ let m
76
+ while ((m = re.exec(docContent)) !== null) {
77
+ // Skip common non-identifier words
78
+ const skip = new Set(['true', 'false', 'null', 'undefined', 'string', 'number', 'object', 'array'])
79
+ if (!skip.has(m[1].toLowerCase())) names.add(m[1])
80
+ }
81
+ }
82
+ return [...names]
83
+ }
84
+
85
+ function analyzeDrift(projectRoot) {
86
+ const docsDir = path.join(projectRoot, 'docs', 'modules')
87
+ if (!fs.existsSync(docsDir)) return []
88
+
89
+ const results = []
90
+
91
+ for (const docFile of fs.readdirSync(docsDir)) {
92
+ if (!docFile.endsWith('.md')) continue
93
+ const moduleName = docFile.replace('.md', '')
94
+ const docPath = path.join(docsDir, docFile)
95
+ const docContent = fs.readFileSync(docPath, 'utf8')
96
+ const docMtime = fs.statSync(docPath).mtimeMs
97
+
98
+ // Find the corresponding source file
99
+ const sourceFile = findSourceFile(projectRoot, moduleName)
100
+ if (!sourceFile) continue
101
+
102
+ const sourceStat = fs.statSync(sourceFile)
103
+ if (sourceStat.mtimeMs <= docMtime) continue // Source not changed since doc update
104
+
105
+ const srcContent = fs.readFileSync(sourceFile, 'utf8')
106
+ const ext = path.extname(sourceFile).slice(1)
107
+ const extractor = EXTRACTORS[ext]
108
+ if (!extractor) continue
109
+
110
+ const srcExports = extractor(srcContent)
111
+ const docMentions = extractNamesFromDoc(docContent)
112
+
113
+ const undocumented = srcExports.filter(name => !docMentions.includes(name))
114
+ const orphaned = docMentions.filter(name => !srcExports.includes(name) && name.length > 2)
115
+
116
+ if (undocumented.length > 0 || orphaned.length > 0) {
117
+ results.push({
118
+ module: moduleName,
119
+ docPath: path.relative(projectRoot, docPath),
120
+ sourceFile: path.relative(projectRoot, sourceFile),
121
+ staleSinceMs: sourceStat.mtimeMs - docMtime,
122
+ undocumented,
123
+ orphaned,
124
+ severity: undocumented.length > 3 ? 'high' : undocumented.length > 0 ? 'medium' : 'low',
125
+ })
126
+ }
127
+ }
128
+
129
+ return results
130
+ }
131
+
132
+ function findSourceFile(root, name) {
133
+ const SEARCH_DIRS = ['src', 'lib', 'app', 'pkg', 'internal', '.']
134
+ const EXTS = ['.js', '.ts', '.mjs', '.py', '.go', '.jsx', '.tsx']
135
+
136
+ for (const dir of SEARCH_DIRS) {
137
+ for (const ext of EXTS) {
138
+ const full = path.join(root, dir, `${name}${ext}`)
139
+ if (fs.existsSync(full)) return full
140
+ }
141
+ }
142
+
143
+ // Recursive search with depth limit
144
+ for (const dir of SEARCH_DIRS) {
145
+ const found = walkFind(path.join(root, dir), name, EXTS, 3)
146
+ if (found) return found
147
+ }
148
+
149
+ return null
150
+ }
151
+
152
+ function walkFind(dir, name, exts, maxDepth, depth = 0) {
153
+ if (depth > maxDepth || !fs.existsSync(dir)) return null
154
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
155
+ if (entry.isFile()) {
156
+ for (const ext of exts) {
157
+ if (entry.name === `${name}${ext}`) return path.join(dir, entry.name)
158
+ }
159
+ } else if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
160
+ const found = walkFind(path.join(dir, entry.name), name, exts, maxDepth, depth + 1)
161
+ if (found) return found
162
+ }
163
+ }
164
+ return null
165
+ }
166
+
167
+ module.exports = { analyzeDrift, extractJs, extractPython, extractGo }
@@ -0,0 +1,62 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ const QUEUE_PATH = path.join('.docutrack', 'queue.json')
7
+
8
+ const EMPTY_QUEUE = { pending: [], lastClear: null }
9
+
10
+ // Folders whose changes should never trigger doc requirements
11
+ const IGNORED_PREFIXES = [
12
+ 'docs/',
13
+ 'docs\\',
14
+ '.docutrack/',
15
+ '.docutrack\\',
16
+ '.claude/',
17
+ '.claude\\',
18
+ 'node_modules/',
19
+ 'node_modules\\',
20
+ ]
21
+
22
+ function isIgnored(filePath) {
23
+ return IGNORED_PREFIXES.some(prefix => filePath.startsWith(prefix))
24
+ }
25
+
26
+ function read() {
27
+ if (!fs.existsSync(QUEUE_PATH)) return { ...EMPTY_QUEUE }
28
+ try {
29
+ return JSON.parse(fs.readFileSync(QUEUE_PATH, 'utf8'))
30
+ } catch {
31
+ return { ...EMPTY_QUEUE }
32
+ }
33
+ }
34
+
35
+ function write(queue) {
36
+ fs.writeFileSync(QUEUE_PATH, JSON.stringify(queue, null, 2))
37
+ }
38
+
39
+ function add(filePath) {
40
+ const normalized = filePath.replace(/\\/g, '/')
41
+ if (isIgnored(normalized)) return
42
+
43
+ const queue = read()
44
+ const alreadyQueued = queue.pending.some(e => e.file === normalized)
45
+ if (alreadyQueued) return
46
+
47
+ queue.pending.push({
48
+ file: normalized,
49
+ addedAt: new Date().toISOString(),
50
+ })
51
+ write(queue)
52
+ }
53
+
54
+ function clear() {
55
+ write({ pending: [], lastClear: new Date().toISOString() })
56
+ }
57
+
58
+ function pendingCount() {
59
+ return read().pending.length
60
+ }
61
+
62
+ module.exports = { read, write, add, clear, pendingCount, QUEUE_PATH }
@@ -0,0 +1,69 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ const SETTINGS_PATH = path.join('.claude', 'settings.json')
7
+
8
+ const DOCUTRACK_HOOKS = {
9
+ PostToolUse: [
10
+ {
11
+ matcher: 'Write|Edit|MultiEdit',
12
+ hooks: [
13
+ {
14
+ type: 'command',
15
+ command: 'node .docutrack/hooks/post-tool-use.js',
16
+ },
17
+ ],
18
+ },
19
+ ],
20
+ Stop: [
21
+ {
22
+ hooks: [
23
+ {
24
+ type: 'command',
25
+ command: 'node .docutrack/hooks/on-stop.js',
26
+ },
27
+ ],
28
+ },
29
+ ],
30
+ }
31
+
32
+ function read() {
33
+ if (!fs.existsSync(SETTINGS_PATH)) return {}
34
+ try {
35
+ return JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'))
36
+ } catch {
37
+ return {}
38
+ }
39
+ }
40
+
41
+ function hasDocutrackHooks(settings) {
42
+ return settings?.hooks?.PostToolUse?.some(h =>
43
+ h.hooks?.some(cmd => cmd.command?.includes('docutrack'))
44
+ )
45
+ }
46
+
47
+ function installHooks() {
48
+ const dir = path.dirname(SETTINGS_PATH)
49
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
50
+
51
+ const settings = read()
52
+
53
+ if (hasDocutrackHooks(settings)) return false // already installed
54
+
55
+ if (!settings.hooks) settings.hooks = {}
56
+
57
+ for (const [event, newEntries] of Object.entries(DOCUTRACK_HOOKS)) {
58
+ if (!settings.hooks[event]) {
59
+ settings.hooks[event] = newEntries
60
+ } else {
61
+ settings.hooks[event].push(...newEntries)
62
+ }
63
+ }
64
+
65
+ fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2))
66
+ return true
67
+ }
68
+
69
+ module.exports = { read, installHooks, hasDocutrackHooks, SETTINGS_PATH }
@@ -0,0 +1,80 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ const SOURCE_EXTS = ['.js', '.ts', '.mjs', '.cjs', '.py', '.go', '.rs', '.rb', '.java', '.kt', '.cs', '.php']
7
+ const SOURCE_DIRS = ['src', 'lib', 'app', 'pkg', 'server', 'backend', 'api']
8
+
9
+ function findStale(projectRoot) {
10
+ const stale = []
11
+ const docsDir = path.join(projectRoot, 'docs', 'modules')
12
+ if (!fs.existsSync(docsDir)) return stale
13
+
14
+ for (const entry of fs.readdirSync(docsDir)) {
15
+ if (!entry.endsWith('.md') || entry === '.gitkeep') continue
16
+
17
+ const docPath = path.join(docsDir, entry)
18
+ const docMtime = fs.statSync(docPath).mtimeMs
19
+ const moduleName = entry.replace('.md', '')
20
+
21
+ const sourceFile = findSourceFile(projectRoot, moduleName)
22
+ if (!sourceFile) continue
23
+
24
+ const srcMtime = fs.statSync(sourceFile).mtimeMs
25
+ if (srcMtime > docMtime) {
26
+ stale.push({
27
+ doc: `docs/modules/${entry}`,
28
+ source: path.relative(projectRoot, sourceFile).replace(/\\/g, '/'),
29
+ docMtime,
30
+ srcMtime,
31
+ staleSinceMs: srcMtime - docMtime,
32
+ })
33
+ }
34
+ }
35
+
36
+ return stale
37
+ }
38
+
39
+ function findSourceFile(root, name) {
40
+ // Try: src/<name>.js, src/services/<name>.js, src/<name>/index.js, etc.
41
+ for (const dir of SOURCE_DIRS) {
42
+ const dirPath = path.join(root, dir)
43
+ if (!fs.existsSync(dirPath)) continue
44
+
45
+ for (const ext of SOURCE_EXTS) {
46
+ // Direct: src/auth.js
47
+ const direct = path.join(dirPath, `${name}${ext}`)
48
+ if (fs.existsSync(direct)) return direct
49
+
50
+ // Index: src/auth/index.js
51
+ const index = path.join(dirPath, name, `index${ext}`)
52
+ if (fs.existsSync(index)) return index
53
+ }
54
+
55
+ // Walk one subdirectory level: src/services/auth.js, src/middleware/auth.js
56
+ try {
57
+ for (const sub of fs.readdirSync(dirPath, { withFileTypes: true })) {
58
+ if (!sub.isDirectory()) continue
59
+ for (const ext of SOURCE_EXTS) {
60
+ const nested = path.join(dirPath, sub.name, `${name}${ext}`)
61
+ if (fs.existsSync(nested)) return nested
62
+ }
63
+ }
64
+ } catch { /* ignore */ }
65
+ }
66
+
67
+ return null
68
+ }
69
+
70
+ function formatAge(ms) {
71
+ const secs = Math.floor(ms / 1000)
72
+ if (secs < 60) return `${secs}s`
73
+ const mins = Math.floor(secs / 60)
74
+ if (mins < 60) return `${mins}m`
75
+ const hours = Math.floor(mins / 60)
76
+ if (hours < 24) return `${hours}h`
77
+ return `${Math.floor(hours / 24)}d`
78
+ }
79
+
80
+ module.exports = { findStale, formatAge }