trackfw 7.6.0 → 8.0.0-rc2
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 +28 -11
- package/bin/trackfw.js +81 -0
- package/package.json +14 -8
- package/bin/trackfw +0 -41
- package/src/branch/prune.js +0 -450
- package/src/branch/runner.js +0 -153
- package/src/changelog/index.js +0 -114
- package/src/commands/adr.js +0 -56
- package/src/commands/agents.js +0 -3
- package/src/commands/audit-surface.js +0 -520
- package/src/commands/barrier.js +0 -822
- package/src/commands/baseline.js +0 -13
- package/src/commands/branch.js +0 -98
- package/src/commands/changelog.js +0 -29
- package/src/commands/commit.js +0 -66
- package/src/commands/configure.js +0 -106
- package/src/commands/context.js +0 -215
- package/src/commands/discover.js +0 -628
- package/src/commands/doctor.js +0 -66
- package/src/commands/help.js +0 -358
- package/src/commands/identity-wizard.js +0 -258
- package/src/commands/index.js +0 -68
- package/src/commands/init.js +0 -296
- package/src/commands/integrations.js +0 -305
- package/src/commands/log.js +0 -31
- package/src/commands/metrics.js +0 -242
- package/src/commands/note.js +0 -24
- package/src/commands/push.js +0 -51
- package/src/commands/release.js +0 -47
- package/src/commands/req.js +0 -98
- package/src/commands/roadmap.js +0 -44
- package/src/commands/serve.js +0 -345
- package/src/commands/ship.js +0 -58
- package/src/commands/skills.js +0 -3
- package/src/commands/status.js +0 -12
- package/src/commands/sync.js +0 -348
- package/src/commands/thirdparty.js +0 -390
- package/src/commands/update-harness.js +0 -1002
- package/src/commands/update.js +0 -500
- package/src/commands/validate.js +0 -57
- package/src/commands/version.js +0 -10
- package/src/commit/runner.js +0 -352
- package/src/config/index.js +0 -457
- package/src/forge/adapter.js +0 -189
- package/src/forge/resolve.js +0 -191
- package/src/generators/adr.js +0 -198
- package/src/generators/codex.js +0 -79
- package/src/generators/date.js +0 -23
- package/src/generators/hooks.js +0 -2414
- package/src/generators/init.js +0 -1563
- package/src/generators/note.js +0 -154
- package/src/generators/req.js +0 -436
- package/src/generators/roadmap.js +0 -841
- package/src/homedir.js +0 -30
- package/src/i18n/index.js +0 -53
- package/src/i18n/locales/en-US.json +0 -124
- package/src/i18n/locales/es-ES.json +0 -124
- package/src/i18n/locales/pt-BR.json +0 -124
- package/src/identity/config.js +0 -134
- package/src/identity/index.js +0 -19
- package/src/identity/preset.js +0 -219
- package/src/identity/slug.js +0 -62
- package/src/integrations/assets/agents/architect.md +0 -61
- package/src/integrations/assets/agents/backend.md +0 -41
- package/src/integrations/assets/agents/code-quality.md +0 -48
- package/src/integrations/assets/agents/data.md +0 -41
- package/src/integrations/assets/agents/dba.md +0 -41
- package/src/integrations/assets/agents/frontend.md +0 -41
- package/src/integrations/assets/agents/iac.md +0 -46
- package/src/integrations/assets/agents/infra.md +0 -43
- package/src/integrations/assets/agents/qa.md +0 -41
- package/src/integrations/assets/agents/security.md +0 -51
- package/src/integrations/assets/agents/tooling.md +0 -47
- package/src/integrations/assets/agents/ux.md +0 -48
- package/src/integrations/assets/catalog.json +0 -48
- package/src/integrations/assets/skills/architecture.md +0 -66
- package/src/integrations/assets/skills/backend.md +0 -67
- package/src/integrations/assets/skills/code-quality.md +0 -78
- package/src/integrations/assets/skills/data.md +0 -75
- package/src/integrations/assets/skills/dba.md +0 -77
- package/src/integrations/assets/skills/frontend.md +0 -70
- package/src/integrations/assets/skills/governance.md +0 -6
- package/src/integrations/assets/skills/iac.md +0 -90
- package/src/integrations/assets/skills/implement.md +0 -6
- package/src/integrations/assets/skills/infra.md +0 -62
- package/src/integrations/assets/skills/plan.md +0 -6
- package/src/integrations/assets/skills/qa.md +0 -63
- package/src/integrations/assets/skills/release.md +0 -6
- package/src/integrations/assets/skills/review.md +0 -6
- package/src/integrations/assets/skills/security.md +0 -74
- package/src/integrations/assets/skills/tooling.md +0 -80
- package/src/integrations/assets/skills/ux.md +0 -70
- package/src/integrations/catalog.js +0 -58
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/doctor_remote.js +0 -224
- package/src/integrations/index.js +0 -171
- package/src/integrations/legacy.js +0 -35
- package/src/integrations/manager.js +0 -438
- package/src/integrations/register-agent.js +0 -106
- package/src/integrations/render.js +0 -563
- package/src/integrations/scaffold_doctor.js +0 -395
- package/src/lib/fatal-error.js +0 -64
- package/src/lib/pathfmt.js +0 -43
- package/src/lib/unknown-command.js +0 -82
- package/src/lib/update-engine.js +0 -242
- package/src/push/runner.js +0 -226
- package/src/release/runner.js +0 -474
- package/src/serve/api_attention.js +0 -24
- package/src/serve/api_board.js +0 -105
- package/src/serve/api_chain.js +0 -246
- package/src/serve/api_file.js +0 -117
- package/src/serve/api_metrics.js +0 -139
- package/src/serve/static/app.js +0 -1151
- package/src/serve/static/index.html +0 -338
- package/src/serve/static/style.css +0 -297
- package/src/ship/runner.js +0 -775
- package/src/thirdparty/fetch.js +0 -140
- package/src/thirdparty/markers.js +0 -207
- package/src/thirdparty/provenance.js +0 -114
- package/src/thirdparty/quarantine.js +0 -113
- package/src/thirdparty/references.js +0 -194
- package/src/validator/git-exec.js +0 -72
- package/src/validator/index.js +0 -4086
- package/src/validator/traceid.js +0 -155
package/src/serve/api_chain.js
DELETED
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
const fs = require('fs')
|
|
4
|
-
const path = require('path')
|
|
5
|
-
const { resolveAgentNamespaces, extractRefPath } = require('../validator')
|
|
6
|
-
const { normalizeRefSeparator } = require('../lib/pathfmt')
|
|
7
|
-
|
|
8
|
-
const ROADMAP_STATES = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* parseFrontmatter extrai todos os campos de um bloco frontmatter YAML (entre --- e ---).
|
|
12
|
-
* Retorna objeto com chaves/valores string.
|
|
13
|
-
* @param {string} content
|
|
14
|
-
* @returns {Object<string, string>}
|
|
15
|
-
*/
|
|
16
|
-
function parseFrontmatter(content) {
|
|
17
|
-
const fields = {}
|
|
18
|
-
const lines = content.split('\n')
|
|
19
|
-
let started = false
|
|
20
|
-
let inFm = false
|
|
21
|
-
for (const line of lines) {
|
|
22
|
-
const trimmed = line.trim()
|
|
23
|
-
if (trimmed === '---') {
|
|
24
|
-
if (!started) { started = true; inFm = true; continue }
|
|
25
|
-
break // fecha frontmatter
|
|
26
|
-
}
|
|
27
|
-
if (!inFm) break
|
|
28
|
-
const colonIdx = trimmed.indexOf(':')
|
|
29
|
-
if (colonIdx < 0) continue
|
|
30
|
-
const key = trimmed.slice(0, colonIdx).trim()
|
|
31
|
-
let val = trimmed.slice(colonIdx + 1).trim()
|
|
32
|
-
val = val.replace(/^["']|["']$/g, '')
|
|
33
|
-
if (key) fields[key] = val
|
|
34
|
-
}
|
|
35
|
-
return fields
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* extractTitle retorna a primeira linha '# ...' ou o nome do arquivo sem extensão.
|
|
40
|
-
* @param {string} content
|
|
41
|
-
* @param {string} filename
|
|
42
|
-
* @returns {string}
|
|
43
|
-
*/
|
|
44
|
-
function extractTitle(content, filename) {
|
|
45
|
-
for (const line of content.split('\n')) {
|
|
46
|
-
const t = line.trim()
|
|
47
|
-
if (t.startsWith('# ')) return t.slice(2).trim()
|
|
48
|
-
}
|
|
49
|
-
return filename.replace(/\.md$/, '')
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* collectNodes lê arquivos .md de um diretório e retorna nós do grafo.
|
|
54
|
-
* @param {string} dir
|
|
55
|
-
* @param {'adr'|'req'|'roadmap'} type
|
|
56
|
-
* @param {string} state
|
|
57
|
-
* @returns {Array<{id: string, type: string, title: string, state: string, fm: Object}>}
|
|
58
|
-
*/
|
|
59
|
-
function collectNodes(dir, type, state) {
|
|
60
|
-
const nodes = []
|
|
61
|
-
let files = []
|
|
62
|
-
try {
|
|
63
|
-
files = fs.readdirSync(dir).filter(f => {
|
|
64
|
-
if (!f.endsWith('.md')) return false
|
|
65
|
-
try { return !fs.statSync(path.join(dir, f)).isDirectory() } catch (_) { return false }
|
|
66
|
-
})
|
|
67
|
-
} catch (_) {
|
|
68
|
-
return nodes
|
|
69
|
-
}
|
|
70
|
-
for (const file of files) {
|
|
71
|
-
let content = ''
|
|
72
|
-
try { content = fs.readFileSync(path.join(dir, file), 'utf8') } catch (_) {}
|
|
73
|
-
const fm = parseFrontmatter(content)
|
|
74
|
-
const title = extractTitle(content, file)
|
|
75
|
-
// node ID do grafo — IDENTIFICADOR emitido em JSON (ADR-2026-09-04, D1 categoria 2),
|
|
76
|
-
// e tambem o alvo de toda aresta: resolveRef() abaixo so devolve valores vindos de
|
|
77
|
-
// fileIndex/titleIndex, isto e, sempre um node id — nunca o valor cru do frontmatter.
|
|
78
|
-
// Normalizar o id aqui, portanto, normaliza `edge.to` por construcao.
|
|
79
|
-
//
|
|
80
|
-
// 🔴 O path.join da linha 71 (fs.readFileSync) e uma EXPRESSAO INDEPENDENTE sobre os
|
|
81
|
-
// mesmos operandos: normalizar esta nao pode afeta-la. O caminho de travessia
|
|
82
|
-
// permanece nativo (ADR D2). Espelha internal/serve/api_chain.go:111.
|
|
83
|
-
const id = normalizeRefSeparator(path.join(dir, file))
|
|
84
|
-
nodes.push({ id, type, title, state, fm, content })
|
|
85
|
-
}
|
|
86
|
-
return nodes
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* handleChain responde ao GET /api/chain com nodes e edges em JSON.
|
|
91
|
-
* @param {object} cfg
|
|
92
|
-
* @param {http.IncomingMessage} req
|
|
93
|
-
* @param {http.ServerResponse} res
|
|
94
|
-
*/
|
|
95
|
-
function handleChain(cfg, req, res) {
|
|
96
|
-
const adrDirs = cfg.adrDirs || ['docs/adr']
|
|
97
|
-
const reqDir = cfg.reqDir || 'docs/req'
|
|
98
|
-
const roadmapDir = cfg.roadmapDir || 'docs/roadmaps'
|
|
99
|
-
const namespacing = cfg.roadmapNamespacing || 'flat'
|
|
100
|
-
|
|
101
|
-
const allNodes = []
|
|
102
|
-
|
|
103
|
-
// ADRs — scan recursivo de subpastas (done/, wip/, etc.)
|
|
104
|
-
for (const adrDir of adrDirs) {
|
|
105
|
-
const subfolders = ['done', 'wip', 'backlog', 'blocked', 'abandoned', '']
|
|
106
|
-
for (const sub of subfolders) {
|
|
107
|
-
const dir = sub ? path.join(adrDir, sub) : adrDir
|
|
108
|
-
const nodes = collectNodes(dir, 'adr', sub || 'unknown')
|
|
109
|
-
allNodes.push(...nodes)
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// REQs
|
|
114
|
-
if (namespacing === 'by_agent') {
|
|
115
|
-
const agents = resolveAgentNamespaces(cfg, reqDir)
|
|
116
|
-
for (const agent of agents) {
|
|
117
|
-
for (const state of ROADMAP_STATES) {
|
|
118
|
-
const dir = path.join(reqDir, agent, state)
|
|
119
|
-
allNodes.push(...collectNodes(dir, 'req', state))
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
} else {
|
|
123
|
-
allNodes.push(...collectNodes(reqDir, 'req', 'unknown'))
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Roadmaps
|
|
127
|
-
if (namespacing === 'by_agent') {
|
|
128
|
-
const agents = resolveAgentNamespaces(cfg, roadmapDir)
|
|
129
|
-
for (const agent of agents) {
|
|
130
|
-
for (const state of ROADMAP_STATES) {
|
|
131
|
-
const dir = path.join(roadmapDir, agent, state)
|
|
132
|
-
allNodes.push(...collectNodes(dir, 'roadmap', state))
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
} else {
|
|
136
|
-
for (const state of ROADMAP_STATES) {
|
|
137
|
-
const dir = path.join(roadmapDir, state)
|
|
138
|
-
allNodes.push(...collectNodes(dir, 'roadmap', state))
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Deduplicar por id (path)
|
|
143
|
-
const nodeMap = new Map()
|
|
144
|
-
for (const n of allNodes) {
|
|
145
|
-
if (!nodeMap.has(n.id)) nodeMap.set(n.id, n)
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Construir índice por título e nome de arquivo para resolver referências
|
|
149
|
-
const titleIndex = new Map() // titulo normalizado -> id
|
|
150
|
-
const fileIndex = new Map() // basename -> id
|
|
151
|
-
for (const [id, n] of nodeMap) {
|
|
152
|
-
const norm = n.title.toLowerCase().trim()
|
|
153
|
-
if (!titleIndex.has(norm)) titleIndex.set(norm, id)
|
|
154
|
-
const base = path.basename(id).replace(/\.md$/, '').toLowerCase()
|
|
155
|
-
if (!fileIndex.has(base)) fileIndex.set(base, id)
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Construir edges a partir dos campos de frontmatter
|
|
159
|
-
const edges = []
|
|
160
|
-
const edgeSet = new Set()
|
|
161
|
-
|
|
162
|
-
function addEdge(from, to) {
|
|
163
|
-
const key = `${from}→${to}`
|
|
164
|
-
if (!edgeSet.has(key)) {
|
|
165
|
-
edgeSet.add(key)
|
|
166
|
-
edges.push({ from, to })
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function resolveRef(val) {
|
|
171
|
-
if (!val) return null
|
|
172
|
-
// Tenta pelo nome de arquivo — path.basename(val), não val cru: o formato canônico
|
|
173
|
-
// gravado por `trackfw req new`/`roadmap new` é o CAMINHO COMPLETO (ADR-2026-08-01), não o
|
|
174
|
-
// basename isolado. Sem o basename() aqui, um vínculo real como
|
|
175
|
-
// "docs/roadmaps/wip/ROADMAP-x.md" nunca batia contra a chave "roadmap-x" do fileIndex —
|
|
176
|
-
// achado do ML-3D: nenhuma aresta REQ/ADR/Roadmap gerada pelo CLI jamais resolvia por este
|
|
177
|
-
// caminho, mascarado porque os testes existentes só cobrem valor já-basename.
|
|
178
|
-
//
|
|
179
|
-
// Por que este arquivo NÃO importa validator.resolveRoadmapRef (ao contrário de
|
|
180
|
-
// internal/serve/api_chain.go, ML-3D): fileIndex é indexado por basename ACROSS todos os
|
|
181
|
-
// estados (wip/backlog/blocked/done/abandoned) desde antes desta correção — um vínculo
|
|
182
|
-
// `roadmap:` "wip/X.md" já resolve contra o node real em "done/X.md" sem fallback extra.
|
|
183
|
-
// O mecanismo do ML-3B/3D (caminho literal gravado com pasta de estado velha) não afeta
|
|
184
|
-
// este CLI porque ele nunca comparou por igualdade de caminho completo — só por basename/
|
|
185
|
-
// título. Ver docs/cli-parity.md, seção "serve: /api/chain".
|
|
186
|
-
const base = path.basename(val).replace(/\.md$/, '').toLowerCase().trim()
|
|
187
|
-
if (fileIndex.has(base)) return fileIndex.get(base)
|
|
188
|
-
// Tenta pelo título
|
|
189
|
-
const norm = val.toLowerCase().trim()
|
|
190
|
-
if (titleIndex.has(norm)) return titleIndex.get(norm)
|
|
191
|
-
return null
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Campos de link padrão
|
|
195
|
-
const reqFields = (cfg.linkFields && cfg.linkFields.req) || ['REQ:', 'req:']
|
|
196
|
-
const adrFields = (cfg.linkFields && cfg.linkFields.adr) || ['ADR:', 'adr:']
|
|
197
|
-
const roadmapFields = (cfg.linkFields && cfg.linkFields.roadmap) || ['Roadmap:', 'roadmap:']
|
|
198
|
-
|
|
199
|
-
for (const [id, node] of nodeMap) {
|
|
200
|
-
const fm = node.fm || {}
|
|
201
|
-
|
|
202
|
-
// Checar campos de frontmatter para criar arestas
|
|
203
|
-
for (const [fmKey, fmVal] of Object.entries(fm)) {
|
|
204
|
-
const keyLower = fmKey.toLowerCase()
|
|
205
|
-
if (keyLower === 'req' || reqFields.some(f => f.toLowerCase().replace(':', '') === keyLower)) {
|
|
206
|
-
const target = resolveRef(fmVal)
|
|
207
|
-
if (target && target !== id) addEdge(id, target)
|
|
208
|
-
}
|
|
209
|
-
if (keyLower === 'adr' || adrFields.some(f => f.toLowerCase().replace(':', '') === keyLower)) {
|
|
210
|
-
const target = resolveRef(fmVal)
|
|
211
|
-
if (target && target !== id) addEdge(id, target)
|
|
212
|
-
}
|
|
213
|
-
if (keyLower === 'roadmap' || roadmapFields.some(f => f.toLowerCase().replace(':', '') === keyLower)) {
|
|
214
|
-
const target = resolveRef(fmVal)
|
|
215
|
-
if (target && target !== id) addEdge(id, target)
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// ML-3D: `trackfw req new` (npm/src/generators/req.js) grava `adr: ""` e `roadmap: ""`
|
|
220
|
-
// SEMPRE vazios no frontmatter — o valor real vive no corpo, em "## Linked ADR / ADR:
|
|
221
|
-
// <path>" e "## Linked Roadmap / Roadmap: <path>". O laço de frontmatter acima nunca
|
|
222
|
-
// encontra esse vínculo para uma REQ gerada pelo próprio CLI (não é caso de borda: é o
|
|
223
|
-
// formato canônico inteiro). validator.extractRefPath varre o CONTEÚDO inteiro
|
|
224
|
-
// linha-a-linha (mesma função usada por validateRefTargetsExist) — ponto único de
|
|
225
|
-
// extração, não duplicado aqui.
|
|
226
|
-
for (const field of ['req', 'adr', 'roadmap']) {
|
|
227
|
-
const val = extractRefPath(node.content || '', field)
|
|
228
|
-
if (!val) continue
|
|
229
|
-
const target = resolveRef(val)
|
|
230
|
-
if (target && target !== id) addEdge(id, target)
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Montar nodes de saída sem o campo fm interno
|
|
235
|
-
const outputNodes = Array.from(nodeMap.values()).map(n => ({
|
|
236
|
-
id: n.id,
|
|
237
|
-
type: n.type,
|
|
238
|
-
title: n.title,
|
|
239
|
-
state: n.state,
|
|
240
|
-
}))
|
|
241
|
-
|
|
242
|
-
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
243
|
-
res.end(JSON.stringify({ nodes: outputNodes, edges }))
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
module.exports = { handleChain }
|
package/src/serve/api_file.js
DELETED
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
const fs = require('fs')
|
|
4
|
-
const path = require('path')
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* isPathAllowed verifica se absPath está dentro de um dos diretórios permitidos.
|
|
8
|
-
* Usa separador no sufixo para evitar que /docs/adr case com /docs/adr2.
|
|
9
|
-
* @param {string} absPath
|
|
10
|
-
* @param {string[]} allowedDirs
|
|
11
|
-
* @returns {boolean}
|
|
12
|
-
*/
|
|
13
|
-
function isPathAllowed(absPath, allowedDirs) {
|
|
14
|
-
for (const dir of allowedDirs) {
|
|
15
|
-
if (absPath === dir || absPath.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
|
-
*
|
|
25
|
-
* Modelo de segurança — contenção em dois estágios:
|
|
26
|
-
*
|
|
27
|
-
* 1. Contenção léxica (sem acesso ao filesystem): path.resolve() normaliza ".."
|
|
28
|
-
* e o resultado deve começar com uma das raízes autorizadas. Caminhos fora
|
|
29
|
-
* das raízes recebem 403 antes de qualquer acesso ao disco, impedindo que a
|
|
30
|
-
* distinção 403 vs 404 sirva de oracle de existência para caminhos arbitrários.
|
|
31
|
-
*
|
|
32
|
-
* 2. Contenção física (resolução de symlinks): fs.realpathSync.native() é chamado
|
|
33
|
-
* no arquivo pedido e em cada raiz autorizada. Um symlink cujo nome está
|
|
34
|
-
* dentro de uma raiz autorizada mas cujo destino físico não está → 403.
|
|
35
|
-
* Qualquer falha de canonicalização (ENOENT, symlink pendente, …) → 404.
|
|
36
|
-
*
|
|
37
|
-
* Um symlink legítimo cujo destino também está dentro de uma raiz autorizada
|
|
38
|
-
* continua sendo servido normalmente.
|
|
39
|
-
*
|
|
40
|
-
* @param {object} cfg
|
|
41
|
-
* @param {http.IncomingMessage} req
|
|
42
|
-
* @param {http.ServerResponse} res
|
|
43
|
-
*/
|
|
44
|
-
function handleFile(cfg, req, res) {
|
|
45
|
-
let urlObj
|
|
46
|
-
try {
|
|
47
|
-
urlObj = new URL(req.url, 'http://localhost')
|
|
48
|
-
} catch (_) {
|
|
49
|
-
res.writeHead(400, { 'Content-Type': 'text/plain' })
|
|
50
|
-
res.end('Bad Request')
|
|
51
|
-
return
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const filePath = urlObj.searchParams.get('path')
|
|
55
|
-
if (!filePath) {
|
|
56
|
-
res.writeHead(400, { 'Content-Type': 'text/plain' })
|
|
57
|
-
res.end('Missing path parameter')
|
|
58
|
-
return
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// ── Estágio 1: Contenção léxica ────────────────────────────────────────────
|
|
62
|
-
// path.resolve() resolve '..' mas NÃO segue symlinks (operação puramente léxica).
|
|
63
|
-
const resolved = path.resolve(filePath)
|
|
64
|
-
|
|
65
|
-
const adrDirs = (cfg.adrDirs || ['docs/adr']).map(d => path.resolve(d))
|
|
66
|
-
const reqDir = path.resolve(cfg.reqDir || 'docs/req')
|
|
67
|
-
const roadmapDir = path.resolve(cfg.roadmapDir || 'docs/roadmaps')
|
|
68
|
-
const allowedDirs = [...adrDirs, reqDir, roadmapDir]
|
|
69
|
-
|
|
70
|
-
if (!isPathAllowed(resolved, allowedDirs)) {
|
|
71
|
-
res.writeHead(403, { 'Content-Type': 'text/plain' })
|
|
72
|
-
res.end('Forbidden')
|
|
73
|
-
return
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ── Estágio 2: Contenção física ────────────────────────────────────────────
|
|
77
|
-
// Canonicalizar o arquivo pedido. Qualquer erro (ENOENT, symlink pendente,
|
|
78
|
-
// loop, permissão) → 404. O arquivo está nominalmente dentro de uma raiz
|
|
79
|
-
// autorizada (estágio 1 passou), portanto 404 é o resultado correto quando
|
|
80
|
-
// ele simplesmente não existe.
|
|
81
|
-
let realResolved
|
|
82
|
-
try {
|
|
83
|
-
realResolved = fs.realpathSync.native(resolved)
|
|
84
|
-
} catch (_) {
|
|
85
|
-
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
86
|
-
res.end('Not Found')
|
|
87
|
-
return
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Canonicalizar as raízes autorizadas. Raízes inexistentes são ignoradas —
|
|
91
|
-
// nenhum arquivo pode residir em um diretório que não existe.
|
|
92
|
-
const realAllowedDirs = allowedDirs.flatMap(d => {
|
|
93
|
-
try { return [fs.realpathSync.native(d)] } catch (_) { return [] }
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
// Contenção física: o destino canônico deve estar dentro de uma raiz canônica.
|
|
97
|
-
if (!isPathAllowed(realResolved, realAllowedDirs)) {
|
|
98
|
-
res.writeHead(403, { 'Content-Type': 'text/plain' })
|
|
99
|
-
res.end('Forbidden')
|
|
100
|
-
return
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// O arquivo existe (realpathSync não falhou) — ler da rota canônica.
|
|
104
|
-
let content
|
|
105
|
-
try {
|
|
106
|
-
content = fs.readFileSync(realResolved, 'utf8')
|
|
107
|
-
} catch (err) {
|
|
108
|
-
res.writeHead(500, { 'Content-Type': 'text/plain' })
|
|
109
|
-
res.end('Internal Server Error')
|
|
110
|
-
return
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
114
|
-
res.end(content)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
module.exports = { handleFile }
|
package/src/serve/api_metrics.js
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
const fs = require('fs')
|
|
4
|
-
const path = require('path')
|
|
5
|
-
const { parseLog, calculate } = require('../commands/metrics')
|
|
6
|
-
const { resolveAgentNamespaces } = require('../validator')
|
|
7
|
-
|
|
8
|
-
const STATES = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
|
|
9
|
-
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* countFilesInState conta arquivos .md em um diretório (sem recursão).
|
|
13
|
-
* @param {string} dir
|
|
14
|
-
* @returns {number}
|
|
15
|
-
*/
|
|
16
|
-
function countFilesInState(dir) {
|
|
17
|
-
try {
|
|
18
|
-
return fs.readdirSync(dir).filter(f => {
|
|
19
|
-
if (!f.endsWith('.md')) return false
|
|
20
|
-
try { return !fs.statSync(path.join(dir, f)).isDirectory() } catch (_) { return false }
|
|
21
|
-
}).length
|
|
22
|
-
} catch (_) {
|
|
23
|
-
return 0
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* buildStateDistribution conta roadmaps por estado.
|
|
29
|
-
* @param {object} cfg
|
|
30
|
-
* @returns {Object<string, number>}
|
|
31
|
-
*/
|
|
32
|
-
function buildStateDistribution(cfg) {
|
|
33
|
-
const dist = { wip: 0, backlog: 0, blocked: 0, done: 0, abandoned: 0 }
|
|
34
|
-
const roadmapDir = cfg.roadmapDir || 'docs/roadmaps'
|
|
35
|
-
const namespacing = cfg.roadmapNamespacing || 'flat'
|
|
36
|
-
|
|
37
|
-
if (namespacing === 'by_agent') {
|
|
38
|
-
const agents = resolveAgentNamespaces(cfg, roadmapDir)
|
|
39
|
-
for (const agent of agents) {
|
|
40
|
-
for (const state of STATES) {
|
|
41
|
-
dist[state] = (dist[state] || 0) + countFilesInState(path.join(roadmapDir, agent, state))
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
} else {
|
|
45
|
-
for (const state of STATES) {
|
|
46
|
-
dist[state] = (dist[state] || 0) + countFilesInState(path.join(roadmapDir, state))
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return dist
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* buildBurndown calcula pontos semanais a partir do log de transições.
|
|
55
|
-
* @param {Array} transitions
|
|
56
|
-
* @returns {Array<{date: string, open: number, closed: number}>}
|
|
57
|
-
*/
|
|
58
|
-
function buildBurndown(transitions) {
|
|
59
|
-
if (!transitions.length) return []
|
|
60
|
-
|
|
61
|
-
let minTs = Infinity
|
|
62
|
-
let maxTs = -Infinity
|
|
63
|
-
for (const tr of transitions) {
|
|
64
|
-
const ms = tr.timestamp.getTime()
|
|
65
|
-
if (ms < minTs) minTs = ms
|
|
66
|
-
if (ms > maxTs) maxTs = ms
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Agrupar por semana (domingo como início)
|
|
70
|
-
const weekMap = new Map()
|
|
71
|
-
|
|
72
|
-
function weekKey(ms) {
|
|
73
|
-
const d = new Date(ms)
|
|
74
|
-
// arredondar para o domingo anterior
|
|
75
|
-
const day = d.getDay() // 0=domingo
|
|
76
|
-
const sunday = new Date(ms - day * MS_PER_DAY)
|
|
77
|
-
return sunday.toISOString().slice(0, 10)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
for (const tr of transitions) {
|
|
81
|
-
const wk = weekKey(tr.timestamp.getTime())
|
|
82
|
-
if (!weekMap.has(wk)) weekMap.set(wk, { open: 0, closed: 0 })
|
|
83
|
-
const entry = weekMap.get(wk)
|
|
84
|
-
if (tr.to === 'done' || tr.to === 'abandoned') {
|
|
85
|
-
entry.closed++
|
|
86
|
-
} else if (tr.to === 'wip' || tr.to === 'backlog') {
|
|
87
|
-
entry.open++
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const sorted = Array.from(weekMap.entries()).sort(([a], [b]) => a.localeCompare(b))
|
|
92
|
-
return sorted.map(([date, v]) => ({ date, open: v.open, closed: v.closed }))
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* handleMetrics responde ao GET /api/metrics com JSON de métricas.
|
|
97
|
-
* @param {object} cfg
|
|
98
|
-
* @param {http.IncomingMessage} req
|
|
99
|
-
* @param {http.ServerResponse} res
|
|
100
|
-
*/
|
|
101
|
-
function handleMetrics(cfg, req, res) {
|
|
102
|
-
const roadmapDir = cfg.roadmapDir || 'docs/roadmaps'
|
|
103
|
-
const logPath = path.join(roadmapDir, '.trackfw-log')
|
|
104
|
-
|
|
105
|
-
const transitions = parseLog(logPath)
|
|
106
|
-
const metrics = calculate(transitions)
|
|
107
|
-
|
|
108
|
-
// Converter cycle time de ms para dias
|
|
109
|
-
const cycleTimeAvgDays = metrics.cycleTimeMeanMs > 0
|
|
110
|
-
? parseFloat((metrics.cycleTimeMeanMs / MS_PER_DAY).toFixed(2))
|
|
111
|
-
: 0
|
|
112
|
-
|
|
113
|
-
// Lead time: da criação (primeira entrada em qualquer estado) até done
|
|
114
|
-
// Usando as mesmas transições — aproximar como cycle time se não houver campo de criação
|
|
115
|
-
// (o campo creation_date não está no log; usamos start → done como lead time também)
|
|
116
|
-
const leadTimeAvgDays = cycleTimeAvgDays
|
|
117
|
-
|
|
118
|
-
// Abandonment rate: abandonados / (done + abandonados)
|
|
119
|
-
const dist = buildStateDistribution(cfg)
|
|
120
|
-
const totalCompleted = (dist.done || 0) + (dist.abandoned || 0)
|
|
121
|
-
const abandonmentRate = totalCompleted > 0
|
|
122
|
-
? parseFloat(((dist.abandoned || 0) / totalCompleted).toFixed(4))
|
|
123
|
-
: 0
|
|
124
|
-
|
|
125
|
-
const burndown = buildBurndown(transitions)
|
|
126
|
-
|
|
127
|
-
const result = {
|
|
128
|
-
lead_time_avg_days: leadTimeAvgDays,
|
|
129
|
-
cycle_time_avg_days: cycleTimeAvgDays,
|
|
130
|
-
abandonment_rate: abandonmentRate,
|
|
131
|
-
state_distribution: dist,
|
|
132
|
-
burndown,
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
136
|
-
res.end(JSON.stringify(result))
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
module.exports = { handleMetrics }
|