trackfw 6.0.0 → 6.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.
@@ -254,22 +254,97 @@ function contentHasMarker(content, markers) {
254
254
  return false
255
255
  }
256
256
 
257
- // adrIsDraft verifica se <adrBasename> contém "Status: Draft" buscando recursivamente nas adrDirs.
258
- function adrIsDraft(basename) {
259
- return adrDraftStatusForRule(basename, null).draft
257
+ // extractAdrHeaderStatus extrai o valor declarado na linha de cabeçalho
258
+ // ("> Date: ... | Status: X") de um ADR. Ancorado à linha — não é substring livre sobre o
259
+ // documento inteiro — para não confundir texto de prosa que mencione "Status: Draft"/"Proposed"
260
+ // (ex: citações de código, exemplos) com o status real do artefato. Retorna '' se a linha não
261
+ // existir.
262
+ function extractAdrHeaderStatus(content) {
263
+ const marker = '| Status: '
264
+ for (const line of content.split('\n')) {
265
+ const trimmed = line.trim()
266
+ const idx = trimmed.indexOf(marker)
267
+ if (idx >= 0) {
268
+ let rest = trimmed.slice(idx + marker.length)
269
+ const pipeIdx = rest.indexOf(' |')
270
+ if (pipeIdx >= 0) rest = rest.slice(0, pipeIdx)
271
+ return rest.trim()
272
+ }
273
+ }
274
+ return ''
260
275
  }
261
276
 
262
- function adrDraftStatusForRule(basename, messages) {
277
+ // extractFrontmatterField extrai o valor de um campo do bloco frontmatter YAML
278
+ // (delimitado por "---" ... "---" no início do arquivo). Retorna '' se o frontmatter
279
+ // estiver ausente ou o campo não existir/estiver vazio.
280
+ function extractFrontmatterField(content, field) {
281
+ if (!content.startsWith('---')) return ''
282
+ const rest = content.slice(3)
283
+ const end = rest.indexOf('\n---')
284
+ if (end < 0) return ''
285
+ const block = rest.slice(0, end)
286
+ for (const line of block.split('\n')) {
287
+ const trimmed = line.trim()
288
+ if (trimmed.startsWith(field + ':')) {
289
+ let val = trimmed.slice(field.length + 1).trim()
290
+ val = val.replace(/^["']|["']$/g, '')
291
+ return val
292
+ }
293
+ }
294
+ return ''
295
+ }
296
+
297
+ // resolveAdrStatus extrai o valor bruto do status de um ADR: frontmatter `status:`
298
+ // primeiro — é o campo machine-readable canônico, o mesmo que os geradores (`adr new`,
299
+ // NewADRDraft) escrevem e que a regra folder_status já usa como fonte de verdade — com
300
+ // fallback para a linha de cabeçalho ("> Date: ... | Status: X") somente se o
301
+ // frontmatter estiver ausente ou sem o campo (cobre ADRs legados sem frontmatter). Em um
302
+ // ADR bem formado os dois concordam. ML-1D (2026-08-01): alinha o Node ao Go e ao
303
+ // Python, que já liam frontmatter-first (ADR-2026-08-01-nocao-canonica-de-adr-nao-aceito).
304
+ function resolveAdrStatus(content) {
305
+ const fm = extractFrontmatterField(content, 'status')
306
+ if (fm) return fm
307
+ return extractAdrHeaderStatus(content)
308
+ }
309
+
310
+ // adrNotAcceptedStatusForRule é o helper canônico de "ADR não aceito": único lugar que conhece o
311
+ // vocabulário Draft/Proposed. Verdadeiro para ADR cujo status seja "Draft" ou "Proposed"; qualquer
312
+ // outro status (Accepted, Superseded, Deprecated, Rejected, ...) conta como aceito por exclusão —
313
+ // não há allowlist fechada de status aceitos.
314
+ //
315
+ // A fonte do status é resolveAdrStatus (frontmatter-first, fallback de cabeçalho — ver acima).
316
+ // Crucial: o fallback de cabeçalho extrai o valor de UMA linha específica
317
+ // (extractAdrHeaderStatus), não faz content.includes('Status: Draft') sobre o documento
318
+ // inteiro — esse era o defeito do código herdado (adrDraftStatusForRule original): um ADR
319
+ // com status real "Accepted" mas cuja prosa cita literalmente a string "Status: Draft"
320
+ // (ex: este próprio ADR, que documenta o bug) seria classificado como não aceito. Ver
321
+ // vault/notes para o caso concreto que expôs isso.
322
+ function adrNotAcceptedStatusForRule(rule, basename, messages) {
263
323
  const p = findAdrFile(basename)
264
- if (!p) return { draft: false, inspected: true }
324
+ if (!p) return { notAccepted: false, status: '', inspected: true }
265
325
  try {
266
- return { draft: fs.readFileSync(p, 'utf8').includes('Status: Draft'), inspected: true }
326
+ const content = fs.readFileSync(p, 'utf8')
327
+ const status = resolveAdrStatus(content)
328
+ const notAccepted = status.toLowerCase() === 'draft' || status.toLowerCase() === 'proposed'
329
+ return { notAccepted, status, inspected: true }
267
330
  } catch (err) {
268
- if (messages) messages.push(inspectionDiagnostic('blocked_by_draft_adr', p, err))
269
- return { draft: false, inspected: false }
331
+ if (messages) messages.push(inspectionDiagnostic(rule, p, err))
332
+ return { notAccepted: false, status: '', inspected: false }
270
333
  }
271
334
  }
272
335
 
336
+ // adrIsDraft verifica se <adrBasename> está em status não aceito (Draft ou Proposed) buscando
337
+ // recursivamente nas adrDirs. Nome mantido por compatibilidade (chamadores existentes); delega no
338
+ // helper canônico adrNotAcceptedStatusForRule.
339
+ function adrIsDraft(basename) {
340
+ return adrDraftStatusForRule(basename, null).draft
341
+ }
342
+
343
+ function adrDraftStatusForRule(basename, messages) {
344
+ const result = adrNotAcceptedStatusForRule('blocked_by_draft_adr', basename, messages)
345
+ return { draft: result.notAccepted, inspected: result.inspected }
346
+ }
347
+
273
348
  // validateWIPHasREQ — roadmaps em wip/ sem marker REQ no conteúdo → violation
274
349
  // Suporta modo by_agent via resolveWIPDirs.
275
350
  function validateWIPHasREQ() {
@@ -419,29 +494,10 @@ function validateWIPHasAcceptanceCriteria() {
419
494
  return violations
420
495
  }
421
496
 
422
- // readWIPConfig lê wip_limit e wip_by_squad do trackfw.yaml no CWD.
423
- // Retorna { limit: 1, bySquad: false } se o arquivo não existe ou campos ausentes.
424
- function readWIPConfig() {
425
- const cfg = { limit: 1, bySquad: false }
426
- let content
427
- try {
428
- content = fs.readFileSync('trackfw.yaml', 'utf8')
429
- } catch (_) {
430
- return cfg
431
- }
432
- for (const line of content.split('\n')) {
433
- const trimmed = line.trim()
434
- if (trimmed.startsWith('wip_limit:')) {
435
- const val = trimmed.slice('wip_limit:'.length).trim().split(/\s+/)[0]
436
- const n = parseInt(val, 10)
437
- if (!isNaN(n) && n > 0) cfg.limit = n
438
- }
439
- if (trimmed.startsWith('wip_by_squad:')) {
440
- const val = trimmed.slice('wip_by_squad:'.length).trim().split(/\s+/)[0]
441
- if (val === 'true') cfg.bySquad = true
442
- }
443
- }
444
- return cfg
497
+ // wipConfigFrom deriva { limit, bySquad } a partir do ProjectConfig já normalizado por
498
+ // config.load() — nenhuma releitura de trackfw.yaml acontece aqui.
499
+ function wipConfigFrom(cfg) {
500
+ return { limit: cfg.wipLimit > 0 ? cfg.wipLimit : 1, bySquad: !!cfg.wipBySquad }
445
501
  }
446
502
 
447
503
  // parseSquadFromFrontmatter extrai o valor do campo "squad:" de um arquivo markdown.
@@ -498,7 +554,7 @@ function validateWIPLimit() {
498
554
  return { violations, warnings }
499
555
  }
500
556
 
501
- const wipCfg = readWIPConfig()
557
+ const wipCfg = wipConfigFrom(cfg)
502
558
 
503
559
  if (!wipCfg.bySquad) {
504
560
  if (files.length > wipCfg.limit) {
@@ -622,13 +678,63 @@ function validateREQsNotBlockedByDraftADRs() {
622
678
  const blockedADRs = parseBlockedADRs(filePath)
623
679
  for (const adrBasename of blockedADRs) {
624
680
  if (adrDraftStatusForRule(adrBasename, violations).draft) {
625
- violations.push(`REQ ${path.basename(filePath)} is blocked by Draft ADR: ${adrBasename}`)
681
+ violations.push(`REQ ${path.basename(filePath)} is blocked by not-accepted ADR: ${adrBasename}`)
626
682
  }
627
683
  }
628
684
  }
629
685
  return violations
630
686
  }
631
687
 
688
+ // reqStatusEquals compara o status de uma REQ (case-insensitive) contra o valor esperado.
689
+ // Casa tanto o frontmatter ("status: X") quanto a linha de cabeçalho ("> Date: ... | Status: X"),
690
+ // mesma lógica de detecção usada por reqStatusIsOpen — duplicada aqui (em vez de generalizar
691
+ // reqStatusIsOpen) para não alterar o comportamento de req_roadmap_lifecycle, que já a consome.
692
+ function reqStatusEquals(content, status) {
693
+ const target = String(status).toLowerCase()
694
+ for (const line of content.split('\n')) {
695
+ const trimmed = line.trim()
696
+ const idx = trimmed.indexOf(':')
697
+ if (idx >= 0 && trimmed.slice(0, idx).trim().toLowerCase() === 'status') {
698
+ return trimmed.slice(idx + 1).trim().replace(/^["']|["']$/g, '').toLowerCase() === target
699
+ }
700
+ const marker = '| Status: '
701
+ const markerIdx = trimmed.indexOf(marker)
702
+ if (markerIdx >= 0) {
703
+ let rest = trimmed.slice(markerIdx + marker.length)
704
+ const pipeIdx = rest.indexOf(' |')
705
+ if (pipeIdx >= 0) rest = rest.slice(0, pipeIdx)
706
+ return rest.trim().toLowerCase() === target
707
+ }
708
+ }
709
+ return false
710
+ }
711
+
712
+ // validateADRAcceptedWhenREQDone — REQ Done cujo ADR vinculado (campo "ADR:") não está aceito
713
+ // (Draft ou Proposed, via o helper canônico adrNotAcceptedStatusForRule) → violation. Fecha a
714
+ // lacuna que deixou um ADR Proposed atravessar sete REQs Done sem nenhum gate detectar.
715
+ function validateADRAcceptedWhenREQDone() {
716
+ const cfg = config.load()
717
+ const files = resolveReqFiles(cfg)
718
+ const violations = []
719
+ for (const filePath of files) {
720
+ const content = readFileForRule('adr_accepted_when_req_done', filePath, violations)
721
+ if (content === null) continue
722
+ if (!reqStatusEquals(content, 'done')) continue
723
+
724
+ const adrRef = extractRefPath(content, 'ADR')
725
+ if (!adrRef) continue
726
+ const adrBasename = path.basename(adrRef)
727
+ const status = adrNotAcceptedStatusForRule('adr_accepted_when_req_done', adrBasename, violations)
728
+ if (status.notAccepted) {
729
+ const reqBasename = path.basename(filePath)
730
+ violations.push(
731
+ `REQ "${reqBasename}" is Done but linked ADR "${adrBasename}" is not accepted (status: ${status.status})`
732
+ )
733
+ }
734
+ }
735
+ return violations
736
+ }
737
+
632
738
  // blockedREQs retorna mapa de reqBasename → [adrBasenames Draft] para uso em getStatus()
633
739
  function blockedREQs() {
634
740
  const cfg = config.load()
@@ -652,37 +758,23 @@ function blockedREQs() {
652
758
  return result
653
759
  }
654
760
 
655
- // readGovernanceMode lê governance_mode e lenient_until do trackfw.yaml no CWD.
656
- // Retorna { mode: 'strict', lenientUntil: null } se o arquivo não existe ou campos ausentes.
657
- function readGovernanceMode() {
658
- let content
659
- try {
660
- content = fs.readFileSync('trackfw.yaml', 'utf8')
661
- } catch (_) {
662
- return { mode: 'strict', lenientUntil: null }
663
- }
664
- let mode = 'strict'
761
+ // governanceModeFrom deriva { mode, lenientUntil } a partir do ProjectConfig já normalizado por
762
+ // config.load() — nenhuma releitura de trackfw.yaml acontece aqui. cfg.governanceMode chega como
763
+ // o valor bruto do campo (string vazia se ausente); cfg.lenientUntil chega como a data literal
764
+ // (ex.: "2026-08-02"), convertida aqui para Date.
765
+ function governanceModeFrom(cfg) {
766
+ const mode = cfg.governanceMode ? cfg.governanceMode : 'strict'
665
767
  let lenientUntil = null
666
- for (const line of content.split('\n')) {
667
- const trimmed = line.trim()
668
- if (trimmed.startsWith('governance_mode:')) {
669
- const val = trimmed.slice('governance_mode:'.length).trim().split(/\s+/)[0]
670
- if (val) mode = val
671
- }
672
- if (trimmed.startsWith('lenient_until:')) {
673
- const val = trimmed.slice('lenient_until:'.length).trim().split(/\s+/)[0]
674
- if (val) {
675
- const d = new Date(val)
676
- if (!isNaN(d.getTime())) lenientUntil = d
677
- }
678
- }
768
+ if (cfg.lenientUntil) {
769
+ const d = new Date(cfg.lenientUntil)
770
+ if (!isNaN(d.getTime())) lenientUntil = d
679
771
  }
680
772
  return { mode, lenientUntil }
681
773
  }
682
774
 
683
775
  // isLenient retorna true se o projeto está em modo lenient e o prazo não expirou.
684
776
  function isLenient() {
685
- const gm = readGovernanceMode()
777
+ const gm = governanceModeFrom(config.load())
686
778
  if (gm.mode !== 'lenient') return false
687
779
  if (!gm.lenientUntil) return true
688
780
  return new Date() < gm.lenientUntil
@@ -690,7 +782,7 @@ function isLenient() {
690
782
 
691
783
  // lenientUntilDate retorna a data de expiração formatada (YYYY-MM-DD) ou ''.
692
784
  function lenientUntilDate() {
693
- const gm = readGovernanceMode()
785
+ const gm = governanceModeFrom(config.load())
694
786
  if (gm.mode !== 'lenient' || !gm.lenientUntil) return ''
695
787
  return gm.lenientUntil.toISOString().slice(0, 10)
696
788
  }
@@ -735,7 +827,7 @@ function extractRefPath(content, field) {
735
827
  let val = trimmed.slice(idx + 1).trim()
736
828
  if (!val || val === '—' || val === '-' || val === '–') return null
737
829
  val = val.split(/\s+/)[0]
738
- val = val.replace(/^["']|["']$/g, '')
830
+ val = val.replace(/^["'`]|["'`]$/g, '')
739
831
  if (val.endsWith('.md')) return val
740
832
  }
741
833
  }
@@ -754,7 +846,7 @@ function validateRefTargetsExist() {
754
846
  const content = readFileForRule('ref_targets_exist', path.join(dir, name), warnings)
755
847
  if (content === null) continue
756
848
  const ref = extractRefPath(content, 'REQ')
757
- if (ref && !referenceExists(ref, [cfg.reqDir])) {
849
+ if (ref && !referenceExists(ref)) {
758
850
  warnings.push(`roadmap "${name}" links to REQ "${ref}" which does not exist`)
759
851
  }
760
852
  }
@@ -766,11 +858,11 @@ function validateRefTargetsExist() {
766
858
  if (content === null) continue
767
859
  const name = path.basename(filePath)
768
860
  const adrRef = extractRefPath(content, 'ADR')
769
- if (adrRef && !referenceExists(adrRef, cfg.adrDirs)) {
861
+ if (adrRef && !referenceExists(adrRef)) {
770
862
  warnings.push(`req "${name}" links to ADR "${adrRef}" which does not exist`)
771
863
  }
772
864
  const roadmapRef = extractRefPath(content, 'Roadmap')
773
- if (roadmapRef && !referenceExists(roadmapRef, [cfg.roadmapDir])) {
865
+ if (roadmapRef && !referenceExists(roadmapRef)) {
774
866
  warnings.push(`req "${name}" links to Roadmap "${roadmapRef}" which does not exist`)
775
867
  }
776
868
  }
@@ -778,7 +870,7 @@ function validateRefTargetsExist() {
778
870
  return warnings
779
871
  }
780
872
 
781
- function referenceExists(ref, roots) {
873
+ function referenceExists(ref) {
782
874
  const expandedRef = config.expandPath ? config.expandPath(ref) : ref
783
875
  if (fs.existsSync(expandedRef)) return true
784
876
  return false
@@ -1131,6 +1223,7 @@ async function validateUnfiltered() {
1131
1223
  applyRule('filename_uniqueness', validateFilenameUniqueness(), violations, warnings)
1132
1224
  applyRule('branch_has_wip_roadmap', validateBranchHasWIPRoadmap(), violations, warnings)
1133
1225
  applyRule('blocked_by_draft_adr', validateREQsNotBlockedByDraftADRs(), violations, warnings)
1226
+ applyRule('adr_accepted_when_req_done', validateADRAcceptedWhenREQDone(), violations, warnings)
1134
1227
  applyRule('note_orphan', validateNoteOrphan(), violations, warnings)
1135
1228
 
1136
1229
  // Regras configuráveis via applyRule (popula _itemMeta automaticamente)
@@ -1187,10 +1280,60 @@ async function validate() {
1187
1280
  return { violations, warnings }
1188
1281
  }
1189
1282
 
1283
+ // ROADMAP_STATES enumera os 6 estados de roadmap na ordem exibida pelo bloco Inventory.
1284
+ const ROADMAP_STATES = ['backlog', 'analyzing', 'wip', 'blocked', 'done', 'abandoned']
1285
+
1286
+ // buildInventorySection monta o bloco "📊 Inventory" — contagem agregada de ADRs, REQs
1287
+ // (discriminadas por status real via reqStatusEquals) e roadmaps (pelos 6 estados, incluindo
1288
+ // "analyzing", historicamente omitido). Namespacing-agnóstico: agrega através de
1289
+ // resolveStateDirs()/resolveReqFiles(), que já resolvem flat vs by_agent.
1290
+ function buildInventorySection(cfg) {
1291
+ let adrCount = 0
1292
+ for (const adrDir of cfg.adrDirs || []) {
1293
+ adrCount += walkDirMdWithPathsForRule('status_inventory', adrDir, []).length
1294
+ }
1295
+
1296
+ const reqFiles = resolveReqFiles(cfg)
1297
+ let reqOpen = 0
1298
+ let reqDone = 0
1299
+ let reqClosed = 0
1300
+ for (const filePath of reqFiles) {
1301
+ let content
1302
+ try {
1303
+ content = fs.readFileSync(filePath, 'utf8')
1304
+ } catch (_) {
1305
+ continue
1306
+ }
1307
+ if (reqStatusEquals(content, 'open')) reqOpen++
1308
+ else if (reqStatusEquals(content, 'done')) reqDone++
1309
+ else if (reqStatusEquals(content, 'closed')) reqClosed++
1310
+ }
1311
+
1312
+ const roadmapCounts = {}
1313
+ let roadmapTotal = 0
1314
+ for (const state of ROADMAP_STATES) {
1315
+ let count = 0
1316
+ for (const dir of resolveStateDirs(cfg, state)) {
1317
+ count += listDir(dir).length
1318
+ }
1319
+ roadmapCounts[state] = count
1320
+ roadmapTotal += count
1321
+ }
1322
+
1323
+ let section = '\n📊 Inventory\n'
1324
+ section += ` ${'ADRs'.padEnd(12)}${adrCount}\n`
1325
+ section += ` ${'REQs'.padEnd(12)}${reqFiles.length} (${reqOpen} Open · ${reqDone} Done · ${reqClosed} Closed)\n`
1326
+ section += ` ${'Roadmaps'.padEnd(12)}${roadmapTotal}\n`
1327
+ section += ` backlog ${roadmapCounts.backlog} · analyzing ${roadmapCounts.analyzing} · wip ${roadmapCounts.wip}\n`
1328
+ section += ` blocked ${roadmapCounts.blocked} · done ${roadmapCounts.done} · abandoned ${roadmapCounts.abandoned}\n`
1329
+ return section
1330
+ }
1331
+
1190
1332
  // getStatus retorna string formatada com o status de governança do projeto
1191
1333
  async function getStatus() {
1192
1334
  const cfg = config.load()
1193
1335
  let out = '── trackfw status ──────────────────────\n'
1336
+ out += buildInventorySection(cfg)
1194
1337
 
1195
1338
  if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
1196
1339
  let agents = cfg.agents || []
@@ -1217,7 +1360,7 @@ async function getStatus() {
1217
1360
  out += `\n🔄 WIP (${wip.length})\n`
1218
1361
  for (const f of wip) out += ` ${f}\n`
1219
1362
 
1220
- const wipCfg = readWIPConfig()
1363
+ const wipCfg = wipConfigFrom(cfg)
1221
1364
  if (wipCfg.bySquad && wip.length > 0) {
1222
1365
  const bySquad = {}
1223
1366
  for (const f of wip) {
@@ -1242,14 +1385,19 @@ async function getStatus() {
1242
1385
  for (const w of staleWIPs) out += ` ${w}\n`
1243
1386
  }
1244
1387
 
1388
+ // Seção: REQs bloqueadas por ADRs não aceitos (Draft ou Proposed). O status exibido por
1389
+ // ADR é resolvido via adrNotAcceptedStatusForRule (helper canônico) em vez de hardcodar
1390
+ // "Draft" — blockedREQs() cobre ambos os status desde que delega em adrIsDraft/
1391
+ // adrDraftStatusForRule, e um rótulo fixo "(Draft)" mentiria para um ADR Proposed.
1245
1392
  const blockedByDraft = blockedREQs()
1246
1393
  const blockedKeys = Object.keys(blockedByDraft)
1247
1394
  if (blockedKeys.length > 0) {
1248
- out += `\n⏳ REQs blocked by Draft ADRs (${blockedKeys.length})\n`
1395
+ out += `\n⏳ REQs blocked by not-accepted ADRs (${blockedKeys.length})\n`
1249
1396
  for (const reqFile of blockedKeys) {
1250
1397
  out += ` ${reqFile}\n`
1251
1398
  for (const adr of blockedByDraft[reqFile]) {
1252
- out += ` → ${adr} (Draft)\n`
1399
+ const { status } = adrNotAcceptedStatusForRule('blocked_by_draft_adr', adr, null)
1400
+ out += ` → ${adr} (${status})\n`
1253
1401
  }
1254
1402
  }
1255
1403
  }
@@ -1291,8 +1439,6 @@ module.exports = {
1291
1439
  resolveStateDirs,
1292
1440
  resolveWIPDirs,
1293
1441
  resolveDoneDirs,
1294
- readGovernanceMode,
1295
- readWIPConfig,
1296
1442
  parseSquadFromFrontmatter,
1297
1443
  validateFrontmatterPresence,
1298
1444
  // novas funções ML-1B
@@ -1316,4 +1462,12 @@ module.exports = {
1316
1462
  isInsideDir,
1317
1463
  walkDirMdWithPaths,
1318
1464
  validateADRDirsExist,
1465
+ // novas funções ML-1B (2026-08-01 — adr_accepted_when_req_done)
1466
+ extractAdrHeaderStatus,
1467
+ adrNotAcceptedStatusForRule,
1468
+ reqStatusEquals,
1469
+ validateADRAcceptedWhenREQDone,
1470
+ // ML-1D (2026-08-01 — reconciliação de paridade: frontmatter-first)
1471
+ extractFrontmatterField,
1472
+ resolveAdrStatus,
1319
1473
  }