trackfw 2.4.1 → 2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
4
4
  "description": "CLI de governança para entrega de software: ADR → REQ → ROADMAP → kanban. Suporte nativo a agentes de IA (Claude Code, Gemini CLI, Cursor).",
5
5
  "keywords": [
6
6
  "cli",
@@ -330,14 +330,19 @@ cmd.action((opts) => {
330
330
  console.log(`\nGovernance Score: ${r.governanceScore}/100`);
331
331
 
332
332
  if (opts.init) {
333
- const yaml = generateYAML(r);
334
- fs.writeFileSync('trackfw.yaml', yaml, 'utf8');
335
- console.log('\n trackfw.yaml generated');
336
- try {
337
- installGates(r, cwd);
338
- console.log('✓ governance gates installed');
339
- } catch (e) {
340
- console.log(`⚠ gates install partial: ${e.message}`);
333
+ const yamlPath = path.join(cwd, 'trackfw.yaml');
334
+ if (fs.existsSync(yamlPath)) {
335
+ console.log('\n trackfw.yaml already exists — skipping (remove it first to regenerate)');
336
+ } else {
337
+ const yaml = generateYAML(r);
338
+ fs.writeFileSync(yamlPath, yaml, 'utf8');
339
+ console.log('\n✓ trackfw.yaml generated');
340
+ try {
341
+ installGates(r, cwd);
342
+ console.log('✓ governance gates installed');
343
+ } catch (e) {
344
+ console.log(`⚠ gates install partial: ${e.message}`);
345
+ }
341
346
  }
342
347
  }
343
348
 
@@ -5,11 +5,31 @@ const { t } = require('../i18n')
5
5
 
6
6
  const cmd = new Command('validate')
7
7
  cmd.description(t('validate.description'))
8
- cmd.action(async () => {
8
+ cmd.option('--json', 'output result as JSON')
9
+ cmd.action(async (options) => {
9
10
  const { violations, warnings } = await validate()
11
+ const lenient = isLenient()
12
+ const mode = lenient ? 'lenient' : 'strict'
13
+ const exitCode = violations.length > 0 ? 1 : 0
14
+
15
+ if (options.json) {
16
+ const output = {
17
+ summary: {
18
+ violations: violations.length,
19
+ warnings: warnings.length,
20
+ mode,
21
+ exit_code: exitCode,
22
+ },
23
+ violations: violations.map(v => ({ message: v })),
24
+ warnings: warnings.map(w => ({ message: w })),
25
+ }
26
+ console.log(JSON.stringify(output, null, 2))
27
+ process.exit(exitCode)
28
+ return
29
+ }
10
30
 
11
31
  // Informar usuário sobre modo lenient
12
- if (isLenient()) {
32
+ if (lenient) {
13
33
  const until = lenientUntilDate()
14
34
  if (until) {
15
35
  console.log(`[LENIENT MODE] ${t('validate.lenient_mode', { date: until })}`)
@@ -15,6 +15,7 @@ function defaults() {
15
15
  wipLimit: 1,
16
16
  wipBySquad: false,
17
17
  requireReqInCommit: false,
18
+ traceIdField: '',
18
19
  // NOVOS campos:
19
20
  linkFields: {
20
21
  req: ['REQ:'],
@@ -165,8 +166,8 @@ function parse(content, cfg) {
165
166
 
166
167
  switch (key) {
167
168
  case 'adr_dirs': inAdrDirs = true; adrDirs = []; break;
168
- case 'req_dir': cfg.reqDir = val; break;
169
- case 'roadmap_dir': cfg.roadmapDir = val; break;
169
+ case 'req_dir': cfg.reqDir = val.replace(/^["']|["']$/g, ''); break;
170
+ case 'roadmap_dir': cfg.roadmapDir = val.replace(/^["']|["']$/g, ''); break;
170
171
  case 'roadmap_namespacing': cfg.roadmapNamespacing = val; break;
171
172
  case 'agents': inAgents = true; agents = []; break;
172
173
  case 'governance_mode': cfg.governanceMode = val; break;
@@ -174,6 +175,7 @@ function parse(content, cfg) {
174
175
  case 'wip_limit': { const n = parseInt(val, 10); if (n > 0) cfg.wipLimit = n; break; }
175
176
  case 'wip_by_squad': cfg.wipBySquad = val === 'true'; break;
176
177
  case 'require_req_in_commit': cfg.requireReqInCommit = val === 'true'; break;
178
+ case 'trace_id_field': cfg.traceIdField = val.replace(/^["']|["']$/g, ''); break;
177
179
  case 'link_fields': inLinkFields = true; break;
178
180
  case 'acceptance_markers': inAcceptanceMarkers = true; acceptanceMarkers = []; break;
179
181
  case 'rules': inRules = true; rules = {}; break;
@@ -4,6 +4,7 @@ const fs = require('fs')
4
4
  const path = require('path')
5
5
  const { execSync } = require('child_process')
6
6
  const config = require('../config')
7
+ const { checkTraceIds } = require('./traceid')
7
8
 
8
9
  const STALE_WIP_DAYS = 7
9
10
 
@@ -768,6 +769,12 @@ async function validateUnfiltered() {
768
769
  // warnings diretos do WIP limit (não configuráveis)
769
770
  warnings.push(...wipLimitResult.warnings)
770
771
 
772
+ // Verificação bidirecional de trace ID (somente se traceIdField configurado)
773
+ const cfg = config.load()
774
+ if (cfg.traceIdField) {
775
+ violations.push(...checkTraceIds(cfg.reqDir, cfg.roadmapDir, cfg.traceIdField))
776
+ }
777
+
771
778
  return { violations, warnings }
772
779
  }
773
780
 
@@ -0,0 +1,142 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ // ESTADOS reconhecidos para REQs e Roadmaps (baseado na pasta onde o arquivo reside)
7
+ const KNOWN_STATES = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
8
+
9
+ // extractFrontmatterField extrai o valor de um campo do frontmatter YAML simples de um arquivo .md.
10
+ // Retorna string vazia se o campo não for encontrado ou o arquivo não tiver frontmatter.
11
+ function extractFrontmatterField(filePath, fieldName) {
12
+ let content
13
+ try { content = fs.readFileSync(filePath, 'utf8') } catch (_) { return '' }
14
+ if (!content.startsWith('---')) return ''
15
+ const end = content.indexOf('\n---', 3)
16
+ const block = end > 0 ? content.slice(3, end) : content.slice(3)
17
+ for (const line of block.split('\n')) {
18
+ const trimmed = line.trim()
19
+ const prefix = fieldName + ':'
20
+ if (trimmed.startsWith(prefix)) {
21
+ return trimmed.slice(prefix.length).trim().replace(/^["']|["']$/g, '')
22
+ }
23
+ }
24
+ return ''
25
+ }
26
+
27
+ // stateFromPath extrai o estado (wip/backlog/blocked/done/abandoned) a partir do caminho do arquivo.
28
+ // Percorre os segmentos de path de trás para frente e retorna o primeiro que for um estado reconhecido.
29
+ // Retorna '' se nenhum segmento for reconhecido.
30
+ function stateFromPath(filePath) {
31
+ const segments = filePath.split(path.sep)
32
+ for (let i = segments.length - 2; i >= 0; i--) {
33
+ if (KNOWN_STATES.includes(segments[i])) return segments[i]
34
+ }
35
+ return ''
36
+ }
37
+
38
+ // walkMd retorna array de caminhos absolutos de todos .md recursivamente dentro de dir.
39
+ function walkMd(dir) {
40
+ const results = []
41
+ function walk(d) {
42
+ let entries
43
+ try { entries = fs.readdirSync(d) } catch (_) { return }
44
+ for (const name of entries) {
45
+ const full = path.join(d, name)
46
+ try {
47
+ if (fs.statSync(full).isDirectory()) { walk(full) }
48
+ else if (name.endsWith('.md')) { results.push(full) }
49
+ } catch (_) {}
50
+ }
51
+ }
52
+ walk(dir)
53
+ return results
54
+ }
55
+
56
+ // checkTraceIds verifica a consistência bidirecional de req_id entre REQs e Roadmaps.
57
+ // Parâmetros:
58
+ // reqDir — caminho absoluto ou relativo do diretório de REQs
59
+ // roadmapDir — caminho absoluto ou relativo do diretório de Roadmaps
60
+ // fieldName — nome do campo de frontmatter que contém o trace ID (ex: 'req_id')
61
+ // Retorna array de strings de violation.
62
+ function checkTraceIds(reqDir, roadmapDir, fieldName) {
63
+ if (!fieldName) return []
64
+
65
+ // --- Indexar REQs ---
66
+ // reqIndex: Map<traceId, [{file, state}]>
67
+ const reqIndex = new Map()
68
+ for (const filePath of walkMd(reqDir)) {
69
+ const traceId = extractFrontmatterField(filePath, fieldName)
70
+ if (!traceId) continue
71
+ const state = stateFromPath(filePath)
72
+ if (!reqIndex.has(traceId)) reqIndex.set(traceId, [])
73
+ reqIndex.get(traceId).push({ file: path.basename(filePath), state })
74
+ }
75
+
76
+ // --- Indexar Roadmaps ---
77
+ // roadmapIndex: Map<traceId, [{file, state}]>
78
+ const roadmapIndex = new Map()
79
+ for (const filePath of walkMd(roadmapDir)) {
80
+ const traceId = extractFrontmatterField(filePath, fieldName)
81
+ if (!traceId) continue
82
+ const state = stateFromPath(filePath)
83
+ if (!roadmapIndex.has(traceId)) roadmapIndex.set(traceId, [])
84
+ roadmapIndex.get(traceId).push({ file: path.basename(filePath), state })
85
+ }
86
+
87
+ const violations = []
88
+
89
+ // traceid_duplicate_req: mesmo req_id em >1 REQ
90
+ for (const [traceId, entries] of reqIndex.entries()) {
91
+ if (entries.length > 1) {
92
+ const files = entries.map(e => e.file).join(', ')
93
+ violations.push(`traceid_duplicate_req: req_id "${traceId}" appears in ${entries.length} REQs: ${files}`)
94
+ }
95
+ }
96
+
97
+ // traceid_duplicate_roadmap: mesmo req_id em >1 Roadmap
98
+ for (const [traceId, entries] of roadmapIndex.entries()) {
99
+ if (entries.length > 1) {
100
+ const files = entries.map(e => e.file).join(', ')
101
+ violations.push(`traceid_duplicate_roadmap: req_id "${traceId}" appears in ${entries.length} Roadmaps: ${files}`)
102
+ }
103
+ }
104
+
105
+ // traceid_orphan_roadmap: Roadmap com req_id sem REQ correspondente
106
+ for (const [traceId, entries] of roadmapIndex.entries()) {
107
+ if (!reqIndex.has(traceId)) {
108
+ for (const e of entries) {
109
+ violations.push(`traceid_orphan_roadmap: roadmap "${e.file}" has req_id "${traceId}" but no matching REQ`)
110
+ }
111
+ }
112
+ }
113
+
114
+ // traceid_orphan_req: REQ com req_id sem Roadmap correspondente
115
+ for (const [traceId, entries] of reqIndex.entries()) {
116
+ if (!roadmapIndex.has(traceId)) {
117
+ for (const e of entries) {
118
+ violations.push(`traceid_orphan_req: req "${e.file}" has req_id "${traceId}" but no matching Roadmap`)
119
+ }
120
+ }
121
+ }
122
+
123
+ // traceid_state_mismatch: REQ e Roadmap com mesmo req_id em estados diferentes
124
+ for (const [traceId, reqEntries] of reqIndex.entries()) {
125
+ if (!roadmapIndex.has(traceId)) continue
126
+ const roadmapEntries = roadmapIndex.get(traceId)
127
+ // Comparar todos os pares (normalmente 1x1, mas suporta duplicados já reportados acima)
128
+ for (const req of reqEntries) {
129
+ for (const rm of roadmapEntries) {
130
+ if (req.state && rm.state && req.state !== rm.state) {
131
+ violations.push(
132
+ `traceid_state_mismatch: req_id "${traceId}" — REQ "${req.file}" is in "${req.state}" but Roadmap "${rm.file}" is in "${rm.state}"`
133
+ )
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ return violations
140
+ }
141
+
142
+ module.exports = { checkTraceIds, extractFrontmatterField, stateFromPath }