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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
4
4
  "description": "CLI de governança para entrega de software: ADR → REQ → ROADMAP → kanban. Suporte nativo a agentes de IA (Claude Code, Gemini CLI, Cursor).",
5
5
  "keywords": [
6
6
  "cli",
@@ -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
@@ -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', () => {})
@@ -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 (inAdrDirs) {
48
- if (line.startsWith('- ')) {
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
- inAdrDirs = false;
53
- if (adrDirs.length) cfg.adrDirs = adrDirs;
54
- }
55
- if (inAgents) {
56
- if (line.startsWith('- ')) {
57
- agents.push(line.slice(2).trim());
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
- inAgents = false;
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': inAdrDirs = true; adrDirs = []; break;
72
- case 'req_dir': cfg.reqDir = val; break;
73
- case 'roadmap_dir': cfg.roadmapDir = val; break;
74
- case 'roadmap_namespacing': cfg.roadmapNamespacing = val; break;
75
- case 'agents': inAgents = true; agents = []; break;
76
- case 'governance_mode': cfg.governanceMode = val; break;
77
- case 'lenient_until': cfg.lenientUntil = val; break;
78
- case 'wip_limit': { const n = parseInt(val, 10); if (n > 0) cfg.wipLimit = n; break; }
79
- case 'wip_by_squad': cfg.wipBySquad = val === 'true'; break;
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 pending lists at EOF
85
- if (inAdrDirs && adrDirs.length) cfg.adrDirs = adrDirs;
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 };
@@ -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 "REQ:" no conteúdo → violation
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.includes('REQ:') || content.includes('REQ: \n')) {
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 "ADR:" no conteúdo → violation
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.includes('ADR:') || content.includes('ADR: \n')) {
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 "REQ:" → violation
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.includes('REQ:') || content.includes('REQ: \n')) {
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 "Roadmap:" → violation
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.includes('Roadmap:') || content.includes('Roadmap: \n')) {
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
- // validate executa todas as validações e retorna { violations, warnings }
697
- async function validate() {
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
- let violations = [
700
- ...validateWIPHasREQ(),
701
- ...validateREQsHaveADR(),
702
- ...validateBlockedHasREQ(),
703
- ...validateREQsHaveRoadmap(),
704
- ...validateADRsAreReferenced(),
705
- ...validateWIPHasAcceptanceCriteria(),
706
- ...validateREQsNotBlockedByDraftADRs(),
707
- ...validateFrontmatterPresence(),
708
- ...validateFilenameUniqueness(),
709
- ...wipLimitResult.violations,
710
- ]
711
- let warnings = [
712
- ...wipLimitResult.warnings,
713
- ...validateStaleWIP(),
714
- ...validateRefTargetsExist(),
715
- ...validateFolderStatusCoherence(),
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
  }