trackfw 6.9.1 → 7.0.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.
@@ -0,0 +1,352 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * commit/runner.js — Core implementation of `trackfw commit`.
5
+ *
6
+ * Mirrors internal/commands/commit.go byte-for-byte in behavior and message text — Go is the
7
+ * behavioral reference (docs/cli-parity.md: "Go is the behavioral reference"). Reuses the same
8
+ * governance matching primitives already shared by `trackfw branch new` (../branch/runner.js) and
9
+ * `trackfw validate`/`trackfw ship` (../validator): validator.branchSlugMatchesRoadmap,
10
+ * validator.branchGovernanceOrientation, validator.branchNoMatchingRoadmapMessage,
11
+ * validator.normalizeBranchSlug — never duplicate this logic.
12
+ *
13
+ * Mirrors Go's stdout/stderr split exactly (internal/commands/commit.go + root.go Execute()):
14
+ * the primary governance message (block reason, or the housekeeping-branch warning) goes to
15
+ * stdout via `writeln`; the short "blocked: ..." error / usage error that Execute() prints from
16
+ * the returned error goes to stderr via `writeErr`.
17
+ *
18
+ * All git operations are injectable for testability — never runs a real `git commit` in unit
19
+ * tests.
20
+ */
21
+
22
+ const { spawnSync } = require('child_process')
23
+ const config = require('../config')
24
+ const validator = require('../validator')
25
+
26
+ // commitProtectedBranches lists branches where a direct `trackfw commit` is never allowed,
27
+ // regardless of governance state — mirrors the same hard rule `trackfw ship` already enforces.
28
+ // Mirrors Go's commitProtectedBranches: literal "main"/"master" only, no dynamic resolution of
29
+ // the repo's configured default branch.
30
+ const COMMIT_PROTECTED_BRANCHES = new Set(['main', 'master'])
31
+
32
+ // commitGovernedPrefixes lists the branch-type prefixes that require a matching roadmap in
33
+ // wip/ or done/ before a commit is allowed — the same vocabulary `trackfw branch new` and the
34
+ // branch_has_wip_roadmap governance rule already enforce. Mirrors Go's commitGovernedPrefixes.
35
+ const COMMIT_GOVERNED_PREFIXES = ['feat/', 'fix/', 'refactor/']
36
+
37
+ /**
38
+ * defaultExecGit runs `git <args...>` and returns { stdout, error }. Mirrors
39
+ * ship/runner.js's defaultExecGit (same pattern, kept local here to avoid a cross-module runtime
40
+ * dependency for a single helper).
41
+ * @param {string[]} args
42
+ * @returns {{ stdout: string, error: Error|null }}
43
+ */
44
+ function defaultExecGit(args) {
45
+ const result = spawnSync('git', args, { encoding: 'utf8' })
46
+ if (result.status !== 0) {
47
+ const msg = (result.stderr || '').trim() || `git ${args.join(' ')} exited with ${result.status}`
48
+ return { stdout: '', error: new Error(msg) }
49
+ }
50
+ return { stdout: (result.stdout || '').trim(), error: null }
51
+ }
52
+
53
+ /**
54
+ * defaultStagedNameStatus runs `git diff --cached --name-status` and returns its raw output.
55
+ * Mirrors Go's defaultStagedNameStatus (internal/commands/commit.go), which reuses the same git
56
+ * subcommand. Used only by buildSuggestedMessage — never touched by the normal `-m` commit flow.
57
+ * @param {function(string[]): {stdout: string, error: Error|null}} execGit
58
+ * @returns {string}
59
+ * @throws {Error} when git fails (e.g. not a git repo)
60
+ */
61
+ function defaultStagedNameStatus(execGit) {
62
+ const result = execGit(['diff', '--cached', '--name-status'])
63
+ if (result.error) throw result.error
64
+ return result.stdout
65
+ }
66
+
67
+ /**
68
+ * defaultCurrentBranch reads the current branch via `git rev-parse --abbrev-ref HEAD` — mirrors
69
+ * Go's defaultCurrentBranch (internal/commands/commit.go), which reuses the same git subcommand.
70
+ * @param {function(string[]): {stdout: string, error: Error|null}} execGit
71
+ * @returns {string}
72
+ * @throws {Error} when git fails (e.g. not a git repo)
73
+ */
74
+ function defaultCurrentBranch(execGit) {
75
+ const result = execGit(['rev-parse', '--abbrev-ref', 'HEAD'])
76
+ if (result.error) throw result.error
77
+ return result.stdout.trim()
78
+ }
79
+
80
+ /**
81
+ * defaultGitCommit runs `git commit -m <message>` with inherited stdio, so Git's own output
82
+ * reaches the user unmodified. Returns Git's own numeric exit code (0 on success), never a
83
+ * hardcoded 1 — same contract as branch/runner.js's defaultGitCheckout.
84
+ * @param {string} message
85
+ * @returns {number} exit code
86
+ */
87
+ function defaultGitCommit(message) {
88
+ const result = spawnSync('git', ['commit', '-m', message], { stdio: 'inherit' })
89
+ if (result.error) return 1
90
+ if (result.status === null) return 1
91
+ return result.status
92
+ }
93
+
94
+ /**
95
+ * commitGovernedBranchPrefix returns the matched prefix (e.g. "feat/") when branch starts with
96
+ * one of COMMIT_GOVERNED_PREFIXES, or null otherwise. Mirrors Go's commitGovernedBranchPrefix.
97
+ * @param {string} branch
98
+ * @returns {string|null}
99
+ */
100
+ function commitGovernedBranchPrefix(branch) {
101
+ for (const prefix of COMMIT_GOVERNED_PREFIXES) {
102
+ if (branch.startsWith(prefix)) return prefix
103
+ }
104
+ return null
105
+ }
106
+
107
+ /**
108
+ * isCommitGatedBranch returns true when branch matches feat|fix|refactor/<slug> — the branches
109
+ * this command requires a matching wip/done roadmap for.
110
+ * @param {string} branch
111
+ * @returns {boolean}
112
+ */
113
+ function isCommitGatedBranch(branch) {
114
+ return commitGovernedBranchPrefix(branch) !== null
115
+ }
116
+
117
+ /**
118
+ * runCommit implements the `trackfw commit -m "<message>"` flow described in ML-2A/ML-2B of
119
+ * docs/roadmaps/wip/ROADMAP-2026-08-14-bloqueio-tecnico-de-comandos-git-brutos-por-subagente-via-deny-hooks-nos-7-runtimes-suportados.md.
120
+ *
121
+ * 0. Requires a non-empty message (mirrors Go's cobra RunE pre-check, before touching git).
122
+ * 1. Reads the current branch. On "main"/"master": always blocked.
123
+ * 2. On a feat/fix/refactor branch: requires a matching roadmap in wip/ or done/ — the same
124
+ * matching logic `trackfw branch new` already uses. No match: blocks with the same
125
+ * governance orientation message `trackfw validate` already prints for
126
+ * branch_has_wip_roadmap.
127
+ * 3. Any other branch shape: allowed, only logs a warning — orchestration/housekeeping
128
+ * branches are not gated by this rule.
129
+ * 4. If it passed (1)-(3): runs `git commit -m <message>`, propagating Git's own output and
130
+ * exit status literally.
131
+ *
132
+ * @param {string} message commit message (-m value)
133
+ * @param {{ loadConfig?: function, resolveWIPDirs?: function, resolveDoneDirs?: function,
134
+ * matchSlug?: function(string, string[], string[]): {matched: boolean, candidates: string[]},
135
+ * execGit?: function(string[]): {stdout: string, error: Error|null},
136
+ * currentBranch?: function(): string,
137
+ * execGitCommit?: function(string): number, writeln?: function(string): void,
138
+ * writeErr?: function(string): void }} deps
139
+ * @returns {number} exit code (0 = success, 1 = blocked/usage error, or Git's own exit code when
140
+ * `git commit` fails — propagated literally, never hardcoded)
141
+ */
142
+ function runCommit(message, deps = {}) {
143
+ const loadConfig = deps.loadConfig || config.load
144
+ const resolveWIPDirs = deps.resolveWIPDirs || validator.resolveWIPDirs
145
+ const resolveDoneDirs = deps.resolveDoneDirs || validator.resolveDoneDirs
146
+ const matchSlug = deps.matchSlug || validator.branchSlugMatchesRoadmap
147
+ const execGit = deps.execGit || defaultExecGit
148
+ const currentBranch = deps.currentBranch || (() => defaultCurrentBranch(execGit))
149
+ const execGitCommit = deps.execGitCommit || defaultGitCommit
150
+ const writeln = deps.writeln || ((s) => process.stdout.write(s + '\n'))
151
+ const writeErr = deps.writeErr || ((s) => process.stderr.write(s + '\n'))
152
+
153
+ if (!message || String(message).trim() === '') {
154
+ writeErr('commit message is required — use -m:\n trackfw commit -m "feat(<scope>): <description>"')
155
+ return 1
156
+ }
157
+
158
+ let branch
159
+ try {
160
+ branch = currentBranch()
161
+ } catch (err) {
162
+ writeErr(`could not determine current branch (are you in a git repo?): ${err.message}`)
163
+ return 1
164
+ }
165
+ branch = String(branch || '').trim()
166
+
167
+ // (a) main/master: always blocked.
168
+ if (COMMIT_PROTECTED_BRANCHES.has(branch)) {
169
+ const msg = `trackfw commit: commit direto em "${branch}" não é permitido. Use 'trackfw branch new <type>/<slug>' primeiro. Ver CLAUDE.md §1.`
170
+ writeln(msg)
171
+ writeErr(`blocked: commit directly on "${branch}" is not permitted`)
172
+ return 1
173
+ }
174
+
175
+ // (b) feat/fix/refactor: require a matching roadmap in wip/ or done/.
176
+ const governedPrefix = commitGovernedBranchPrefix(branch)
177
+ if (governedPrefix) {
178
+ const cfg = loadConfig()
179
+ const wipDirs = resolveWIPDirs(cfg)
180
+ const doneDirs = resolveDoneDirs(cfg)
181
+ const slug = branch.slice(governedPrefix.length)
182
+ const normalizedSlug = validator.normalizeBranchSlug(slug)
183
+ const { matched, candidates } = matchSlug(normalizedSlug, wipDirs, doneDirs)
184
+
185
+ if (!matched) {
186
+ const msg = (!candidates || candidates.length === 0)
187
+ ? validator.branchGovernanceOrientation(branch)
188
+ : validator.branchNoMatchingRoadmapMessage(branch, candidates)
189
+ writeln(msg)
190
+ writeErr(`blocked: no matching roadmap in wip/ nor done/ for "${branch}"`)
191
+ return 1
192
+ }
193
+ } else {
194
+ // (c) branches outside the feat/fix/refactor pattern (e.g. doc/housekeeping branches):
195
+ // allow without requiring a roadmap, but warn.
196
+ writeln(`trackfw commit: branch "${branch}" does not follow feat/fix/refactor — committing without a roadmap check.`)
197
+ }
198
+
199
+ // (d) passed all checks: commit. Git's own stdio (inherited) already reached the user
200
+ // unmodified — do not reformat, duplicate, or replace this exit code with a generic 1.
201
+ return execGitCommit(message)
202
+ }
203
+
204
+ // commitCommandDirs lists the directories (across the 3 supported CLIs) where a new (status "A")
205
+ // file signals a new CLI command was added — used by the "feat" heuristic rule below. Mirrors
206
+ // Go's commitCommandDirs.
207
+ const COMMIT_COMMAND_DIRS = ['internal/commands/', 'npm/src/commands/', 'pypi/trackfw/commands/']
208
+
209
+ /**
210
+ * parseStagedNameStatus parses raw `git diff --cached --name-status` output (tab-separated
211
+ * "<status>\t<path>" lines) into { status, path } entries, skipping blank lines. Mirrors Go's
212
+ * parseStagedNameStatus.
213
+ * @param {string} raw
214
+ * @returns {{status: string, path: string}[]}
215
+ */
216
+ function parseStagedNameStatus(raw) {
217
+ const files = []
218
+ for (const rawLine of String(raw || '').split('\n')) {
219
+ const line = rawLine.replace(/\r+$/, '')
220
+ if (line.trim() === '') continue
221
+ const tabIdx = line.indexOf('\t')
222
+ if (tabIdx === -1) continue
223
+ const status = line.slice(0, tabIdx).trim()
224
+ const path = line.slice(tabIdx + 1)
225
+ files.push({ status, path })
226
+ }
227
+ return files
228
+ }
229
+
230
+ /**
231
+ * isTestFile reports whether path matches one of the recognized test-file naming conventions
232
+ * across the 3 supported stacks: *_test.go, *.test.js, test_*.py, *_test.py. Mirrors Go's
233
+ * isTestFile.
234
+ * @param {string} path
235
+ * @returns {boolean}
236
+ */
237
+ function isTestFile(path) {
238
+ const idx = path.lastIndexOf('/')
239
+ const base = idx === -1 ? path : path.slice(idx + 1)
240
+ if (base.endsWith('_test.go')) return true
241
+ if (base.endsWith('.test.js')) return true
242
+ if (base.startsWith('test_') && base.endsWith('.py')) return true
243
+ if (base.endsWith('_test.py')) return true
244
+ return false
245
+ }
246
+
247
+ /**
248
+ * isDocsFile reports whether path lives under docs/ or vault/, or has a .md extension. Mirrors
249
+ * Go's isDocsFile.
250
+ * @param {string} path
251
+ * @returns {boolean}
252
+ */
253
+ function isDocsFile(path) {
254
+ if (path.startsWith('docs/') || path.startsWith('vault/')) return true
255
+ return path.endsWith('.md')
256
+ }
257
+
258
+ /**
259
+ * isUnderAnyDir reports whether path starts with any of the given directory prefixes. Mirrors
260
+ * Go's isUnderAnyDir.
261
+ * @param {string} path
262
+ * @param {string[]} dirs
263
+ * @returns {boolean}
264
+ */
265
+ function isUnderAnyDir(path, dirs) {
266
+ return dirs.some((d) => path.startsWith(d))
267
+ }
268
+
269
+ /**
270
+ * suggestedCommitType returns the Conventional Commits type suggested for a set of staged files,
271
+ * following the fixed-priority heuristic documented in ML-1A/ML-2A of
272
+ * docs/roadmaps/wip/ROADMAP-2026-08-15-trackfw-commit-sugere-mensagem-de-commit-a-partir-do-diff-staged.md
273
+ * (first matching rule wins — this is a deliberately simple heuristic, not an attempt at perfect
274
+ * classification). Mirrors Go's suggestedCommitType.
275
+ * 1. every staged file matches a test-file pattern -> "test"
276
+ * 2. every staged file is under docs/ or vault/, or has a .md extension -> "docs"
277
+ * 3. at least one new ("A") file lives under one of COMMIT_COMMAND_DIRS -> "feat"
278
+ * 4. otherwise -> "fix"
279
+ * @param {{status: string, path: string}[]} files
280
+ * @returns {string}
281
+ */
282
+ function suggestedCommitType(files) {
283
+ let allTests = true
284
+ let allDocs = true
285
+ let hasNewCommandFile = false
286
+
287
+ for (const f of files) {
288
+ if (!isTestFile(f.path)) allTests = false
289
+ if (!isDocsFile(f.path)) allDocs = false
290
+ if (f.status === 'A' && isUnderAnyDir(f.path, COMMIT_COMMAND_DIRS)) hasNewCommandFile = true
291
+ }
292
+
293
+ if (allTests) return 'test'
294
+ if (allDocs) return 'docs'
295
+ if (hasNewCommandFile) return 'feat'
296
+ return 'fix'
297
+ }
298
+
299
+ /**
300
+ * buildSuggestedMessage implements `trackfw commit --suggest`: it reads the staged diff via
301
+ * deps.stagedNameStatus, classifies it with suggestedCommitType, and renders the heuristic
302
+ * Conventional Commits skeleton described in ML-1A/ML-2A. It never calls deps.execGitCommit — no
303
+ * commit ever happens as a side effect of this function. Mirrors Go's buildSuggestedMessage
304
+ * byte-for-byte in output template.
305
+ * @param {{ stagedNameStatus?: function(): string }} deps
306
+ * @returns {string}
307
+ * @throws {Error} when staged files cannot be read, or nothing is staged
308
+ */
309
+ function buildSuggestedMessage(deps = {}) {
310
+ const stagedNameStatus = deps.stagedNameStatus || (() => defaultStagedNameStatus(deps.execGit || defaultExecGit))
311
+
312
+ let raw
313
+ try {
314
+ raw = stagedNameStatus()
315
+ } catch (err) {
316
+ throw new Error(`could not read staged changes (are you in a git repo?): ${err.message}`)
317
+ }
318
+
319
+ const files = parseStagedNameStatus(raw)
320
+ if (files.length === 0) {
321
+ throw new Error('nothing staged — `git add` files first')
322
+ }
323
+
324
+ const commitType = suggestedCommitType(files)
325
+
326
+ const lines = []
327
+ lines.push('# Sugestão heurística — NÃO é uma mensagem pronta, revise antes de usar.')
328
+ lines.push(`# Tipo sugerido: ${commitType}`)
329
+ lines.push('')
330
+ lines.push(`${commitType}(<escopo>): <descrição>`)
331
+ lines.push('')
332
+ lines.push('## Arquivos staged')
333
+ for (const f of files) {
334
+ lines.push(`${f.status} ${f.path}`)
335
+ }
336
+
337
+ return lines.join('\n')
338
+ }
339
+
340
+ module.exports = {
341
+ runCommit,
342
+ buildSuggestedMessage,
343
+ defaultExecGit,
344
+ defaultCurrentBranch,
345
+ defaultGitCommit,
346
+ defaultStagedNameStatus,
347
+ isCommitGatedBranch,
348
+ commitGovernedBranchPrefix,
349
+ suggestedCommitType,
350
+ isTestFile,
351
+ isDocsFile,
352
+ }
@@ -48,6 +48,9 @@ function defaults() {
48
48
  backend: '',
49
49
  frontend: '',
50
50
  pkgManager: '',
51
+ // ML-2A (agentes especialistas aceitam contexto de convenções) field — mirrors Go's
52
+ // UpdateConfig.AgentConventions: agent_conventions, free-text, multi-line (default: '').
53
+ agentConventions: '',
51
54
  },
