trackfw 2.3.0 → 2.4.1
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 +113 -33
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().replace(/^["']|["']$/g, '');
|
|
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
|
@@ -127,6 +127,16 @@ function parseBlockedADRs(filePath) {
|
|
|
127
127
|
return adrs
|
|
128
128
|
}
|
|
129
129
|
|
|
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
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return false
|
|
138
|
+
}
|
|
139
|
+
|
|
130
140
|
// adrIsDraft verifica se <adrBasename> contém "Status: Draft" buscando recursivamente nas adrDirs.
|
|
131
141
|
function adrIsDraft(basename) {
|
|
132
142
|
const p = findAdrFile(basename)
|
|
@@ -136,7 +146,7 @@ function adrIsDraft(basename) {
|
|
|
136
146
|
} catch (_) { return false }
|
|
137
147
|
}
|
|
138
148
|
|
|
139
|
-
// validateWIPHasREQ — roadmaps em wip/ sem
|
|
149
|
+
// validateWIPHasREQ — roadmaps em wip/ sem marker REQ no conteúdo → violation
|
|
140
150
|
// Suporta modo by_agent via resolveWIPDirs.
|
|
141
151
|
function validateWIPHasREQ() {
|
|
142
152
|
const cfg = config.load()
|
|
@@ -147,7 +157,7 @@ function validateWIPHasREQ() {
|
|
|
147
157
|
for (const name of entries) {
|
|
148
158
|
try {
|
|
149
159
|
const content = fs.readFileSync(path.join(wipDir, name), 'utf8')
|
|
150
|
-
if (!content
|
|
160
|
+
if (!contentHasMarker(content, cfg.linkFields.req)) {
|
|
151
161
|
violations.push(`roadmap "${name}" is in wip but has no linked REQ`)
|
|
152
162
|
}
|
|
153
163
|
} catch (_) {
|
|
@@ -158,7 +168,7 @@ function validateWIPHasREQ() {
|
|
|
158
168
|
return violations
|
|
159
169
|
}
|
|
160
170
|
|
|
161
|
-
// validateREQsHaveADR — REQs em <reqDir>/ sem
|
|
171
|
+
// validateREQsHaveADR — REQs em <reqDir>/ sem marker ADR no conteúdo → violation
|
|
162
172
|
function validateREQsHaveADR() {
|
|
163
173
|
const cfg = config.load()
|
|
164
174
|
const entries = listDir(cfg.reqDir)
|
|
@@ -166,7 +176,7 @@ function validateREQsHaveADR() {
|
|
|
166
176
|
for (const name of entries) {
|
|
167
177
|
try {
|
|
168
178
|
const content = fs.readFileSync(path.join(cfg.reqDir, name), 'utf8')
|
|
169
|
-
if (!content
|
|
179
|
+
if (!contentHasMarker(content, cfg.linkFields.adr)) {
|
|
170
180
|
violations.push(`req "${name}" has no linked ADR`)
|
|
171
181
|
}
|
|
172
182
|
} catch (_) {
|
|
@@ -176,7 +186,7 @@ function validateREQsHaveADR() {
|
|
|
176
186
|
return violations
|
|
177
187
|
}
|
|
178
188
|
|
|
179
|
-
// validateBlockedHasREQ — roadmaps em <roadmapDir>/blocked/ sem
|
|
189
|
+
// validateBlockedHasREQ — roadmaps em <roadmapDir>/blocked/ sem marker REQ → violation
|
|
180
190
|
function validateBlockedHasREQ() {
|
|
181
191
|
const cfg = config.load()
|
|
182
192
|
const entries = listDir(cfg.roadmapDir + '/blocked')
|
|
@@ -184,7 +194,7 @@ function validateBlockedHasREQ() {
|
|
|
184
194
|
for (const name of entries) {
|
|
185
195
|
try {
|
|
186
196
|
const content = fs.readFileSync(path.join(cfg.roadmapDir + '/blocked', name), 'utf8')
|
|
187
|
-
if (!content
|
|
197
|
+
if (!contentHasMarker(content, cfg.linkFields.req)) {
|
|
188
198
|
violations.push(`roadmap "${name}" is in blocked but has no linked REQ`)
|
|
189
199
|
}
|
|
190
200
|
} catch (_) {
|
|
@@ -194,7 +204,7 @@ function validateBlockedHasREQ() {
|
|
|
194
204
|
return violations
|
|
195
205
|
}
|
|
196
206
|
|
|
197
|
-
// validateREQsHaveRoadmap — REQs sem
|
|
207
|
+
// validateREQsHaveRoadmap — REQs sem marker Roadmap → violation
|
|
198
208
|
function validateREQsHaveRoadmap() {
|
|
199
209
|
const cfg = config.load()
|
|
200
210
|
const entries = listDir(cfg.reqDir)
|
|
@@ -202,7 +212,7 @@ function validateREQsHaveRoadmap() {
|
|
|
202
212
|
for (const name of entries) {
|
|
203
213
|
try {
|
|
204
214
|
const content = fs.readFileSync(path.join(cfg.reqDir, name), 'utf8')
|
|
205
|
-
if (!content
|
|
215
|
+
if (!contentHasMarker(content, cfg.linkFields.roadmap)) {
|
|
206
216
|
violations.push(`req "${name}" has no linked Roadmap`)
|
|
207
217
|
}
|
|
208
218
|
} catch (_) {
|
|
@@ -250,11 +260,7 @@ function validateWIPHasAcceptanceCriteria() {
|
|
|
250
260
|
for (const name of entries) {
|
|
251
261
|
try {
|
|
252
262
|
const content = fs.readFileSync(path.join(wipDir, name), 'utf8')
|
|
253
|
-
const hasBlock =
|
|
254
|
-
content.includes('## Acceptance Criteria') ||
|
|
255
|
-
content.includes('## Critérios de Aceite') ||
|
|
256
|
-
content.includes('acceptance criteria') ||
|
|
257
|
-
content.includes('Acceptance Criteria:')
|
|
263
|
+
const hasBlock = contentHasMarker(content, cfg.acceptanceMarkers)
|
|
258
264
|
if (!hasBlock) {
|
|
259
265
|
violations.push(`roadmap "${name}" is in wip but has no acceptance criteria block`)
|
|
260
266
|
}
|
|
@@ -693,32 +699,99 @@ function validateFilenameUniqueness() {
|
|
|
693
699
|
return violations
|
|
694
700
|
}
|
|
695
701
|
|
|
696
|
-
//
|
|
697
|
-
|
|
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() {
|
|
698
747
|
const wipLimitResult = validateWIPLimit()
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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 e warnings 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
|
+
const baselineWarnSet = new Set(baseline.warnings || [])
|
|
786
|
+
warnings = warnings.filter(w => !baselineWarnSet.has(w))
|
|
787
|
+
}
|
|
788
|
+
|
|
717
789
|
// Modo lenient: mover violations para warnings, exit code 0
|
|
718
790
|
if (isLenient()) {
|
|
719
791
|
warnings = [...warnings, ...violations]
|
|
720
792
|
violations = []
|
|
721
793
|
}
|
|
794
|
+
|
|
722
795
|
return { violations, warnings }
|
|
723
796
|
}
|
|
724
797
|
|
|
@@ -800,6 +873,9 @@ async function getStatus() {
|
|
|
800
873
|
|
|
801
874
|
module.exports = {
|
|
802
875
|
validate,
|
|
876
|
+
validateUnfiltered,
|
|
877
|
+
loadBaseline,
|
|
878
|
+
saveBaseline,
|
|
803
879
|
getStatus,
|
|
804
880
|
isLenient,
|
|
805
881
|
lenientUntilDate,
|
|
@@ -830,4 +906,8 @@ module.exports = {
|
|
|
830
906
|
validateRefTargetsExist,
|
|
831
907
|
validateFolderStatusCoherence,
|
|
832
908
|
validateFilenameUniqueness,
|
|
909
|
+
// novas funções ML-2B
|
|
910
|
+
contentHasMarker,
|
|
911
|
+
ruleSeverity,
|
|
912
|
+
applyRule,
|
|
833
913
|
}
|