trackfw 7.0.0 → 7.2.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.
@@ -8,16 +8,78 @@
8
8
  */
9
9
 
10
10
  const { spawnSync } = require('child_process')
11
- const fs = require('fs')
12
- const path = require('path')
13
11
  const { load: loadConfig, reset: resetConfig } = require('../config')
14
12
  const { resolve: forgeResolve } = require('../forge/resolve')
15
13
  const { forgeAdapter } = require('../forge/adapter')
14
+ const validator = require('../validator')
15
+ const { evaluateBranchIntegration, DECISION: BRANCH_PRUNE_DECISION } = require('../branch/prune')
16
16
 
17
17
  // Git subcommands that modify local or remote state.
18
18
  // In --dry-run mode these are printed but not executed.
19
19
  const GIT_WRITE_COMMANDS = new Set(['commit', 'push', 'fetch'])
20
20
 
21
+ // force-with-lease refusal messages. Named constants so the ML-1B parity gate has a single
22
+ // place to compare byte-for-byte across the 3 CLIs. Byte-identical to Go's
23
+ // forceLease*Msg/Fmt constants (internal/commands/ship.go) and Python's FORCE_LEASE_* below.
24
+ // Never expose "--force" (raw) as a flag — see
25
+ // ADR-2026-08-19-caminho-governado-para-push-forcado-e-tag-de-release.md.
26
+ const FORCE_LEASE_NO_FORGE_CLI_MSG =
27
+ 'trackfw ship --force-with-lease requires a forge CLI (gh, glab, or az) to confirm an open pull/merge request before rewriting remote history. No forge CLI is available for this repository — install and authenticate it, or push without --force-with-lease.'
28
+
29
+ function forceLeaseNoPROpenMsg(branch) {
30
+ return `trackfw ship --force-with-lease refuses to run: branch "${branch}" has no open pull/merge request. Open the PR/MR first (trackfw ship without --force-with-lease, or your forge's web UI), then retry.`
31
+ }
32
+
33
+ function forceLeaseCannotVerifyMsg(branch, cliName, errMessage) {
34
+ return `trackfw ship --force-with-lease could not verify whether branch "${branch}" has an open pull/merge request (${cliName} CLI error: ${errMessage}). Refusing rather than risking a force push without a verified PR — check your ${cliName} CLI authentication and retry.`
35
+ }
36
+
37
+ /**
38
+ * defaultCheckPROpen queries the resolved forge CLI for an open PR/MR whose source branch is
39
+ * `branch`, using the same list-based query shape for every forge: empty result means "no PR"
40
+ * (exit 0), any non-zero exit or unparseable output means "cannot verify" (thrown — never
41
+ * conflated with "no PR"). bitbucket and "manual" never reach here: runShip only calls
42
+ * checkPROpen when adapter.available is true, and bitbucket's adapter is always available=false.
43
+ * @param {object} adapter
44
+ * @param {string} branch
45
+ * @returns {boolean}
46
+ */
47
+ function defaultCheckPROpen(adapter, branch) {
48
+ let args
49
+ switch (adapter.forge) {
50
+ case 'github':
51
+ args = ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'number']
52
+ break
53
+ case 'gitlab':
54
+ // glab mr list: --source-branch filters by source branch, --state opened matches
55
+ // gh's "open" state, -F json requests machine-readable output (glab's own flag,
56
+ // not an external jq/GNU dependency).
57
+ args = ['mr', 'list', '--source-branch', branch, '--state', 'opened', '-F', 'json']
58
+ break
59
+ case 'azure':
60
+ // az defaults to --output json; passed explicitly here for clarity, not reliance
61
+ // on the ambient default.
62
+ args = ['repos', 'pr', 'list', '--source-branch', branch, '--status', 'active', '--output', 'json']
63
+ break
64
+ default:
65
+ throw new Error(`no PR/MR query defined for forge "${adapter.forge}"`)
66
+ }
67
+
68
+ const result = spawnSync(adapter.cliName, args, { encoding: 'utf8' })
69
+ if (result.status !== 0) {
70
+ const msg = (result.stderr || '').trim() || `${adapter.cliName} exited with ${result.status}`
71
+ throw new Error(msg)
72
+ }
73
+
74
+ let parsed
75
+ try {
76
+ parsed = JSON.parse(result.stdout || '[]')
77
+ } catch (e) {
78
+ throw new Error(`could not parse ${adapter.cliName} output: ${e.message}`)
79
+ }
80
+ return Array.isArray(parsed) && parsed.length > 0
81
+ }
82
+
21
83
  /**
22
84
  * isGitWriteCmd returns true when the first arg is a write-mode git subcommand.
23
85
  * @param {string[]} args
@@ -77,66 +139,25 @@ function defaultCheckGovernance() {
77
139
 
78
140
  /**
79
141
  * checkShipGovernance — hard gate (bypasses config/baseline/lenient).
80
- * Checks:
81
- * 1. Current branch has a matching roadmap in wip/
82
- * 2. WIP roadmaps have a linked REQ
142
+ *
143
+ * Delegates entirely to the shared validator functions already used by `trackfw validate`,
144
+ * `trackfw branch new` and `trackfw commit` — never reimplement this logic locally. Byte-
145
+ * identical to Go's CheckShipGovernance (internal/validator/validator.go), which has the same
146
+ * no-args shape and the same two checks:
147
+ * 1. validateBranchHasWIPRoadmap — current branch (feat/fix/refactor only; re-derived
148
+ * internally from TRACKFW_BRANCH/git, same as Go) has a matching roadmap in wip/ OR done/
149
+ * 2. validateWIPHasREQ — every roadmap in wip/ has a linked REQ
150
+ * Before this ML, this function reimplemented both checks locally with its own wording (no
151
+ * "nor done/" clause, no done/ directory scan at all) — see
152
+ * vault/notes/ship-checkgovernance-error-stream-wording-divergence-2026-08-16.md. Duplicating
153
+ * the check was the actual root cause of the drift, not just the wording.
83
154
  * @returns {string[]} violation messages
84
155
  */
