trackfw 2.1.0 → 2.3.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.
Files changed (2) hide show
  1. package/package.json +10 -3
  2. package/src/validator/index.js +240 -19
package/package.json CHANGED
@@ -1,13 +1,20 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "2.1.0",
4
- "description": "Governed software delivery framework: ADR → REQ → ROADMAP → kanban",
3
+ "version": "2.3.0",
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",
7
7
  "adr",
8
+ "architecture-decision-records",
8
9
  "roadmap",
9
10
  "governance",
10
- "delivery"
11
+ "software-delivery",
12
+ "devops",
13
+ "ai-agents",
14
+ "developer-tools",
15
+ "kanban",
16
+ "req",
17
+ "trackfw"
11
18
  ],
12
19
  "homepage": "https://github.com/kgsaran/trackfw",
13
20
  "repository": {
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs')
4
4
  const path = require('path')
5
+ const { execSync } = require('child_process')
5
6
  const config = require('../config')
6
7
 
7
8
  const STALE_WIP_DAYS = 7
@@ -22,6 +23,64 @@ function listDir(dir) {
22
23
  }
23
24
  }
24
25
 
26
+ // walkDirMd retorna basenames de todos .md recursivamente dentro de dir.
27
+ function walkDirMd(dir) {
28
+ const results = []
29
+ function walk(d) {
30
+ let entries
31
+ try { entries = fs.readdirSync(d) } catch (_) { return }
32
+ for (const name of entries) {
33
+ const full = path.join(d, name)
34
+ try {
35
+ if (fs.statSync(full).isDirectory()) { walk(full) }
36
+ else if (name.endsWith('.md')) { results.push(name) }
37
+ } catch (_) {}
38
+ }
39
+ }
40
+ walk(dir)
41
+ return results
42
+ }
43
+
44
+ // findAdrFile busca o basename recursivamente em todos os adrDirs configurados.
45
+ // Retorna o caminho completo se encontrado, ou null.
46
+ function findAdrFile(basename) {
47
+ const cfg = config.load()
48
+ for (const adrDir of cfg.adrDirs) {
49
+ function search(d) {
50
+ let entries
51
+ try { entries = fs.readdirSync(d) } catch (_) { return null }
52
+ for (const name of entries) {
53
+ const full = path.join(d, name)
54
+ try {
55
+ if (fs.statSync(full).isDirectory()) {
56
+ const r = search(full)
57
+ if (r) return r
58
+ } else if (name === basename) {
59
+ return full
60
+ }
61
+ } catch (_) {}
62
+ }
63
+ return null
64
+ }
65
+ const found = search(adrDir)
66
+ if (found) return found
67
+ }
68
+ return null
69
+ }
70
+
71
+ // gitLastModifiedTime retorna o timestamp (ms) do último commit que tocou o arquivo via git log.
72
+ // Retorna null em caso de erro ou se não houver commits.
73
+ function gitLastModifiedTime(filePath) {
74
+ try {
75
+ const out = execSync(`git log -1 --format=%ct -- "${filePath}"`, {
76
+ encoding: 'utf8',
77
+ stdio: ['pipe', 'pipe', 'pipe']
78
+ }).trim()
79
+ if (out) return parseInt(out, 10) * 1000 // converter para ms
80
+ } catch (_) {}
81
+ return null
82
+ }
83
+
25
84
  // resolveWIPDirs retorna todos os diretórios wip/ conforme o modo de namespacing.
