trackfw 2.1.1 → 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.
- package/package.json +1 -1
- package/src/validator/index.js +240 -19
package/package.json
CHANGED
package/src/validator/index.js
CHANGED
|
@@ -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"
|
|
130
|
+
// adrIsDraft verifica se <adrBasename> contém "Status: Draft" buscando recursivamente nas adrDirs.
|
|
72
131
|
function adrIsDraft(basename) {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
462
|
-
|
|
515
|
+
for (const f of walkDirMd(adrDir)) {
|
|
516
|
+
const fullPath = findAdrFile(f)
|
|
517
|
+
if (!fullPath) continue
|
|
463
518
|
try {
|
|
464
|
-
const content = fs.readFileSync(
|
|
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
|
}
|