trackfw 1.1.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/adr.js +1 -1
- package/src/commands/context.js +189 -0
- package/src/commands/discover.js +359 -0
- package/src/commands/index.js +4 -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/req.js +1 -1
- package/src/commands/roadmap.js +6 -1
- package/src/commands/sync.js +362 -0
- package/src/commands/validate.js +9 -1
- package/src/config/index.js +92 -0
- package/src/generators/adr.js +20 -7
- package/src/generators/init.js +51 -1
- package/src/generators/req.js +12 -3
- package/src/generators/roadmap.js +292 -58
- 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 +369 -97
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 config = require('../config')
|
|
5
6
|
|
|
6
7
|
const STALE_WIP_DAYS = 7
|
|
7
8
|
|
|
@@ -21,6 +22,22 @@ function listDir(dir) {
|
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
// resolveWIPDirs retorna todos os diretórios wip/ conforme o modo de namespacing.
|
|
26
|
+
function resolveWIPDirs(cfg) {
|
|
27
|
+
if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
|
|
28
|
+
let agents = cfg.agents || []
|
|
29
|
+
if (agents.length === 0) {
|
|
30
|
+
try {
|
|
31
|
+
agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
|
|
32
|
+
try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
|
|
33
|
+
})
|
|
34
|
+
} catch (_) { agents = [] }
|
|
35
|
+
}
|
|
36
|
+
return agents.map(agent => cfg.roadmapDir + '/' + agent + '/wip')
|
|
37
|
+
}
|
|
38
|
+
return [cfg.roadmapDir + '/wip']
|
|
39
|
+
}
|
|
40
|
+
|
|
24
41
|
// parseBlockedADRs extrai basenames de ADRs da seção "## Blocked by ADRs" de um arquivo REQ.
|
|
25
42
|
function parseBlockedADRs(filePath) {
|
|
26
43
|
let content
|
|
@@ -51,40 +68,52 @@ function parseBlockedADRs(filePath) {
|
|
|
51
68
|
return adrs
|
|
52
69
|
}
|
|
53
70
|
|
|
54
|
-
// adrIsDraft verifica se
|
|
71
|
+
// adrIsDraft verifica se <adrBasename> contém "Status: Draft" em alguma das adrDirs configuradas.
|
|
55
72
|
function adrIsDraft(basename) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
73
|
+
const cfg = config.load()
|
|
74
|
+
for (const adrDir of cfg.adrDirs) {
|
|
75
|
+
const p = path.join(adrDir, basename)
|
|
76
|
+
if (fs.existsSync(p)) {
|
|
77
|
+
try {
|
|
78
|
+
return fs.readFileSync(p, 'utf8').includes('Status: Draft')
|
|
79
|
+
} catch (_) {
|
|
80
|
+
// ignorar erro de leitura
|
|
81
|
+
}
|
|
82
|
+
}
|
|
61
83
|
}
|
|
84
|
+
return false
|
|
62
85
|
}
|
|
63
86
|
|
|
64
|
-
// validateWIPHasREQ — roadmaps em
|
|
87
|
+
// validateWIPHasREQ — roadmaps em wip/ sem "REQ:" no conteúdo → violation
|
|
88
|
+
// Suporta modo by_agent via resolveWIPDirs.
|
|
65
89
|
function validateWIPHasREQ() {
|
|
66
|
-
const
|
|
90
|
+
const cfg = config.load()
|
|
91
|
+
const wipDirs = resolveWIPDirs(cfg)
|
|
67
92
|
const violations = []
|
|
68
|
-
for (const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
93
|
+
for (const wipDir of wipDirs) {
|
|
94
|
+
const entries = listDir(wipDir)
|
|
95
|
+
for (const name of entries) {
|
|
96
|
+
try {
|
|
97
|
+
const content = fs.readFileSync(path.join(wipDir, name), 'utf8')
|
|
98
|
+
if (!content.includes('REQ:') || content.includes('REQ: \n')) {
|
|
99
|
+
violations.push(`roadmap "${name}" is in wip but has no linked REQ`)
|
|
100
|
+
}
|
|
101
|
+
} catch (_) {
|
|
102
|
+
// ignorar erro de leitura
|
|
73
103
|
}
|
|
74
|
-
} catch (_) {
|
|
75
|
-
// ignorar erro de leitura
|
|
76
104
|
}
|
|
77
105
|
}
|
|
78
106
|
return violations
|
|
79
107
|
}
|
|
80
108
|
|
|
81
|
-
// validateREQsHaveADR — REQs em
|
|
109
|
+
// validateREQsHaveADR — REQs em <reqDir>/ sem "ADR:" no conteúdo → violation
|
|
82
110
|
function validateREQsHaveADR() {
|
|
83
|
-
const
|
|
111
|
+
const cfg = config.load()
|
|
112
|
+
const entries = listDir(cfg.reqDir)
|
|
84
113
|
const violations = []
|
|
85
114
|
for (const name of entries) {
|
|
86
115
|
try {
|
|
87
|
-
const content = fs.readFileSync(path.join(
|
|
116
|
+
const content = fs.readFileSync(path.join(cfg.reqDir, name), 'utf8')
|
|
88
117
|
if (!content.includes('ADR:') || content.includes('ADR: \n')) {
|
|
89
118
|
violations.push(`req "${name}" has no linked ADR`)
|
|
90
119
|
}
|
|
@@ -95,13 +124,14 @@ function validateREQsHaveADR() {
|
|
|
95
124
|
return violations
|
|
96
125
|
}
|
|
97
126
|
|
|
98
|
-
// validateBlockedHasREQ — roadmaps em
|
|
127
|
+
// validateBlockedHasREQ — roadmaps em <roadmapDir>/blocked/ sem "REQ:" → violation
|
|
99
128
|
function validateBlockedHasREQ() {
|
|
100
|
-
const
|
|
129
|
+
const cfg = config.load()
|
|
130
|
+
const entries = listDir(cfg.roadmapDir + '/blocked')
|
|
101
131
|
const violations = []
|
|
102
132
|
for (const name of entries) {
|
|
103
133
|
try {
|
|
104
|
-
const content = fs.readFileSync(path.join('
|
|
134
|
+
const content = fs.readFileSync(path.join(cfg.roadmapDir + '/blocked', name), 'utf8')
|
|
105
135
|
if (!content.includes('REQ:') || content.includes('REQ: \n')) {
|
|
106
136
|
violations.push(`roadmap "${name}" is in blocked but has no linked REQ`)
|
|
107
137
|
}
|
|
@@ -114,11 +144,12 @@ function validateBlockedHasREQ() {
|
|
|
114
144
|
|
|
115
145
|
// validateREQsHaveRoadmap — REQs sem "Roadmap:" → violation
|
|
116
146
|
function validateREQsHaveRoadmap() {
|
|
117
|
-
const
|
|
147
|
+
const cfg = config.load()
|
|
148
|
+
const entries = listDir(cfg.reqDir)
|
|
118
149
|
const violations = []
|
|
119
150
|
for (const name of entries) {
|
|
120
151
|
try {
|
|
121
|
-
const content = fs.readFileSync(path.join(
|
|
152
|
+
const content = fs.readFileSync(path.join(cfg.reqDir, name), 'utf8')
|
|
122
153
|
if (!content.includes('Roadmap:') || content.includes('Roadmap: \n')) {
|
|
123
154
|
violations.push(`req "${name}" has no linked Roadmap`)
|
|
124
155
|
}
|
|
@@ -129,15 +160,19 @@ function validateREQsHaveRoadmap() {
|
|
|
129
160
|
return violations
|
|
130
161
|
}
|
|
131
162
|
|
|
132
|
-
// validateADRsAreReferenced — ADRs em
|
|
163
|
+
// validateADRsAreReferenced — ADRs em adrDirs não referenciados em nenhuma REQ → violation
|
|
133
164
|
function validateADRsAreReferenced() {
|
|
134
|
-
const
|
|
135
|
-
|
|
165
|
+
const cfg = config.load()
|
|
166
|
+
let adrs = []
|
|
167
|
+
for (const adrDir of cfg.adrDirs) {
|
|
168
|
+
adrs = adrs.concat(listDir(adrDir))
|
|
169
|
+
}
|
|
136
170
|
|
|
171
|
+
const reqEntries = listDir(cfg.reqDir)
|
|
137
172
|
let combined = ''
|
|
138
173
|
for (const name of reqEntries) {
|
|
139
174
|
try {
|
|
140
|
-
combined += fs.readFileSync(path.join(
|
|
175
|
+
combined += fs.readFileSync(path.join(cfg.reqDir, name), 'utf8')
|
|
141
176
|
} catch (_) {
|
|
142
177
|
// ignorar
|
|
143
178
|
}
|
|
@@ -153,63 +188,173 @@ function validateADRsAreReferenced() {
|
|
|
153
188
|
}
|
|
154
189
|
|
|
155
190
|
// validateWIPHasAcceptanceCriteria — roadmaps wip sem bloco de critérios de aceite → violation
|
|
191
|
+
// Suporta modo by_agent via resolveWIPDirs.
|
|
156
192
|
function validateWIPHasAcceptanceCriteria() {
|
|
157
|
-
const
|
|
193
|
+
const cfg = config.load()
|
|
194
|
+
const wipDirs = resolveWIPDirs(cfg)
|
|
158
195
|
const violations = []
|
|
159
|
-
for (const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
content.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
196
|
+
for (const wipDir of wipDirs) {
|
|
197
|
+
const entries = listDir(wipDir)
|
|
198
|
+
for (const name of entries) {
|
|
199
|
+
try {
|
|
200
|
+
const content = fs.readFileSync(path.join(wipDir, name), 'utf8')
|
|
201
|
+
const hasBlock =
|
|
202
|
+
content.includes('## Acceptance Criteria') ||
|
|
203
|
+
content.includes('## Critérios de Aceite') ||
|
|
204
|
+
content.includes('acceptance criteria') ||
|
|
205
|
+
content.includes('Acceptance Criteria:')
|
|
206
|
+
if (!hasBlock) {
|
|
207
|
+
violations.push(`roadmap "${name}" is in wip but has no acceptance criteria block`)
|
|
208
|
+
}
|
|
209
|
+
} catch (_) {
|
|
210
|
+
// ignorar
|
|
169
211
|
}
|
|
170
|
-
} catch (_) {
|
|
171
|
-
// ignorar
|
|
172
212
|
}
|
|
173
213
|
}
|
|
174
214
|
return violations
|
|
175
215
|
}
|
|
176
216
|
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
182
226
|
}
|
|
183
|
-
|
|
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
|
|
184
240
|
}
|
|
185
241
|
|
|
186
|
-
//
|
|
187
|
-
|
|
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: [] }.
|
|
262
|
+
function validateWIPLimit() {
|
|
263
|
+
const cfg = config.load()
|
|
264
|
+
const violations = []
|
|
265
|
+
const warnings = []
|
|
266
|
+
|
|
267
|
+
if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
|
|
268
|
+
let agents = cfg.agents || []
|
|
269
|
+
if (agents.length === 0) {
|
|
270
|
+
try {
|
|
271
|
+
agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
|
|
272
|
+
try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
|
|
273
|
+
})
|
|
274
|
+
} catch (_) { agents = [] }
|
|
275
|
+
}
|
|
276
|
+
const limit = cfg.wipLimit > 0 ? cfg.wipLimit : 1
|
|
277
|
+
for (const agent of agents) {
|
|
278
|
+
const entries = listDir(cfg.roadmapDir + '/' + agent + '/wip')
|
|
279
|
+
if (entries.length > limit) {
|
|
280
|
+
warnings.push(`${entries.length} roadmaps in wip/ for agent "${agent}" (limit: ${limit}) — consider focusing`)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { violations, warnings }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// modo flat (global ou por squad)
|
|
188
287
|
let files = []
|
|
189
288
|
try {
|
|
190
|
-
files = fs.readdirSync('
|
|
191
|
-
.filter(f =>
|
|
192
|
-
.map(f => path.join('
|
|
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))
|
|
193
292
|
} catch (_) {
|
|
194
|
-
return
|
|
293
|
+
return { violations, warnings }
|
|
195
294
|
}
|
|
196
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 }
|
|
303
|
+
}
|
|
304
|
+
|
|
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
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return { violations, warnings }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// validateSingleWIP — alias retrocompatível de validateWIPLimit (modo flat)
|
|
321
|
+
function validateSingleWIP() {
|
|
322
|
+
return validateWIPLimit()
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// validateStaleWIP — roadmaps wip com mtime >= 7 dias → warning
|
|
326
|
+
// Suporta modo by_agent via resolveWIPDirs.
|
|
327
|
+
function validateStaleWIP() {
|
|
328
|
+
const cfg = config.load()
|
|
329
|
+
const wipDirs = resolveWIPDirs(cfg)
|
|
197
330
|
const warnings = []
|
|
198
331
|
const now = Date.now()
|
|
199
|
-
|
|
332
|
+
|
|
333
|
+
for (const wipDir of wipDirs) {
|
|
334
|
+
let files = []
|
|
200
335
|
try {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (days >= STALE_WIP_DAYS) {
|
|
205
|
-
const lastModified = stat.mtime.toISOString().slice(0, 10)
|
|
206
|
-
const basename = path.basename(filePath)
|
|
207
|
-
warnings.push(
|
|
208
|
-
`roadmap/wip/${basename} has been in WIP for ${days} days (last modified ${lastModified})`
|
|
209
|
-
)
|
|
210
|
-
}
|
|
336
|
+
files = fs.readdirSync(wipDir)
|
|
337
|
+
.filter(f => f.endsWith('.md'))
|
|
338
|
+
.map(f => path.join(wipDir, f))
|
|
211
339
|
} catch (_) {
|
|
212
|
-
|
|
340
|
+
continue
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
for (const filePath of files) {
|
|
344
|
+
try {
|
|
345
|
+
const stat = fs.statSync(filePath)
|
|
346
|
+
const ageMs = now - stat.mtimeMs
|
|
347
|
+
const days = Math.floor(ageMs / (1000 * 60 * 60 * 24))
|
|
348
|
+
if (days >= STALE_WIP_DAYS) {
|
|
349
|
+
const lastModified = stat.mtime.toISOString().slice(0, 10)
|
|
350
|
+
const basename = path.basename(filePath)
|
|
351
|
+
warnings.push(
|
|
352
|
+
`roadmap/wip/${basename} has been in WIP for ${days} days (last modified ${lastModified})`
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
} catch (_) {
|
|
356
|
+
// ignorar
|
|
357
|
+
}
|
|
213
358
|
}
|
|
214
359
|
}
|
|
215
360
|
return warnings
|
|
@@ -217,10 +362,11 @@ function validateStaleWIP() {
|
|
|
217
362
|
|
|
218
363
|
// validateREQsNotBlockedByDraftADRs — REQs Open com ADRs Draft na seção "## Blocked by ADRs" → violation
|
|
219
364
|
function validateREQsNotBlockedByDraftADRs() {
|
|
220
|
-
const
|
|
365
|
+
const cfg = config.load()
|
|
366
|
+
const entries = listDir(cfg.reqDir)
|
|
221
367
|
const violations = []
|
|
222
368
|
for (const name of entries) {
|
|
223
|
-
const filePath = path.join(
|
|
369
|
+
const filePath = path.join(cfg.reqDir, name)
|
|
224
370
|
let content
|
|
225
371
|
try {
|
|
226
372
|
content = fs.readFileSync(filePath, 'utf8')
|
|
@@ -241,10 +387,11 @@ function validateREQsNotBlockedByDraftADRs() {
|
|
|
241
387
|
|
|
242
388
|
// blockedREQs retorna mapa de reqBasename → [adrBasenames Draft] para uso em getStatus()
|
|
243
389
|
function blockedREQs() {
|
|
244
|
-
const
|
|
390
|
+
const cfg = config.load()
|
|
391
|
+
const entries = listDir(cfg.reqDir)
|
|
245
392
|
const result = {}
|
|
246
393
|
for (const name of entries) {
|
|
247
|
-
const filePath = path.join(
|
|
394
|
+
const filePath = path.join(cfg.reqDir, name)
|
|
248
395
|
let content
|
|
249
396
|
try {
|
|
250
397
|
content = fs.readFileSync(filePath, 'utf8')
|
|
@@ -262,9 +409,84 @@ function blockedREQs() {
|
|
|
262
409
|
return result
|
|
263
410
|
}
|
|
264
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
|
+
|
|
265
486
|
// validate executa todas as validações e retorna { violations, warnings }
|
|
266
487
|
async function validate() {
|
|
267
|
-
const
|
|
488
|
+
const wipLimitResult = validateWIPLimit()
|
|
489
|
+
let violations = [
|
|
268
490
|
...validateWIPHasREQ(),
|
|
269
491
|
...validateREQsHaveADR(),
|
|
270
492
|
...validateBlockedHasREQ(),
|
|
@@ -272,50 +494,92 @@ async function validate() {
|
|
|
272
494
|
...validateADRsAreReferenced(),
|
|
273
495
|
...validateWIPHasAcceptanceCriteria(),
|
|
274
496
|
...validateREQsNotBlockedByDraftADRs(),
|
|
497
|
+
...validateFrontmatterPresence(),
|
|
498
|
+
...wipLimitResult.violations,
|
|
275
499
|
]
|
|
276
|
-
|
|
277
|
-
...
|
|
500
|
+
let warnings = [
|
|
501
|
+
...wipLimitResult.warnings,
|
|
278
502
|
...validateStaleWIP(),
|
|
279
503
|
]
|
|
504
|
+
// Modo lenient: mover violations para warnings, exit code 0
|
|
505
|
+
if (isLenient()) {
|
|
506
|
+
warnings = [...warnings, ...violations]
|
|
507
|
+
violations = []
|
|
508
|
+
}
|
|
280
509
|
return { violations, warnings }
|
|
281
510
|
}
|
|
282
511
|
|
|
283
512
|
// getStatus retorna string formatada com o status de governança do projeto
|
|
284
513
|
async function getStatus() {
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
const done = listDir('docs/roadmaps/done')
|
|
514
|
+
const cfg = config.load()
|
|
515
|
+
let out = '── trackfw status ──────────────────────\n'
|
|
288
516
|
|
|
289
|
-
|
|
290
|
-
|
|
517
|
+
if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
|
|
518
|
+
let agents = cfg.agents || []
|
|
519
|
+
if (agents.length === 0) {
|
|
520
|
+
try {
|
|
521
|
+
agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
|
|
522
|
+
try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
|
|
523
|
+
})
|
|
524
|
+
} catch (_) { agents = [] }
|
|
525
|
+
}
|
|
526
|
+
out += '\n⚙ WIP by Agent\n'
|
|
527
|
+
for (const agent of agents) {
|
|
528
|
+
const wip = listDir(cfg.roadmapDir + '/' + agent + '/wip')
|
|
529
|
+
if (wip.length > 0) {
|
|
530
|
+
out += ` [${agent}] WIP (${wip.length})\n`
|
|
531
|
+
wip.forEach(f => { out += ` ${f}\n` })
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
} else {
|
|
535
|
+
const wip = listDir(cfg.roadmapDir + '/wip')
|
|
536
|
+
const blocked = listDir(cfg.roadmapDir + '/blocked')
|
|
537
|
+
const done = listDir(cfg.roadmapDir + '/done')
|
|
291
538
|
|
|
292
|
-
|
|
293
|
-
|
|
539
|
+
out += `\n🔄 WIP (${wip.length})\n`
|
|
540
|
+
for (const f of wip) out += ` ${f}\n`
|
|
294
541
|
|
|
295
|
-
|
|
296
|
-
|
|
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
|
+
}
|
|
297
557
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
558
|
+
out += `\n❌ Blocked (${blocked.length})\n`
|
|
559
|
+
for (const f of blocked) out += ` ${f}\n`
|
|
560
|
+
|
|
561
|
+
const staleWIPs = validateStaleWIP()
|
|
562
|
+
if (staleWIPs.length > 0) {
|
|
563
|
+
out += `\n⚠ Stale WIP (${staleWIPs.length})\n`
|
|
564
|
+
for (const w of staleWIPs) out += ` ${w}\n`
|
|
565
|
+
}
|
|
303
566
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
567
|
+
const blockedByDraft = blockedREQs()
|
|
568
|
+
const blockedKeys = Object.keys(blockedByDraft)
|
|
569
|
+
if (blockedKeys.length > 0) {
|
|
570
|
+
out += `\n⏳ REQs blocked by Draft ADRs (${blockedKeys.length})\n`
|
|
571
|
+
for (const reqFile of blockedKeys) {
|
|
572
|
+
out += ` ${reqFile}\n`
|
|
573
|
+
for (const adr of blockedByDraft[reqFile]) {
|
|
574
|
+
out += ` → ${adr} (Draft)\n`
|
|
575
|
+
}
|
|
312
576
|
}
|
|
313
577
|
}
|
|
314
|
-
}
|
|
315
578
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
579
|
+
out += `\n✅ Done (last 5)\n`
|
|
580
|
+
const last5 = done.length > 5 ? done.slice(done.length - 5) : done
|
|
581
|
+
for (const f of last5) out += ` ${f}\n`
|
|
582
|
+
}
|
|
319
583
|
|
|
320
584
|
out += '\n────────────────────────────────────────\n'
|
|
321
585
|
return out
|
|
@@ -324,6 +588,8 @@ async function getStatus() {
|
|
|
324
588
|
module.exports = {
|
|
325
589
|
validate,
|
|
326
590
|
getStatus,
|
|
591
|
+
isLenient,
|
|
592
|
+
lenientUntilDate,
|
|
327
593
|
// exportadas para testes unitários
|
|
328
594
|
validateWIPHasREQ,
|
|
329
595
|
validateREQsHaveADR,
|
|
@@ -331,10 +597,16 @@ module.exports = {
|
|
|
331
597
|
validateREQsHaveRoadmap,
|
|
332
598
|
validateADRsAreReferenced,
|
|
333
599
|
validateWIPHasAcceptanceCriteria,
|
|
600
|
+
validateWIPLimit,
|
|
334
601
|
validateSingleWIP,
|
|
335
602
|
validateStaleWIP,
|
|
336
603
|
validateREQsNotBlockedByDraftADRs,
|
|
337
604
|
parseBlockedADRs,
|
|
338
605
|
adrIsDraft,
|
|
339
606
|
listDir,
|
|
607
|
+
resolveWIPDirs,
|
|
608
|
+
readGovernanceMode,
|
|
609
|
+
readWIPConfig,
|
|
610
|
+
parseSquadFromFrontmatter,
|
|
611
|
+
validateFrontmatterPresence,
|
|
340
612
|
}
|