trackfw 1.1.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/adr.js +1 -1
- package/src/commands/context.js +189 -0
- package/src/commands/discover.js +359 -0
- package/src/commands/index.js +4 -0
- package/src/commands/init.js +11 -2
- package/src/commands/metrics.js +235 -0
- package/src/commands/plugins.js +73 -0
- package/src/commands/req.js +1 -1
- package/src/commands/roadmap.js +6 -1
- package/src/commands/sync.js +362 -0
- package/src/commands/validate.js +9 -1
- package/src/config/index.js +92 -0
- package/src/generators/adr.js +20 -7
- package/src/generators/init.js +51 -1
- package/src/generators/req.js +12 -3
- package/src/generators/roadmap.js +292 -58
- package/src/i18n/locales/en-US.json +9 -1
- package/src/i18n/locales/es-ES.json +9 -1
- package/src/i18n/locales/pt-BR.json +9 -1
- package/src/validator/index.js +369 -97
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { Command } = require('commander')
|
|
4
|
+
const https = require('https')
|
|
5
|
+
const http = require('http')
|
|
6
|
+
const fs = require('fs')
|
|
7
|
+
const path = require('path')
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Config helpers
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Lê um campo de trackfw.yaml (parse linha a linha, sem dependências externas).
|
|
15
|
+
* @param {string} field
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function readConfigField(field) {
|
|
19
|
+
try {
|
|
20
|
+
const data = fs.readFileSync('trackfw.yaml', 'utf8')
|
|
21
|
+
const prefix = field + ':'
|
|
22
|
+
for (const line of data.split('\n')) {
|
|
23
|
+
const trimmed = line.trimStart()
|
|
24
|
+
if (trimmed.startsWith(prefix)) {
|
|
25
|
+
let value = trimmed.slice(prefix.length).trim()
|
|
26
|
+
if (value.length >= 2 &&
|
|
27
|
+
((value[0] === '"' && value[value.length - 1] === '"') ||
|
|
28
|
+
(value[0] === "'" && value[value.length - 1] === "'"))) {
|
|
29
|
+
value = value.slice(1, -1)
|
|
30
|
+
}
|
|
31
|
+
return value
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} catch (_) { /* sem arquivo */ }
|
|
35
|
+
return ''
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getConfig(field, envVar) {
|
|
39
|
+
return readConfigField(field) || process.env[envVar] || ''
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// HTTP helper
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Faz uma requisição HTTP/HTTPS simples com corpo JSON.
|
|
48
|
+
* @returns {Promise<{status: number, body: string}>}
|
|
49
|
+
*/
|
|
50
|
+
function request(url, options, bodyStr) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const parsed = new URL(url)
|
|
53
|
+
const lib = parsed.protocol === 'https:' ? https : http
|
|
54
|
+
const reqOptions = {
|
|
55
|
+
hostname: parsed.hostname,
|
|
56
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
57
|
+
path: parsed.pathname + parsed.search,
|
|
58
|
+
method: options.method || 'GET',
|
|
59
|
+
headers: options.headers || {}
|
|
60
|
+
}
|
|
61
|
+
const req = lib.request(reqOptions, (res) => {
|
|
62
|
+
let data = ''
|
|
63
|
+
res.on('data', (chunk) => { data += chunk })
|
|
64
|
+
res.on('end', () => resolve({ status: res.statusCode, body: data }))
|
|
65
|
+
})
|
|
66
|
+
req.on('error', reject)
|
|
67
|
+
if (bodyStr) req.write(bodyStr)
|
|
68
|
+
req.end()
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Linear client
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Cria issue no Linear via GraphQL.
|
|
78
|
+
* @param {string} apiKey
|
|
79
|
+
* @param {string} teamId
|
|
80
|
+
* @param {string} title
|
|
81
|
+
* @param {string} description
|
|
82
|
+
* @returns {Promise<string>} issue identifier (ex: "ENG-123")
|
|
83
|
+
*/
|
|
84
|
+
async function linearCreateIssue(apiKey, teamId, title, description) {
|
|
85
|
+
const query = `mutation IssueCreate($title: String!, $description: String!, $teamId: String!) {
|
|
86
|
+
issueCreate(input: {title: $title, description: $description, teamId: $teamId}) {
|
|
87
|
+
success
|
|
88
|
+
issue { id identifier }
|
|
89
|
+
}
|
|
90
|
+
}`
|
|
91
|
+
const payload = JSON.stringify({
|
|
92
|
+
query,
|
|
93
|
+
variables: { title, description, teamId }
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const res = await request('https://api.linear.app/graphql', {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: {
|
|
99
|
+
'Content-Type': 'application/json',
|
|
100
|
+
'Authorization': apiKey,
|
|
101
|
+
'Content-Length': Buffer.byteLength(payload)
|
|
102
|
+
}
|
|
103
|
+
}, payload)
|
|
104
|
+
|
|
105
|
+
if (res.status !== 200) {
|
|
106
|
+
throw new Error(`Linear: unexpected status ${res.status}: ${res.body}`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const data = JSON.parse(res.body)
|
|
110
|
+
if (data.errors && data.errors.length > 0) {
|
|
111
|
+
throw new Error(`Linear API error: ${data.errors[0].message}`)
|
|
112
|
+
}
|
|
113
|
+
if (!data.data.issueCreate.success) {
|
|
114
|
+
throw new Error('Linear: issueCreate returned success=false')
|
|
115
|
+
}
|
|
116
|
+
return data.data.issueCreate.issue.identifier
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Jira client
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Cria issue no Jira via REST API v3.
|
|
125
|
+
* @returns {Promise<string>} issue key (ex: "ENG-456")
|
|
126
|
+
*/
|
|
127
|
+
async function jiraCreateIssue(baseUrl, email, token, project, title, description) {
|
|
128
|
+
const payload = JSON.stringify({
|
|
129
|
+
fields: {
|
|
130
|
+
project: { key: project },
|
|
131
|
+
summary: title,
|
|
132
|
+
description: {
|
|
133
|
+
type: 'doc',
|
|
134
|
+
version: 1,
|
|
135
|
+
content: [{
|
|
136
|
+
type: 'paragraph',
|
|
137
|
+
content: [{ type: 'text', text: description }]
|
|
138
|
+
}]
|
|
139
|
+
},
|
|
140
|
+
issuetype: { name: 'Story' }
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const creds = Buffer.from(`${email}:${token}`).toString('base64')
|
|
145
|
+
const url = baseUrl.replace(/\/$/, '') + '/rest/api/3/issue'
|
|
146
|
+
|
|
147
|
+
const res = await request(url, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: {
|
|
150
|
+
'Content-Type': 'application/json',
|
|
151
|
+
'Accept': 'application/json',
|
|
152
|
+
'Authorization': `Basic ${creds}`,
|
|
153
|
+
'Content-Length': Buffer.byteLength(payload)
|
|
154
|
+
}
|
|
155
|
+
}, payload)
|
|
156
|
+
|
|
157
|
+
if (res.status !== 201) {
|
|
158
|
+
throw new Error(`Jira: unexpected status ${res.status}: ${res.body}`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const data = JSON.parse(res.body)
|
|
162
|
+
if (!data.key) {
|
|
163
|
+
throw new Error('Jira: response missing issue key')
|
|
164
|
+
}
|
|
165
|
+
return data.key
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// REQ file helpers
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
function isStatusOpen(text) {
|
|
173
|
+
for (const line of text.split('\n')) {
|
|
174
|
+
if (line.includes('| Status:')) {
|
|
175
|
+
return line.includes('Status: Open')
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function extractField(text, field) {
|
|
182
|
+
const prefix = '| ' + field + ':'
|
|
183
|
+
for (const line of text.split('\n')) {
|
|
184
|
+
const trimmed = line.trim()
|
|
185
|
+
if (trimmed.startsWith(prefix)) {
|
|
186
|
+
return trimmed.slice(prefix.length).trim()
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return ''
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function extractTitle(text) {
|
|
193
|
+
for (const line of text.split('\n')) {
|
|
194
|
+
if (line.startsWith('# REQ: ')) {
|
|
195
|
+
return line.slice('# REQ: '.length)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return ''
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function extractMotivation(text) {
|
|
202
|
+
const lines = text.split('\n')
|
|
203
|
+
let inSection = false
|
|
204
|
+
const parts = []
|
|
205
|
+
for (const line of lines) {
|
|
206
|
+
if (line.startsWith('## Motivation') || line.startsWith('## Motivação')) {
|
|
207
|
+
inSection = true
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
if (inSection) {
|
|
211
|
+
if (line.startsWith('## ')) break
|
|
212
|
+
parts.push(line)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return parts.join('\n').trim()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function injectField(text, field, value) {
|
|
219
|
+
const prefix = '| ' + field + ':'
|
|
220
|
+
const lines = text.split('\n')
|
|
221
|
+
|
|
222
|
+
// se campo já existe, substituir
|
|
223
|
+
for (let i = 0; i < lines.length; i++) {
|
|
224
|
+
if (lines[i].trim().startsWith(prefix)) {
|
|
225
|
+
lines[i] = `| ${field}: ${value}`
|
|
226
|
+
return lines.join('\n')
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// inserir após a linha com | Status:
|
|
231
|
+
for (let i = 0; i < lines.length; i++) {
|
|
232
|
+
if (lines[i].includes('| Status:')) {
|
|
233
|
+
lines.splice(i + 1, 0, `| ${field}: ${value}`)
|
|
234
|
+
return lines.join('\n')
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return text
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Core sync logic
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {Function} createFn (title, desc) => Promise<issueId>
|
|
247
|
+
* @param {string} issueField
|
|
248
|
+
* @returns {Promise<Array<{reqPath, issueId, skipped, error}>>}
|
|
249
|
+
*/
|
|
250
|
+
async function syncToProvider(createFn, issueField) {
|
|
251
|
+
const reqDir = 'docs/req'
|
|
252
|
+
let files = []
|
|
253
|
+
try {
|
|
254
|
+
files = fs.readdirSync(reqDir)
|
|
255
|
+
.filter(f => f.endsWith('.md'))
|
|
256
|
+
.map(f => path.join(reqDir, f))
|
|
257
|
+
} catch (_) {
|
|
258
|
+
return []
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const results = []
|
|
262
|
+
for (const f of files) {
|
|
263
|
+
let text
|
|
264
|
+
try {
|
|
265
|
+
text = fs.readFileSync(f, 'utf8')
|
|
266
|
+
} catch (e) {
|
|
267
|
+
results.push({ reqPath: f, skipped: false, error: e })
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// pular se não é Open
|
|
272
|
+
if (!isStatusOpen(text)) {
|
|
273
|
+
results.push({ reqPath: f, skipped: true })
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// pular se já tem issue vinculado
|
|
278
|
+
if (extractField(text, issueField) !== '') {
|
|
279
|
+
results.push({ reqPath: f, skipped: true })
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const title = extractTitle(text)
|
|
284
|
+
const desc = extractMotivation(text)
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const issueId = await createFn(title, desc)
|
|
288
|
+
const updated = injectField(text, issueField, issueId)
|
|
289
|
+
fs.writeFileSync(f, updated, 'utf8')
|
|
290
|
+
results.push({ reqPath: f, issueId, skipped: false })
|
|
291
|
+
} catch (e) {
|
|
292
|
+
results.push({ reqPath: f, skipped: false, error: e })
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return results
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function syncToLinear() {
|
|
299
|
+
const apiKey = getConfig('linear_api_key', 'LINEAR_API_KEY')
|
|
300
|
+
const teamId = getConfig('linear_team_id', 'LINEAR_TEAM_ID')
|
|
301
|
+
if (!apiKey) throw new Error('Linear API key not found. Set LINEAR_API_KEY env var or linear_api_key in trackfw.yaml')
|
|
302
|
+
if (!teamId) throw new Error('Linear Team ID not found. Set LINEAR_TEAM_ID env var or linear_team_id in trackfw.yaml')
|
|
303
|
+
return syncToProvider((title, desc) => linearCreateIssue(apiKey, teamId, title, desc), 'linear_issue')
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function syncToJira() {
|
|
307
|
+
const baseUrl = getConfig('jira_base_url', 'JIRA_BASE_URL')
|
|
308
|
+
const email = getConfig('jira_email', 'JIRA_EMAIL')
|
|
309
|
+
const token = getConfig('jira_token', 'JIRA_TOKEN')
|
|
310
|
+
const project = getConfig('jira_project', 'JIRA_PROJECT')
|
|
311
|
+
if (!baseUrl) throw new Error('Jira base URL not found. Set JIRA_BASE_URL env var or jira_base_url in trackfw.yaml')
|
|
312
|
+
if (!email) throw new Error('Jira email not found. Set JIRA_EMAIL env var or jira_email in trackfw.yaml')
|
|
313
|
+
if (!token) throw new Error('Jira API token not found. Set JIRA_TOKEN env var or jira_token in trackfw.yaml')
|
|
314
|
+
if (!project) throw new Error('Jira project key not found. Set JIRA_PROJECT env var or jira_project in trackfw.yaml')
|
|
315
|
+
return syncToProvider((title, desc) => jiraCreateIssue(baseUrl, email, token, project, title, desc), 'jira_issue')
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Commander command
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
const syncCmd = new Command('sync')
|
|
323
|
+
.description('Sync Open REQs to a project management tool')
|
|
324
|
+
.requiredOption('--to <target>', 'Target PM tool: linear or jira')
|
|
325
|
+
.action(async (options) => {
|
|
326
|
+
let results
|
|
327
|
+
try {
|
|
328
|
+
switch (options.to) {
|
|
329
|
+
case 'linear':
|
|
330
|
+
results = await syncToLinear()
|
|
331
|
+
break
|
|
332
|
+
case 'jira':
|
|
333
|
+
results = await syncToJira()
|
|
334
|
+
break
|
|
335
|
+
default:
|
|
336
|
+
console.error(`Unknown target "${options.to}" — use --to=linear or --to=jira`)
|
|
337
|
+
process.exit(1)
|
|
338
|
+
}
|
|
339
|
+
} catch (e) {
|
|
340
|
+
console.error(e.message)
|
|
341
|
+
process.exit(1)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!results || results.length === 0) {
|
|
345
|
+
console.log('No REQs found in docs/req/')
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
console.log(`${'REQ'.padEnd(55)} ISSUE`)
|
|
350
|
+
console.log(`${'-'.repeat(54)} ${'-'.repeat(10)}`)
|
|
351
|
+
for (const r of results) {
|
|
352
|
+
if (r.skipped) {
|
|
353
|
+
console.log(`${r.reqPath.padEnd(55)} (skipped)`)
|
|
354
|
+
} else if (r.error) {
|
|
355
|
+
console.log(`${r.reqPath.padEnd(55)} ERROR: ${r.error.message}`)
|
|
356
|
+
} else {
|
|
357
|
+
console.log(`${r.reqPath.padEnd(55)} ${r.issueId}`)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
module.exports = syncCmd
|
package/src/commands/validate.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
const { Command } = require('commander')
|
|
3
|
-
const { validate } = require('../validator')
|
|
3
|
+
const { validate, isLenient, lenientUntilDate } = require('../validator')
|
|
4
4
|
const { t } = require('../i18n')
|
|
5
5
|
|
|
6
6
|
const cmd = new Command('validate')
|
|
@@ -8,6 +8,14 @@ cmd.description(t('validate.description'))
|
|
|
8
8
|
cmd.action(async () => {
|
|
9
9
|
const { violations, warnings } = await validate()
|
|
10
10
|
|
|
11
|
+
// Informar usuário sobre modo lenient
|
|
12
|
+
if (isLenient()) {
|
|
13
|
+
const until = lenientUntilDate()
|
|
14
|
+
if (until) {
|
|
15
|
+
console.log(`[LENIENT MODE] ${t('validate.lenient_mode', { date: until })}`)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
if (violations.length === 0 && warnings.length === 0) {
|
|
12
20
|
console.log(t('validate.ok'))
|
|
13
21
|
return
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
function defaults() {
|
|
7
|
+
return {
|
|
8
|
+
adrDirs: ['docs/adr'],
|
|
9
|
+
reqDir: 'docs/req',
|
|
10
|
+
roadmapDir: 'docs/roadmaps',
|
|
11
|
+
roadmapNamespacing: 'flat',
|
|
12
|
+
agents: [],
|
|
13
|
+
governanceMode: '',
|
|
14
|
+
lenientUntil: '',
|
|
15
|
+
wipLimit: 1,
|
|
16
|
+
wipBySquad: false,
|
|
17
|
+
requireReqInCommit: false,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let _instance = null;
|
|
22
|
+
|
|
23
|
+
function load(cwd) {
|
|
24
|
+
if (_instance) return _instance;
|
|
25
|
+
_instance = defaults();
|
|
26
|
+
const yamlPath = path.join(cwd || process.cwd(), 'trackfw.yaml');
|
|
27
|
+
if (!fs.existsSync(yamlPath)) return _instance;
|
|
28
|
+
const content = fs.readFileSync(yamlPath, 'utf8');
|
|
29
|
+
parse(content, _instance);
|
|
30
|
+
return _instance;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function reset() {
|
|
34
|
+
_instance = null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parse(content, cfg) {
|
|
38
|
+
const lines = content.split('\n');
|
|
39
|
+
let inAdrDirs = false;
|
|
40
|
+
let inAgents = false;
|
|
41
|
+
let adrDirs = [];
|
|
42
|
+
let agents = [];
|
|
43
|
+
|
|
44
|
+
for (const rawLine of lines) {
|
|
45
|
+
const line = rawLine.trim();
|
|
46
|
+
|
|
47
|
+
if (inAdrDirs) {
|
|
48
|
+
if (line.startsWith('- ')) {
|
|
49
|
+
adrDirs.push(line.slice(2).trim());
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
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());
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
inAgents = false;
|
|
61
|
+
if (agents.length) cfg.agents = agents;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const colonIdx = line.indexOf(':');
|
|
65
|
+
if (colonIdx < 0) continue;
|
|
66
|
+
const key = line.slice(0, colonIdx).trim();
|
|
67
|
+
const val = line.slice(colonIdx + 1).trim();
|
|
68
|
+
if (!key) continue;
|
|
69
|
+
|
|
70
|
+
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;
|
|
80
|
+
case 'require_req_in_commit': cfg.requireReqInCommit = val === 'true'; break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// flush pending lists at EOF
|
|
85
|
+
if (inAdrDirs && adrDirs.length) cfg.adrDirs = adrDirs;
|
|
86
|
+
if (inAgents && agents.length) cfg.agents = agents;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const NAMESPACING_FLAT = 'flat'
|
|
90
|
+
const NAMESPACING_BY_AGENT = 'by_agent'
|
|
91
|
+
|
|
92
|
+
module.exports = { load, reset, defaults, NAMESPACING_FLAT, NAMESPACING_BY_AGENT };
|
package/src/generators/adr.js
CHANGED
|
@@ -40,18 +40,25 @@ function today() {
|
|
|
40
40
|
* @returns {Promise<void>}
|
|
41
41
|
*/
|
|
42
42
|
async function newADR(content) {
|
|
43
|
-
|
|
43
|
+
const adrDir = require('../config').load().adrDirs[0]
|
|
44
|
+
fs.mkdirSync(adrDir, { recursive: true })
|
|
44
45
|
|
|
45
46
|
const slug = toSlug(content.title)
|
|
46
47
|
const date = today()
|
|
47
|
-
const filename =
|
|
48
|
+
const filename = `${adrDir}/ADR-${date}-${slug}.md`
|
|
48
49
|
|
|
49
50
|
const contextSection = content.context || '<!-- What is the situation that motivates this decision? -->'
|
|
50
51
|
const decisionSection = content.decision || '<!-- What was decided? -->'
|
|
51
52
|
const consequencesSection = content.consequences || '<!-- What are the positive and negative consequences of this decision? -->'
|
|
52
53
|
const alternativesSection = content.alternatives || '<!-- What other options were evaluated and why were they rejected? -->'
|
|
53
54
|
|
|
54
|
-
const body =
|
|
55
|
+
const body = `---
|
|
56
|
+
status: Proposed
|
|
57
|
+
date: ${date}
|
|
58
|
+
author: ""
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
# ADR: ${content.title}
|
|
55
62
|
|
|
56
63
|
> Date: ${date} | Status: Proposed
|
|
57
64
|
|
|
@@ -129,10 +136,10 @@ function parseADRStatus(filepath) {
|
|
|
129
136
|
* @returns {Promise<string>} basename do arquivo criado
|
|
130
137
|
*/
|
|
131
138
|
async function newADRDraft(slug) {
|
|
132
|
-
|
|
139
|
+
const adrDir = require('../config').load().adrDirs[0]
|
|
140
|
+
fs.mkdirSync(adrDir, { recursive: true })
|
|
133
141
|
|
|
134
142
|
// Verificar idempotência: buscar arquivo existente com o mesmo slug
|
|
135
|
-
const adrDir = 'docs/adr'
|
|
136
143
|
const existing = fs.existsSync(adrDir)
|
|
137
144
|
? fs.readdirSync(adrDir).find((f) => f.match(new RegExp(`^ADR-.*-${slug}\\.md$`)))
|
|
138
145
|
: null
|
|
@@ -144,10 +151,16 @@ async function newADRDraft(slug) {
|
|
|
144
151
|
|
|
145
152
|
const date = today()
|
|
146
153
|
const filename = `ADR-${date}-${slug}.md`
|
|
147
|
-
const filepath = path.join(
|
|
154
|
+
const filepath = path.join(adrDir, filename)
|
|
148
155
|
const title = slugToTitle(slug)
|
|
149
156
|
|
|
150
|
-
const body =
|
|
157
|
+
const body = `---
|
|
158
|
+
status: Draft
|
|
159
|
+
date: ${date}
|
|
160
|
+
author: ""
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
# ADR: ${title}
|
|
151
164
|
|
|
152
165
|
> Date: ${date} | Status: Draft
|
|
153
166
|
|
package/src/generators/init.js
CHANGED
|
@@ -28,6 +28,7 @@ async function scaffold(cfg) {
|
|
|
28
28
|
generateValidateScript(cfg)
|
|
29
29
|
generateCIWorkflow(cfg)
|
|
30
30
|
generateGitHooks(cfg)
|
|
31
|
+
generateCommitMsgHook(cfg)
|
|
31
32
|
generateClaudeMD(cfg)
|
|
32
33
|
if (cfg.backend === 'java') generatePomXml(cfg)
|
|
33
34
|
generateClaudeCommands()
|
|
@@ -39,7 +40,7 @@ async function scaffold(cfg) {
|
|
|
39
40
|
|
|
40
41
|
function writeTrackfwConfig(cfg) {
|
|
41
42
|
const today = new Date().toISOString().slice(0, 10)
|
|
42
|
-
|
|
43
|
+
let content = `# trackfw configuration
|
|
43
44
|
# generated: ${today}
|
|
44
45
|
|
|
45
46
|
frontend: ${cfg.frontend || ''}
|
|
@@ -48,7 +49,19 @@ backend_framework: ${cfg.backendFramework || ''}
|
|
|
48
49
|
pkg_manager: ${cfg.pkgManager || ''}
|
|
49
50
|
hooks: ${cfg.hooks || ''}
|
|
50
51
|
ci: ${cfg.ci || ''}
|
|
52
|
+
require_req_in_commit: ${cfg.requireReqInCommit ? 'true' : 'false'}
|
|
53
|
+
|
|
54
|
+
# governance paths (edit to match your project structure)
|
|
55
|
+
adr_dirs:
|
|
56
|
+
- docs/adr
|
|
57
|
+
req_dir: docs/req
|
|
58
|
+
roadmap_dir: docs/roadmaps
|
|
59
|
+
roadmap_namespacing: flat
|
|
51
60
|
`
|
|
61
|
+
if (cfg.brownfieldMode) {
|
|
62
|
+
const until = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
|
|
63
|
+
content += `governance_mode: lenient\nlenient_until: ${until}\n`
|
|
64
|
+
}
|
|
52
65
|
fs.writeFileSync('trackfw.yaml', content, 'utf8')
|
|
53
66
|
console.log(' ✓ trackfw.yaml')
|
|
54
67
|
}
|
|
@@ -195,6 +208,42 @@ function generateLefthookHook() {
|
|
|
195
208
|
console.log(' ✓ lefthook.yml')
|
|
196
209
|
}
|
|
197
210
|
|
|
211
|
+
function generateCommitMsgHook(cfg) {
|
|
212
|
+
if (!cfg.requireReqInCommit || cfg.hooks === 'none') return
|
|
213
|
+
|
|
214
|
+
const script = [
|
|
215
|
+
'#!/bin/sh',
|
|
216
|
+
'# trackfw: require REQ reference in feat/* and fix/* branches',
|
|
217
|
+
'BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")',
|
|
218
|
+
'case "$BRANCH" in',
|
|
219
|
+
' feat/*|fix/*)',
|
|
220
|
+
' if ! grep -qE "^(REQ|req): " "$1"; then',
|
|
221
|
+
' echo "ERROR: Commits in feat/* and fix/* branches require a REQ reference."',
|
|
222
|
+
' echo " Add to commit body: REQ: REQ-YYYY-MM-DD-your-req-slug"',
|
|
223
|
+
' exit 1',
|
|
224
|
+
' fi',
|
|
225
|
+
' ;;',
|
|
226
|
+
'esac',
|
|
227
|
+
'',
|
|
228
|
+
].join('\n')
|
|
229
|
+
|
|
230
|
+
if (cfg.hooks === 'husky') {
|
|
231
|
+
fs.mkdirSync('.husky', { recursive: true })
|
|
232
|
+
fs.writeFileSync('.husky/commit-msg', script, { encoding: 'utf8', mode: 0o755 })
|
|
233
|
+
console.log(' ✓ .husky/commit-msg')
|
|
234
|
+
} else if (cfg.hooks === 'lefthook') {
|
|
235
|
+
const lefthookPath = 'lefthook.yml'
|
|
236
|
+
const existing = fs.existsSync(lefthookPath) ? fs.readFileSync(lefthookPath, 'utf8') : ''
|
|
237
|
+
if (!existing.includes('commit-msg:')) {
|
|
238
|
+
const addition = '\ncommit-msg:\n scripts:\n "trackfw-req-check.sh":\n runner: sh\n'
|
|
239
|
+
fs.writeFileSync(lefthookPath, existing + addition, 'utf8')
|
|
240
|
+
}
|
|
241
|
+
fs.mkdirSync('.lefthook/commit-msg', { recursive: true })
|
|
242
|
+
fs.writeFileSync('.lefthook/commit-msg/trackfw-req-check.sh', script, { encoding: 'utf8', mode: 0o755 })
|
|
243
|
+
console.log(' ✓ .lefthook/commit-msg/trackfw-req-check.sh')
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
198
247
|
// ---------------------------------------------------------------------------
|
|
199
248
|
// pom.xml (Java / Spring Boot)
|
|
200
249
|
// ---------------------------------------------------------------------------
|
|
@@ -691,6 +740,7 @@ module.exports = {
|
|
|
691
740
|
generateValidateScript,
|
|
692
741
|
generateCIWorkflow,
|
|
693
742
|
generateGitHooks,
|
|
743
|
+
generateCommitMsgHook,
|
|
694
744
|
generateClaudeMD,
|
|
695
745
|
generateClaudeCommands,
|
|
696
746
|
installAgents,
|
package/src/generators/req.js
CHANGED
|
@@ -69,11 +69,12 @@ function toSlug(s) {
|
|
|
69
69
|
* @returns {Promise<void>}
|
|
70
70
|
*/
|
|
71
71
|
async function newREQ(content) {
|
|
72
|
-
|
|
72
|
+
const reqDir = require('../config').load().reqDir
|
|
73
|
+
fs.mkdirSync(reqDir, { recursive: true })
|
|
73
74
|
|
|
74
75
|
const slug = toSlug(content.title)
|
|
75
76
|
const date = new Date().toISOString().slice(0, 10)
|
|
76
|
-
const filename =
|
|
77
|
+
const filename = `${reqDir}/REQ-${date}-${slug}.md`
|
|
77
78
|
|
|
78
79
|
const motivationSection = content.motivation || '<!-- Why is this requirement needed? What problem does it solve? -->'
|
|
79
80
|
const criteriaSection = content.criteria || '- [ ]\n- [ ]'
|
|
@@ -100,7 +101,15 @@ async function newREQ(content) {
|
|
|
100
101
|
blockedSection = lines.join('\n')
|
|
101
102
|
}
|
|
102
103
|
|
|
103
|
-
const body =
|
|
104
|
+
const body = `---
|
|
105
|
+
status: Open
|
|
106
|
+
date: ${date}
|
|
107
|
+
author: ""
|
|
108
|
+
adr: ""
|
|
109
|
+
roadmap: ""
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
# REQ: ${content.title}
|
|
104
113
|
|
|
105
114
|
${statusLine}
|
|
106
115
|
|