trackfw 3.0.0 → 3.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.
Files changed (52) hide show
  1. package/README.md +9 -2
  2. package/package.json +1 -1
  3. package/src/commands/discover.js +18 -0
  4. package/src/commands/index.js +2 -0
  5. package/src/commands/init.js +40 -2
  6. package/src/commands/log.js +2 -1
  7. package/src/commands/note.js +24 -0
  8. package/src/commands/req.js +7 -1
  9. package/src/commands/roadmap.js +4 -3
  10. package/src/commands/ship.js +49 -0
  11. package/src/config/index.js +5 -2
  12. package/src/forge/adapter.js +189 -0
  13. package/src/forge/resolve.js +191 -0
  14. package/src/generators/adr.js +13 -4
  15. package/src/generators/date.js +23 -0
  16. package/src/generators/init.js +105 -3
  17. package/src/generators/note.js +154 -0
  18. package/src/generators/req.js +88 -5
  19. package/src/generators/roadmap.js +96 -20
  20. package/src/i18n/locales/en-US.json +2 -1
  21. package/src/i18n/locales/es-ES.json +2 -1
  22. package/src/i18n/locales/pt-BR.json +2 -1
  23. package/src/identity/preset.js +22 -0
  24. package/src/integrations/assets/agents/architect.md +32 -0
  25. package/src/integrations/assets/agents/backend.md +32 -0
  26. package/src/integrations/assets/agents/code-quality.md +29 -0
  27. package/src/integrations/assets/agents/data.md +32 -0
  28. package/src/integrations/assets/agents/dba.md +32 -0
  29. package/src/integrations/assets/agents/frontend.md +32 -0
  30. package/src/integrations/assets/agents/iac.md +46 -0
  31. package/src/integrations/assets/agents/infra.md +34 -0
  32. package/src/integrations/assets/agents/qa.md +32 -0
  33. package/src/integrations/assets/agents/security.md +29 -0
  34. package/src/integrations/assets/agents/tooling.md +47 -0
  35. package/src/integrations/assets/agents/ux.md +29 -0
  36. package/src/integrations/assets/catalog.json +16 -2
  37. package/src/integrations/assets/skills/architecture.md +66 -0
  38. package/src/integrations/assets/skills/backend.md +67 -0
  39. package/src/integrations/assets/skills/code-quality.md +78 -0
  40. package/src/integrations/assets/skills/data.md +75 -0
  41. package/src/integrations/assets/skills/dba.md +77 -0
  42. package/src/integrations/assets/skills/frontend.md +70 -0
  43. package/src/integrations/assets/skills/iac.md +90 -0
  44. package/src/integrations/assets/skills/infra.md +62 -0
  45. package/src/integrations/assets/skills/qa.md +63 -0
  46. package/src/integrations/assets/skills/security.md +74 -0
  47. package/src/integrations/assets/skills/tooling.md +80 -0
  48. package/src/integrations/assets/skills/ux.md +70 -0
  49. package/src/integrations/render.js +57 -6
  50. package/src/ship/runner.js +454 -0
  51. package/src/validator/index.js +321 -111
  52. package/src/validator/traceid.js +1 -1
@@ -7,6 +7,7 @@ const config = require('../config')
7
7
  const { checkTraceIds } = require('./traceid')
8
8
 
9
9
  const STALE_WIP_DAYS = 7
10
+ let staleWipNowMs = () => Date.now()
10
11
 
11
12
  // listDir retorna array de nomes de arquivo (não-diretórios) em dir.
12
13
  // Retorna [] se o diretório não existir.
@@ -25,6 +26,44 @@ function listDir(dir) {
25
26
  }
26
27
  }
27
28
 
29
+ // tryListDir tenta listar o diretório distinguindo "não existe" de outros erros.
30
+ // Retorna { entries: string[], readError: Error|null }.
31
+ // readError é null tanto no caso de sucesso quanto quando o diretório não existe (ENOENT) —
32
+ // diretório ausente é esperado para estados que o projeto não usa.
33
+ // readError não-null indica que o diretório EXISTE mas não pôde ser lido (ENOTDIR, EPERM…).
34
+ function tryListDir(dir) {
35
+ const expanded = config.expandPath ? config.expandPath(dir) : dir
36
+ try {
37
+ const entries = fs.readdirSync(expanded).filter(name => {
38
+ try { return !fs.statSync(path.join(expanded, name)).isDirectory() } catch (_) { return false }
39
+ })
40
+ return { entries, readError: null }
41
+ } catch (err) {
42
+ if (err && err.code === 'ENOENT') return { entries: [], readError: null }
43
+ return { entries: [], readError: err }
44
+ }
45
+ }
46
+
47
+ function inspectionDiagnostic(rule, target, err) {
48
+ const cause = err && err.message ? err.message : String(err)
49
+ return `${rule}: could not inspect "${target}": ${cause}`
50
+ }
51
+
52
+ function listDirForRule(rule, dir, messages) {
53
+ const { entries, readError } = tryListDir(dir)
54
+ if (readError) messages.push(inspectionDiagnostic(rule, dir, readError))
55
+ return entries
56
+ }
57
+
58
+ function readFileForRule(rule, filePath, messages) {
59
+ try {
60
+ return fs.readFileSync(filePath, 'utf8')
61
+ } catch (err) {
62
+ messages.push(inspectionDiagnostic(rule, filePath, err))
63
+ return null
64
+ }
65
+ }
66
+
28
67
  // isInsideDir retorna true se childPath estiver contido ou for igual a parentDir.