85
156
  function checkShipGovernance() {
86
- const violations = []
87
-
88
- // Resolve roadmap dir via config module (single source of truth; default: docs/roadmaps)
89
- const roadmapDir = resolveRoadmapDir()
90
- const wipDir = path.join(roadmapDir, 'wip')
91
-
92
- // Get branch name
93
- const branchResult = defaultExecGit(['symbolic-ref', '--short', 'HEAD'])
94
- const branch = branchResult.error ? '' : branchResult.stdout.trim()
95
-
96
- // Check branch has matching roadmap in wip/ — gated types only (feat/fix/refactor); chore/docs
97
- // are exempted by the outer runShip Step 2 skip and never reach this function in practice, but
98
- // isGatedShipBranch keeps this check correct in isolation too.
99
- if (branch && isGatedShipBranch(branch)) {
100
- const slug = normalizeBranchSlug(branch.split('/').slice(1).join('/'))
101
- let hasMatch = false
102
- let wipFiles = []
103
-
104
- if (fs.existsSync(wipDir)) {
105
- wipFiles = fs.readdirSync(wipDir).filter(f => f.endsWith('.md'))
106
- for (const f of wipFiles) {
107
- if (normalizeBranchSlug(f).includes(slug)) {
108
- hasMatch = true
109
- break
110
- }
111
- }
112
- }
113
-
114
- if (wipFiles.length === 0) {
115
- violations.push(
116
- `branch "${branch}" is a feat/fix/refactor branch but no roadmap is in wip/ — create governance artifacts first:\n` +
117
- ' trackfw req new "<title>"\n' +
118
- ' trackfw roadmap new "<title>"\n' +
119
- ' trackfw roadmap move <name> wip'
120
- )
121
- } else if (!hasMatch) {
122
- violations.push(
123
- `branch "${branch}" has no matching roadmap in wip/ (found: ${wipFiles.join(', ')}) — ` +
124
- 'include the branch slug in the roadmap filename'
125
- )
126
- }
127
-
128
- // Check WIP roadmaps have a linked REQ
129
- if (hasMatch && fs.existsSync(wipDir)) {
130
- for (const f of wipFiles) {
131
- const content = fs.readFileSync(path.join(wipDir, f), 'utf8')
132
- if (!content.includes('REQ:') && !content.includes('req:')) {
133
- violations.push(`roadmap "${f}" is in wip but has no linked REQ`)
134
- }
135
- }
136
- }
137
- }
138
-
139
- return violations
157
+ return [
158
+ ...validator.validateBranchHasWIPRoadmap(),
159
+ ...validator.validateWIPHasREQ(),
160
+ ]
140
161
  }
