trackfw 2.16.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.
- package/README.md +18 -2
- package/package.json +1 -1
- package/src/commands/discover.js +18 -0
- package/src/commands/index.js +2 -0
- package/src/commands/init.js +50 -4
- package/src/commands/integrations.js +75 -4
- package/src/commands/log.js +2 -1
- package/src/commands/note.js +24 -0
- package/src/commands/req.js +7 -1
- package/src/commands/roadmap.js +4 -3
- package/src/commands/ship.js +49 -0
- package/src/config/index.js +5 -2
- package/src/forge/adapter.js +189 -0
- package/src/forge/resolve.js +191 -0
- package/src/generators/adr.js +13 -4
- package/src/generators/date.js +23 -0
- package/src/generators/init.js +133 -8
- package/src/generators/note.js +154 -0
- package/src/generators/req.js +88 -5
- package/src/generators/roadmap.js +96 -20
- package/src/i18n/locales/en-US.json +2 -1
- package/src/i18n/locales/es-ES.json +2 -1
- package/src/i18n/locales/pt-BR.json +2 -1
- package/src/identity/preset.js +22 -0
- package/src/integrations/assets/agents/architect.md +32 -0
- package/src/integrations/assets/agents/backend.md +32 -0
- package/src/integrations/assets/agents/code-quality.md +29 -0
- package/src/integrations/assets/agents/data.md +32 -0
- package/src/integrations/assets/agents/dba.md +32 -0
- package/src/integrations/assets/agents/frontend.md +32 -0
- package/src/integrations/assets/agents/iac.md +46 -0
- package/src/integrations/assets/agents/infra.md +34 -0
- package/src/integrations/assets/agents/qa.md +32 -0
- package/src/integrations/assets/agents/security.md +29 -0
- package/src/integrations/assets/agents/tooling.md +47 -0
- package/src/integrations/assets/agents/ux.md +29 -0
- package/src/integrations/assets/catalog.json +16 -2
- package/src/integrations/assets/skills/architecture.md +66 -0
- package/src/integrations/assets/skills/backend.md +67 -0
- package/src/integrations/assets/skills/code-quality.md +78 -0
- package/src/integrations/assets/skills/data.md +75 -0
- package/src/integrations/assets/skills/dba.md +77 -0
- package/src/integrations/assets/skills/frontend.md +70 -0
- package/src/integrations/assets/skills/iac.md +90 -0
- package/src/integrations/assets/skills/infra.md +62 -0
- package/src/integrations/assets/skills/qa.md +63 -0
- package/src/integrations/assets/skills/security.md +74 -0
- package/src/integrations/assets/skills/tooling.md +80 -0
- package/src/integrations/assets/skills/ux.md +70 -0
- package/src/integrations/render.js +57 -6
- package/src/ship/runner.js +454 -0
- package/src/validator/index.js +321 -111
- package/src/validator/traceid.js +1 -1
package/src/validator/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
138
|
-
|
|
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 + '/
|
|
201
|
+
return agents.map(agent => cfg.roadmapDir + '/' + agent + '/' + state)
|
|
149
202
|
}
|
|
150
|
-
return [cfg.roadmapDir + '/
|
|
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 (
|
|
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 =
|
|
280
|
+
const entries = listDirForRule('wip_has_req', wipDir, violations)
|
|
210
281
|
for (const name of entries) {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
248
|
-
|
|
249
|
-
|
|
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 = `
|
|
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
|
|
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
|
-
|
|
323
|
-
|
|
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 =
|
|
409
|
+
const entries = listDirForRule('wip_acceptance', wipDir, violations)
|
|
346
410
|
for (const name of entries) {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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 =
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
|
491
|
-
|
|
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 >=
|
|
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
|
-
|
|
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
|
-
|
|
516
|
-
|
|
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 (
|
|
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
|
|
635
|
-
if (trimmed.
|
|
636
|
-
let val = trimmed.slice(
|
|
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
|
|
751
|
+
const dirs = [...resolveWIPDirs(cfg), ...resolveStateDirs(cfg, 'blocked')]
|
|
652
752
|
for (const dir of dirs) {
|
|
653
|
-
for (const name of
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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, '
|
|
940
|
-
for (const msg of adrDirsExistResult.warnings) { _setMeta(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,
|