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.
@@ -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}}",
@@ -214,10 +214,55 @@ function validateWIPHasAcceptanceCriteria() {
214
214
  return violations
215
215
  }
216
216
 
217
- // validateWIPLimit mais de wipLimit roadmaps em wip → warning.
218
- // Em modo by_agent, verifica por agente individualmente.
217
+ // readWIPConfig 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 dir = cfg.roadmapDir + '/' + agent + '/wip'
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 entries = listDir(cfg.roadmapDir + '/wip')
244
- const limit = cfg.wipLimit > 0 ? cfg.wipLimit : 1
245
- if (entries.length > limit) {
246
- return [`${entries.length} roadmaps in wip/ (limit: ${limit})`]
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 violations = [
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
- const warnings = [
355
- ...validateWIPLimit(),
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
  }