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,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(` \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 }
|