141
162
 
142
163
  /**
@@ -172,8 +193,17 @@ function normalizeBranchSlug(value) {
172
193
  }
173
194
 
174
195
  /**
175
- * detectPendingSquashMerges warns about remote branches with non-empty diffs vs origin/main.
176
- * Non-blocking.
196
+ * detectPendingSquashMerges warns about remote branches with genuinely pending work vs
197
+ * origin/main. Non-blocking.
198
+ *
199
+ * Reuses evaluateBranchIntegration (branch/prune.js) — the same touched-files heuristic
200
+ * `trackfw branch prune` uses — instead of a naive bidirectional `git diff origin/main <branch>
201
+ * --stat`. The naive check false-positives on a branch that was squash-merged and is now merely
202
+ * stale (main advanced further afterwards): it always shows a non-empty diff even though nothing
203
+ * from the branch is actually missing from main. Only DECISION.PENDING_WORK — genuine,
204
+ * unintegrated work — surfaces this warning; every other decision (no_own_work,
205
+ * content_identical, review_doc_config, no_merge_base, eval_error) stays silent, the same
206
+ * posture the naive check had on error (skip, no warning).
177
207
  * @param {string} currentBranch
178
208
  * @param {function} execGit
179
209
  * @param {function} writeln
@@ -188,9 +218,8 @@ function detectPendingSquashMerges(currentBranch, execGit, writeln) {
188
218
  const shortName = candidate.replace(/^origin\//, '')
189
219
  if (shortName === currentBranch) continue
190
220
 
191
- const { stdout: diff, error: derr } = execGit(['diff', 'origin/main', candidate, '--stat'])
192
- if (derr) continue
193
- if (diff.trim()) {
221
+ const evalResult = evaluateBranchIntegration(candidate, execGit)
222
+ if (evalResult.decision === BRANCH_PRUNE_DECISION.PENDING_WORK) {
194
223
  writeln(`Warning: branch "${shortName}" appears to have unmerged changes vs origin/main.`)
195
224
  }
196
225
  }
@@ -273,10 +302,17 @@ function allDocOnly(files) {
273
302
  */
274
303
  const COMMIT_MESSAGE_SEP = '\x1e'
275
304
 