26
85
  function resolveWIPDirs(cfg) {
27
86
  if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
@@ -68,20 +127,13 @@ function parseBlockedADRs(filePath) {
68
127
  return adrs
69
128
  }
70
129
 
71
- // adrIsDraft verifica se <adrBasename> contém "Status: Draft" em alguma das adrDirs configuradas.
130
+ // adrIsDraft verifica se <adrBasename> contém "Status: Draft" buscando recursivamente nas adrDirs.
72
131
  function adrIsDraft(basename) {
73
- const cfg = config.load()
74
- for (const adrDir of cfg.adrDirs) {
75
- const p = path.join(adrDir, basename)
76
- if (fs.existsSync(p)) {
77
- try {
78
- return fs.readFileSync(p, 'utf8').includes('Status: Draft')
79
- } catch (_) {
80
- // ignorar erro de leitura
81
- }
82
- }
83
- }
84
- return false
132
+ const p = findAdrFile(basename)
133
+ if (!p) return false
134
+ try {
135
+ return fs.readFileSync(p, 'utf8').includes('Status: Draft')
136
+ } catch (_) { return false }
85
137
  }
86
138
 
87
139
  // validateWIPHasREQ — roadmaps em wip/ sem "REQ:" no conteúdo → violation
@@ -165,7 +217,7 @@ function validateADRsAreReferenced() {
165
217
  const cfg = config.load()
166
218
  let adrs = []
167
219
  for (const adrDir of cfg.adrDirs) {
168
- adrs = adrs.concat(listDir(adrDir))
220
+ adrs = adrs.concat(walkDirMd(adrDir))
169
221
  }
170
222
 
171
223
  const reqEntries = listDir(cfg.reqDir)
@@ -343,10 +395,12 @@ function validateStaleWIP() {
343
395
  for (const filePath of files) {
344
396
  try {
345
397
  const stat = fs.statSync(filePath)
346
- const ageMs = now - stat.mtimeMs
398
+ const gitTime = gitLastModifiedTime(filePath)
399
+ const ageMs = now - (gitTime !== null ? gitTime : stat.mtimeMs)
347
400
  const days = Math.floor(ageMs / (1000 * 60 * 60 * 24))
348
401
  if (days >= STALE_WIP_DAYS) {
349
- const lastModified = stat.mtime.toISOString().slice(0, 10)
402
+ const refTime = gitTime !== null ? gitTime : stat.mtimeMs
403
+ const lastModified = new Date(refTime).toISOString().slice(0, 10)
350
404
  const basename = path.basename(filePath)
351
405
  warnings.push(
352
406
  `roadmap/wip/${basename} has been in WIP for ${days} days (last modified ${lastModified})`
@@ -458,10 +512,11 @@ function validateFrontmatterPresence() {
458
512
  const violations = []
459
513
 
460
514
  for (const adrDir of cfg.adrDirs) {
461
- const files = listDir(adrDir).filter(f => f.endsWith('.md'))
462
- for (const f of files) {
515
+ for (const f of walkDirMd(adrDir)) {
516
+ const fullPath = findAdrFile(f)
517
+ if (!fullPath) continue
463
518
  try {
464
- const content = fs.readFileSync(path.join(adrDir, f), 'utf8')
519
+ const content = fs.readFileSync(fullPath, 'utf8')
465
520
  if (!content.startsWith('---')) {
466
521
  violations.push(`adr "${f}" has no frontmatter block`)
467
522
  }
@@ -483,6 +538,161 @@ function validateFrontmatterPresence() {
483
538
  return violations
484
539
  }
485
540
 
541
+ // extractRefPath extrai o valor de um campo (ex: "REQ", "ADR", "Roadmap") que aponta para .md
542
+ function extractRefPath(content, field) {
543
+ for (const line of content.split('\n')) {
544
+ const trimmed = line.trim()
545
+ const prefix = field + ':'
546
+ if (trimmed.startsWith(prefix)) {
547
+ let val = trimmed.slice(prefix.length).trim()
548
+ if (!val || val === '—' || val === '-' || val === '–') return null
549
+ val = val.split(/\s+/)[0]
550
+ if (val.endsWith('.md')) return val
551
+ }
552
+ }
553
+ return null
554
+ }
555
+
556
+ // validateRefTargetsExist — verifica se os arquivos referenciados em REQ:, ADR: e Roadmap: existem
557
+ function validateRefTargetsExist() {
558
+ const cfg = config.load()
559
+ const warnings = []
560
+
561
+ // Roadmaps em wip e blocked: verificar REQ:
562
+ const dirs = [...resolveWIPDirs(cfg), cfg.roadmapDir + '/blocked']
563
+ for (const dir of dirs) {
564
+ for (const name of listDir(dir)) {
565
+ try {
566
+ const content = fs.readFileSync(path.join(dir, name), 'utf8')
567
+ const ref = extractRefPath(content, 'REQ')
568
+ if (ref && !fs.existsSync(ref)) {
569
+ warnings.push(`roadmap "${name}" links to REQ "${ref}" which does not exist`)
570
+ }
571
+ } catch (_) {}
572
+ }
573
+ }
574
+
575
+ // REQs: verificar ADR: e Roadmap:
576
+ for (const name of listDir(cfg.reqDir)) {
577
+ try {
578
+ const content = fs.readFileSync(path.join(cfg.reqDir, name), 'utf8')
579
+ const adrRef = extractRefPath(content, 'ADR')
580
+ if (adrRef && !fs.existsSync(adrRef)) {
581
+ warnings.push(`req "${name}" links to ADR "${adrRef}" which does not exist`)
582
+ }
583
+ const roadmapRef = extractRefPath(content, 'Roadmap')
584
+ if (roadmapRef && !fs.existsSync(roadmapRef)) {
585
+ warnings.push(`req "${name}" links to Roadmap "${roadmapRef}" which does not exist`)
586
+ }
587
+ } catch (_) {}
588
+ }
589
+
590
+ return warnings
591
+ }
592
+
593
+ // FOLDER_TO_STATUS mapeia pasta de estado para os valores válidos de status no frontmatter
594
+ const FOLDER_TO_STATUS = {
595
+ wip: ['WIP', 'wip', 'In Progress'],
596
+ backlog: ['Backlog', 'backlog'],
597
+ blocked: ['Blocked', 'blocked'],
598
+ done: ['Done', 'done'],
599
+ abandoned: ['Abandoned', 'abandoned'],
600
+ }
601
+
602
+ // validateFolderStatusCoherence — verifica se o status declarado no frontmatter condiz com a pasta
603
+ function validateFolderStatusCoherence() {
604
+ const cfg = config.load()
605
+ const warnings = []
606
+ const states = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
607
+
608
+ let dirs = []
609
+ if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
610
+ let agents = cfg.agents || []
611
+ if (agents.length === 0) {
612
+ try { agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
613
+ try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
614
+ }) } catch (_) { agents = [] }
615
+ }
616
+ for (const agent of agents) {
617
+ for (const state of states) {
618
+ dirs.push({ dir: path.join(cfg.roadmapDir, agent, state), state })
619
+ }
620
+ }
621
+ } else {
622
+ for (const state of states) {
623
+ dirs.push({ dir: path.join(cfg.roadmapDir, state), state })
624
+ }
625
+ }
626
+
627
+ for (const { dir, state } of dirs) {
628
+ for (const name of listDir(dir).filter(f => f.endsWith('.md'))) {
629
+ try {
630
+ const content = fs.readFileSync(path.join(dir, name), 'utf8')
631
+ // Extrair status do frontmatter
632
+ let declared = ''
633
+ if (content.startsWith('---')) {
634
+ const end = content.indexOf('\n---', 3)
635
+ if (end > 0) {
636
+ for (const line of content.slice(3, end).split('\n')) {
637
+ const t = line.trim()
638
+ if (t.startsWith('status:')) {
639
+ declared = t.slice('status:'.length).trim().replace(/['"]/g, '')
640
+ break
641
+ }
642
+ }
643
+ }
644
+ }
645
+ if (!declared) continue
646
+ const expected = FOLDER_TO_STATUS[state] || []
647
+ if (!expected.some(e => e.toLowerCase() === declared.toLowerCase())) {
648
+ warnings.push(`roadmap "${name}": folder is "${state}" but status declares "${declared}"`)
649
+ }
650
+ } catch (_) {}
651
+ }
652
+ }
653
+ return warnings
654
+ }
655
+
656
+ // validateFilenameUniqueness — verifica que o mesmo filename não aparece em múltiplos estados
657
+ function validateFilenameUniqueness() {
658
+ const cfg = config.load()
659
+ const states = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
660
+ const seen = {} // filename → [states]
661
+
662
+ if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
663
+ let agents = cfg.agents || []
664
+ if (agents.length === 0) {
665
+ try { agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
666
+ try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
667
+ }) } catch (_) { agents = [] }
668
+ }
669
+ for (const agent of agents) {
670
+ for (const state of states) {
671
+ for (const name of listDir(path.join(cfg.roadmapDir, agent, state))) {
672
+ const key = agent + '/' + name
673
+ if (!seen[key]) seen[key] = []
674
+ seen[key].push(state)
675
+ }
676
+ }
677
+ }
678
+ } else {
679
+ for (const state of states) {
680
+ for (const name of listDir(path.join(cfg.roadmapDir, state))) {
681
+ if (!seen[name]) seen[name] = []
682
+ seen[name].push(state)
683
+ }
684
+ }
685
+ }
686
+
687
+ const violations = []
688
+ for (const [name, stateList] of Object.entries(seen)) {
689
+ if (stateList.length > 1) {
690
+ violations.push(`roadmap "${name}" appears in multiple states: [${stateList.join(', ')}]`)
691
+ }
692
+ }
693
+ return violations
694
+ }
695
+
486
696
  // validate executa todas as validações e retorna { violations, warnings }
487
697
  async function validate() {
488
698
  const wipLimitResult = validateWIPLimit()
@@ -495,11 +705,14 @@ async function validate() {
495
705
  ...validateWIPHasAcceptanceCriteria(),
496
706
  ...validateREQsNotBlockedByDraftADRs(),
497
707
  ...validateFrontmatterPresence(),
708
+ ...validateFilenameUniqueness(),
498
709
  ...wipLimitResult.violations,
499
710
  ]
500
711
  let warnings = [
501
712
  ...wipLimitResult.warnings,
502
713
  ...validateStaleWIP(),
714
+ ...validateRefTargetsExist(),
715
+ ...validateFolderStatusCoherence(),
503
716
  ]
504
717
  // Modo lenient: mover violations para warnings, exit code 0
505
718
  if (isLenient()) {
@@ -609,4 +822,12 @@ module.exports = {
609
822
  readWIPConfig,
610
823
  parseSquadFromFrontmatter,
611
824
  validateFrontmatterPresence,
825
+ // novas funções ML-1B
826
+ walkDirMd,
827
+ findAdrFile,
828
+ gitLastModifiedTime,
829
+ extractRefPath,
830
+ validateRefTargetsExist,
831
+ validateFolderStatusCoherence,
832
+ validateFilenameUniqueness,
612
833
  }