trackfw 2.6.0 → 2.7.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 +88 -3
- package/package.json +1 -1
- package/src/commands/index.js +3 -0
- package/src/commands/metrics.js +7 -0
- package/src/commands/serve.js +184 -0
- package/src/serve/api_board.js +104 -0
- package/src/serve/api_chain.js +223 -0
- package/src/serve/api_file.js +80 -0
- package/src/serve/api_metrics.js +145 -0
- package/src/serve/static/app.js +687 -0
- package/src/serve/static/index.html +199 -0
- package/src/serve/static/style.css +153 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* isPathAllowed verifica se o filePath resolvido está dentro de um dos diretórios permitidos.
|
|
8
|
+
* @param {string} resolved - path.resolve(filePath)
|
|
9
|
+
* @param {string[]} allowedDirs - lista de diretórios permitidos (já resolvidos)
|
|
10
|
+
* @returns {boolean}
|
|
11
|
+
*/
|
|
12
|
+
function isPathAllowed(resolved, allowedDirs) {
|
|
13
|
+
for (const dir of allowedDirs) {
|
|
14
|
+
// garantir que o path começa com dir + separador
|
|
15
|
+
if (resolved === dir || resolved.startsWith(dir + path.sep)) {
|
|
16
|
+
return true
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* handleFile responde ao GET /api/file?path=... com o conteúdo do arquivo.
|
|
24
|
+
* Retorna 400 se path ausente, 403 se fora dos diretórios permitidos, 404 se não existe.
|
|
25
|
+
* @param {object} cfg
|
|
26
|
+
* @param {http.IncomingMessage} req
|
|
27
|
+
* @param {http.ServerResponse} res
|
|
28
|
+
*/
|
|
29
|
+
function handleFile(cfg, req, res) {
|
|
30
|
+
let urlObj
|
|
31
|
+
try {
|
|
32
|
+
urlObj = new URL(req.url, 'http://localhost')
|
|
33
|
+
} catch (_) {
|
|
34
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' })
|
|
35
|
+
res.end('Bad Request')
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const filePath = urlObj.searchParams.get('path')
|
|
40
|
+
if (!filePath) {
|
|
41
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' })
|
|
42
|
+
res.end('Missing path parameter')
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Resolver o path absoluto para verificar segurança
|
|
47
|
+
const resolved = path.resolve(filePath)
|
|
48
|
+
|
|
49
|
+
// Montar lista de diretórios permitidos a partir da config
|
|
50
|
+
const adrDirs = (cfg.adrDirs || ['docs/adr']).map(d => path.resolve(d))
|
|
51
|
+
const reqDir = path.resolve(cfg.reqDir || 'docs/req')
|
|
52
|
+
const roadmapDir = path.resolve(cfg.roadmapDir || 'docs/roadmaps')
|
|
53
|
+
const allowedDirs = [...adrDirs, reqDir, roadmapDir]
|
|
54
|
+
|
|
55
|
+
if (!isPathAllowed(resolved, allowedDirs)) {
|
|
56
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' })
|
|
57
|
+
res.end('Forbidden')
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!fs.existsSync(resolved)) {
|
|
62
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
63
|
+
res.end('Not Found')
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let content
|
|
68
|
+
try {
|
|
69
|
+
content = fs.readFileSync(resolved, 'utf8')
|
|
70
|
+
} catch (err) {
|
|
71
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' })
|
|
72
|
+
res.end('Internal Server Error')
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
77
|
+
res.end(content)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { handleFile }
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const { parseLog, calculate } = require('../commands/metrics')
|
|
6
|
+
|
|
7
|
+
const STATES = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
|
|
8
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* countFilesInState conta arquivos .md em um diretório (sem recursão).
|
|
12
|
+
* @param {string} dir
|
|
13
|
+
* @returns {number}
|
|
14
|
+
*/
|
|
15
|
+
function countFilesInState(dir) {
|
|
16
|
+
try {
|
|
17
|
+
return fs.readdirSync(dir).filter(f => {
|
|
18
|
+
if (!f.endsWith('.md')) return false
|
|
19
|
+
try { return !fs.statSync(path.join(dir, f)).isDirectory() } catch (_) { return false }
|
|
20
|
+
}).length
|
|
21
|
+
} catch (_) {
|
|
22
|
+
return 0
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* buildStateDistribution conta roadmaps por estado.
|
|
28
|
+
* @param {object} cfg
|
|
29
|
+
* @returns {Object<string, number>}
|
|
30
|
+
*/
|
|
31
|
+
function buildStateDistribution(cfg) {
|
|
32
|
+
const dist = { wip: 0, backlog: 0, blocked: 0, done: 0, abandoned: 0 }
|
|
33
|
+
const roadmapDir = cfg.roadmapDir || 'docs/roadmaps'
|
|
34
|
+
const namespacing = cfg.roadmapNamespacing || 'flat'
|
|
35
|
+
|
|
36
|
+
if (namespacing === 'by_agent') {
|
|
37
|
+
let agents = cfg.agents || []
|
|
38
|
+
if (!agents.length) {
|
|
39
|
+
try {
|
|
40
|
+
agents = fs.readdirSync(roadmapDir).filter(f => {
|
|
41
|
+
try { return fs.statSync(path.join(roadmapDir, f)).isDirectory() } catch (_) { return false }
|
|
42
|
+
})
|
|
43
|
+
} catch (_) { agents = [] }
|
|
44
|
+
}
|
|
45
|
+
for (const agent of agents) {
|
|
46
|
+
for (const state of STATES) {
|
|
47
|
+
dist[state] = (dist[state] || 0) + countFilesInState(path.join(roadmapDir, agent, state))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
for (const state of STATES) {
|
|
52
|
+
dist[state] = (dist[state] || 0) + countFilesInState(path.join(roadmapDir, state))
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return dist
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* buildBurndown calcula pontos semanais a partir do log de transições.
|
|
61
|
+
* @param {Array} transitions
|
|
62
|
+
* @returns {Array<{date: string, open: number, closed: number}>}
|
|
63
|
+
*/
|
|
64
|
+
function buildBurndown(transitions) {
|
|
65
|
+
if (!transitions.length) return []
|
|
66
|
+
|
|
67
|
+
let minTs = Infinity
|
|
68
|
+
let maxTs = -Infinity
|
|
69
|
+
for (const tr of transitions) {
|
|
70
|
+
const ms = tr.timestamp.getTime()
|
|
71
|
+
if (ms < minTs) minTs = ms
|
|
72
|
+
if (ms > maxTs) maxTs = ms
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Agrupar por semana (domingo como início)
|
|
76
|
+
const weekMap = new Map()
|
|
77
|
+
|
|
78
|
+
function weekKey(ms) {
|
|
79
|
+
const d = new Date(ms)
|
|
80
|
+
// arredondar para o domingo anterior
|
|
81
|
+
const day = d.getDay() // 0=domingo
|
|
82
|
+
const sunday = new Date(ms - day * MS_PER_DAY)
|
|
83
|
+
return sunday.toISOString().slice(0, 10)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const tr of transitions) {
|
|
87
|
+
const wk = weekKey(tr.timestamp.getTime())
|
|
88
|
+
if (!weekMap.has(wk)) weekMap.set(wk, { open: 0, closed: 0 })
|
|
89
|
+
const entry = weekMap.get(wk)
|
|
90
|
+
if (tr.to === 'done' || tr.to === 'abandoned') {
|
|
91
|
+
entry.closed++
|
|
92
|
+
} else if (tr.to === 'wip' || tr.to === 'backlog') {
|
|
93
|
+
entry.open++
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const sorted = Array.from(weekMap.entries()).sort(([a], [b]) => a.localeCompare(b))
|
|
98
|
+
return sorted.map(([date, v]) => ({ date, open: v.open, closed: v.closed }))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* handleMetrics responde ao GET /api/metrics com JSON de métricas.
|
|
103
|
+
* @param {object} cfg
|
|
104
|
+
* @param {http.IncomingMessage} req
|
|
105
|
+
* @param {http.ServerResponse} res
|
|
106
|
+
*/
|
|
107
|
+
function handleMetrics(cfg, req, res) {
|
|
108
|
+
const roadmapDir = cfg.roadmapDir || 'docs/roadmaps'
|
|
109
|
+
const logPath = path.join(roadmapDir, '.trackfw-log')
|
|
110
|
+
|
|
111
|
+
const transitions = parseLog(logPath)
|
|
112
|
+
const metrics = calculate(transitions)
|
|
113
|
+
|
|
114
|
+
// Converter cycle time de ms para dias
|
|
115
|
+
const cycleTimeAvgDays = metrics.cycleTimeMeanMs > 0
|
|
116
|
+
? parseFloat((metrics.cycleTimeMeanMs / MS_PER_DAY).toFixed(2))
|
|
117
|
+
: 0
|
|
118
|
+
|
|
119
|
+
// Lead time: da criação (primeira entrada em qualquer estado) até done
|
|
120
|
+
// Usando as mesmas transições — aproximar como cycle time se não houver campo de criação
|
|
121
|
+
// (o campo creation_date não está no log; usamos start → done como lead time também)
|
|
122
|
+
const leadTimeAvgDays = cycleTimeAvgDays
|
|
123
|
+
|
|
124
|
+
// Abandonment rate: abandonados / (done + abandonados)
|
|
125
|
+
const dist = buildStateDistribution(cfg)
|
|
126
|
+
const totalCompleted = (dist.done || 0) + (dist.abandoned || 0)
|
|
127
|
+
const abandonmentRate = totalCompleted > 0
|
|
128
|
+
? parseFloat(((dist.abandoned || 0) / totalCompleted).toFixed(4))
|
|
129
|
+
: 0
|
|
130
|
+
|
|
131
|
+
const burndown = buildBurndown(transitions)
|
|
132
|
+
|
|
133
|
+
const result = {
|
|
134
|
+
lead_time_avg_days: leadTimeAvgDays,
|
|
135
|
+
cycle_time_avg_days: cycleTimeAvgDays,
|
|
136
|
+
abandonment_rate: abandonmentRate,
|
|
137
|
+
state_distribution: dist,
|
|
138
|
+
burndown,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
142
|
+
res.end(JSON.stringify(result))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { handleMetrics }
|