52
55
  sync: {
53
56
  linearApiKey: '',
@@ -258,6 +261,7 @@ function parse(content, cfg) {
258
261
  if (stringVal(m, 'backend') !== undefined) cfg.update.backend = m.backend;
259
262
  if (stringVal(m, 'frontend') !== undefined) cfg.update.frontend = m.frontend;
260
263
  if (stringVal(m, 'pkg_manager') !== undefined) cfg.update.pkgManager = m.pkg_manager;
264
+ if (stringVal(m, 'agent_conventions') !== undefined) cfg.update.agentConventions = m.agent_conventions;
261
265
  if (stringVal(m, 'linear_api_key') !== undefined) cfg.sync.linearApiKey = m.linear_api_key;
262
266
  if (stringVal(m, 'linear_team_id') !== undefined) cfg.sync.linearTeamId = m.linear_team_id;
263
267
  if (stringVal(m, 'jira_base_url') !== undefined) cfg.sync.jiraBaseUrl = m.jira_base_url;
@@ -299,6 +303,28 @@ function parseRulesFromContent(content) {
299
303
  return cfg.rules;
300
304
  }
301
305
 
306
+ // readAgentConventions reads the `agent_conventions` key directly out of <cwd>/trackfw.yaml,
307
+ // bypassing the load() singleton — mirrors Go's config.ReadAgentConventions, needed here because
308
+ // generators (init.js's injectOrUpdateRules) inject rules into agent files for a given cwd that is
309
+ // not necessarily the process's own working directory the singleton is cached against. The file
310
+ // being absent, unreadable, malformed or simply missing the key are all treated the same: return
311
+ // '' silently, never an error — this is a best-effort read of an optional, free-text field.
312
+ function readAgentConventions(cwd) {
313
+ let content;
314
+ try {
315
+ content = fs.readFileSync(path.join(cwd || process.cwd(), 'trackfw.yaml'), 'utf8');
316
+ } catch (_) {
317
+ return '';
318
+ }
319
+ const cfg = { rules: {}, credentialGuard: {}, update: { agentConventions: '' }, sync: {}, linkFields: {} };
320
+ try {
321
+ parse(content, cfg);
322
+ } catch (_) {
323
+ return '';
324
+ }
325
+ return cfg.update.agentConventions || '';
326
+ }
327
+
302
328
  const NAMESPACING_FLAT = 'flat';
303
329
  const NAMESPACING_BY_AGENT = 'by_agent';
304
330
 
@@ -308,6 +334,7 @@ module.exports = {
308
334
  defaults,
309
335
  expandPath,
310
336
  parseRulesFromContent,
337
+ readAgentConventions,
311
338
  NAMESPACING_FLAT,
312
339
  NAMESPACING_BY_AGENT,
313
340
  MALFORMED_CONFIG_MESSAGE,