trackfw 7.3.0 → 7.5.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,43 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * pathfmt — ponto único de normalização de separador do CLI Node
5
+ * (ADR-2026-09-04 "Separador POSIX nos artefatos autorados cujo consumidor não é o
6
+ * sistema de arquivos", D3: "Não espalhar `strings.ReplaceAll` pelos chamadores. Uma
7
+ * função por runtime, aplicada onde o valor SAI").
8
+ *
9
+ * 🔴 ESCOPO NEGATIVO (D2 da mesma ADR) — esta função é de EMISSÃO, nunca de travessia.
10
+ * Não aplicar a um caminho antes de `fs.readFileSync`/`fs.statSync`/`fs.writeFileSync`:
11
+ * normalizar cegamente quebra UNC (`\\server\share`) e o prefixo de caminho longo
12
+ * (`\\?\`), que exige backslash EXCLUSIVAMENTE — nem o Windows converte. O modo de falha
13
+ * seria intermitente, que é o pior. `path.join`/`path.relative` continuam sendo o certo
14
+ * para caminho que vai a uma syscall.
15
+ *
16
+ * Aplicar apenas às três categorias que a D1 nomeia, cujo consumidor NÃO é o filesystem:
17
+ * 1. texto de relatório / saída para humano (displayPath, mensagens)
18
+ * 2. chave de dicionário ou identificador (provenanceKey, node ID, edge.to)
19
+ * 3. string de comando interpretada por shell (command de hook, gate de wave)
20
+ *
21
+ * Substituição INCONDICIONAL de "\" por "/", não `path.posix.normalize` nem uma troca de
22
+ * `path.sep`: em POSIX `path.sep` é "/" e uma troca por separador seria no-op estrutural,
23
+ * o que não normalizaria um valor sujo herdado de um commit feito no Windows — exatamente
24
+ * o defeito que esta função existe para curar
25
+ * (docs/seguranca/2026-09-01-modelo-de-ameaca-do-separador-em-artefato.md). É a mesma
26
+ * semântica já pinada em Go (`internal/validator/validator.go:normalizeRefSeparator`) e
27
+ * Python (`pypi/trackfw/pathfmt.py:normalize_ref_separator`); D4 exige que os 3 sejam
28
+ * byte-idênticos, então esta escolha não é livre — é herdada.
29
+ *
30
+ * Custo aceito e nomeado: em POSIX um "\" literal dentro de um nome de arquivo é DADO, e
31
+ * esta função o converteria. Vale só para o texto emitido (display/chave), nunca para o
32
+ * caminho realmente aberto, que não passa por aqui.
33
+ *
34
+ * NÃO aplicar ao buffer inteiro de um arquivo — só ao valor já extraído/derivado.
35
+ *
36
+ * @param {string} p valor já extraído ou derivado (nunca o buffer de um arquivo)
37
+ * @returns {string}
38
+ */
39
+ function normalizeRefSeparator(p) {
40
+ return p.replace(/\\/g, '/')
41
+ }
42
+
43
+ module.exports = { normalizeRefSeparator }
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { normalizeRefSeparator } = require('./pathfmt')
4
+
3
5
  // update-engine.js — shared state machine for `trackfw update` and
4
6
  // `trackfw update harness` (ML-6C of ROADMAP-2026-07-29-barrier-governanca-
5
7
  // e-autoridade-do-orquestrador). Both commands report one of exactly four
@@ -169,6 +171,17 @@ function summarize(targets) {
169
171
  // fell through to the absolute-path branch even though homeRoot had
170
172
  // already been normalized. Stripping a trailing separator from
171
173
  // normalizedHome (root "/" excepted) before the prefix check closes this.
174
+ // ML-2A (ADR-2026-09-04, D1 categoria 1 — "texto de relatorio / saida para humano"):
175
+ // os TRES retornos de caminho passam por normalizeRefSeparator. A variavel que recebe
176
+ // esta funcao chama-se literalmente displayPath (npm/src/commands/update-harness.js) e
177
+ // vai para o campo `path` do documento JSON do relatorio — o consumidor nunca e o
178
+ // sistema de arquivos. E o "~" que ela emite ja e POSIX-ismo puro (nenhum shell do
179
+ // Windows expande til), entao devolver "~\\.claude\\settings.json" era incoerente com a
180
+ // decisao ja tomada ao escolher o til.
181
+ //
182
+ // 🔴 A normalizacao e do VALOR DE SAIDA. path.normalize acima continua operando no
183
+ // separador nativo, e `absPath` (o caminho que o chamador realmente abre) nao e
184
+ // alterado por esta funcao — ela nao muda como o produto abre arquivo (ADR D2).
172
185
  function tildeify(homeRoot, absPath) {
173
186
  let normalizedHome = path.normalize(homeRoot)
174
187
  if (normalizedHome.length > path.sep.length && normalizedHome.endsWith(path.sep)) {
@@ -176,8 +189,8 @@ function tildeify(homeRoot, absPath) {
176
189
  }
177
190
  const normalizedPath = path.normalize(absPath)
178
191
  if (normalizedPath === normalizedHome) return '~'
179
- if (normalizedPath.startsWith(normalizedHome + path.sep)) return '~' + normalizedPath.slice(normalizedHome.length)
180
- return normalizedPath
192
+ if (normalizedPath.startsWith(normalizedHome + path.sep)) return normalizeRefSeparator('~' + normalizedPath.slice(normalizedHome.length))
193
+ return normalizeRefSeparator(normalizedPath)
181
194
  }
182
195
 
183
196
  // validateTargets — throws (usage error) when the caller asked for an id
@@ -2,6 +2,8 @@
2
2
 
3
3
  const fs = require('fs')
4
4
  const path = require('path')
5
+ const { resolveAgentNamespaces } = require('../validator')
6
+ const { normalizeRefSeparator } = require('../lib/pathfmt')
5
7
 
6
8
  const STATES = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
7
9
 
@@ -45,9 +47,15 @@ function scanState(dir, state, agent, roadmapDir) {
45
47
  let content = ''
46
48
  try { content = fs.readFileSync(path.join(dir, file), 'utf8') } catch (_) {}
47
49
  const title = extractTitle(content, file)
48
- const relPath = agent
50
+ // IDENTIFICADOR emitido em JSON, nao caminho de travessia (ADR-2026-09-04, D1
51
+ // categoria 2): o frontend devolve este valor verbatim em GET /api/file?path=...,
52
+ // onde o servidor refaz path.join — no Windows o join reconverte "/" para o
53
+ // separador nativo, entao o round-trip fecha. O path.join da linha 47 acima, que
54
+ // e o caminho realmente lido por fs.readFileSync, permanece intocado: as duas
55
+ // expressoes sao independentes sobre os mesmos operandos (ADR D2).
56
+ const relPath = normalizeRefSeparator(agent
49
57
  ? path.join(roadmapDir, agent, state, file)
50
- : path.join(roadmapDir, state, file)
58
+ : path.join(roadmapDir, state, file))
51
59
 
52
60
  items.push({ file, title, state, agent: agent || '', path: relPath })
53
61
  }
@@ -68,14 +76,7 @@ function handleBoard(cfg, req, res) {
68
76
  const agentSet = new Set()
69
77
 
70
78
  if (namespacing === 'by_agent') {
71
- let agents = cfg.agents || []
72
- if (!agents.length) {
73
- try {
74
- agents = fs.readdirSync(roadmapDir).filter(f => {
75
- try { return fs.statSync(path.join(roadmapDir, f)).isDirectory() } catch (_) { return false }
76
- })
77
- } catch (_) { agents = [] }
78
- }
79
+ const agents = resolveAgentNamespaces(cfg, roadmapDir)
79
80
  for (const agent of agents) {
80
81
  agentSet.add(agent)
81
82
  for (const state of STATES) {
@@ -2,6 +2,8 @@
2
2
 
3
3
  const fs = require('fs')
4
4
  const path = require('path')
5
+ const { resolveAgentNamespaces, extractRefPath } = require('../validator')
6
+ const { normalizeRefSeparator } = require('../lib/pathfmt')
5
7
 
6
8
  const ROADMAP_STATES = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
7
9
 
@@ -70,8 +72,16 @@ function collectNodes(dir, type, state) {
70
72
  try { content = fs.readFileSync(path.join(dir, file), 'utf8') } catch (_) {}
71
73
  const fm = parseFrontmatter(content)
72
74
  const title = extractTitle(content, file)
73
- const id = path.join(dir, file)
74
- nodes.push({ id, type, title, state, fm })
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 })
75
85
  }
76
86
  return nodes
77
87
  }
@@ -102,14 +112,7 @@ function handleChain(cfg, req, res) {
102
112
 
103
113
  // REQs
104
114
  if (namespacing === 'by_agent') {
105
- let agents = cfg.agents || []
106
- if (!agents.length) {
107
- try {
108
- agents = fs.readdirSync(reqDir).filter(f => {
109
- try { return fs.statSync(path.join(reqDir, f)).isDirectory() } catch (_) { return false }
110
- })
111
- } catch (_) {}
112
- }
115
+ const agents = resolveAgentNamespaces(cfg, reqDir)
113
116
  for (const agent of agents) {
114
117
  for (const state of ROADMAP_STATES) {
115
118
  const dir = path.join(reqDir, agent, state)
@@ -122,14 +125,7 @@ function handleChain(cfg, req, res) {
122
125
 
123
126
  // Roadmaps
124
127
  if (namespacing === 'by_agent') {
125
- let agents = cfg.agents || []
126
- if (!agents.length) {
127
- try {
128
- agents = fs.readdirSync(roadmapDir).filter(f => {
129
- try { return fs.statSync(path.join(roadmapDir, f)).isDirectory() } catch (_) { return false }
130
- })
131
- } catch (_) {}
132
- }
128
+ const agents = resolveAgentNamespaces(cfg, roadmapDir)
133
129
  for (const agent of agents) {
134
130
  for (const state of ROADMAP_STATES) {
135
131
  const dir = path.join(roadmapDir, agent, state)
@@ -173,8 +169,21 @@ function handleChain(cfg, req, res) {
173
169
 
174
170
  function resolveRef(val) {
175
171
  if (!val) return null
176
- // Tenta pelo nome de arquivo
177
- const base = val.replace(/\.md$/, '').toLowerCase().trim()
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()
178
187
  if (fileIndex.has(base)) return fileIndex.get(base)
179
188
  // Tenta pelo título
180
189
  const norm = val.toLowerCase().trim()
@@ -206,6 +215,20 @@ function handleChain(cfg, req, res) {
206
215
  if (target && target !== id) addEdge(id, target)
207
216
  }
208
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
+ }
209
232
  }
210
233
 
211
234
  // Montar nodes de saída sem o campo fm interno
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs')
4
4
  const path = require('path')
5
5
  const { parseLog, calculate } = require('../commands/metrics')
6
+ const { resolveAgentNamespaces } = require('../validator')
6
7
 
7
8
  const STATES = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
8
9
  const MS_PER_DAY = 24 * 60 * 60 * 1000
@@ -34,14 +35,7 @@ function buildStateDistribution(cfg) {
34
35
  const namespacing = cfg.roadmapNamespacing || 'flat'
35
36
 
36
37
  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
- }
38
+ const agents = resolveAgentNamespaces(cfg, roadmapDir)
45
39
  for (const agent of agents) {
46
40
  for (const state of STATES) {
47
41
  dist[state] = (dist[state] || 0) + countFilesInState(path.join(roadmapDir, agent, state))