305
+ // GIT_SYMBOLIC_REF_ORIGIN_HEAD_PREFIX is the fixed prefix `git symbolic-ref
306
+ // refs/remotes/origin/HEAD` always returns before the branch name, because "origin" is the
307
+ // literal ref namespace queried — never derived from the output itself. Stripping this exact
308
+ // prefix (instead of cutting at the last '/') is what makes defaultBaseBranch correct for a
309
+ // default branch that itself contains a slash (e.g. "release/7.2"): lastIndexOf('/') used to
310
+ // cut at "7.2", discarding "release/". Mirrors Go's ship.go gitSymbolicRefOriginHeadPrefix.
311
+ const GIT_SYMBOLIC_REF_ORIGIN_HEAD_PREFIX = 'refs/remotes/origin/'
312
+
276
313
  /**
277
314
  * defaultBaseBranch resolves the repository's default branch for `git log <base>..HEAD`.
278
- * Tries `git symbolic-ref refs/remotes/origin/HEAD` (format "refs/remotes/origin/main" —
279
- * only the name after the last slash is kept) and falls back to "main" when that fails or
315
+ * Tries `git symbolic-ref refs/remotes/origin/HEAD` and falls back to "main" when that fails or
280
316
  * yields nothing.
281
317
  * @param {function} execGit
282
318
  * @returns {string}
@@ -285,9 +321,9 @@ function defaultBaseBranch(execGit) {
285
321
  const { stdout, error } = execGit(['symbolic-ref', 'refs/remotes/origin/HEAD'])
286
322
  if (error) return 'main'
287
323
  const out = (stdout || '').trim()
288
- const idx = out.lastIndexOf('/')
289
- if (idx < 0 || idx + 1 >= out.length) return 'main'
290
- return out.slice(idx + 1)
324
+ if (!out.startsWith(GIT_SYMBOLIC_REF_ORIGIN_HEAD_PREFIX)) return 'main'
325
+ const name = out.slice(GIT_SYMBOLIC_REF_ORIGIN_HEAD_PREFIX.length)
326
+ return name === '' ? 'main' : name
291
327
  }
292
328
 
293
329
  /**
@@ -396,6 +432,15 @@ function runShip(opts, deps = {}) {
396
432
  const execGit = deps.execGit || defaultExecGit
397
433
  const checkGovernanceFn = deps.checkGovernance || defaultCheckGovernance
398
434
  const writeln = deps.writeln || ((s) => process.stdout.write(s + '\n'))
435
+ // writeErr — the terminal error line for every abort path below, written to STDERR with the
436
+ // "Error: " prefix. Mirrors Go exactly: ship.go returns these same messages as a bare
437
+ // fmt.Errorf(...), and internal/commands/root.go's Execute() wrapper prints them to stderr as
438
+ // `fmt.Fprintln(os.Stderr, cmd.ErrPrefix(), err.Error())` — cmd.ErrPrefix() is "Error:", and
439
+ // Fprintln inserts exactly one space between operands, so the wire format is
440
+ // "Error: <message>\n". Every multi-line detail printed BEFORE the abort (violation lists,
441
+ // remediation hints, "Note: ..." blocks) stays on stdout via writeln, same as Go's deps.out —
442
+ // only this final one-line (or one-block) summary moves to stderr.
443
+ const writeErr = deps.writeErr || ((s) => process.stderr.write(`Error: ${s}\n`))
399
444
 
400
445
  // Inner git wrapper: skips write commands in dry-run mode.
401
446
  function git(args) {
@@ -416,20 +461,20 @@ function runShip(opts, deps = {}) {
416
461
  // ─── Step 1: Branch validation ─────────────────────────────────────────────
417
462
  const branchResult = execGit(['symbolic-ref', '--short', 'HEAD'])
418
463
  if (branchResult.error) {
419
- writeln(`error: could not determine current branch (are you in a git repo?): ${branchResult.error.message}`)
464
+ writeErr(`could not determine current branch (are you in a git repo?): ${branchResult.error.message}`)
420
465
  return 1
421
466
  }
422
467
  const branch = branchResult.stdout.trim()
423
468
 
424
469
  // main/master is blocked unconditionally — the doc-only exception never applies here.
425
470
  if (branch === 'main' || branch === 'master') {
426
- writeln(`error: trackfw ship cannot run on "${branch}" — use a feature branch:\n git checkout -b feat/<slug>`)
471
+ writeErr(`trackfw ship cannot run on "${branch}" — use a feature branch:\n git checkout -b feat/<slug>`)
427
472
  return 1
428
473
  }
429
474
 
430
475
  if (!docOnly && !isShipBranch(branch)) {
431
- writeln(
432
- `error: branch "${branch}" does not match the required pattern feat|fix|refactor|chore|docs/<slug>\n` +
476
+ writeErr(
477
+ `branch "${branch}" does not match the required pattern feat|fix|refactor|chore|docs/<slug>\n` +
433
478
  'Rename your branch or create a new one:\n git checkout -b feat/<slug>'
434
479
  )
435
480
  return 1
@@ -461,13 +506,66 @@ function runShip(opts, deps = {}) {
461
506
  writeln("mode or per-rule severity configured in trackfw.yaml. If 'trackfw validate'")
462
507
  writeln("passes but 'trackfw ship' aborts here, you likely have lenient mode")
463
508
  writeln("configured — ship always requires REQ + roadmap in wip/.")
464
- writeln(`\nerror: governance check failed: ${violations.length} violation(s)`)
509
+ writeErr(`governance check failed: ${violations.length} violation(s)`)
465
510
  return 1
466
511
  }
467
512
 
468
513
  writeln('Governance: OK')
469
514
  }
470
515
 
516
+ // ─── Step 2.5: force-with-lease gate ───────────────────────────────────────
517
+ // Runs before any write (commit/push) — a refusal here must never leave a local
518
+ // commit the caller cannot push. Read-only, so it runs in --dry-run too, same
519
+ // posture as the read-only calls in Step 0 / Step 7.
520
+ //
521
+ // forceLeaseAdapter/forceLeaseResolution are reused by Step 7 below to avoid a
522
+ // second forge resolution and a duplicate "Forge: ..." line, and because Step 7
523
+ // must skip PR/MR creation entirely once this gate has confirmed one is already
524
+ // open — creating a second one would be a spurious failure on every successful
525
+ // force push.
526
+ let forceLeaseAdapter = null
527
+ let forceLeaseResolution = null
528
+ if (opts.forceWithLease) {
529
+ const { stdout: gateRemoteURLRaw } = execGit(['remote', 'get-url', 'origin'])
530
+ const gateRemoteURL = (gateRemoteURLRaw || '').trim()
531
+
532
+ let resolution
533
+ try {
534
+ resolution = forgeResolve({
535
+ flagForge: opts.forge || '',
536
+ configForge: deps.configForge || '',
537
+ remoteURL: gateRemoteURL,
538
+ repoDir: deps.repoDir || '',
539
+ })
540
+ } catch (resErr) {
541
+ writeErr(resErr.message)
542
+ return 1
543
+ }
544
+
545
+ const adapter = forgeAdapter(resolution.forge, deps.availFn || undefined)
546
+ if (resolution.forge === 'manual' || !adapter.available) {
547
+ writeErr(FORCE_LEASE_NO_FORGE_CLI_MSG)
548
+ return 1
549
+ }
550
+
551
+ const checkPROpen = deps.checkPROpen || defaultCheckPROpen
552
+ let open
553
+ try {
554
+ open = checkPROpen(adapter, branch)
555
+ } catch (prErr) {
556
+ writeErr(forceLeaseCannotVerifyMsg(branch, adapter.cliName, prErr.message))
557
+ return 1
558
+ }
559
+ if (!open) {
560
+ writeErr(forceLeaseNoPROpenMsg(branch))
561
+ return 1
562
+ }
563
+
564
+ writeln(`force-with-lease: open ${adapter.noun} confirmed for "${branch}" (${resolution.forge}).`)
565
+ forceLeaseAdapter = adapter
566
+ forceLeaseResolution = resolution
567
+ }
568
+
471
569
  // ─── Step 3: Squash-merge detection ────────────────────────────────────────
472
570
  if (opts.dryRun) {
473
571
  writeln('[dry-run] git fetch origin --prune')
@@ -490,9 +588,16 @@ function runShip(opts, deps = {}) {
490
588
  writeln('────────────────────────────────────────────────────────\n')
491
589
 
492
590
  // Reuses stagedFiles read at the top of the function (Step 0) — never re-query git here.
493
- if (stagedFiles.length === 0) {
494
- writeln(
495
- 'error: nothing is staged — stage your files explicitly before running ship:\n' +
591
+ //
592
+ // --force-with-lease push-only mode: a rebase that resolved conflicts already
593
+ // committed the result (the index is clean afterwards) — there is nothing left to
594
+ // stage or commit, only to push. Only --force-with-lease grants this exception;
595
+ // without it, "nothing staged" still aborts exactly as before (non-regression).
596
+ const pushOnly = opts.forceWithLease && stagedFiles.length === 0
597
+
598
+ if (stagedFiles.length === 0 && !opts.forceWithLease) {
599
+ writeErr(
600
+ 'nothing is staged — stage your files explicitly before running ship:\n' +
496
601
  ' git add <file1> <file2> ...\n' +
497
602
  "Never use 'git add .' or 'git add -A'"
498
603
  )
@@ -500,29 +605,38 @@ function runShip(opts, deps = {}) {
500
605
  }
501
606
 
502
607
  // ─── Step 5: Commit ────────────────────────────────────────────────────────
503
- if (!opts.message) {
504
- writeln(
505
- 'error: commit message is required — use -m:\n' +
506
- ' trackfw ship -m "feat(<scope>): <description>"'
507
- )
508
- return 1
509
- }
608
+ if (pushOnly) {
609
+ writeln('Nothing staged — --force-with-lease pushes existing commits only, no new commit.')
610
+ } else {
611
+ if (!opts.message) {
612
+ writeErr(
613
+ 'commit message is required — use -m:\n' +
614
+ ' trackfw ship -m "feat(<scope>): <description>"'
615
+ )
616
+ return 1
617
+ }
510
618
 
511
- const { error: commitErr } = git(['commit', '-m', opts.message])
512
- if (commitErr) {
513
- writeln(`error: git commit failed: ${commitErr.message}`)
514
- return 1
515
- }
619
+ const { error: commitErr } = git(['commit', '-m', opts.message])
620
+ if (commitErr) {
621
+ writeErr(`git commit failed: ${commitErr.message}`)
622
+ return 1
623
+ }
516
624
 
517
- if (!opts.dryRun) {
518
- writeln(`Committed: ${opts.message}`)
625
+ if (!opts.dryRun) {
626
+ writeln(`Committed: ${opts.message}`)
627
+ }
519
628
  }
520
629
 
521
630
  // ─── Step 6: Push ──────────────────────────────────────────────────────────
522
- const pushArgs = buildPushArgs(branch, execGit)
631
+ let pushArgs = buildPushArgs(branch, execGit)
632
+ if (opts.forceWithLease) {
633
+ // Fixed position: push --force-with-lease [-u] origin <branch> — identical
634
+ // across the 3 CLIs (ML-1B's parity gate compares this literally).
635
+ pushArgs = [pushArgs[0], '--force-with-lease', ...pushArgs.slice(1)]
636
+ }
523
637
  const { error: pushErr } = git(pushArgs)
524
638
  if (pushErr) {
525
- writeln(`error: git push failed: ${pushErr.message}`)
639
+ writeErr(`git push failed: ${pushErr.message}`)
526
640
  return 1
527
641
  }
528
642
 
@@ -531,6 +645,17 @@ function runShip(opts, deps = {}) {
531
645
  }
532
646
 
533
647
  // ─── Step 7: Open PR/MR ────────────────────────────────────────────────────
648
+ // --force-with-lease only ever reaches here after Step 2.5 confirmed a PR/MR is
649
+ // already open on this branch — creating another one would be a spurious failure
650
+ // on every successful force push. Reuses the adapter/resolution Step 2.5 already
651
+ // computed instead of resolving the forge a second time.
652
+ if (opts.forceWithLease) {
653
+ writeln(`Forge: ${forceLeaseResolution.forge} (source: ${forceLeaseResolution.source})`)
654
+ writeln(`${forceLeaseAdapter.noun} already open — skipping creation (--force-with-lease).`)
655
+ writeln('\nship complete.')
656
+ return 0
657
+ }
658
+
534
659
  const { stdout: remoteURLRaw } = execGit(['remote', 'get-url', 'origin'])
535
660
  const remoteURL = (remoteURLRaw || '').trim()
536
661
 
@@ -638,4 +763,12 @@ module.exports = {
638
763
  gitCommitsSince,
639
764
  buildPRBody,
640
765
  COMMIT_MESSAGE_SEP,
766
+ detectPendingSquashMerges,
767
+ defaultCheckPROpen,
768
+ FORCE_LEASE_NO_FORGE_CLI_MSG,
769
+ forceLeaseNoPROpenMsg,
770
+ forceLeaseCannotVerifyMsg,
771
+ // Exported for reuse by push/runner.js (AC2 of REQ-2026-08-22-trackfw-push).
772
+ buildPushArgs,
773
+ defaultExecGit,
641
774
  }