trackfw 2.0.0 → 2.1.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/commands/context.js +189 -0
- package/src/commands/index.js +3 -0
- package/src/commands/init.js +11 -2
- package/src/commands/metrics.js +235 -0
- package/src/commands/plugins.js +73 -0
- package/src/commands/roadmap.js +6 -1
- package/src/commands/sync.js +362 -0
- package/src/commands/validate.js +9 -1
- package/src/generators/adr.js +14 -2
- package/src/generators/init.js +44 -1
- package/src/generators/req.js +9 -1
- package/src/generators/roadmap.js +142 -1
- package/src/i18n/locales/en-US.json +9 -1
- package/src/i18n/locales/es-ES.json +9 -1
- package/src/i18n/locales/pt-BR.json +9 -1
- package/src/validator/index.js +188 -15
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"gitHooks": "Git hooks?",
|
|
12
12
|
"ci": "CI system?",
|
|
13
13
|
"aiTools": "Which AI assistants do you use?",
|
|
14
|
+
"require_req_in_commit": "Require REQ reference in commit messages for feat/fix branches?",
|
|
14
15
|
"projectType_fullstack": "Full-stack (frontend + backend)",
|
|
15
16
|
"projectType_frontend": "Frontend only",
|
|
16
17
|
"projectType_backend": "Backend only",
|
|
@@ -87,7 +88,8 @@
|
|
|
87
88
|
"description": "Validate governance rules (use as CI gate)",
|
|
88
89
|
"ok": "✓ No violations found.",
|
|
89
90
|
"violations": "✗ Violations ({{count}}):",
|
|
90
|
-
"warnings": "⚠ Warnings ({{count}}):"
|
|
91
|
+
"warnings": "⚠ Warnings ({{count}}):",
|
|
92
|
+
"lenient_mode": "Governance violations treated as warnings until {{date}}"
|
|
91
93
|
},
|
|
92
94
|
"status": {
|
|
93
95
|
"description": "Show project governance status"
|
|
@@ -114,6 +116,12 @@
|
|
|
114
116
|
"success": "Plugin \"{{name}}\" removed."
|
|
115
117
|
}
|
|
116
118
|
},
|
|
119
|
+
"metrics": {
|
|
120
|
+
"description": "Show delivery metrics",
|
|
121
|
+
"no_data": "No transitions recorded yet.",
|
|
122
|
+
"since": "Filter by period (e.g. 7d, 30d, 90d)",
|
|
123
|
+
"export": "Export metrics to CSV file"
|
|
124
|
+
},
|
|
117
125
|
"errors": {
|
|
118
126
|
"notFound": "Not found: {{path}}",
|
|
119
127
|
"downloadFailed": "download failed: HTTP {{status}} for {{url}}",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"gitHooks": "¿Git hooks?",
|
|
12
12
|
"ci": "¿Sistema de CI?",
|
|
13
13
|
"aiTools": "¿Qué asistentes de IA usas?",
|
|
14
|
+
"require_req_in_commit": "¿Requerir referencia REQ en mensajes de commit de ramas feat/fix?",
|
|
14
15
|
"projectType_fullstack": "Full-stack (frontend + backend)",
|
|
15
16
|
"projectType_frontend": "Solo frontend",
|
|
16
17
|
"projectType_backend": "Solo backend",
|
|
@@ -87,7 +88,8 @@
|
|
|
87
88
|
"description": "Validar reglas de gobernanza (úsalo como gate de CI)",
|
|
88
89
|
"ok": "✓ No se encontraron violaciones.",
|
|
89
90
|
"violations": "✗ Violaciones ({{count}}):",
|
|
90
|
-
"warnings": "⚠ Avisos ({{count}}):"
|
|
91
|
+
"warnings": "⚠ Avisos ({{count}}):",
|
|
92
|
+
"lenient_mode": "Las violaciones de gobernanza se tratan como avisos hasta {{date}}"
|
|
91
93
|
},
|
|
92
94
|
"status": {
|
|
93
95
|
"description": "Mostrar el estado actual de gobernanza del proyecto"
|
|
@@ -114,6 +116,12 @@
|
|
|
114
116
|
"success": "Plugin \"{{name}}\" eliminado."
|
|
115
117
|
}
|
|
116
118
|
},
|
|
119
|
+
"metrics": {
|
|
120
|
+
"description": "Muestra métricas de entrega",
|
|
121
|
+
"no_data": "No hay transiciones registradas aún.",
|
|
122
|
+
"since": "Filtrar por período (ej: 7d, 30d, 90d)",
|
|
123
|
+
"export": "Exportar métricas a archivo CSV"
|
|
124
|
+
},
|
|
117
125
|
"errors": {
|
|
118
126
|
"notFound": "No encontrado: {{path}}",
|
|
119
127
|
"downloadFailed": "fallo en la descarga: HTTP {{status}} para {{url}}",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"gitHooks": "Git hooks?",
|
|
12
12
|
"ci": "Sistema de CI?",
|
|
13
13
|
"aiTools": "Quais assistentes de IA você usa?",
|
|
14
|
+
"require_req_in_commit": "Exigir referência REQ nas mensagens de commit de branches feat/fix?",
|
|
14
15
|
"projectType_fullstack": "Full-stack (frontend + backend)",
|
|
15
16
|
"projectType_frontend": "Somente frontend",
|
|
16
17
|
"projectType_backend": "Somente backend",
|
|
@@ -87,7 +88,8 @@
|
|
|
87
88
|
"description": "Validar regras de governança (use como gate de CI)",
|
|
88
89
|
"ok": "✓ Nenhuma violação encontrada.",
|
|
89
90
|
"violations": "✗ Violações ({{count}}):",
|
|
90
|
-
"warnings": "⚠ Avisos ({{count}}):"
|
|
91
|
+
"warnings": "⚠ Avisos ({{count}}):",
|
|
92
|
+
"lenient_mode": "Violações de governança tratadas como avisos até {{date}}"
|
|
91
93
|
},
|
|
92
94
|
"status": {
|
|
93
95
|
"description": "Exibir o estado atual de governança do projeto"
|
|
@@ -114,6 +116,12 @@
|
|
|
114
116
|
"success": "Plugin \"{{name}}\" removido."
|
|
115
117
|
}
|
|
116
118
|
},
|
|
119
|
+
"metrics": {
|
|
120
|
+
"description": "Exibe métricas de delivery",
|
|
121
|
+
"no_data": "Nenhuma transição registrada ainda.",
|
|
122
|
+
"since": "Filtrar por período (ex: 7d, 30d, 90d)",
|
|
123
|
+
"export": "Exportar métricas para arquivo CSV"
|
|
124
|
+
},
|
|
117
125
|
"errors": {
|
|
118
126
|
"notFound": "Não encontrado: {{path}}",
|
|
119
127
|
"downloadFailed": "falha no download: HTTP {{status}} para {{url}}",
|
package/src/validator/index.js
CHANGED
|
@@ -214,10 +214,55 @@ function validateWIPHasAcceptanceCriteria() {
|
|
|
214
214
|
return violations
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
//
|
|
218
|
-
//
|
|
217
|
+
// readWIPConfig lê wip_limit e wip_by_squad do trackfw.yaml no CWD.
|
|
218
|
+
// Retorna { limit: 1, bySquad: false } se o arquivo não existe ou campos ausentes.
|
|
219
|
+
function readWIPConfig() {
|
|
220
|
+
const cfg = { limit: 1, bySquad: false }
|
|
221
|
+
let content
|
|
222
|
+
try {
|
|
223
|
+
content = fs.readFileSync('trackfw.yaml', 'utf8')
|
|
224
|
+
} catch (_) {
|
|
225
|
+
return cfg
|
|
226
|
+
}
|
|
227
|
+
for (const line of content.split('\n')) {
|
|
228
|
+
const trimmed = line.trim()
|
|
229
|
+
if (trimmed.startsWith('wip_limit:')) {
|
|
230
|
+
const val = trimmed.slice('wip_limit:'.length).trim().split(/\s+/)[0]
|
|
231
|
+
const n = parseInt(val, 10)
|
|
232
|
+
if (!isNaN(n) && n > 0) cfg.limit = n
|
|
233
|
+
}
|
|
234
|
+
if (trimmed.startsWith('wip_by_squad:')) {
|
|
235
|
+
const val = trimmed.slice('wip_by_squad:'.length).trim().split(/\s+/)[0]
|
|
236
|
+
if (val === 'true') cfg.bySquad = true
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return cfg
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// parseSquadFromFrontmatter extrai o valor do campo "squad:" de um arquivo markdown.
|
|
243
|
+
// Retorna string vazia se ausente ou vazio.
|
|
244
|
+
function parseSquadFromFrontmatter(filePath) {
|
|
245
|
+
let content
|
|
246
|
+
try {
|
|
247
|
+
content = fs.readFileSync(filePath, 'utf8')
|
|
248
|
+
} catch (_) {
|
|
249
|
+
return ''
|
|
250
|
+
}
|
|
251
|
+
for (const line of content.split('\n')) {
|
|
252
|
+
const trimmed = line.trim()
|
|
253
|
+
if (trimmed.startsWith('squad:')) {
|
|
254
|
+
return trimmed.slice('squad:'.length).trim()
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return ''
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// validateWIPLimit — verifica o WIP limit por agente, por squad ou global conforme trackfw.yaml.
|
|
261
|
+
// Retorna { violations: [], warnings: [] }.
|
|
219
262
|
function validateWIPLimit() {
|
|
220
263
|
const cfg = config.load()
|
|
264
|
+
const violations = []
|
|
265
|
+
const warnings = []
|
|
221
266
|
|
|
222
267
|
if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
|
|
223
268
|
let agents = cfg.agents || []
|
|
@@ -228,24 +273,48 @@ function validateWIPLimit() {
|
|
|
228
273
|
})
|
|
229
274
|
} catch (_) { agents = [] }
|
|
230
275
|
}
|
|
231
|
-
const warnings = []
|
|
232
276
|
const limit = cfg.wipLimit > 0 ? cfg.wipLimit : 1
|
|
233
277
|
for (const agent of agents) {
|
|
234
|
-
const
|
|
235
|
-
const entries = listDir(dir)
|
|
278
|
+
const entries = listDir(cfg.roadmapDir + '/' + agent + '/wip')
|
|
236
279
|
if (entries.length > limit) {
|
|
237
|
-
warnings.push(`${entries.length} roadmaps in wip/ for agent "${agent}" (limit: ${limit})`)
|
|
280
|
+
warnings.push(`${entries.length} roadmaps in wip/ for agent "${agent}" (limit: ${limit}) — consider focusing`)
|
|
238
281
|
}
|
|
239
282
|
}
|
|
240
|
-
return warnings
|
|
283
|
+
return { violations, warnings }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// modo flat (global ou por squad)
|
|
287
|
+
let files = []
|
|
288
|
+
try {
|
|
289
|
+
files = fs.readdirSync(path.join(cfg.roadmapDir, 'wip'))
|
|
290
|
+
.filter(f => { try { return !fs.statSync(path.join(cfg.roadmapDir, 'wip', f)).isDirectory() } catch (_) { return false } })
|
|
291
|
+
.map(f => path.join(cfg.roadmapDir, 'wip', f))
|
|
292
|
+
} catch (_) {
|
|
293
|
+
return { violations, warnings }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const wipCfg = readWIPConfig()
|
|
297
|
+
|
|
298
|
+
if (!wipCfg.bySquad) {
|
|
299
|
+
if (files.length > wipCfg.limit) {
|
|
300
|
+
warnings.push(`${files.length} roadmaps in wip/ (limit: ${wipCfg.limit}) — consider focusing`)
|
|
301
|
+
}
|
|
302
|
+
return { violations, warnings }
|
|
241
303
|
}
|
|
242
304
|
|
|
243
|
-
const
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
305
|
+
const bySquad = {}
|
|
306
|
+
for (const f of files) {
|
|
307
|
+
let squad = parseSquadFromFrontmatter(f)
|
|
308
|
+
if (!squad) squad = '(no squad)'
|
|
309
|
+
if (!bySquad[squad]) bySquad[squad] = []
|
|
310
|
+
bySquad[squad].push(path.basename(f))
|
|
311
|
+
}
|
|
312
|
+
for (const [squad, items] of Object.entries(bySquad)) {
|
|
313
|
+
if (items.length > wipCfg.limit) {
|
|
314
|
+
warnings.push(`squad "${squad}" has ${items.length} roadmaps in wip/ (limit: ${wipCfg.limit})`)
|
|
315
|
+
}
|
|
247
316
|
}
|
|
248
|
-
return
|
|
317
|
+
return { violations, warnings }
|
|
249
318
|
}
|
|
250
319
|
|
|
251
320
|
// validateSingleWIP — alias retrocompatível de validateWIPLimit (modo flat)
|
|
@@ -340,9 +409,84 @@ function blockedREQs() {
|
|
|
340
409
|
return result
|
|
341
410
|
}
|
|
342
411
|
|
|
412
|
+
// readGovernanceMode lê governance_mode e lenient_until do trackfw.yaml no CWD.
|
|
413
|
+
// Retorna { mode: 'strict', lenientUntil: null } se o arquivo não existe ou campos ausentes.
|
|
414
|
+
function readGovernanceMode() {
|
|
415
|
+
let content
|
|
416
|
+
try {
|
|
417
|
+
content = fs.readFileSync('trackfw.yaml', 'utf8')
|
|
418
|
+
} catch (_) {
|
|
419
|
+
return { mode: 'strict', lenientUntil: null }
|
|
420
|
+
}
|
|
421
|
+
let mode = 'strict'
|
|
422
|
+
let lenientUntil = null
|
|
423
|
+
for (const line of content.split('\n')) {
|
|
424
|
+
const trimmed = line.trim()
|
|
425
|
+
if (trimmed.startsWith('governance_mode:')) {
|
|
426
|
+
const val = trimmed.slice('governance_mode:'.length).trim().split(/\s+/)[0]
|
|
427
|
+
if (val) mode = val
|
|
428
|
+
}
|
|
429
|
+
if (trimmed.startsWith('lenient_until:')) {
|
|
430
|
+
const val = trimmed.slice('lenient_until:'.length).trim().split(/\s+/)[0]
|
|
431
|
+
if (val) {
|
|
432
|
+
const d = new Date(val)
|
|
433
|
+
if (!isNaN(d.getTime())) lenientUntil = d
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return { mode, lenientUntil }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// isLenient retorna true se o projeto está em modo lenient e o prazo não expirou.
|
|
441
|
+
function isLenient() {
|
|
442
|
+
const gm = readGovernanceMode()
|
|
443
|
+
if (gm.mode !== 'lenient') return false
|
|
444
|
+
if (!gm.lenientUntil) return true
|
|
445
|
+
return new Date() < gm.lenientUntil
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// lenientUntilDate retorna a data de expiração formatada (YYYY-MM-DD) ou ''.
|
|
449
|
+
function lenientUntilDate() {
|
|
450
|
+
const gm = readGovernanceMode()
|
|
451
|
+
if (gm.mode !== 'lenient' || !gm.lenientUntil) return ''
|
|
452
|
+
return gm.lenientUntil.toISOString().slice(0, 10)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// validateFrontmatterPresence — verifica presença de frontmatter em ADRs e REQs
|
|
456
|
+
function validateFrontmatterPresence() {
|
|
457
|
+
const cfg = config.load()
|
|
458
|
+
const violations = []
|
|
459
|
+
|
|
460
|
+
for (const adrDir of cfg.adrDirs) {
|
|
461
|
+
const files = listDir(adrDir).filter(f => f.endsWith('.md'))
|
|
462
|
+
for (const f of files) {
|
|
463
|
+
try {
|
|
464
|
+
const content = fs.readFileSync(path.join(adrDir, f), 'utf8')
|
|
465
|
+
if (!content.startsWith('---')) {
|
|
466
|
+
violations.push(`adr "${f}" has no frontmatter block`)
|
|
467
|
+
}
|
|
468
|
+
} catch (_) {}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
let reqFiles = []
|
|
473
|
+
try { reqFiles = listDir(cfg.reqDir).filter(f => f.endsWith('.md')) } catch (_) {}
|
|
474
|
+
for (const f of reqFiles) {
|
|
475
|
+
try {
|
|
476
|
+
const content = fs.readFileSync(path.join(cfg.reqDir, f), 'utf8')
|
|
477
|
+
if (!content.startsWith('---')) {
|
|
478
|
+
violations.push(`req "${f}" has no frontmatter block`)
|
|
479
|
+
}
|
|
480
|
+
} catch (_) {}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return violations
|
|
484
|
+
}
|
|
485
|
+
|
|
343
486
|
// validate executa todas as validações e retorna { violations, warnings }
|
|
344
487
|
async function validate() {
|
|
345
|
-
const
|
|
488
|
+
const wipLimitResult = validateWIPLimit()
|
|
489
|
+
let violations = [
|
|
346
490
|
...validateWIPHasREQ(),
|
|
347
491
|
...validateREQsHaveADR(),
|
|
348
492
|
...validateBlockedHasREQ(),
|
|
@@ -350,11 +494,18 @@ async function validate() {
|
|
|
350
494
|
...validateADRsAreReferenced(),
|
|
351
495
|
...validateWIPHasAcceptanceCriteria(),
|
|
352
496
|
...validateREQsNotBlockedByDraftADRs(),
|
|
497
|
+
...validateFrontmatterPresence(),
|
|
498
|
+
...wipLimitResult.violations,
|
|
353
499
|
]
|
|
354
|
-
|
|
355
|
-
...
|
|
500
|
+
let warnings = [
|
|
501
|
+
...wipLimitResult.warnings,
|
|
356
502
|
...validateStaleWIP(),
|
|
357
503
|
]
|
|
504
|
+
// Modo lenient: mover violations para warnings, exit code 0
|
|
505
|
+
if (isLenient()) {
|
|
506
|
+
warnings = [...warnings, ...violations]
|
|
507
|
+
violations = []
|
|
508
|
+
}
|
|
358
509
|
return { violations, warnings }
|
|
359
510
|
}
|
|
360
511
|
|
|
@@ -388,6 +539,22 @@ async function getStatus() {
|
|
|
388
539
|
out += `\n🔄 WIP (${wip.length})\n`
|
|
389
540
|
for (const f of wip) out += ` ${f}\n`
|
|
390
541
|
|
|
542
|
+
const wipCfg = readWIPConfig()
|
|
543
|
+
if (wipCfg.bySquad && wip.length > 0) {
|
|
544
|
+
const bySquad = {}
|
|
545
|
+
for (const f of wip) {
|
|
546
|
+
let squad = parseSquadFromFrontmatter(path.join(cfg.roadmapDir, 'wip', f))
|
|
547
|
+
if (!squad) squad = '(no squad)'
|
|
548
|
+
bySquad[squad] = (bySquad[squad] || 0) + 1
|
|
549
|
+
}
|
|
550
|
+
out += `\n⚙ WIP by Squad (limit: ${wipCfg.limit} per squad)\n`
|
|
551
|
+
for (const [squad, count] of Object.entries(bySquad)) {
|
|
552
|
+
const status = count > wipCfg.limit ? '⚠' : '✓'
|
|
553
|
+
const noun = count === 1 ? 'roadmap' : 'roadmaps'
|
|
554
|
+
out += ` ${(squad + ':').padEnd(20)} ${count} ${noun} ${status}\n`
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
391
558
|
out += `\n❌ Blocked (${blocked.length})\n`
|
|
392
559
|
for (const f of blocked) out += ` ${f}\n`
|
|
393
560
|
|
|
@@ -421,6 +588,8 @@ async function getStatus() {
|
|
|
421
588
|
module.exports = {
|
|
422
589
|
validate,
|
|
423
590
|
getStatus,
|
|
591
|
+
isLenient,
|
|
592
|
+
lenientUntilDate,
|
|
424
593
|
// exportadas para testes unitários
|
|
425
594
|
validateWIPHasREQ,
|
|
426
595
|
validateREQsHaveADR,
|
|
@@ -436,4 +605,8 @@ module.exports = {
|
|
|
436
605
|
adrIsDraft,
|
|
437
606
|
listDir,
|
|
438
607
|
resolveWIPDirs,
|
|
608
|
+
readGovernanceMode,
|
|
609
|
+
readWIPConfig,
|
|
610
|
+
parseSquadFromFrontmatter,
|
|
611
|
+
validateFrontmatterPresence,
|
|
439
612
|
}
|