docutrack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -0
- package/bin/docutrack.js +67 -0
- package/package.json +38 -0
- package/src/analyzer/complexity.js +145 -0
- package/src/analyzer/detect.js +124 -0
- package/src/analyzer/index.js +121 -0
- package/src/analyzer/parsers/express.js +110 -0
- package/src/analyzer/parsers/fastapi.js +89 -0
- package/src/commands/analyze.js +47 -0
- package/src/commands/badge.js +79 -0
- package/src/commands/check.js +187 -0
- package/src/commands/clear.js +17 -0
- package/src/commands/export.js +182 -0
- package/src/commands/init.js +182 -0
- package/src/commands/onboard.js +288 -0
- package/src/commands/scan.js +121 -0
- package/src/commands/serve.js +48 -0
- package/src/commands/status.js +94 -0
- package/src/utils/drift.js +167 -0
- package/src/utils/queue.js +62 -0
- package/src/utils/settings.js +69 -0
- package/src/utils/stale.js +80 -0
- package/src/viewer/index.html +1411 -0
- package/src/viewer/server.js +652 -0
- package/templates/ARCHITECTURE.md +51 -0
- package/templates/agents/documentalista.md +113 -0
- package/templates/claude-snippet.md +39 -0
- package/templates/commands/adr-new.md +58 -0
- package/templates/commands/arch-review.md +59 -0
- package/templates/commands/ask-docs.md +26 -0
- package/templates/commands/doc-map.md +50 -0
- package/templates/docs/api/.gitkeep +0 -0
- package/templates/docs/decisions/.gitkeep +0 -0
- package/templates/docs/modules/.gitkeep +0 -0
- package/templates/docutrack.config.json +13 -0
- package/templates/github/workflows/docutrack-docs.yml +42 -0
- package/templates/github/workflows/docutrack-gate.yml +31 -0
- package/templates/github/workflows/docutrack-pr.yml +93 -0
- package/templates/hooks/on-stop.js +39 -0
- package/templates/hooks/post-tool-use.js +52 -0
- package/templates/stacks/express/ARCHITECTURE.md +67 -0
- package/templates/stacks/express/documentalista.md +63 -0
- package/templates/stacks/fastapi/ARCHITECTURE.md +68 -0
- package/templates/stacks/fastapi/documentalista.md +88 -0
- package/templates/stacks/go/ARCHITECTURE.md +68 -0
- package/templates/stacks/go/documentalista.md +89 -0
- package/templates/stacks/monorepo/ARCHITECTURE.md +60 -0
- package/templates/stacks/monorepo/documentalista.md +59 -0
- package/templates/stacks/nextjs/ARCHITECTURE.md +76 -0
- package/templates/stacks/nextjs/documentalista.md +93 -0
|
@@ -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 }
|