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,110 @@
1
+ 'use strict'
2
+
3
+ const path = require('path')
4
+
5
+ const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head']
6
+
7
+ // Matches: (app|router|server|this).(method)('path' or "path" or `path`)
8
+ const ROUTE_RE = /(?:app|router|server|this|fastify|koa)\s*\.\s*(get|post|put|patch|delete|options|head)\s*\(\s*[`'"](\/[^`'"]*)[`'"]/gi
9
+
10
+ // Matches: .route('/path').get(h).post(h)...
11
+ const ROUTE_CHAIN_PATH_RE = /\.route\s*\(\s*[`'"](\/[^`'"]*)[`'"]\s*\)/g
12
+ const ROUTE_CHAIN_METHOD_RE = /\.\s*(get|post|put|patch|delete|options|head)\s*\(/gi
13
+
14
+ function parse(filePath, content) {
15
+ const tag = inferTag(filePath)
16
+ const routes = []
17
+ const seen = new Set()
18
+
19
+ // Standard method routes
20
+ let m
21
+ const re = new RegExp(ROUTE_RE.source, 'gi')
22
+ while ((m = re.exec(content)) !== null) {
23
+ const method = m[1].toUpperCase()
24
+ const rawPath = m[2]
25
+ const opPath = toOpenApiPath(rawPath)
26
+ const key = `${method}:${opPath}`
27
+ if (!seen.has(key)) {
28
+ seen.add(key)
29
+ routes.push(makeRoute(method, opPath, tag))
30
+ }
31
+ }
32
+
33
+ // .route('/path').get().post() chains
34
+ const chainRe = new RegExp(ROUTE_CHAIN_PATH_RE.source, 'g')
35
+ while ((m = chainRe.exec(content)) !== null) {
36
+ const opPath = toOpenApiPath(m[1])
37
+ // Grab the segment after this .route(...) match and find chained methods
38
+ const segment = content.slice(m.index + m[0].length, m.index + m[0].length + 200)
39
+ const methodRe = new RegExp(ROUTE_CHAIN_METHOD_RE.source, 'g')
40
+ let mm
41
+ while ((mm = methodRe.exec(segment)) !== null) {
42
+ const method = mm[1].toUpperCase()
43
+ if (!HTTP_METHODS.includes(method.toLowerCase())) continue
44
+ const key = `${method}:${opPath}`
45
+ if (!seen.has(key)) {
46
+ seen.add(key)
47
+ routes.push(makeRoute(method, opPath, tag))
48
+ }
49
+ }
50
+ }
51
+
52
+ return routes
53
+ }
54
+
55
+ function makeRoute(method, opPath, tag) {
56
+ const params = extractPathParams(opPath)
57
+ const route = {
58
+ method,
59
+ path: opPath,
60
+ tag,
61
+ summary: '',
62
+ operationId: toOperationId(method, opPath),
63
+ parameters: params.map(p => ({
64
+ name: p,
65
+ in: 'path',
66
+ required: true,
67
+ schema: { type: 'string' },
68
+ })),
69
+ responses: { '200': { description: 'OK' } },
70
+ }
71
+ if (['POST', 'PUT', 'PATCH'].includes(method)) {
72
+ route.requestBody = {
73
+ content: { 'application/json': { schema: { type: 'object' } } },
74
+ }
75
+ }
76
+ return route
77
+ }
78
+
79
+ function toOpenApiPath(expressPath) {
80
+ return expressPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '{$1}')
81
+ }
82
+
83
+ function extractPathParams(openApiPath) {
84
+ const params = []
85
+ const re = /\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g
86
+ let m
87
+ while ((m = re.exec(openApiPath)) !== null) params.push(m[1])
88
+ return params
89
+ }
90
+
91
+ function inferTag(filePath) {
92
+ const base = path.basename(filePath, path.extname(filePath))
93
+ return base
94
+ .replace(/\.routes?$/, '').replace(/\.controller$/, '').replace(/\.handler$/, '')
95
+ .replace(/[-_]/g, ' ')
96
+ .split(' ')[0]
97
+ .toLowerCase()
98
+ }
99
+
100
+ function toOperationId(method, opPath) {
101
+ const parts = opPath
102
+ .replace(/\{([^}]+)\}/g, (_, name) => 'By' + name.charAt(0).toUpperCase() + name.slice(1))
103
+ .replace(/^\//, '').split('/')
104
+ .filter(Boolean)
105
+ .map(p => p.charAt(0).toUpperCase() + p.slice(1))
106
+ .join('')
107
+ return method.toLowerCase() + (parts || 'Root')
108
+ }
109
+
110
+ module.exports = { parse }
@@ -0,0 +1,89 @@
1
+ 'use strict'
2
+
3
+ const path = require('path')
4
+
5
+ const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head']
6
+
7
+ // @app.get('/path') or @router.post('/path') or @app_v2.delete('/path/{id}')
8
+ const DECORATOR_RE = /@\w+\.(get|post|put|patch|delete|options|head)\s*\(\s*["'](\/[^"']+)["']/gi
9
+
10
+ // async def function_name — to infer operation name
11
+ const DEF_RE = /(?:async\s+)?def\s+(\w+)\s*\(/g
12
+
13
+ function parse(filePath, content) {
14
+ const tag = inferTag(filePath)
15
+ const routes = []
16
+ const seen = new Set()
17
+
18
+ // Collect function names (for operationId inference)
19
+ const functions = []
20
+ let fm
21
+ const defRe = new RegExp(DEF_RE.source, 'g')
22
+ while ((fm = defRe.exec(content)) !== null) functions.push({ name: fm[1], index: fm.index })
23
+
24
+ let m
25
+ const re = new RegExp(DECORATOR_RE.source, 'gi')
26
+ while ((m = re.exec(content)) !== null) {
27
+ const method = m[1].toUpperCase()
28
+ const opPath = m[2] // FastAPI already uses {param} syntax
29
+
30
+ const key = `${method}:${opPath}`
31
+ if (seen.has(key)) continue
32
+ seen.add(key)
33
+
34
+ // Find the function that follows this decorator
35
+ const nearestFn = functions.find(f => f.index > m.index)
36
+ const operationId = nearestFn?.name || toOperationId(method, opPath)
37
+
38
+ const params = extractPathParams(opPath)
39
+ const route = {
40
+ method,
41
+ path: opPath,
42
+ tag,
43
+ summary: '',
44
+ operationId,
45
+ parameters: params.map(p => ({
46
+ name: p,
47
+ in: 'path',
48
+ required: true,
49
+ schema: { type: 'string' },
50
+ })),
51
+ responses: { '200': { description: 'OK' } },
52
+ }
53
+
54
+ if (['POST', 'PUT', 'PATCH'].includes(method)) {
55
+ route.requestBody = {
56
+ content: { 'application/json': { schema: { type: 'object' } } },
57
+ }
58
+ }
59
+
60
+ routes.push(route)
61
+ }
62
+
63
+ return routes
64
+ }
65
+
66
+ function extractPathParams(p) {
67
+ const params = []
68
+ const re = /\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g
69
+ let m
70
+ while ((m = re.exec(p)) !== null) params.push(m[1])
71
+ return params
72
+ }
73
+
74
+ function inferTag(filePath) {
75
+ return path.basename(filePath, path.extname(filePath))
76
+ .replace(/[-_]/g, ' ').split(' ')[0].toLowerCase()
77
+ }
78
+
79
+ function toOperationId(method, opPath) {
80
+ const parts = opPath
81
+ .replace(/\{([^}]+)\}/g, (_, n) => 'By' + n.charAt(0).toUpperCase() + n.slice(1))
82
+ .replace(/^\//, '').split('/')
83
+ .filter(Boolean)
84
+ .map(p => p.charAt(0).toUpperCase() + p.slice(1))
85
+ .join('')
86
+ return method.toLowerCase() + (parts || 'Root')
87
+ }
88
+
89
+ module.exports = { parse }
@@ -0,0 +1,47 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { analyze } = require('../analyzer/index')
6
+
7
+ const OUT_PATH = path.join('docs', 'api', 'openapi.json')
8
+
9
+ async function run(args) {
10
+ const projectRoot = process.cwd()
11
+
12
+ if (!fs.existsSync('.docutrack')) {
13
+ console.log('\nDocuTrack is not initialized. Run "npx docutrack init" first.\n')
14
+ process.exit(1)
15
+ }
16
+
17
+ const quiet = args.includes('--quiet') || args.includes('-q')
18
+
19
+ if (!quiet) console.log('\nDocuTrack — analyzing project routes...\n')
20
+
21
+ const spec = analyze(projectRoot)
22
+ const endpointCount = Object.values(spec.paths).reduce((n, p) => n + Object.keys(p).length, 0)
23
+
24
+ if (!quiet) {
25
+ console.log(` Project : ${spec.info.title}`)
26
+ console.log(` Framework : ${spec.info.description.match(/Framework detected: (.+)\./)?.[1] || 'unknown'}`)
27
+ console.log(` Endpoints : ${endpointCount}`)
28
+ console.log(` Output : ${OUT_PATH}\n`)
29
+ }
30
+
31
+ const outDir = path.dirname(OUT_PATH)
32
+ if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true })
33
+ fs.writeFileSync(OUT_PATH, JSON.stringify(spec, null, 2))
34
+
35
+ if (!quiet) {
36
+ if (endpointCount === 0) {
37
+ console.log(' No routes detected. Make sure your routes are in one of:')
38
+ console.log(' routes/, src/routes/, api/, src/api/, controllers/, src/controllers/\n')
39
+ } else {
40
+ console.log(` Done. Open "docutrack serve" and click API Explorer to browse.\n`)
41
+ }
42
+ }
43
+
44
+ return spec
45
+ }
46
+
47
+ module.exports = { run }
@@ -0,0 +1,79 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { read: readQueue } = require('../utils/queue')
6
+
7
+ const OUT_PATH = path.join('docs', 'badge.svg')
8
+
9
+ function coverageColor(pct) {
10
+ if (pct >= 80) return '#22c55e' // green
11
+ if (pct >= 60) return '#f59e0b' // amber
12
+ if (pct >= 40) return '#f97316' // orange
13
+ return '#ef4444' // red
14
+ }
15
+
16
+ function makeSvg(pct) {
17
+ const label = 'docs'
18
+ const value = `${pct}%`
19
+ const labelW = 38
20
+ const valueW = value.length <= 3 ? 32 : value.length <= 4 ? 38 : 44
21
+ const totalW = labelW + valueW
22
+ const color = coverageColor(pct)
23
+
24
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="20" role="img" aria-label="docs: ${pct}%">
25
+ <title>docs: ${pct}%</title>
26
+ <linearGradient id="s" x2="0" y2="100%">
27
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
28
+ <stop offset="1" stop-opacity=".1"/>
29
+ </linearGradient>
30
+ <clipPath id="r"><rect width="${totalW}" height="20" rx="3" fill="#fff"/></clipPath>
31
+ <g clip-path="url(#r)">
32
+ <rect width="${labelW}" height="20" fill="#555"/>
33
+ <rect x="${labelW}" width="${valueW}" height="20" fill="${color}"/>
34
+ <rect width="${totalW}" height="20" fill="url(#s)"/>
35
+ </g>
36
+ <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110">
37
+ <text x="${Math.round(labelW / 2 * 10)}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(labelW - 10) * 10}" lengthAdjust="spacing">${label}</text>
38
+ <text x="${Math.round(labelW / 2 * 10)}" y="140" transform="scale(.1)" textLength="${(labelW - 10) * 10}" lengthAdjust="spacing">${label}</text>
39
+ <text x="${Math.round((labelW + valueW / 2) * 10)}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(valueW - 10) * 10}" lengthAdjust="spacing">${value}</text>
40
+ <text x="${Math.round((labelW + valueW / 2) * 10)}" y="140" transform="scale(.1)" textLength="${(valueW - 10) * 10}" lengthAdjust="spacing">${value}</text>
41
+ </g>
42
+ </svg>`
43
+ }
44
+
45
+ async function run() {
46
+ if (!fs.existsSync('.docutrack')) {
47
+ console.log('\nDocuTrack is not initialized. Run "npx docutrack init" first.\n')
48
+ process.exit(1)
49
+ }
50
+
51
+ const queue = readQueue()
52
+ const pending = queue.pending?.length || 0
53
+
54
+ // Count doc files
55
+ const docsDir = 'docs'
56
+ let docCount = 0
57
+ function walkDocs(dir) {
58
+ if (!fs.existsSync(dir)) return
59
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
60
+ if (e.isDirectory()) walkDocs(path.join(dir, e.name))
61
+ else if (e.name.endsWith('.md') && e.name !== '.gitkeep') docCount++
62
+ }
63
+ }
64
+ walkDocs(docsDir)
65
+
66
+ const total = docCount + pending
67
+ const pct = total > 0 ? Math.round((docCount / total) * 100) : 100
68
+
69
+ const svg = makeSvg(pct)
70
+ const outDir = path.dirname(OUT_PATH)
71
+ if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true })
72
+ fs.writeFileSync(OUT_PATH, svg)
73
+
74
+ console.log(`\nBadge generated: ${OUT_PATH} (coverage: ${pct}%)`)
75
+ console.log('\nAdd to your README:')
76
+ console.log(` ![DocuTrack](./docs/badge.svg)\n`)
77
+ }
78
+
79
+ module.exports = { run }
@@ -0,0 +1,187 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { findStale } = require('../utils/stale')
6
+ const { analyzeDrift } = require('../utils/drift')
7
+ const { analyzeComplexity } = require('../analyzer/complexity')
8
+ const { read: readQueue } = require('../utils/queue')
9
+
10
+ const RESET = '\x1b[0m'
11
+ const RED = '\x1b[31m'
12
+ const YELLOW = '\x1b[33m'
13
+ const GREEN = '\x1b[32m'
14
+ const BOLD = '\x1b[1m'
15
+ const DIM = '\x1b[2m'
16
+
17
+ function color(level, text) {
18
+ if (level === 'critical') return `${RED}${text}${RESET}`
19
+ if (level === 'warn') return `${YELLOW}${text}${RESET}`
20
+ return `${GREEN}${text}${RESET}`
21
+ }
22
+
23
+ async function run(args) {
24
+ if (!fs.existsSync('.docutrack')) {
25
+ console.log('\nDocuTrack is not initialized. Run "npx docutrack init" first.\n')
26
+ process.exit(1)
27
+ }
28
+
29
+ const isJson = args.includes('--json')
30
+ const isCI = args.includes('--ci')
31
+ const root = process.cwd()
32
+
33
+ if (!isJson) console.log(`\n${BOLD}DocuTrack Health Check${RESET}\n${'─'.repeat(52)}`)
34
+
35
+ // ── 1. Queue status ────────────────────────────────────
36
+ const queue = readQueue(root)
37
+ const pendingCount = queue.pending.length
38
+
39
+ // ── 2. Stale docs ──────────────────────────────────────
40
+ const stale = findStale(root)
41
+
42
+ // ── 3. Drift analysis ─────────────────────────────────
43
+ let driftResults = []
44
+ try { driftResults = analyzeDrift(root) } catch { /* no docs yet */ }
45
+
46
+ // ── 4. Complexity ──────────────────────────────────────
47
+ let complexityReport = { files: [], summary: { total: 0, critical: 0, warnings: 0, healthy: 0 } }
48
+ try { complexityReport = analyzeComplexity(root) } catch { /* no src yet */ }
49
+
50
+ const criticalFiles = complexityReport.files.filter(f => f.warnings.some(w => w.level === 'critical'))
51
+ const warnFiles = complexityReport.files.filter(f =>
52
+ f.warnings.some(w => w.level === 'warn') && !f.warnings.some(w => w.level === 'critical')
53
+ )
54
+
55
+ // ── JSON output for CI ─────────────────────────────────
56
+ if (isJson) {
57
+ const report = {
58
+ pending: pendingCount,
59
+ stale: stale.length,
60
+ drift: driftResults.map(d => ({ module: d.module, severity: d.severity, undocumented: d.undocumented, orphaned: d.orphaned })),
61
+ complexity: { summary: complexityReport.summary, critical: criticalFiles.map(f => ({ file: f.file, score: f.score, warnings: f.warnings })) },
62
+ ok: pendingCount === 0 && stale.length === 0 && driftResults.filter(d => d.severity === 'high').length === 0 && criticalFiles.length === 0,
63
+ }
64
+ console.log(JSON.stringify(report, null, 2))
65
+ if (!report.ok && isCI) process.exit(1)
66
+ return
67
+ }
68
+
69
+ // ── Human-readable output ──────────────────────────────
70
+
71
+ // Pending
72
+ const pendingLabel = pendingCount === 0
73
+ ? `${GREEN}✓ 0 pending${RESET}`
74
+ : `${YELLOW}⚠ ${pendingCount} files need documentation${RESET}`
75
+ console.log(` Queue : ${pendingLabel}`)
76
+
77
+ // Stale
78
+ const staleLabel = stale.length === 0
79
+ ? `${GREEN}✓ 0 stale docs${RESET}`
80
+ : `${YELLOW}⚠ ${stale.length} stale${RESET}`
81
+ console.log(` Stale docs : ${staleLabel}`)
82
+
83
+ // Drift
84
+ const highDrift = driftResults.filter(d => d.severity === 'high').length
85
+ const driftLabel = driftResults.length === 0
86
+ ? `${GREEN}✓ no drift${RESET}`
87
+ : highDrift > 0
88
+ ? `${RED}✗ ${highDrift} high-drift module${highDrift !== 1 ? 's' : ''}${RESET}`
89
+ : `${YELLOW}⚠ ${driftResults.length} module${driftResults.length !== 1 ? 's' : ''} drifted${RESET}`
90
+ console.log(` Doc drift : ${driftLabel}`)
91
+
92
+ // Complexity
93
+ const complexityLabel = criticalFiles.length === 0
94
+ ? warnFiles.length === 0
95
+ ? `${GREEN}✓ ${complexityReport.summary.total} files healthy${RESET}`
96
+ : `${YELLOW}⚠ ${warnFiles.length} complex${RESET}`
97
+ : `${RED}✗ ${criticalFiles.length} critical${RESET}${criticalFiles.length > 0 && warnFiles.length > 0 ? ` + ${warnFiles.length} warnings` : ''}`
98
+ console.log(` Complexity : ${complexityLabel}`)
99
+
100
+ // ── Detail sections ────────────────────────────────────
101
+
102
+ if (stale.length > 0) {
103
+ console.log(`\n${BOLD}Stale Docs${RESET}`)
104
+ for (const s of stale.slice(0, 8)) {
105
+ const age = formatMs(s.staleSinceMs)
106
+ console.log(` ${YELLOW}${s.doc}${RESET} ${DIM}(${age} behind)${RESET}`)
107
+ }
108
+ if (stale.length > 8) console.log(` ${DIM}…and ${stale.length - 8} more${RESET}`)
109
+ }
110
+
111
+ if (driftResults.length > 0) {
112
+ console.log(`\n${BOLD}Documentation Drift${RESET}`)
113
+ for (const d of driftResults.slice(0, 6)) {
114
+ console.log(` ${color(d.severity, d.severity.toUpperCase())} ${BOLD}${d.module}${RESET} ${DIM}← ${d.sourceFile}${RESET}`)
115
+ if (d.undocumented.length > 0) {
116
+ console.log(` ${RED}+undocumented${RESET}: ${d.undocumented.slice(0, 5).join(', ')}${d.undocumented.length > 5 ? ` …+${d.undocumented.length - 5}` : ''}`)
117
+ }
118
+ if (d.orphaned.length > 0) {
119
+ console.log(` ${DIM}-orphaned${RESET}: ${d.orphaned.slice(0, 5).join(', ')}${d.orphaned.length > 5 ? ` …+${d.orphaned.length - 5}` : ''}`)
120
+ }
121
+ }
122
+ if (driftResults.length > 6) console.log(` ${DIM}…and ${driftResults.length - 6} more${RESET}`)
123
+ }
124
+
125
+ if (criticalFiles.length > 0 || warnFiles.length > 0) {
126
+ console.log(`\n${BOLD}Complexity${RESET}`)
127
+ const toShow = [...criticalFiles.slice(0, 4), ...warnFiles.slice(0, 3)]
128
+ for (const f of toShow) {
129
+ const rel = path.relative(root, f.file)
130
+ const level = f.warnings.some(w => w.level === 'critical') ? 'critical' : 'warn'
131
+ const msgs = f.warnings.map(w => w.message).join(' · ')
132
+ console.log(` ${color(level, level === 'critical' ? '✗' : '⚠')} ${rel} ${DIM}(score ${f.score})${RESET}`)
133
+ console.log(` ${DIM}${msgs}${RESET}`)
134
+ }
135
+ const shown = Math.min(4, criticalFiles.length) + Math.min(3, warnFiles.length)
136
+ const total = criticalFiles.length + warnFiles.length
137
+ if (total > shown) console.log(` ${DIM}…and ${total - shown} more${RESET}`)
138
+ }
139
+
140
+ // ── Suggestions ────────────────────────────────────────
141
+ const suggestions = buildSuggestions({ pendingCount, stale, driftResults, criticalFiles })
142
+ if (suggestions.length > 0) {
143
+ console.log(`\n${BOLD}Suggested Actions${RESET}`)
144
+ for (const s of suggestions) console.log(` → ${s}`)
145
+ }
146
+
147
+ // ── Overall verdict ────────────────────────────────────
148
+ const isHealthy = pendingCount === 0 && stale.length === 0 && highDrift === 0 && criticalFiles.length === 0
149
+ console.log(`\n${'─'.repeat(52)}`)
150
+ if (isHealthy) {
151
+ console.log(` ${GREEN}${BOLD}✓ Project documentation is healthy.${RESET}\n`)
152
+ } else {
153
+ const issues = [
154
+ pendingCount > 0 && `${pendingCount} pending`,
155
+ stale.length > 0 && `${stale.length} stale`,
156
+ driftResults.length > 0 && `${driftResults.length} drifted`,
157
+ criticalFiles.length > 0 && `${criticalFiles.length} complex`,
158
+ ].filter(Boolean).join(', ')
159
+ console.log(` ${YELLOW}${BOLD}⚠ Issues found: ${issues}${RESET}\n`)
160
+ }
161
+
162
+ if (isCI && !isHealthy) process.exit(1)
163
+ }
164
+
165
+ function buildSuggestions({ pendingCount, stale, driftResults, criticalFiles }) {
166
+ const s = []
167
+ if (pendingCount > 0) s.push('Run the documentalista subagent to clear the queue (or use /arch-review)')
168
+ if (stale.length > 0) s.push(`Update docs for ${stale.length} stale module${stale.length !== 1 ? 's' : ''}, then run "docutrack clear"`)
169
+ if (driftResults.filter(d => d.severity === 'high').length > 0) {
170
+ const mods = driftResults.filter(d => d.severity === 'high').map(d => d.module).slice(0, 3).join(', ')
171
+ s.push(`High drift: add missing exports to docs for ${mods}`)
172
+ }
173
+ if (criticalFiles.length > 0) {
174
+ const f = path.basename(criticalFiles[0].file)
175
+ s.push(`Consider splitting ${f} — it exceeds complexity thresholds`)
176
+ }
177
+ return s
178
+ }
179
+
180
+ function formatMs(ms) {
181
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`
182
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`
183
+ if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`
184
+ return `${Math.round(ms / 86_400_000)}d`
185
+ }
186
+
187
+ module.exports = { run }
@@ -0,0 +1,17 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const { clear, pendingCount } = require('../utils/queue')
5
+
6
+ async function run() {
7
+ if (!fs.existsSync('.docutrack')) {
8
+ console.log('\nDocuTrack is not initialized. Run "npx docutrack init" first.\n')
9
+ process.exit(1)
10
+ }
11
+
12
+ const before = pendingCount()
13
+ clear()
14
+ console.log(`\nQueue cleared. Removed ${before} pending item(s).\n`)
15
+ }
16
+
17
+ module.exports = { run }