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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "2.1.1",
3
+ "version": "2.4.0",
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();
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 };
@@ -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
- // adrIsDraft verifica se <adrBasename> contém "Status: Draft" em alguma das adrDirs configuradas.
72
- function adrIsDraft(basename) {
73
- const cfg = config.load()
74
- for (const adrDir of cfg.adrDirs) {
75
- const p = path.join(adrDir, basename)
76
- if (fs.existsSync(p)) {
77
- try {
78
- return fs.readFileSync(p, 'utf8').includes('Status: Draft')
79
- } catch (_) {
80
- // ignorar erro de leitura
81
- }
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
- // validateWIPHasREQ roadmaps em wip/ sem "REQ:" no conteúdo violation
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.includes('REQ:') || content.includes('REQ: \n')) {
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 "ADR:" no conteúdo → violation
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.includes('ADR:') || content.includes('ADR: \n')) {
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 "REQ:" → violation
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.includes('REQ:') || content.includes('REQ: \n')) {
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 "Roadmap:" → violation
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.includes('Roadmap:') || content.includes('Roadmap: \n')) {
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(listDir(adrDir))
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 ageMs = now - stat.mtimeMs
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 lastModified = stat.mtime.toISOString().slice(0, 10)
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 files = listDir(adrDir).filter(f => f.endsWith('.md'))
462
- for (const f of files) {
521
+ for (const f of walkDirMd(adrDir)) {
522
+ const fullPath = findAdrFile(f)
523
+ if (!fullPath) continue
463
524
  try {
464
- const content = fs.readFileSync(path.join(adrDir, f), 'utf8')
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
- // validate executa todas as validações e retorna { violations, warnings }
487
- async function validate() {
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
- let violations = [
490
- ...validateWIPHasREQ(),
491
- ...validateREQsHaveADR(),
492
- ...validateBlockedHasREQ(),
493
- ...validateREQsHaveRoadmap(),
494
- ...validateADRsAreReferenced(),
495
- ...validateWIPHasAcceptanceCriteria(),
496
- ...validateREQsNotBlockedByDraftADRs(),
497
- ...validateFrontmatterPresence(),
498
- ...wipLimitResult.violations,
499
- ]
500
- let warnings = [
501
- ...wipLimitResult.warnings,
502
- ...validateStaleWIP(),
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
  }