trackfw 2.1.1 → 2.4.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/baseline.js +13 -0
- package/src/commands/configure.js +106 -0
- package/src/commands/help.js +226 -0
- package/src/commands/index.js +3 -0
- package/src/config/index.js +123 -25
- package/src/validator/index.js +346 -47
package/package.json
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { Command } = require('commander')
|
|
3
|
+
const { validateUnfiltered, saveBaseline } = require('../validator')
|
|
4
|
+
|
|
5
|
+
const cmd = new Command('baseline')
|
|
6
|
+
cmd.description('Grava snapshot das violations atuais em .trackfw-baseline.json')
|
|
7
|
+
cmd.action(async () => {
|
|
8
|
+
const { violations, warnings } = await validateUnfiltered()
|
|
9
|
+
saveBaseline(violations, warnings)
|
|
10
|
+
console.log(`Baseline gravado: ${violations.length} violations, ${warnings.length} warnings`)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
module.exports = cmd
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { Command } = require('commander')
|
|
3
|
+
const readline = require('readline')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const path = require('path')
|
|
6
|
+
|
|
7
|
+
const DEFAULTS = {
|
|
8
|
+
adr_dirs: 'docs/adr',
|
|
9
|
+
req_dir: 'docs/req',
|
|
10
|
+
roadmap_dir: 'docs/roadmaps',
|
|
11
|
+
wip_limit: '1',
|
|
12
|
+
link_req: 'REQ:'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Pergunta ao usuário via readline e retorna a resposta como Promise<string>.
|
|
17
|
+
* Se o usuário pressionar Enter sem digitar nada, retorna o valor padrão.
|
|
18
|
+
* @param {readline.Interface} rl
|
|
19
|
+
* @param {string} question
|
|
20
|
+
* @param {string} defaultValue
|
|
21
|
+
* @returns {Promise<string>}
|
|
22
|
+
*/
|
|
23
|
+
function ask(rl, question, defaultValue) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
rl.question(question, (answer) => {
|
|
26
|
+
resolve(answer.trim() || defaultValue)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Executa o wizard interativo e grava trackfw.yaml.
|
|
33
|
+
* Exportada separadamente para facilitar testes.
|
|
34
|
+
* @param {readline.Interface} [rl] — opcional; se não fornecido, cria um novo
|
|
35
|
+
* @param {string} [cwd] — diretório de trabalho (padrão: process.cwd())
|
|
36
|
+
* @returns {Promise<{ fieldsWritten: number, filePath: string }>}
|
|
37
|
+
*/
|
|
38
|
+
async function runConfigure(rl, cwd) {
|
|
39
|
+
const dir = cwd || process.cwd()
|
|
40
|
+
const yamlPath = path.join(dir, 'trackfw.yaml')
|
|
41
|
+
let ownRl = false
|
|
42
|
+
|
|
43
|
+
if (!rl) {
|
|
44
|
+
rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
45
|
+
ownRl = true
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
// Verificar se já existe e perguntar se recria
|
|
50
|
+
if (fs.existsSync(yamlPath)) {
|
|
51
|
+
const answer = await ask(rl, 'trackfw.yaml já existe. Recriar do zero? (s/N) ', 'N')
|
|
52
|
+
if (answer.toLowerCase() !== 's') {
|
|
53
|
+
console.log('Operação cancelada.')
|
|
54
|
+
return { fieldsWritten: 0, filePath: yamlPath }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Coletar campos via prompts
|
|
59
|
+
const adrDirs = await ask(rl, `ADR dirs [${DEFAULTS.adr_dirs}]: `, DEFAULTS.adr_dirs)
|
|
60
|
+
const reqDir = await ask(rl, `REQ dir [${DEFAULTS.req_dir}]: `, DEFAULTS.req_dir)
|
|
61
|
+
const roadmapDir = await ask(rl, `Roadmap dir [${DEFAULTS.roadmap_dir}]: `, DEFAULTS.roadmap_dir)
|
|
62
|
+
const wipLimit = await ask(rl, `WIP limit [${DEFAULTS.wip_limit}]: `, DEFAULTS.wip_limit)
|
|
63
|
+
const linkReq = await ask(rl, `Marcador de REQ [${DEFAULTS.link_req}]: `, DEFAULTS.link_req)
|
|
64
|
+
|
|
65
|
+
// Montar campos customizados (somente diferenças dos defaults)
|
|
66
|
+
const custom = {}
|
|
67
|
+
if (adrDirs !== DEFAULTS.adr_dirs) custom.adr_dirs = adrDirs
|
|
68
|
+
if (reqDir !== DEFAULTS.req_dir) custom.req_dir = reqDir
|
|
69
|
+
if (roadmapDir !== DEFAULTS.roadmap_dir) custom.roadmap_dir = roadmapDir
|
|
70
|
+
if (wipLimit !== DEFAULTS.wip_limit) custom.wip_limit = wipLimit
|
|
71
|
+
if (linkReq !== DEFAULTS.link_req) custom['link_fields.req'] = linkReq
|
|
72
|
+
|
|
73
|
+
// Gerar conteúdo do YAML
|
|
74
|
+
let content = '# trackfw.yaml — gerado por trackfw configure\n'
|
|
75
|
+
const fieldsWritten = Object.keys(custom).length
|
|
76
|
+
|
|
77
|
+
if (fieldsWritten > 0) {
|
|
78
|
+
for (const [key, value] of Object.entries(custom)) {
|
|
79
|
+
if (key === 'adr_dirs') {
|
|
80
|
+
content += `adr_dirs:\n - ${value}\n`
|
|
81
|
+
} else if (key === 'link_fields.req') {
|
|
82
|
+
content += `link_fields:\n req:\n - "${value}"\n`
|
|
83
|
+
} else {
|
|
84
|
+
content += `${key}: ${value}\n`
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fs.writeFileSync(yamlPath, content, 'utf8')
|
|
90
|
+
console.log(`trackfw.yaml gravado com ${fieldsWritten} campos customizados`)
|
|
91
|
+
|
|
92
|
+
return { fieldsWritten, filePath: yamlPath }
|
|
93
|
+
} finally {
|
|
94
|
+
if (ownRl) rl.close()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const cmd = new Command('configure')
|
|
99
|
+
cmd.description('Wizard interativo para criar/atualizar trackfw.yaml')
|
|
100
|
+
cmd.action(async () => {
|
|
101
|
+
await runConfigure()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
module.exports = cmd
|
|
105
|
+
module.exports.runConfigure = runConfigure
|
|
106
|
+
module.exports.DEFAULTS = DEFAULTS
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { Command } = require('commander')
|
|
3
|
+
|
|
4
|
+
const configDocs = {
|
|
5
|
+
adr_dirs: {
|
|
6
|
+
type: 'list of strings',
|
|
7
|
+
default: '["docs/adr"]',
|
|
8
|
+
description: 'Diretórios onde os ADRs são armazenados.',
|
|
9
|
+
example: 'adr_dirs:\n - docs/adr\n - docs/adr/zeus',
|
|
10
|
+
impact: 'Todos os diretórios listados são varridos na validação de ADRs.'
|
|
11
|
+
},
|
|
12
|
+
req_dir: {
|
|
13
|
+
type: 'string',
|
|
14
|
+
default: '"docs/req"',
|
|
15
|
+
description: 'Diretório onde as REQs são armazenadas.',
|
|
16
|
+
example: 'req_dir: docs/requisicoes',
|
|
17
|
+
impact: 'Altera onde o trackfw busca e cria arquivos de REQ.'
|
|
18
|
+
},
|
|
19
|
+
roadmap_dir: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
default: '"docs/roadmaps"',
|
|
22
|
+
description: 'Diretório raiz dos roadmaps.',
|
|
23
|
+
example: 'roadmap_dir: docs/roadmaps',
|
|
24
|
+
impact: 'Subtrees backlog/, wip/, blocked/, done/, abandoned/ são relativos a este diretório.'
|
|
25
|
+
},
|
|
26
|
+
roadmap_namespacing: {
|
|
27
|
+
type: 'flat|by_agent',
|
|
28
|
+
default: '"flat"',
|
|
29
|
+
description: 'Estratégia de namespacing dos roadmaps.',
|
|
30
|
+
example: 'roadmap_namespacing: by_agent',
|
|
31
|
+
impact: 'Com by_agent, roadmaps ficam em subpastas por agente (ex: wip/apolo/RM-001.md).'
|
|
32
|
+
},
|
|
33
|
+
agents: {
|
|
34
|
+
type: 'list of strings',
|
|
35
|
+
default: '[]',
|
|
36
|
+
description: 'Lista de agentes ativos no projeto.',
|
|
37
|
+
example: 'agents:\n - apolo\n - afrodite',
|
|
38
|
+
impact: 'Agentes listados têm suas subpastas criadas automaticamente em roadmap_dir.'
|
|
39
|
+
},
|
|
40
|
+
governance_mode: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
default: '""',
|
|
43
|
+
description: 'Modo de governança (strict, lenient).',
|
|
44
|
+
example: 'governance_mode: strict',
|
|
45
|
+
impact: 'strict bloqueia commits/merges com violations; lenient emite apenas avisos.'
|
|
46
|
+
},
|
|
47
|
+
lenient_until: {
|
|
48
|
+
type: 'date (YYYY-MM-DD)',
|
|
49
|
+
default: '""',
|
|
50
|
+
description: 'Data até quando o modo lenient está ativo.',
|
|
51
|
+
example: 'lenient_until: 2026-12-31',
|
|
52
|
+
impact: 'Após a data, o modo volta automaticamente para strict.'
|
|
53
|
+
},
|
|
54
|
+
wip_limit: {
|
|
55
|
+
type: 'integer',
|
|
56
|
+
default: '1',
|
|
57
|
+
description: 'Limite de itens WIP simultâneos.',
|
|
58
|
+
example: 'wip_limit: 3',
|
|
59
|
+
impact: 'Aumentar reduz a frequência de bloqueio.'
|
|
60
|
+
},
|
|
61
|
+
wip_by_squad: {
|
|
62
|
+
type: 'boolean',
|
|
63
|
+
default: 'false',
|
|
64
|
+
description: 'Aplicar limite WIP por squad individualmente.',
|
|
65
|
+
example: 'wip_by_squad: true',
|
|
66
|
+
impact: 'Cada squad tem seu próprio contador de WIP em vez de um limite global.'
|
|
67
|
+
},
|
|
68
|
+
require_req_in_commit: {
|
|
69
|
+
type: 'boolean',
|
|
70
|
+
default: 'false',
|
|
71
|
+
description: 'Exigir referência de REQ em mensagens de commit.',
|
|
72
|
+
example: 'require_req_in_commit: true',
|
|
73
|
+
impact: 'Commits sem menção a uma REQ são rejeitados pelo hook de pre-commit.'
|
|
74
|
+
},
|
|
75
|
+
'link_fields.req': {
|
|
76
|
+
type: 'list of strings',
|
|
77
|
+
default: '["REQ:"]',
|
|
78
|
+
description: 'Marcadores que identificam link a REQ.',
|
|
79
|
+
example: 'link_fields:\n req:\n - "REQ:"\n - "Requisito:"',
|
|
80
|
+
impact: 'Qualquer marcador listado é aceito para detectar vínculo com REQ.'
|
|
81
|
+
},
|
|
82
|
+
'link_fields.adr': {
|
|
83
|
+
type: 'list of strings',
|
|
84
|
+
default: '["ADR:"]',
|
|
85
|
+
description: 'Marcadores que identificam link a ADR.',
|
|
86
|
+
example: 'link_fields:\n adr:\n - "ADR:"\n - "Decision:"',
|
|
87
|
+
impact: 'Qualquer marcador listado é aceito para detectar vínculo com ADR.'
|
|
88
|
+
},
|
|
89
|
+
'link_fields.roadmap': {
|
|
90
|
+
type: 'list of strings',
|
|
91
|
+
default: '["Roadmap:"]',
|
|
92
|
+
description: 'Marcadores que identificam link a Roadmap.',
|
|
93
|
+
example: 'link_fields:\n roadmap:\n - "Roadmap:"',
|
|
94
|
+
impact: 'Qualquer marcador listado é aceito para detectar vínculo com Roadmap.'
|
|
95
|
+
},
|
|
96
|
+
acceptance_markers: {
|
|
97
|
+
type: 'list of strings',
|
|
98
|
+
default: '["## Acceptance Criteria", "## Critérios de Aceite"]',
|
|
99
|
+
description: 'Marcadores de critério de aceite.',
|
|
100
|
+
example: 'acceptance_markers:\n - "## Acceptance Criteria"\n - "## AC"',
|
|
101
|
+
impact: 'Roadmaps WIP sem nenhum desses marcadores disparam a regra wip_acceptance.'
|
|
102
|
+
},
|
|
103
|
+
'rules.wip_has_req': {
|
|
104
|
+
type: 'off|warning|error',
|
|
105
|
+
default: '"error"',
|
|
106
|
+
description: 'Severidade: WIP sem REQ linkada.',
|
|
107
|
+
example: 'rules:\n wip_has_req: warning',
|
|
108
|
+
impact: 'error bloqueia; warning apenas reporta; off desativa a regra.'
|
|
109
|
+
},
|
|
110
|
+
'rules.wip_acceptance': {
|
|
111
|
+
type: 'off|warning|error',
|
|
112
|
+
default: '"error"',
|
|
113
|
+
description: 'Severidade: WIP sem critérios de aceite.',
|
|
114
|
+
example: 'rules:\n wip_acceptance: warning',
|
|
115
|
+
impact: 'error bloqueia; warning apenas reporta; off desativa a regra.'
|
|
116
|
+
},
|
|
117
|
+
'rules.wip_limit': {
|
|
118
|
+
type: 'off|warning|error',
|
|
119
|
+
default: '"error"',
|
|
120
|
+
description: 'Severidade: excesso de itens WIP.',
|
|
121
|
+
example: 'rules:\n wip_limit: warning',
|
|
122
|
+
impact: 'error bloqueia; warning apenas reporta; off desativa a regra.'
|
|
123
|
+
},
|
|
124
|
+
'rules.stale_wip': {
|
|
125
|
+
type: 'off|warning|error',
|
|
126
|
+
default: '"warning"',
|
|
127
|
+
description: 'Severidade: WIP sem atualização recente.',
|
|
128
|
+
example: 'rules:\n stale_wip: error',
|
|
129
|
+
impact: 'error bloqueia; warning apenas reporta; off desativa a regra.'
|
|
130
|
+
},
|
|
131
|
+
'rules.adr_orphan': {
|
|
132
|
+
type: 'off|warning|error',
|
|
133
|
+
default: '"warning"',
|
|
134
|
+
description: 'Severidade: ADR sem REQ vinculada.',
|
|
135
|
+
example: 'rules:\n adr_orphan: error',
|
|
136
|
+
impact: 'error bloqueia; warning apenas reporta; off desativa a regra.'
|
|
137
|
+
},
|
|
138
|
+
'rules.ref_targets_exist': {
|
|
139
|
+
type: 'off|warning|error',
|
|
140
|
+
default: '"warning"',
|
|
141
|
+
description: 'Severidade: referências com destino inexistente.',
|
|
142
|
+
example: 'rules:\n ref_targets_exist: error',
|
|
143
|
+
impact: 'error bloqueia; warning apenas reporta; off desativa a regra.'
|
|
144
|
+
},
|
|
145
|
+
'rules.folder_status': {
|
|
146
|
+
type: 'off|warning|error',
|
|
147
|
+
default: '"warning"',
|
|
148
|
+
description: 'Severidade: coerência entre pasta e status do arquivo.',
|
|
149
|
+
example: 'rules:\n folder_status: error',
|
|
150
|
+
impact: 'error bloqueia; warning apenas reporta; off desativa a regra.'
|
|
151
|
+
},
|
|
152
|
+
'rules.filename_uniqueness': {
|
|
153
|
+
type: 'off|warning|error',
|
|
154
|
+
default: '"error"',
|
|
155
|
+
description: 'Severidade: nomes de arquivo duplicados.',
|
|
156
|
+
example: 'rules:\n filename_uniqueness: warning',
|
|
157
|
+
impact: 'error bloqueia; warning apenas reporta; off desativa a regra.'
|
|
158
|
+
},
|
|
159
|
+
'rules.blocked_by_draft_adr': {
|
|
160
|
+
type: 'off|warning|error',
|
|
161
|
+
default: '"error"',
|
|
162
|
+
description: 'Severidade: REQ bloqueada por ADR em rascunho.',
|
|
163
|
+
example: 'rules:\n blocked_by_draft_adr: warning',
|
|
164
|
+
impact: 'error bloqueia; warning apenas reporta; off desativa a regra.'
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Retorna string com a listagem tabular de todas as keys configuráveis.
|
|
170
|
+
* @returns {string}
|
|
171
|
+
*/
|
|
172
|
+
function listKeys() {
|
|
173
|
+
const COL_KEY = 28
|
|
174
|
+
const COL_DEFAULT = 36
|
|
175
|
+
const header = 'KEY'.padEnd(COL_KEY) + 'DEFAULT'.padEnd(COL_DEFAULT) + 'DESCRIÇÃO'
|
|
176
|
+
const sep = '─'.repeat(COL_KEY + COL_DEFAULT + 40)
|
|
177
|
+
const lines = [header, sep]
|
|
178
|
+
for (const [key, doc] of Object.entries(configDocs)) {
|
|
179
|
+
lines.push(
|
|
180
|
+
key.padEnd(COL_KEY) +
|
|
181
|
+
doc.default.padEnd(COL_DEFAULT) +
|
|
182
|
+
doc.description
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
return lines.join('\n')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Retorna string com a documentação detalhada de uma key.
|
|
190
|
+
* Retorna null se a key não existir.
|
|
191
|
+
* @param {string} key
|
|
192
|
+
* @returns {string|null}
|
|
193
|
+
*/
|
|
194
|
+
function describeKey(key) {
|
|
195
|
+
const doc = configDocs[key]
|
|
196
|
+
if (!doc) return null
|
|
197
|
+
return [
|
|
198
|
+
key,
|
|
199
|
+
` Type: ${doc.type}`,
|
|
200
|
+
` Default: ${doc.default}`,
|
|
201
|
+
` Desc: ${doc.description}`,
|
|
202
|
+
` Example:`,
|
|
203
|
+
...doc.example.split('\n').map(l => ` ${l}`),
|
|
204
|
+
` Impact: ${doc.impact}`
|
|
205
|
+
].join('\n')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const cmd = new Command('help')
|
|
209
|
+
cmd.description('Exibe documentação das keys configuráveis do trackfw.yaml')
|
|
210
|
+
cmd.argument('[key]', 'key específica para detalhar')
|
|
211
|
+
cmd.action((key) => {
|
|
212
|
+
if (!key) {
|
|
213
|
+
console.log(listKeys())
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
const output = describeKey(key)
|
|
217
|
+
if (!output) {
|
|
218
|
+
console.error(`chave desconhecida: ${key}`)
|
|
219
|
+
process.exit(1)
|
|
220
|
+
}
|
|
221
|
+
console.log(output)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
module.exports = cmd
|
|
225
|
+
module.exports.listKeys = listKeys
|
|
226
|
+
module.exports.describeKey = describeKey
|
package/src/commands/index.js
CHANGED
|
@@ -22,6 +22,9 @@ function createProgram() {
|
|
|
22
22
|
program.addCommand(require('./metrics'))
|
|
23
23
|
program.addCommand(require('./sync'))
|
|
24
24
|
program.addCommand(require('./context'))
|
|
25
|
+
program.addCommand(require('./baseline'))
|
|
26
|
+
program.addCommand(require('./help'))
|
|
27
|
+
program.addCommand(require('./configure'))
|
|
25
28
|
|
|
26
29
|
// plugin dispatch — comandos desconhecidos tentam executar plugin
|
|
27
30
|
program.hook('preSubcommand', () => {})
|
package/src/config/index.js
CHANGED
|
@@ -15,6 +15,24 @@ function defaults() {
|
|
|
15
15
|
wipLimit: 1,
|
|
16
16
|
wipBySquad: false,
|
|
17
17
|
requireReqInCommit: false,
|
|
18
|
+
// NOVOS campos:
|
|
19
|
+
linkFields: {
|
|
20
|
+
req: ['REQ:'],
|
|
21
|
+
adr: ['ADR:'],
|
|
22
|
+
roadmap: ['Roadmap:'],
|
|
23
|
+
},
|
|
24
|
+
acceptanceMarkers: ['## Acceptance Criteria', '## Critérios de Aceite'],
|
|
25
|
+
rules: {
|
|
26
|
+
wip_has_req: 'error',
|
|
27
|
+
wip_acceptance: 'error',
|
|
28
|
+
wip_limit: 'error',
|
|
29
|
+
stale_wip: 'warning',
|
|
30
|
+
adr_orphan: 'warning',
|
|
31
|
+
ref_targets_exist: 'warning',
|
|
32
|
+
folder_status: 'warning',
|
|
33
|
+
filename_uniqueness: 'error',
|
|
34
|
+
blocked_by_draft_adr: 'error',
|
|
35
|
+
},
|
|
18
36
|
};
|
|
19
37
|
}
|
|
20
38
|
|
|
@@ -36,31 +54,109 @@ function reset() {
|
|
|
36
54
|
|
|
37
55
|
function parse(content, cfg) {
|
|
38
56
|
const lines = content.split('\n');
|
|
57
|
+
|
|
58
|
+
// estados existentes
|
|
39
59
|
let inAdrDirs = false;
|
|
40
60
|
let inAgents = false;
|
|
41
61
|
let adrDirs = [];
|
|
42
62
|
let agents = [];
|
|
43
63
|
|
|
64
|
+
// NOVOS estados
|
|
65
|
+
let inLinkFields = false;
|
|
66
|
+
let inLinkFieldsReq = false;
|
|
67
|
+
let inLinkFieldsAdr = false;
|
|
68
|
+
let inLinkFieldsRoadmap = false;
|
|
69
|
+
let linkFieldsReq = [];
|
|
70
|
+
let linkFieldsAdr = [];
|
|
71
|
+
let linkFieldsRoadmap = [];
|
|
72
|
+
|
|
73
|
+
let inAcceptanceMarkers = false;
|
|
74
|
+
let acceptanceMarkers = [];
|
|
75
|
+
|
|
76
|
+
let inRules = false;
|
|
77
|
+
let rules = {};
|
|
78
|
+
|
|
79
|
+
function flushBlocks() {
|
|
80
|
+
if (inAdrDirs && adrDirs.length) cfg.adrDirs = adrDirs;
|
|
81
|
+
if (inAgents && agents.length) cfg.agents = agents;
|
|
82
|
+
if (inLinkFields) {
|
|
83
|
+
if (inLinkFieldsReq && linkFieldsReq.length) cfg.linkFields.req = linkFieldsReq;
|
|
84
|
+
if (inLinkFieldsAdr && linkFieldsAdr.length) cfg.linkFields.adr = linkFieldsAdr;
|
|
85
|
+
if (inLinkFieldsRoadmap && linkFieldsRoadmap.length) cfg.linkFields.roadmap = linkFieldsRoadmap;
|
|
86
|
+
}
|
|
87
|
+
if (inAcceptanceMarkers && acceptanceMarkers.length) cfg.acceptanceMarkers = acceptanceMarkers;
|
|
88
|
+
if (inRules && Object.keys(rules).length) Object.assign(cfg.rules, rules);
|
|
89
|
+
// reset
|
|
90
|
+
inAdrDirs = false; adrDirs = [];
|
|
91
|
+
inAgents = false; agents = [];
|
|
92
|
+
inLinkFields = false;
|
|
93
|
+
inLinkFieldsReq = false; inLinkFieldsAdr = false; inLinkFieldsRoadmap = false;
|
|
94
|
+
linkFieldsReq = []; linkFieldsAdr = []; linkFieldsRoadmap = [];
|
|
95
|
+
inAcceptanceMarkers = false; acceptanceMarkers = [];
|
|
96
|
+
inRules = false; rules = {};
|
|
97
|
+
}
|
|
98
|
+
|
|
44
99
|
for (const rawLine of lines) {
|
|
45
100
|
const line = rawLine.trim();
|
|
101
|
+
if (!line) continue;
|
|
102
|
+
const hasIndent = rawLine.length > 0 && (rawLine[0] === ' ' || rawLine[0] === '\t');
|
|
103
|
+
|
|
104
|
+
if (!hasIndent) {
|
|
105
|
+
flushBlocks();
|
|
106
|
+
}
|
|
46
107
|
|
|
47
|
-
if (
|
|
48
|
-
if (
|
|
49
|
-
adrDirs.push(line.slice(2).trim());
|
|
108
|
+
if (hasIndent) {
|
|
109
|
+
if (inAdrDirs) {
|
|
110
|
+
if (line.startsWith('- ')) adrDirs.push(line.slice(2).trim());
|
|
50
111
|
continue;
|
|
51
112
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (
|
|
57
|
-
|
|
113
|
+
if (inAgents) {
|
|
114
|
+
if (line.startsWith('- ')) agents.push(line.slice(2).trim());
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (inAcceptanceMarkers) {
|
|
118
|
+
if (line.startsWith('- ')) {
|
|
119
|
+
let val = line.slice(2).trim();
|
|
120
|
+
val = val.replace(/^["']|["']$/g, '');
|
|
121
|
+
acceptanceMarkers.push(val);
|
|
122
|
+
}
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (inRules) {
|
|
126
|
+
const colonIdx = line.indexOf(':');
|
|
127
|
+
if (colonIdx > 0) {
|
|
128
|
+
const k = line.slice(0, colonIdx).trim();
|
|
129
|
+
const v = line.slice(colonIdx + 1).trim();
|
|
130
|
+
if (k) rules[k] = v;
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (inLinkFields) {
|
|
135
|
+
if (line.startsWith('- ')) {
|
|
136
|
+
let val = line.slice(2).trim();
|
|
137
|
+
val = val.replace(/^["']|["']$/g, '');
|
|
138
|
+
if (inLinkFieldsReq) linkFieldsReq.push(val);
|
|
139
|
+
else if (inLinkFieldsAdr) linkFieldsAdr.push(val);
|
|
140
|
+
else if (inLinkFieldsRoadmap) linkFieldsRoadmap.push(val);
|
|
141
|
+
} else {
|
|
142
|
+
// sub-chave dentro de link_fields
|
|
143
|
+
const colonIdx = line.indexOf(':');
|
|
144
|
+
const subKey = colonIdx > 0 ? line.slice(0, colonIdx).trim() : line.replace(':', '').trim();
|
|
145
|
+
// flush sub-campo anterior
|
|
146
|
+
if (inLinkFieldsReq && linkFieldsReq.length) { cfg.linkFields.req = linkFieldsReq; linkFieldsReq = []; }
|
|
147
|
+
if (inLinkFieldsAdr && linkFieldsAdr.length) { cfg.linkFields.adr = linkFieldsAdr; linkFieldsAdr = []; }
|
|
148
|
+
if (inLinkFieldsRoadmap && linkFieldsRoadmap.length) { cfg.linkFields.roadmap = linkFieldsRoadmap; linkFieldsRoadmap = []; }
|
|
149
|
+
inLinkFieldsReq = false; inLinkFieldsAdr = false; inLinkFieldsRoadmap = false;
|
|
150
|
+
if (subKey === 'req') inLinkFieldsReq = true;
|
|
151
|
+
else if (subKey === 'adr') inLinkFieldsAdr = true;
|
|
152
|
+
else if (subKey === 'roadmap') inLinkFieldsRoadmap = true;
|
|
153
|
+
}
|
|
58
154
|
continue;
|
|
59
155
|
}
|
|
60
|
-
|
|
61
|
-
if (agents.length) cfg.agents = agents;
|
|
156
|
+
continue;
|
|
62
157
|
}
|
|
63
158
|
|
|
159
|
+
// linha top-level
|
|
64
160
|
const colonIdx = line.indexOf(':');
|
|
65
161
|
if (colonIdx < 0) continue;
|
|
66
162
|
const key = line.slice(0, colonIdx).trim();
|
|
@@ -68,25 +164,27 @@ function parse(content, cfg) {
|
|
|
68
164
|
if (!key) continue;
|
|
69
165
|
|
|
70
166
|
switch (key) {
|
|
71
|
-
case 'adr_dirs':
|
|
72
|
-
case 'req_dir':
|
|
73
|
-
case 'roadmap_dir':
|
|
74
|
-
case 'roadmap_namespacing':
|
|
75
|
-
case 'agents':
|
|
76
|
-
case 'governance_mode':
|
|
77
|
-
case 'lenient_until':
|
|
78
|
-
case 'wip_limit':
|
|
79
|
-
case 'wip_by_squad':
|
|
167
|
+
case 'adr_dirs': inAdrDirs = true; adrDirs = []; break;
|
|
168
|
+
case 'req_dir': cfg.reqDir = val; break;
|
|
169
|
+
case 'roadmap_dir': cfg.roadmapDir = val; break;
|
|
170
|
+
case 'roadmap_namespacing': cfg.roadmapNamespacing = val; break;
|
|
171
|
+
case 'agents': inAgents = true; agents = []; break;
|
|
172
|
+
case 'governance_mode': cfg.governanceMode = val; break;
|
|
173
|
+
case 'lenient_until': cfg.lenientUntil = val; break;
|
|
174
|
+
case 'wip_limit': { const n = parseInt(val, 10); if (n > 0) cfg.wipLimit = n; break; }
|
|
175
|
+
case 'wip_by_squad': cfg.wipBySquad = val === 'true'; break;
|
|
80
176
|
case 'require_req_in_commit': cfg.requireReqInCommit = val === 'true'; break;
|
|
177
|
+
case 'link_fields': inLinkFields = true; break;
|
|
178
|
+
case 'acceptance_markers': inAcceptanceMarkers = true; acceptanceMarkers = []; break;
|
|
179
|
+
case 'rules': inRules = true; rules = {}; break;
|
|
81
180
|
}
|
|
82
181
|
}
|
|
83
182
|
|
|
84
|
-
// flush
|
|
85
|
-
|
|
86
|
-
if (inAgents && agents.length) cfg.agents = agents;
|
|
183
|
+
// flush final (EOF)
|
|
184
|
+
flushBlocks();
|
|
87
185
|
}
|
|
88
186
|
|
|
89
|
-
const NAMESPACING_FLAT = 'flat'
|
|
90
|
-
const NAMESPACING_BY_AGENT = 'by_agent'
|
|
187
|
+
const NAMESPACING_FLAT = 'flat';
|
|
188
|
+
const NAMESPACING_BY_AGENT = 'by_agent';
|
|
91
189
|
|
|
92
190
|
module.exports = { load, reset, defaults, NAMESPACING_FLAT, NAMESPACING_BY_AGENT };
|
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 { execSync } = require('child_process')
|
|
5
6
|
const config = require('../config')
|
|
6
7
|
|
|
7
8
|
const STALE_WIP_DAYS = 7
|
|
@@ -22,6 +23,64 @@ function listDir(dir) {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
// walkDirMd retorna basenames de todos .md recursivamente dentro de dir.
|
|
27
|
+
function walkDirMd(dir) {
|
|
28
|
+
const results = []
|
|
29
|
+
function walk(d) {
|
|
30
|
+
let entries
|
|
31
|
+
try { entries = fs.readdirSync(d) } catch (_) { return }
|
|
32
|
+
for (const name of entries) {
|
|
33
|
+
const full = path.join(d, name)
|
|
34
|
+
try {
|
|
35
|
+
if (fs.statSync(full).isDirectory()) { walk(full) }
|
|
36
|
+
else if (name.endsWith('.md')) { results.push(name) }
|
|
37
|
+
} catch (_) {}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
walk(dir)
|
|
41
|
+
return results
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// findAdrFile busca o basename recursivamente em todos os adrDirs configurados.
|
|
45
|
+
// Retorna o caminho completo se encontrado, ou null.
|
|
46
|
+
function findAdrFile(basename) {
|
|
47
|
+
const cfg = config.load()
|
|
48
|
+
for (const adrDir of cfg.adrDirs) {
|
|
49
|
+
function search(d) {
|
|
50
|
+
let entries
|
|
51
|
+
try { entries = fs.readdirSync(d) } catch (_) { return null }
|
|
52
|
+
for (const name of entries) {
|
|
53
|
+
const full = path.join(d, name)
|
|
54
|
+
try {
|
|
55
|
+
if (fs.statSync(full).isDirectory()) {
|
|
56
|
+
const r = search(full)
|
|
57
|
+
if (r) return r
|
|
58
|
+
} else if (name === basename) {
|
|
59
|
+
return full
|
|
60
|
+
}
|
|
61
|
+
} catch (_) {}
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
const found = search(adrDir)
|
|
66
|
+
if (found) return found
|
|
67
|
+
}
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// gitLastModifiedTime retorna o timestamp (ms) do último commit que tocou o arquivo via git log.
|
|
72
|
+
// Retorna null em caso de erro ou se não houver commits.
|
|
73
|
+
function gitLastModifiedTime(filePath) {
|
|
74
|
+
try {
|
|
75
|
+
const out = execSync(`git log -1 --format=%ct -- "${filePath}"`, {
|
|
76
|
+
encoding: 'utf8',
|
|
77
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
78
|
+
}).trim()
|
|
79
|
+
if (out) return parseInt(out, 10) * 1000 // converter para ms
|
|
80
|
+
} catch (_) {}
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
25
84
|
// resolveWIPDirs retorna todos os diretórios wip/ conforme o modo de namespacing.
|
|
26
85
|
function resolveWIPDirs(cfg) {
|
|
27
86
|
if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
|
|
@@ -68,23 +127,26 @@ function parseBlockedADRs(filePath) {
|
|
|
68
127
|
return adrs
|
|
69
128
|
}
|
|
70
129
|
|
|
71
|
-
//
|
|
72
|
-
function
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (fs.existsSync(p)) {
|
|
77
|
-
try {
|
|
78
|
-
return fs.readFileSync(p, 'utf8').includes('Status: Draft')
|
|
79
|
-
} catch (_) {
|
|
80
|
-
// ignorar erro de leitura
|
|
81
|
-
}
|
|
130
|
+
// contentHasMarker retorna true se o conteúdo contém algum dos markers sem espaço em branco após.
|
|
131
|
+
function contentHasMarker(content, markers) {
|
|
132
|
+
for (const marker of markers) {
|
|
133
|
+
if (content.includes(marker) && !content.includes(marker + ' \n')) {
|
|
134
|
+
return true
|
|
82
135
|
}
|
|
83
136
|
}
|
|
84
137
|
return false
|
|
85
138
|
}
|
|
86
139
|
|
|
87
|
-
//
|
|
140
|
+
// adrIsDraft verifica se <adrBasename> contém "Status: Draft" buscando recursivamente nas adrDirs.
|
|
141
|
+
function adrIsDraft(basename) {
|
|
142
|
+
const p = findAdrFile(basename)
|
|
143
|
+
if (!p) return false
|
|
144
|
+
try {
|
|
145
|
+
return fs.readFileSync(p, 'utf8').includes('Status: Draft')
|
|
146
|
+
} catch (_) { return false }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// validateWIPHasREQ — roadmaps em wip/ sem marker REQ no conteúdo → violation
|
|
88
150
|
// Suporta modo by_agent via resolveWIPDirs.
|
|
89
151
|
function validateWIPHasREQ() {
|
|
90
152
|
const cfg = config.load()
|
|
@@ -95,7 +157,7 @@ function validateWIPHasREQ() {
|
|
|
95
157
|
for (const name of entries) {
|
|
96
158
|
try {
|
|
97
159
|
const content = fs.readFileSync(path.join(wipDir, name), 'utf8')
|
|
98
|
-
if (!content
|
|
160
|
+
if (!contentHasMarker(content, cfg.linkFields.req)) {
|
|
99
161
|
violations.push(`roadmap "${name}" is in wip but has no linked REQ`)
|
|
100
162
|
}
|
|
101
163
|
} catch (_) {
|
|
@@ -106,7 +168,7 @@ function validateWIPHasREQ() {
|
|
|
106
168
|
return violations
|
|
107
169
|
}
|
|
108
170
|
|
|
109
|
-
// validateREQsHaveADR — REQs em <reqDir>/ sem
|
|
171
|
+
// validateREQsHaveADR — REQs em <reqDir>/ sem marker ADR no conteúdo → violation
|
|
110
172
|
function validateREQsHaveADR() {
|
|
111
173
|
const cfg = config.load()
|
|
112
174
|
const entries = listDir(cfg.reqDir)
|
|
@@ -114,7 +176,7 @@ function validateREQsHaveADR() {
|
|
|
114
176
|
for (const name of entries) {
|
|
115
177
|
try {
|
|
116
178
|
const content = fs.readFileSync(path.join(cfg.reqDir, name), 'utf8')
|
|
117
|
-
if (!content
|
|
179
|
+
if (!contentHasMarker(content, cfg.linkFields.adr)) {
|
|
118
180
|
violations.push(`req "${name}" has no linked ADR`)
|
|
119
181
|
}
|
|
120
182
|
} catch (_) {
|
|
@@ -124,7 +186,7 @@ function validateREQsHaveADR() {
|
|
|
124
186
|
return violations
|
|
125
187
|
}
|
|
126
188
|
|
|
127
|
-
// validateBlockedHasREQ — roadmaps em <roadmapDir>/blocked/ sem
|
|
189
|
+
// validateBlockedHasREQ — roadmaps em <roadmapDir>/blocked/ sem marker REQ → violation
|
|
128
190
|
function validateBlockedHasREQ() {
|
|
129
191
|
const cfg = config.load()
|
|
130
192
|
const entries = listDir(cfg.roadmapDir + '/blocked')
|
|
@@ -132,7 +194,7 @@ function validateBlockedHasREQ() {
|
|
|
132
194
|
for (const name of entries) {
|
|
133
195
|
try {
|
|
134
196
|
const content = fs.readFileSync(path.join(cfg.roadmapDir + '/blocked', name), 'utf8')
|
|
135
|
-
if (!content
|
|
197
|
+
if (!contentHasMarker(content, cfg.linkFields.req)) {
|
|
136
198
|
violations.push(`roadmap "${name}" is in blocked but has no linked REQ`)
|
|
137
199
|
}
|
|
138
200
|
} catch (_) {
|
|
@@ -142,7 +204,7 @@ function validateBlockedHasREQ() {
|
|
|
142
204
|
return violations
|
|
143
205
|
}
|
|
144
206
|
|
|
145
|
-
// validateREQsHaveRoadmap — REQs sem
|
|
207
|
+
// validateREQsHaveRoadmap — REQs sem marker Roadmap → violation
|
|
146
208
|
function validateREQsHaveRoadmap() {
|
|
147
209
|
const cfg = config.load()
|
|
148
210
|
const entries = listDir(cfg.reqDir)
|
|
@@ -150,7 +212,7 @@ function validateREQsHaveRoadmap() {
|
|
|
150
212
|
for (const name of entries) {
|
|
151
213
|
try {
|
|
152
214
|
const content = fs.readFileSync(path.join(cfg.reqDir, name), 'utf8')
|
|
153
|
-
if (!content
|
|
215
|
+
if (!contentHasMarker(content, cfg.linkFields.roadmap)) {
|
|
154
216
|
violations.push(`req "${name}" has no linked Roadmap`)
|
|
155
217
|
}
|
|
156
218
|
} catch (_) {
|
|
@@ -165,7 +227,7 @@ function validateADRsAreReferenced() {
|
|
|
165
227
|
const cfg = config.load()
|
|
166
228
|
let adrs = []
|
|
167
229
|
for (const adrDir of cfg.adrDirs) {
|
|
168
|
-
adrs = adrs.concat(
|
|
230
|
+
adrs = adrs.concat(walkDirMd(adrDir))
|
|
169
231
|
}
|
|
170
232
|
|
|
171
233
|
const reqEntries = listDir(cfg.reqDir)
|
|
@@ -198,11 +260,7 @@ function validateWIPHasAcceptanceCriteria() {
|
|
|
198
260
|
for (const name of entries) {
|
|
199
261
|
try {
|
|
200
262
|
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:')
|
|
263
|
+
const hasBlock = contentHasMarker(content, cfg.acceptanceMarkers)
|
|
206
264
|
if (!hasBlock) {
|
|
207
265
|
violations.push(`roadmap "${name}" is in wip but has no acceptance criteria block`)
|
|
208
266
|
}
|
|
@@ -343,10 +401,12 @@ function validateStaleWIP() {
|
|
|
343
401
|
for (const filePath of files) {
|
|
344
402
|
try {
|
|
345
403
|
const stat = fs.statSync(filePath)
|
|
346
|
-
const
|
|
404
|
+
const gitTime = gitLastModifiedTime(filePath)
|
|
405
|
+
const ageMs = now - (gitTime !== null ? gitTime : stat.mtimeMs)
|
|
347
406
|
const days = Math.floor(ageMs / (1000 * 60 * 60 * 24))
|
|
348
407
|
if (days >= STALE_WIP_DAYS) {
|
|
349
|
-
const
|
|
408
|
+
const refTime = gitTime !== null ? gitTime : stat.mtimeMs
|
|
409
|
+
const lastModified = new Date(refTime).toISOString().slice(0, 10)
|
|
350
410
|
const basename = path.basename(filePath)
|
|
351
411
|
warnings.push(
|
|
352
412
|
`roadmap/wip/${basename} has been in WIP for ${days} days (last modified ${lastModified})`
|
|
@@ -458,10 +518,11 @@ function validateFrontmatterPresence() {
|
|
|
458
518
|
const violations = []
|
|
459
519
|
|
|
460
520
|
for (const adrDir of cfg.adrDirs) {
|
|
461
|
-
const
|
|
462
|
-
|
|
521
|
+
for (const f of walkDirMd(adrDir)) {
|
|
522
|
+
const fullPath = findAdrFile(f)
|
|
523
|
+
if (!fullPath) continue
|
|
463
524
|
try {
|
|
464
|
-
const content = fs.readFileSync(
|
|
525
|
+
const content = fs.readFileSync(fullPath, 'utf8')
|
|
465
526
|
if (!content.startsWith('---')) {
|
|
466
527
|
violations.push(`adr "${f}" has no frontmatter block`)
|
|
467
528
|
}
|
|
@@ -483,29 +544,252 @@ function validateFrontmatterPresence() {
|
|
|
483
544
|
return violations
|
|
484
545
|
}
|
|
485
546
|
|
|
486
|
-
//
|
|
487
|
-
|
|
547
|
+
// extractRefPath extrai o valor de um campo (ex: "REQ", "ADR", "Roadmap") que aponta para .md
|
|
548
|
+
function extractRefPath(content, field) {
|
|
549
|
+
for (const line of content.split('\n')) {
|
|
550
|
+
const trimmed = line.trim()
|
|
551
|
+
const prefix = field + ':'
|
|
552
|
+
if (trimmed.startsWith(prefix)) {
|
|
553
|
+
let val = trimmed.slice(prefix.length).trim()
|
|
554
|
+
if (!val || val === '—' || val === '-' || val === '–') return null
|
|
555
|
+
val = val.split(/\s+/)[0]
|
|
556
|
+
if (val.endsWith('.md')) return val
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return null
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// validateRefTargetsExist — verifica se os arquivos referenciados em REQ:, ADR: e Roadmap: existem
|
|
563
|
+
function validateRefTargetsExist() {
|
|
564
|
+
const cfg = config.load()
|
|
565
|
+
const warnings = []
|
|
566
|
+
|
|
567
|
+
// Roadmaps em wip e blocked: verificar REQ:
|
|
568
|
+
const dirs = [...resolveWIPDirs(cfg), cfg.roadmapDir + '/blocked']
|
|
569
|
+
for (const dir of dirs) {
|
|
570
|
+
for (const name of listDir(dir)) {
|
|
571
|
+
try {
|
|
572
|
+
const content = fs.readFileSync(path.join(dir, name), 'utf8')
|
|
573
|
+
const ref = extractRefPath(content, 'REQ')
|
|
574
|
+
if (ref && !fs.existsSync(ref)) {
|
|
575
|
+
warnings.push(`roadmap "${name}" links to REQ "${ref}" which does not exist`)
|
|
576
|
+
}
|
|
577
|
+
} catch (_) {}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// REQs: verificar ADR: e Roadmap:
|
|
582
|
+
for (const name of listDir(cfg.reqDir)) {
|
|
583
|
+
try {
|
|
584
|
+
const content = fs.readFileSync(path.join(cfg.reqDir, name), 'utf8')
|
|
585
|
+
const adrRef = extractRefPath(content, 'ADR')
|
|
586
|
+
if (adrRef && !fs.existsSync(adrRef)) {
|
|
587
|
+
warnings.push(`req "${name}" links to ADR "${adrRef}" which does not exist`)
|
|
588
|
+
}
|
|
589
|
+
const roadmapRef = extractRefPath(content, 'Roadmap')
|
|
590
|
+
if (roadmapRef && !fs.existsSync(roadmapRef)) {
|
|
591
|
+
warnings.push(`req "${name}" links to Roadmap "${roadmapRef}" which does not exist`)
|
|
592
|
+
}
|
|
593
|
+
} catch (_) {}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return warnings
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// FOLDER_TO_STATUS mapeia pasta de estado para os valores válidos de status no frontmatter
|
|
600
|
+
const FOLDER_TO_STATUS = {
|
|
601
|
+
wip: ['WIP', 'wip', 'In Progress'],
|
|
602
|
+
backlog: ['Backlog', 'backlog'],
|
|
603
|
+
blocked: ['Blocked', 'blocked'],
|
|
604
|
+
done: ['Done', 'done'],
|
|
605
|
+
abandoned: ['Abandoned', 'abandoned'],
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// validateFolderStatusCoherence — verifica se o status declarado no frontmatter condiz com a pasta
|
|
609
|
+
function validateFolderStatusCoherence() {
|
|
610
|
+
const cfg = config.load()
|
|
611
|
+
const warnings = []
|
|
612
|
+
const states = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
|
|
613
|
+
|
|
614
|
+
let dirs = []
|
|
615
|
+
if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
|
|
616
|
+
let agents = cfg.agents || []
|
|
617
|
+
if (agents.length === 0) {
|
|
618
|
+
try { agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
|
|
619
|
+
try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
|
|
620
|
+
}) } catch (_) { agents = [] }
|
|
621
|
+
}
|
|
622
|
+
for (const agent of agents) {
|
|
623
|
+
for (const state of states) {
|
|
624
|
+
dirs.push({ dir: path.join(cfg.roadmapDir, agent, state), state })
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
} else {
|
|
628
|
+
for (const state of states) {
|
|
629
|
+
dirs.push({ dir: path.join(cfg.roadmapDir, state), state })
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
for (const { dir, state } of dirs) {
|
|
634
|
+
for (const name of listDir(dir).filter(f => f.endsWith('.md'))) {
|
|
635
|
+
try {
|
|
636
|
+
const content = fs.readFileSync(path.join(dir, name), 'utf8')
|
|
637
|
+
// Extrair status do frontmatter
|
|
638
|
+
let declared = ''
|
|
639
|
+
if (content.startsWith('---')) {
|
|
640
|
+
const end = content.indexOf('\n---', 3)
|
|
641
|
+
if (end > 0) {
|
|
642
|
+
for (const line of content.slice(3, end).split('\n')) {
|
|
643
|
+
const t = line.trim()
|
|
644
|
+
if (t.startsWith('status:')) {
|
|
645
|
+
declared = t.slice('status:'.length).trim().replace(/['"]/g, '')
|
|
646
|
+
break
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if (!declared) continue
|
|
652
|
+
const expected = FOLDER_TO_STATUS[state] || []
|
|
653
|
+
if (!expected.some(e => e.toLowerCase() === declared.toLowerCase())) {
|
|
654
|
+
warnings.push(`roadmap "${name}": folder is "${state}" but status declares "${declared}"`)
|
|
655
|
+
}
|
|
656
|
+
} catch (_) {}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return warnings
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// validateFilenameUniqueness — verifica que o mesmo filename não aparece em múltiplos estados
|
|
663
|
+
function validateFilenameUniqueness() {
|
|
664
|
+
const cfg = config.load()
|
|
665
|
+
const states = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
|
|
666
|
+
const seen = {} // filename → [states]
|
|
667
|
+
|
|
668
|
+
if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
|
|
669
|
+
let agents = cfg.agents || []
|
|
670
|
+
if (agents.length === 0) {
|
|
671
|
+
try { agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
|
|
672
|
+
try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
|
|
673
|
+
}) } catch (_) { agents = [] }
|
|
674
|
+
}
|
|
675
|
+
for (const agent of agents) {
|
|
676
|
+
for (const state of states) {
|
|
677
|
+
for (const name of listDir(path.join(cfg.roadmapDir, agent, state))) {
|
|
678
|
+
const key = agent + '/' + name
|
|
679
|
+
if (!seen[key]) seen[key] = []
|
|
680
|
+
seen[key].push(state)
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
} else {
|
|
685
|
+
for (const state of states) {
|
|
686
|
+
for (const name of listDir(path.join(cfg.roadmapDir, state))) {
|
|
687
|
+
if (!seen[name]) seen[name] = []
|
|
688
|
+
seen[name].push(state)
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const violations = []
|
|
694
|
+
for (const [name, stateList] of Object.entries(seen)) {
|
|
695
|
+
if (stateList.length > 1) {
|
|
696
|
+
violations.push(`roadmap "${name}" appears in multiple states: [${stateList.join(', ')}]`)
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return violations
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ruleSeverity retorna a severidade configurada para uma regra ('error'|'warning'|'off').
|
|
703
|
+
function ruleSeverity(name) {
|
|
704
|
+
const cfg = config.load()
|
|
705
|
+
return cfg.rules[name] || 'error'
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// applyRule distribui msgs para violations ou warnings conforme a severidade configurada.
|
|
709
|
+
// Se severidade for 'off', descarta silenciosamente.
|
|
710
|
+
function applyRule(ruleName, msgs, violations, warnings) {
|
|
711
|
+
if (!msgs || msgs.length === 0) return
|
|
712
|
+
const severity = ruleSeverity(ruleName)
|
|
713
|
+
if (severity === 'off') return
|
|
714
|
+
if (severity === 'warning') {
|
|
715
|
+
warnings.push(...msgs)
|
|
716
|
+
} else {
|
|
717
|
+
violations.push(...msgs)
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const BASELINE_FILE = '.trackfw-baseline.json'
|
|
722
|
+
|
|
723
|
+
// loadBaseline carrega o baseline do arquivo .trackfw-baseline.json.
|
|
724
|
+
// Retorna null se o arquivo não existir.
|
|
725
|
+
function loadBaseline() {
|
|
726
|
+
try {
|
|
727
|
+
const data = fs.readFileSync(BASELINE_FILE, 'utf8')
|
|
728
|
+
return JSON.parse(data)
|
|
729
|
+
} catch (e) {
|
|
730
|
+
if (e.code === 'ENOENT') return null
|
|
731
|
+
throw new Error(`Erro ao ler baseline: ${e.message}`)
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// saveBaseline salva snapshot de violations e warnings em .trackfw-baseline.json.
|
|
736
|
+
function saveBaseline(violations, warnings) {
|
|
737
|
+
const bf = {
|
|
738
|
+
created: new Date().toISOString(),
|
|
739
|
+
violations,
|
|
740
|
+
warnings,
|
|
741
|
+
}
|
|
742
|
+
fs.writeFileSync(BASELINE_FILE, JSON.stringify(bf, null, 2), 'utf8')
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// validateUnfiltered executa todas as validações e retorna { violations, warnings } sem ratchet.
|
|
746
|
+
async function validateUnfiltered() {
|
|
488
747
|
const wipLimitResult = validateWIPLimit()
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
748
|
+
const violations = []
|
|
749
|
+
const warnings = []
|
|
750
|
+
|
|
751
|
+
// Regras com severidade configurável via applyRule
|
|
752
|
+
applyRule('wip_has_req', validateWIPHasREQ(), violations, warnings)
|
|
753
|
+
applyRule('wip_acceptance', validateWIPHasAcceptanceCriteria(), violations, warnings)
|
|
754
|
+
applyRule('wip_limit', wipLimitResult.violations, violations, warnings)
|
|
755
|
+
applyRule('adr_orphan', validateADRsAreReferenced(), violations, warnings)
|
|
756
|
+
applyRule('stale_wip', validateStaleWIP(), violations, warnings)
|
|
757
|
+
applyRule('ref_targets_exist', validateRefTargetsExist(), violations, warnings)
|
|
758
|
+
applyRule('folder_status', validateFolderStatusCoherence(), violations, warnings)
|
|
759
|
+
applyRule('filename_uniqueness', validateFilenameUniqueness(), violations, warnings)
|
|
760
|
+
applyRule('blocked_by_draft_adr', validateREQsNotBlockedByDraftADRs(), violations, warnings)
|
|
761
|
+
|
|
762
|
+
// Regras diretas (sem configuração de severidade): violations sempre
|
|
763
|
+
violations.push(...validateREQsHaveADR())
|
|
764
|
+
violations.push(...validateBlockedHasREQ())
|
|
765
|
+
violations.push(...validateREQsHaveRoadmap())
|
|
766
|
+
violations.push(...validateFrontmatterPresence())
|
|
767
|
+
|
|
768
|
+
// warnings diretos do WIP limit (não configuráveis)
|
|
769
|
+
warnings.push(...wipLimitResult.warnings)
|
|
770
|
+
|
|
771
|
+
return { violations, warnings }
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// validate executa todas as validações, aplica ratchet (baseline) e modo lenient.
|
|
775
|
+
// Retorna { violations, warnings }.
|
|
776
|
+
async function validate() {
|
|
777
|
+
const result = await validateUnfiltered()
|
|
778
|
+
let { violations, warnings } = result
|
|
779
|
+
|
|
780
|
+
// Ratchet: filtrar violations que já estavam no baseline
|
|
781
|
+
const baseline = loadBaseline()
|
|
782
|
+
if (baseline) {
|
|
783
|
+
const baselineSet = new Set(baseline.violations || [])
|
|
784
|
+
violations = violations.filter(v => !baselineSet.has(v))
|
|
785
|
+
}
|
|
786
|
+
|
|
504
787
|
// Modo lenient: mover violations para warnings, exit code 0
|
|
505
788
|
if (isLenient()) {
|
|
506
789
|
warnings = [...warnings, ...violations]
|
|
507
790
|
violations = []
|
|
508
791
|
}
|
|
792
|
+
|
|
509
793
|
return { violations, warnings }
|
|
510
794
|
}
|
|
511
795
|
|
|
@@ -587,6 +871,9 @@ async function getStatus() {
|
|
|
587
871
|
|
|
588
872
|
module.exports = {
|
|
589
873
|
validate,
|
|
874
|
+
validateUnfiltered,
|
|
875
|
+
loadBaseline,
|
|
876
|
+
saveBaseline,
|
|
590
877
|
getStatus,
|
|
591
878
|
isLenient,
|
|
592
879
|
lenientUntilDate,
|
|
@@ -609,4 +896,16 @@ module.exports = {
|
|
|
609
896
|
readWIPConfig,
|
|
610
897
|
parseSquadFromFrontmatter,
|
|
611
898
|
validateFrontmatterPresence,
|
|
899
|
+
// novas funções ML-1B
|
|
900
|
+
walkDirMd,
|
|
901
|
+
findAdrFile,
|
|
902
|
+
gitLastModifiedTime,
|
|
903
|
+
extractRefPath,
|
|
904
|
+
validateRefTargetsExist,
|
|
905
|
+
validateFolderStatusCoherence,
|
|
906
|
+
validateFilenameUniqueness,
|
|
907
|
+
// novas funções ML-2B
|
|
908
|
+
contentHasMarker,
|
|
909
|
+
ruleSeverity,
|
|
910
|
+
applyRule,
|
|
612
911
|
}
|