29
68
  function isInsideDir(parentDir, childPath) {
30
69
  if (!parentDir || !childPath) return false
@@ -34,17 +73,28 @@ function isInsideDir(parentDir, childPath) {
34
73
 
35
74
  // walkDirMdWithPaths retorna { name, fullPath } de todos .md recursivamente dentro de dir.
36
75
  function walkDirMdWithPaths(dir) {
76
+ return walkDirMdWithPathsForRule(null, dir, null)
77
+ }
78
+
79
+ function walkDirMdWithPathsForRule(rule, dir, messages) {
37
80
  const results = []
38
81
  const expandedDir = config.expandPath ? config.expandPath(dir) : dir
39
82
  function walk(d) {
40
83
  let entries
41
- try { entries = fs.readdirSync(d) } catch (_) { return }
84
+ try {
85
+ entries = fs.readdirSync(d)
86
+ } catch (err) {
87
+ if (messages && err && err.code !== 'ENOENT') messages.push(inspectionDiagnostic(rule, d, err))
88
+ return
89
+ }
42
90
  for (const name of entries) {
43
91
  const full = path.join(d, name)
44
92
  try {
45
93
  if (fs.statSync(full).isDirectory()) { walk(full) }
46
94
  else if (name.endsWith('.md')) { results.push({ name, fullPath: full }) }
47
- } catch (_) {}
95
+ } catch (err) {
96
+ if (messages) messages.push(inspectionDiagnostic(rule, full, err))
97
+ }
48
98
  }
49
99
  }
50
100
  walk(expandedDir)
@@ -104,7 +154,7 @@ function resolveReqFiles(cfg) {
104
154
  if (!reqDir) return []
105
155
  const namespacing = cfg.roadmapNamespacing || cfg.roadmap_namespacing || ''
106
156
  if (namespacing === 'by_agent') {
107
- const STATES = ['backlog', 'wip', 'blocked', 'done', 'abandoned']
157
+ const STATES = ['backlog', 'analyzing', 'wip', 'blocked', 'done', 'abandoned']
108
158
  let agents = cfg.agents || []
109
159
  if (!agents.length) {
110
160
  try {
@@ -134,8 +184,11 @@ function resolveReqFiles(cfg) {
134
184
  } catch (_) { return [] }
135
185
  }
136
186
 
137
- // resolveWIPDirs retorna todos os diretórios wip/ conforme o modo de namespacing.
138
- function resolveWIPDirs(cfg) {
187
+ // resolveStateDirs retorna todos os diretórios de um estado (ex: 'wip', 'done') conforme o modo de
188
+ // namespacing. É a fonte única de resolução de caminho por estado — resolveWIPDirs e resolveDoneDirs
189
+ // são wrappers finos sobre esta função. Duplicar a lógica aqui foi a causa raiz de defeitos
190
+ // anteriores (roadmap_dir divergente entre runtimes).
191
+ function resolveStateDirs(cfg, state) {
139
192
  if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
140
193
  let agents = cfg.agents || []
141
194
  if (agents.length === 0) {
@@ -145,9 +198,19 @@ function resolveWIPDirs(cfg) {
145
198
  })
146
199
  } catch (_) { agents = [] }
147
200
  }
148
- return agents.map(agent => cfg.roadmapDir + '/' + agent + '/wip')
201
+ return agents.map(agent => cfg.roadmapDir + '/' + agent + '/' + state)
149
202
  }
150
- return [cfg.roadmapDir + '/wip']
203
+ return [cfg.roadmapDir + '/' + state]
204
+ }
205
+
206
+ // resolveWIPDirs retorna todos os diretórios wip/ conforme o modo de namespacing.
207
+ function resolveWIPDirs(cfg) {
208
+ return resolveStateDirs(cfg, 'wip')
209
+ }
210
+
211
+ // resolveDoneDirs retorna todos os diretórios done/ conforme o modo de namespacing.
212
+ function resolveDoneDirs(cfg) {
213
+ return resolveStateDirs(cfg, 'done')
151
214
  }
152
215
 
153
216
  // parseBlockedADRs extrai basenames de ADRs da seção "## Blocked by ADRs" de um arquivo REQ.
@@ -181,9 +244,10 @@ function parseBlockedADRs(filePath) {
181
244
  }
182
245
 
183
246
  // contentHasMarker retorna true se o conteúdo contém algum dos markers sem espaço em branco após.
247
+ // P3: verifica tanto "\n" quanto "\r\n" para detectar campos vazios em arquivos CRLF.
184
248
  function contentHasMarker(content, markers) {
185
249
  for (const marker of markers) {
186
- if (content.includes(marker) && !content.includes(marker + ' \n')) {
250
+ if (content.includes(marker) && !content.includes(marker + ' \n') && !content.includes(marker + ' \r\n')) {
187
251
  return true
188
252
  }
189
253
  }
@@ -192,11 +256,18 @@ function contentHasMarker(content, markers) {
192
256
 
193
257
  // adrIsDraft verifica se <adrBasename> contém "Status: Draft" buscando recursivamente nas adrDirs.
194
258
  function adrIsDraft(basename) {
259
+ return adrDraftStatusForRule(basename, null).draft
260
+ }
261
+
262
+ function adrDraftStatusForRule(basename, messages) {
195
263
  const p = findAdrFile(basename)
196
- if (!p) return false
264
+ if (!p) return { draft: false, inspected: true }
197
265
  try {
198
- return fs.readFileSync(p, 'utf8').includes('Status: Draft')
199
- } catch (_) { return false }
266
+ return { draft: fs.readFileSync(p, 'utf8').includes('Status: Draft'), inspected: true }
267
+ } catch (err) {
268
+ if (messages) messages.push(inspectionDiagnostic('blocked_by_draft_adr', p, err))
269
+ return { draft: false, inspected: false }
270
+ }
200
271
  }
201
272
 
202
273
  // validateWIPHasREQ — roadmaps em wip/ sem marker REQ no conteúdo → violation
@@ -206,15 +277,12 @@ function validateWIPHasREQ() {
206
277
  const wipDirs = resolveWIPDirs(cfg)
207
278
  const violations = []
208
279
  for (const wipDir of wipDirs) {
209
- const entries = listDir(wipDir)
280
+ const entries = listDirForRule('wip_has_req', wipDir, violations)
210
281
  for (const name of entries) {
211
- try {
212
- const content = fs.readFileSync(path.join(wipDir, name), 'utf8')
213
- if (!contentHasMarker(content, cfg.linkFields.req)) {
214
- violations.push(`roadmap "${name}" is in wip but has no linked REQ`)
215
- }
216
- } catch (_) {
217
- // ignorar erro de leitura
282
+ const content = readFileForRule('wip_has_req', path.join(wipDir, name), violations)
283
+ if (content === null) continue
284
+ if (!contentHasMarker(content, cfg.linkFields.req)) {
285
+ violations.push(`roadmap "${name}" is in wip but has no linked REQ`)
218
286
  }
219
287
  }
220
288
  }
@@ -242,16 +310,15 @@ function validateREQsHaveADR() {
242
310
  // validateBlockedHasREQ — roadmaps em <roadmapDir>/blocked/ sem marker REQ → violation
243
311
  function validateBlockedHasREQ() {
244
312
  const cfg = config.load()
245
- const entries = listDir(cfg.roadmapDir + '/blocked')
246
313
  const violations = []
247
- for (const name of entries) {
248
- try {
249
- const content = fs.readFileSync(path.join(cfg.roadmapDir + '/blocked', name), 'utf8')
314
+ for (const blockedDir of resolveStateDirs(cfg, 'blocked')) {
315
+ const entries = listDirForRule('blocked_has_req', blockedDir, violations)
316
+ for (const name of entries) {
317
+ const content = readFileForRule('blocked_has_req', path.join(blockedDir, name), violations)
318
+ if (content === null) continue
250
319
  if (!contentHasMarker(content, cfg.linkFields.req)) {
251
320
  violations.push(`roadmap "${name}" is in blocked but has no linked REQ`)
252
321
  }
253
- } catch (_) {
254
- // ignorar
255
322
  }
256
323
  }
257
324
  return violations
@@ -285,7 +352,7 @@ function validateADRDirsExist() {
285
352
  const expanded = config.expandPath ? config.expandPath(adrDir) : adrDir
286
353
  const absDir = path.resolve(expanded)
287
354
  if (!fs.existsSync(absDir)) {
288
- const msg = `adr directory "${adrDir}" does not exist`
355
+ const msg = `adr_dir "${adrDir}" does not exist`
289
356
  if (cfg.strictCiPaths) {
290
357
  violations.push(msg)
291
358
  } else {
@@ -301,6 +368,7 @@ function validateADRDirsExist() {
301
368
  function validateADRsAreReferenced() {
302
369
  const cfg = config.load()
303
370
  const cwd = process.cwd()
371
+ const violations = []
304
372
  let adrs = []
305
373
  for (const adrDir of cfg.adrDirs || []) {
306
374
  const expanded = config.expandPath ? config.expandPath(adrDir) : adrDir
@@ -309,7 +377,7 @@ function validateADRsAreReferenced() {
309
377
  if (!isInsideDir(cwd, absDir)) {
310
378
  continue
311
379
  }
312
- for (const item of walkDirMdWithPaths(absDir)) {
380
+ for (const item of walkDirMdWithPathsForRule('adr_orphan', absDir, violations)) {
313
381
  if (isInsideDir(cwd, item.fullPath)) {
314
382
  adrs.push(item.name)
315
383
  }
@@ -319,14 +387,10 @@ function validateADRsAreReferenced() {
319
387
  const reqFiles = resolveReqFiles(cfg)
320
388
  let combined = ''
321
389
  for (const filePath of reqFiles) {
322
- try {
323
- combined += fs.readFileSync(filePath, 'utf8')
324
- } catch (_) {
325
- // ignorar
326
- }
390
+ const content = readFileForRule('adr_orphan', filePath, violations)
391
+ if (content !== null) combined += content
327
392
  }
328
393
 
329
- const violations = []
330
394
  for (const adr of adrs) {
331
395
  if (!combined.includes(adr)) {
332
396
  violations.push(`adr "${adr}" is not referenced by any REQ`)
@@ -342,16 +406,13 @@ function validateWIPHasAcceptanceCriteria() {
342
406
  const wipDirs = resolveWIPDirs(cfg)
343
407
  const violations = []
344
408
  for (const wipDir of wipDirs) {
345
- const entries = listDir(wipDir)
409
+ const entries = listDirForRule('wip_acceptance', wipDir, violations)
346
410
  for (const name of entries) {
347
- try {
348
- const content = fs.readFileSync(path.join(wipDir, name), 'utf8')
349
- const hasBlock = contentHasMarker(content, cfg.acceptanceMarkers)
350
- if (!hasBlock) {
351
- violations.push(`roadmap "${name}" is in wip but has no acceptance criteria block`)
352
- }
353
- } catch (_) {
354
- // ignorar
411
+ const content = readFileForRule('wip_acceptance', path.join(wipDir, name), violations)
412
+ if (content === null) continue
413
+ const hasBlock = contentHasMarker(content, cfg.acceptanceMarkers)
414
+ if (!hasBlock) {
415
+ violations.push(`roadmap "${name}" is in wip but has no acceptance criteria block`)
355
416
  }
356
417
  }
357
418
  }
@@ -468,61 +529,99 @@ function validateSingleWIP() {
468
529
 
469
530
  // validateStaleWIP — roadmaps wip com mtime >= 7 dias → warning
470
531
  // Suporta modo by_agent via resolveWIPDirs.
532
+ function roadmapLogIdentity(cfg, filePath) {
533
+ const basename = path.basename(filePath)
534
+ if (cfg.roadmapNamespacing !== config.NAMESPACING_BY_AGENT) return basename
535
+ const agent = path.basename(path.dirname(path.dirname(filePath)))
536
+ return `${agent}/${basename}`
537
+ }
538
+
539
+ function parseTransitionLogLine(line) {
540
+ const fields = String(line).trim().split(/\s+/).filter(Boolean)
541
+ if (fields.length < 5) return null
542
+ const timestamp = Date.parse(`${fields[0]}T${fields[1]}:00`)
543
+ if (Number.isNaN(timestamp)) return null
544
+ const arrow = fields.findIndex((field, index) => index >= 3 && (field === '→' || field === '->'))
545
+ if (arrow < 0 || arrow + 1 >= fields.length) return null
546
+ return { timestamp, name: fields[2], toState: fields[arrow + 1] }
547
+ }
548
+
549
+ function latestWipTransitionTime(cfg, filePath) {
550
+ let content
551
+ const logPath = path.join(cfg.roadmapDir, '.trackfw-log')
552
+ try {
553
+ content = fs.readFileSync(logPath, 'utf8')
554
+ } catch (err) {
555
+ return { time: null, diagnostics: err && err.code === 'ENOENT' ? [] : [inspectionDiagnostic('stale_wip', logPath, err)] }
556
+ }
557
+ const identity = roadmapLogIdentity(cfg, filePath)
558
+ let latest = null
559
+ const diagnostics = []
560
+ for (const line of content.split('\n')) {
561
+ if (!line.trim()) continue
562
+ const parsed = parseTransitionLogLine(line)
563
+ if (!parsed) {
564
+ diagnostics.push(`stale_wip: invalid support line in "${logPath}": "${line}"`)
565
+ continue
566
+ }
567
+ if (parsed.name !== identity || parsed.toState !== 'wip') continue
568
+ if (latest === null || parsed.timestamp > latest) latest = parsed.timestamp
569
+ }
570
+ return { time: latest, diagnostics }
571
+ }
572
+
471
573
  function validateStaleWIP() {
472
574
  const cfg = config.load()
473
575
  const wipDirs = resolveWIPDirs(cfg)
474
576
  const warnings = []
475
- const now = Date.now()
577
+ const now = staleWipNowMs()
578
+ const thresholdDays = cfg.staleWipDays > 0 ? cfg.staleWipDays : STALE_WIP_DAYS
476
579
 
477
580
  for (const wipDir of wipDirs) {
478
- let files = []
479
- try {
480
- files = fs.readdirSync(wipDir)
481
- .filter(f => f.endsWith('.md'))
482
- .map(f => path.join(wipDir, f))
483
- } catch (_) {
484
- continue
485
- }
581
+ const files = listDirForRule('stale_wip', wipDir, warnings)
582
+ .filter(f => f.endsWith('.md'))
583
+ .map(f => path.join(wipDir, f))
486
584
 
487
585
  for (const filePath of files) {
488
586
  try {
489
587
  const stat = fs.statSync(filePath)
490
- const gitTime = gitLastModifiedTime(filePath)
491
- const ageMs = now - (gitTime !== null ? gitTime : stat.mtimeMs)
588
+ const logResult = latestWipTransitionTime(cfg, filePath)
589
+ warnings.push(...logResult.diagnostics)
590
+ const refTime = logResult.time !== null ? logResult.time : stat.mtimeMs
591
+ const ageMs = now - refTime
492
592
  const days = Math.floor(ageMs / (1000 * 60 * 60 * 24))
493
- if (days >= STALE_WIP_DAYS) {
494
- const refTime = gitTime !== null ? gitTime : stat.mtimeMs
593
+ if (days >= thresholdDays) {
495
594
  const lastModified = new Date(refTime).toISOString().slice(0, 10)
496
595
  const basename = path.basename(filePath)
497
596
  warnings.push(
498
597
  `roadmap/wip/${basename} has been in WIP for ${days} days (last modified ${lastModified})`
499
598
  )
500
599
  }
501
- } catch (_) {
502
- // ignorar
600
+ } catch (err) {
601
+ warnings.push(inspectionDiagnostic('stale_wip', filePath, err))
503
602
  }
504
603
  }
505
604
  }
506
605
  return warnings
507
606
  }
508
607
 
608
+ function setStaleWipNowForTests(fn) {
609
+ staleWipNowMs = fn || (() => Date.now())
610
+ }
611
+
509
612
  // validateREQsNotBlockedByDraftADRs — REQs Open com ADRs Draft na seção "## Blocked by ADRs" → violation
510
613
  function validateREQsNotBlockedByDraftADRs() {
511
614
  const cfg = config.load()
512
615
  const files = resolveReqFiles(cfg)
513
616
  const violations = []
514
617
  for (const filePath of files) {
515
- let content
516
- try {
517
- content = fs.readFileSync(filePath, 'utf8')
518
- } catch (_) {
519
- continue
520
- }
618
+ const content = readFileForRule('blocked_by_draft_adr', filePath, violations)
619
+ if (content === null) continue
521
620
  if (!content.includes('Status: Open')) continue
522
621
 
523
622
  const blockedADRs = parseBlockedADRs(filePath)
524
623
  for (const adrBasename of blockedADRs) {
525
- if (adrIsDraft(adrBasename)) {
624
+ if (adrDraftStatusForRule(adrBasename, violations).draft) {
526
625
  violations.push(`REQ ${path.basename(filePath)} is blocked by Draft ADR: ${adrBasename}`)
527
626
  }
528
627
  }
@@ -631,11 +730,12 @@ function validateFrontmatterPresence() {
631
730
  function extractRefPath(content, field) {
632
731
  for (const line of content.split('\n')) {
633
732
  const trimmed = line.trim()
634
- const prefix = field + ':'
635
- if (trimmed.startsWith(prefix)) {
636
- let val = trimmed.slice(prefix.length).trim()
733
+ const idx = trimmed.indexOf(':')
734
+ if (idx !== -1 && trimmed.slice(0, idx).trim().toLowerCase() === field.toLowerCase()) {
735
+ let val = trimmed.slice(idx + 1).trim()
637
736
  if (!val || val === '—' || val === '-' || val === '–') return null
638
737
  val = val.split(/\s+/)[0]
738
+ val = val.replace(/^["']|["']$/g, '')
639
739
  if (val.endsWith('.md')) return val
640
740
  }
641
741
  }
@@ -648,33 +748,31 @@ function validateRefTargetsExist() {
648
748
  const warnings = []
649
749
 
650
750
  // Roadmaps em wip e blocked: verificar REQ:
651
- const dirs = [...resolveWIPDirs(cfg), cfg.roadmapDir + '/blocked']
751
+ const dirs = [...resolveWIPDirs(cfg), ...resolveStateDirs(cfg, 'blocked')]
652
752
  for (const dir of dirs) {
653
- for (const name of listDir(dir)) {
654
- try {
655
- const content = fs.readFileSync(path.join(dir, name), 'utf8')
656
- const ref = extractRefPath(content, 'REQ')
657
- if (ref && !referenceExists(ref, [cfg.reqDir])) {
658
- warnings.push(`roadmap "${name}" links to REQ "${ref}" which does not exist`)
659
- }
660
- } catch (_) {}
753
+ for (const name of listDirForRule('ref_targets_exist', dir, warnings)) {
754
+ const content = readFileForRule('ref_targets_exist', path.join(dir, name), warnings)
755
+ if (content === null) continue
756
+ const ref = extractRefPath(content, 'REQ')
757
+ if (ref && !referenceExists(ref, [cfg.reqDir])) {
758
+ warnings.push(`roadmap "${name}" links to REQ "${ref}" which does not exist`)
759
+ }
661
760
  }
662
761
  }
663
762
 
664
763
  // REQs: verificar ADR: e Roadmap:
665
764
  for (const filePath of resolveReqFiles(cfg)) {
666
- try {
667
- const content = fs.readFileSync(filePath, 'utf8')
668
- const name = path.basename(filePath)
669
- const adrRef = extractRefPath(content, 'ADR')
670
- if (adrRef && !referenceExists(adrRef, cfg.adrDirs)) {
671
- warnings.push(`req "${name}" links to ADR "${adrRef}" which does not exist`)
672
- }
673
- const roadmapRef = extractRefPath(content, 'Roadmap')
674
- if (roadmapRef && !referenceExists(roadmapRef, [cfg.roadmapDir])) {
675
- warnings.push(`req "${name}" links to Roadmap "${roadmapRef}" which does not exist`)
676
- }
677
- } catch (_) {}
765
+ const content = readFileForRule('ref_targets_exist', filePath, warnings)
766
+ if (content === null) continue
767
+ const name = path.basename(filePath)
768
+ const adrRef = extractRefPath(content, 'ADR')
769
+ if (adrRef && !referenceExists(adrRef, cfg.adrDirs)) {
770
+ warnings.push(`req "${name}" links to ADR "${adrRef}" which does not exist`)
771
+ }
772
+ const roadmapRef = extractRefPath(content, 'Roadmap')
773
+ if (roadmapRef && !referenceExists(roadmapRef, [cfg.roadmapDir])) {
774
+ warnings.push(`req "${name}" links to Roadmap "${roadmapRef}" which does not exist`)
775
+ }
678
776
  }
679
777
 
680
778
  return warnings
@@ -683,10 +781,43 @@ function validateRefTargetsExist() {
683
781
  function referenceExists(ref, roots) {
684
782
  const expandedRef = config.expandPath ? config.expandPath(ref) : ref
685
783
  if (fs.existsSync(expandedRef)) return true
686
- const basename = path.basename(ref)
687
- for (const root of roots || []) {
688
- const expandedRoot = config.expandPath ? config.expandPath(root) : root
689
- if (walkDirMd(expandedRoot).some(filePath => path.basename(filePath) === basename)) return true
784
+ return false
785
+ }
786
+
787
+ function validateREQRoadmapLifecycle() {
788
+ const cfg = config.load()
789
+ const warnings = []
790
+ for (const filePath of resolveReqFiles(cfg)) {
791
+ try {
792
+ const content = fs.readFileSync(filePath, 'utf8')
793
+ if (!reqStatusIsOpen(content)) continue
794
+ const ref = extractRefPath(content, 'Roadmap')
795
+ if (!ref) continue
796
+ const expandedRef = config.expandPath ? config.expandPath(ref) : ref
797
+ if (!fs.existsSync(expandedRef)) continue
798
+ if (path.basename(path.dirname(expandedRef)) === 'done') {
799
+ warnings.push(`req "${path.basename(filePath)}" is Open but linked Roadmap "${ref}" is in done/`)
800
+ }
801
+ } catch (_) {}
802
+ }
803
+ return warnings
804
+ }
805
+
806
+ function reqStatusIsOpen(content) {
807
+ for (const line of content.split('\n')) {
808
+ const trimmed = line.trim()
809
+ const idx = trimmed.indexOf(':')
810
+ if (idx >= 0 && trimmed.slice(0, idx).trim().toLowerCase() === 'status') {
811
+ return trimmed.slice(idx + 1).trim().replace(/^["']|["']$/g, '').toLowerCase() === 'open'
812
+ }
813
+ const marker = '| Status: '
814
+ const markerIdx = trimmed.indexOf(marker)
815
+ if (markerIdx >= 0) {
816
+ let rest = trimmed.slice(markerIdx + marker.length)
817
+ const pipeIdx = rest.indexOf(' |')
818
+ if (pipeIdx >= 0) rest = rest.slice(0, pipeIdx)
819
+ return rest.trim().toLowerCase() === 'open'
820
+ }
690
821
  }
691
822
  return false
692
823
  }
@@ -695,6 +826,7 @@ function referenceExists(ref, roots) {
695
826
  const FOLDER_TO_STATUS = {
696
827
  wip: ['WIP', 'wip', 'In Progress'],
697
828
  backlog: ['Backlog', 'backlog'],
829
+ analyzing: ['Analyzing', 'analyzing'],
698
830
  blocked: ['Blocked', 'blocked'],
699
831
  done: ['Done', 'done'],
700
832
  abandoned: ['Abandoned', 'abandoned'],
@@ -704,7 +836,7 @@ const FOLDER_TO_STATUS = {
704
836
  function validateFolderStatusCoherence() {
705
837
  const cfg = config.load()
706
838
  const warnings = []
707
- const states = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
839
+ const states = ['wip', 'backlog', 'analyzing', 'blocked', 'done', 'abandoned']
708
840
 
709
841
  let dirs = []
710
842
  if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
@@ -726,7 +858,13 @@ function validateFolderStatusCoherence() {
726
858
  }
727
859
 
728
860
  for (const { dir, state } of dirs) {
729
- for (const name of listDir(dir).filter(f => f.endsWith('.md'))) {
861
+ // P2: distinguir "diretório ausente" (esperado) de outros erros (reportar).
862
+ const { entries, readError } = tryListDir(dir)
863
+ if (readError) {
864
+ warnings.push(`folder_status: could not read directory "${dir}": ${readError.message}`)
865
+ continue
866
+ }
867
+ for (const name of entries.filter(f => f.endsWith('.md'))) {
730
868
  try {
731
869
  const content = fs.readFileSync(path.join(dir, name), 'utf8')
732
870
  // Extrair status do frontmatter
@@ -757,9 +895,10 @@ function validateFolderStatusCoherence() {
757
895
  // validateFilenameUniqueness — verifica que o mesmo filename não aparece em múltiplos estados
758
896
  function validateFilenameUniqueness() {
759
897
  const cfg = config.load()
760
- const states = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
898
+ const states = ['wip', 'backlog', 'analyzing', 'blocked', 'done', 'abandoned']
761
899
  const seen = {} // filename → [states]
762
900
 
901
+ const listErrors = []
763
902
  if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
764
903
  let agents = cfg.agents || []
765
904
  if (agents.length === 0) {
@@ -769,7 +908,13 @@ function validateFilenameUniqueness() {
769
908
  }
770
909
  for (const agent of agents) {
771
910
  for (const state of states) {
772
- for (const name of listDir(path.join(cfg.roadmapDir, agent, state))) {
911
+ const dir = path.join(cfg.roadmapDir, agent, state)
912
+ const { entries, readError } = tryListDir(dir)
913
+ if (readError) {
914
+ listErrors.push(`filename_uniqueness: could not read directory "${dir}": ${readError.message}`)
915
+ continue
916
+ }
917
+ for (const name of entries) {
773
918
  const key = agent + '/' + name
774
919
  if (!seen[key]) seen[key] = []
775
920
  seen[key].push(state)
@@ -778,23 +923,36 @@ function validateFilenameUniqueness() {
778
923
  }
779
924
  } else {
780
925
  for (const state of states) {
781
- for (const name of listDir(path.join(cfg.roadmapDir, state))) {
926
+ const dir = path.join(cfg.roadmapDir, state)
927
+ const { entries, readError } = tryListDir(dir)
928
+ if (readError) {
929
+ listErrors.push(`filename_uniqueness: could not read directory "${dir}": ${readError.message}`)
930
+ continue
931
+ }
932
+ for (const name of entries) {
782
933
  if (!seen[name]) seen[name] = []
783
934
  seen[name].push(state)
784
935
  }
785
936
  }
786
937
  }
787
938
 
788
- const violations = []
789
- for (const [name, stateList] of Object.entries(seen)) {
939
+ const violations = [...listErrors]
940
+ // P3: ordenar os estados dentro de cada mensagem e as mensagens pelo nome
941
+ // para garantir saída determinística independente de ordem de inserção.
942
+ const sortedNames = Object.keys(seen).sort()
943
+ for (const name of sortedNames) {
944
+ const stateList = seen[name]
790
945
  if (stateList.length > 1) {
791
- violations.push(`roadmap "${name}" appears in multiple states: [${stateList.join(', ')}]`)
946
+ const sortedStates = [...stateList].sort()
947
+ violations.push(`roadmap "${name}" appears in multiple states: [${sortedStates.join(', ')}]`)
792
948
  }
793
949
  }
794
950
  return violations
795
951
  }
796
952
 
797
- // validateBranchHasWIPRoadmap — verifica que branch feat/fix/refactor tem ao menos um roadmap em wip/
953
+ // validateBranchHasWIPRoadmap — verifica que branch feat/fix/refactor tem ao menos um roadmap em
954
+ // wip/ ou done/ cujo slug case com a branch. Aceita done/ para permitir encerramento do roadmap na
955
+ // própria branch, conforme a Definition of Done, sem reprovar o gate.
798
956
  function validateBranchHasWIPRoadmap() {
799
957
  const { execSync } = require('child_process')
800
958
  let branch = process.env.TRACKFW_BRANCH || ''
@@ -815,24 +973,63 @@ function validateBranchHasWIPRoadmap() {
815
973
 
816
974
  const cfg = config.load()
817
975
  const wipDirs = resolveWIPDirs(cfg)
976
+ const doneDirs = resolveDoneDirs(cfg)
818
977
  const branchSlug = normalizeBranchSlug(branch.split('/', 2)[1])
819
- const wipFiles = []
820
- for (const wipDir of wipDirs) {
821
- const files = listDir(wipDir).filter(f => f.endsWith('.md'))
822
- wipFiles.push(...files)
978
+ // candidates reúne todos os roadmaps encontrados em wip/ e done/.
979
+ const candidates = []
980
+ for (const dir of [...wipDirs, ...doneDirs]) {
981
+ const files = listDir(dir).filter(f => f.endsWith('.md'))
982
+ candidates.push(...files)
823
983
  if (files.some(file => normalizeBranchSlug(file).includes(branchSlug))) return []
824
984
  }
825
985
 
826
- if (wipFiles.length === 0) {
827
- return [`branch "${branch}" is a feat/fix/refactor branch but no roadmap is in wip/ — create governance artifacts first:\n trackfw req new "title"\n trackfw roadmap new "title"\n trackfw roadmap move <name> wip`]
986
+ if (candidates.length === 0) {
987
+ return [`branch "${branch}" is a feat/fix/refactor branch but no roadmap is in wip/ nor done/ — create governance artifacts first:\n trackfw req new "title"\n trackfw roadmap new "title"\n trackfw roadmap move <name> wip`]
828
988
  }
829
- return [`branch "${branch}" has no matching roadmap in wip/ (found: ${wipFiles.join(', ')}) — include the branch slug in the roadmap filename or set TRACKFW_BRANCH explicitly in CI`]
989
+ // P3: sort for deterministic output regardless of filesystem ordering.
990
+ const sorted = [...candidates].sort()
991
+ const display = sorted.slice(0, 3)
992
+ const suffix = sorted.length > 3 ? `, e mais ${sorted.length - 3}` : ''
993
+ return [`branch "${branch}" has no matching roadmap in wip/ nor done/ (found: ${display.join(', ')}${suffix}) — include the branch slug in the roadmap filename or set TRACKFW_BRANCH explicitly in CI`]
830
994
  }
831
995
 
832
996
  function normalizeBranchSlug(value) {
833
997
  return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
834
998
  }
835
999
 
1000
+ /**
1001
+ * Detecta notas em vault/notes/ não referenciadas pelo index.md.
1002
+ * index.md não conta como nota órfã. Projeto sem vault/ retorna [].
1003
+ * Aceita link markdown `[texto](arquivo.md)` ou wikilink `[[nome]]`.
1004
+ * @param {string} [cwd]
1005
+ * @returns {string[]}
1006
+ */
1007
+ function validateNoteOrphan(cwd) {
1008
+ const root = cwd || process.cwd()
1009
+ const vaultDir = path.join(root, 'vault', 'notes')
1010
+ if (!fs.existsSync(vaultDir)) return []
1011
+
1012
+ const indexPath = path.join(vaultDir, 'index.md')
1013
+ let indexContent = ''
1014
+ if (fs.existsSync(indexPath)) {
1015
+ indexContent = fs.readFileSync(indexPath, 'utf8')
1016
+ }
1017
+
1018
+ const notes = fs.readdirSync(vaultDir).filter((f) => f.endsWith('.md') && f !== 'index.md')
1019
+ const msgs = []
1020
+ for (const filename of notes) {
1021
+ const nameWithoutExt = filename.replace(/\.md$/, '')
1022
+ const referenced =
1023
+ indexContent.includes(`(${filename})`) ||
1024
+ indexContent.includes(`[[${nameWithoutExt}]]`) ||
1025
+ indexContent.includes(`[[${filename}]]`)
1026
+ if (!referenced) {
1027
+ msgs.push(`note "${filename}" is not referenced in vault/notes/index.md`)
1028
+ }
1029
+ }
1030
+ return msgs
1031
+ }
1032
+
836
1033
  function isGitWorktree(dir) {
837
1034
  try {
838
1035
  const out = execSync('git rev-parse --is-inside-work-tree', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], cwd: dir })
@@ -863,10 +1060,18 @@ function resetMeta() {
863
1060
  _itemMeta.clear()
864
1061
  }
865
1062
 
1063
+ // RULE_DEFAULTS mapeia regras cujo default NÃO é 'error'.
1064
+ const RULE_DEFAULTS = {
1065
+ note_orphan: 'warning',
1066
+ }
1067
+
866
1068
  // ruleSeverity retorna a severidade configurada para uma regra ('error'|'warning'|'off').
1069
+ // Prioridade: trackfw.yaml rules: > RULE_DEFAULTS > 'error'.
867
1070
  function ruleSeverity(name) {
868
1071
  const cfg = config.load()
869
- return cfg.rules[name] || 'error'
1072
+ if (cfg.rules[name]) return cfg.rules[name]
1073
+ if (RULE_DEFAULTS[name]) return RULE_DEFAULTS[name]
1074
+ return 'error'
870
1075
  }
871
1076
 
872
1077
  // applyRule distribui msgs para violations ou warnings conforme a severidade configurada.
@@ -921,10 +1126,12 @@ async function validateUnfiltered() {
921
1126
  applyRule('adr_orphan', validateADRsAreReferenced(), violations, warnings)
922
1127
  applyRule('stale_wip', validateStaleWIP(), violations, warnings)
923
1128
  applyRule('ref_targets_exist', validateRefTargetsExist(), violations, warnings)
1129
+ for (const msg of validateREQRoadmapLifecycle()) { _setMeta(msg, 'req_roadmap_lifecycle'); warnings.push(msg) }
924
1130
  applyRule('folder_status', validateFolderStatusCoherence(), violations, warnings)
925
1131
  applyRule('filename_uniqueness', validateFilenameUniqueness(), violations, warnings)
926
1132
  applyRule('branch_has_wip_roadmap', validateBranchHasWIPRoadmap(), violations, warnings)
927
1133
  applyRule('blocked_by_draft_adr', validateREQsNotBlockedByDraftADRs(), violations, warnings)
1134
+ applyRule('note_orphan', validateNoteOrphan(), violations, warnings)
928
1135
 
929
1136
  // Regras configuráveis via applyRule (popula _itemMeta automaticamente)
930
1137
  applyRule('req_has_adr', validateREQsHaveADR(), violations, warnings)
@@ -936,8 +1143,8 @@ async function validateUnfiltered() {
936
1143
 
937
1144
  // Validação de existência dos adr_dirs (retorna violations se strictCiPaths, senão warnings)
938
1145
  const adrDirsExistResult = validateADRDirsExist()
939
- for (const msg of adrDirsExistResult.violations) { _setMeta(msg, 'adr_dirs_exist'); violations.push(msg) }
940
- for (const msg of adrDirsExistResult.warnings) { _setMeta(msg, 'adr_dirs_exist'); warnings.push(msg) }
1146
+ for (const msg of adrDirsExistResult.violations) { _setMeta(msg, 'adr_dir_exists'); violations.push(msg) }
1147
+ for (const msg of adrDirsExistResult.warnings) { _setMeta(msg, 'adr_dir_exists'); warnings.push(msg) }
941
1148
 
942
1149
  // warnings diretos do WIP limit (não configuráveis)
943
1150
  for (const msg of wipLimitResult.warnings) { _setMeta(msg, 'wip_limit'); warnings.push(msg) }
@@ -1074,10 +1281,12 @@ module.exports = {
1074
1281
  validateWIPLimit,
1075
1282
  validateSingleWIP,
1076
1283
  validateStaleWIP,
1284
+ setStaleWipNowForTests,
1077
1285
  validateREQsNotBlockedByDraftADRs,
1078
1286
  parseBlockedADRs,
1079
1287
  adrIsDraft,
1080
1288
  listDir,
1289
+ tryListDir,
1081
1290
  resolveReqFiles,
1082
1291
  resolveWIPDirs,
1083
1292
  readGovernanceMode,
@@ -1090,6 +1299,7 @@ module.exports = {
1090
1299
  gitLastModifiedTime,
1091
1300
  extractRefPath,
1092
1301
  validateRefTargetsExist,
1302
+ validateREQRoadmapLifecycle,
1093
1303
  validateFolderStatusCoherence,
1094
1304
  validateFilenameUniqueness,
1095
1305
  validateBranchHasWIPRoadmap,