trackfw 2.5.4 → 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.
@@ -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 }