trackfw 7.1.0 → 7.3.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,366 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * scaffold_doctor.js — scaffold artifact coverage for `trackfw doctor`
5
+ * (ADR-2026-08-27-doctor-cobre-artefatos-de-scaffold-por-comparacao-com-o-template-
6
+ * com-propriedade-dada-pelo-caminho.md).
7
+ *
8
+ * Mirrors internal/generators/scaffold_doctor.go (Go, canonical source of truth).
9
+ * Same design decisions apply — see that file's doc comment for the full rationale.
10
+ *
11
+ * Key decisions:
12
+ * - Property by path, not manifest (AC3): scaffold artifacts are identified by
13
+ * well-known namespace paths. No manifest entry is written or read.
14
+ * - Sibling classifier (AC15): scaffold artifacts are never in the manifest, so
15
+ * routing them through classifyDoctor would produce wrong remedies. The finding
16
+ * kinds SCAFFOLD_DIVERGENT / SCAFFOLD_MISSING are separate, with zero claim and
17
+ * a trackfw-update remedy.
18
+ * - Config-rendered templates (AC12): buildValidateScript(cfg) varies with
19
+ * cfg.backend/cfg.frontend — rendered from the project's own trackfw.yaml.
20
+ * - Eligibility for slash commands (AC14): only checked when
21
+ * .claude/commands/trackfw/ directory already exists.
22
+ * - Conditional artifacts (AC13): CI workflow only when cfg.ci declares it.
23
+ * - Neutral blame message (AC16): binary version stated; direction ambiguous.
24
+ * - Execute-bit checking (REQ-2026-08-28, AC2–AC5, AC10, AC11):
25
+ * The five scripts written with mode 0o755 are additionally checked for the
26
+ * owner-execute bit. The check uses (stat.mode & 0o100) !== 0 (not === 0o755)
27
+ * so umask-narrowed modes (0o750, 0o700) are accepted (AC10). Non-executable
28
+ * artifacts (slash commands, CI workflows) carry execBit=false and are never
29
+ * accused of a missing execute bit (AC4/AC11). Content divergence takes
30
+ * precedence over mode (at most one finding per artifact). Content correct +
31
+ * bit missing → SCAFFOLD_WRONG_MODE (AC3 distinct state). On Windows
32
+ * (process.platform === 'win32') the execute bit is not representable on NTFS,
33
+ * so the mode check is suppressed entirely — AC5.
34
+ *
35
+ * _platform is a module-level constant seeded from process.platform so that unit
36
+ * tests can override it via the exported _setPlatformForTest helper (AC5 testability).
37
+ */
38
+
39
+ const fs = require('fs')
40
+ const path = require('path')
41
+ const yaml = require('yaml')
42
+
43
+ const { SIGNAL_SCRIPT, CLEANUP_SCRIPT, CREDENTIAL_GUARD_SCRIPT, GIT_BRANCH_GUARD_SCRIPT } =
44
+ require('../generators/hooks')
45
+ const { CLAUDE_COMMANDS, buildValidateScript, buildGitHubActionsWorkflowContent, buildGitLabCIWorkflowContent } =
46
+ require('../generators/init')
47
+
48
+ // Finding kind constants — mirrors Go's DoctorScaffoldDivergent / DoctorScaffoldMissing /
49
+ // DoctorScaffoldWrongMode.
50
+ const SCAFFOLD_DIVERGENT = 'scaffold-divergent'
51
+ const SCAFFOLD_MISSING = 'scaffold-missing'
52
+ // SCAFFOLD_WRONG_MODE: content correct but owner-execute bit missing (AC3).
53
+ // Only emitted for artifacts with execBit=true; never on Windows (AC5).
54
+ const SCAFFOLD_WRONG_MODE = 'scaffold-wrong-mode'
55
+
56
+ // PYTHON_VALIDATE_SCRIPT_FORM is the byte-exact content Python's `trackfw init` and
57
+ // `trackfw update` (validate-script target) write to scripts/trackfw-validate.sh.
58
+ // Accepted by the set-membership check in checkValidateScriptArtifact so that a
59
+ // project initialized by the Python runtime does not produce a false-positive
60
+ // scaffold-divergent finding in the Node.js doctor.
61
+ //
62
+ // Scope of the exception: ONLY scripts/trackfw-validate.sh uses set-membership.
63
+ // All other scaffold artifacts use single-template equality.
64
+ // See docs/cli-parity.md, "validate.sh — pertencimento a conjunto (set-membership, escopado)".
65
+ const PYTHON_VALIDATE_SCRIPT_FORM = '#!/usr/bin/env bash\nset -euo pipefail\ntrackfw validate\n'
66
+
67
+ // Paths used by the scaffold doctor (mirrors Go's exported constants).
68
+ const CLAUDE_COMMANDS_DIR_PATH = '.claude/commands/trackfw'
69
+ const GITHUB_ACTIONS_WORKFLOW_PATH = '.github/workflows/trackfw-gate.yml'
70
+ const GITLAB_CI_WORKFLOW_PATH = '.gitlab-ci-trackfw.yml'
71
+
72
+ // _platform is seeded from process.platform at module load time.
73
+ // Tests override it via _setPlatformForTest to exercise the Windows guard (AC5).
74
+ let _platform = process.platform
75
+
76
+ /**
77
+ * _setPlatformForTest overrides the platform string used by mode checks.
78
+ * ONLY for use in unit tests. Returns a restore function.
79
+ *
80
+ * @param {string} plat - e.g. 'win32' or 'darwin'
81
+ * @returns {function} call to restore the original value
82
+ */
83
+ function _setPlatformForTest(plat) {
84
+ const prev = _platform
85
+ _platform = plat
86
+ return () => { _platform = prev }
87
+ }
88
+
89
+ /**
90
+ * execBitPresent returns true when the file at absPath has the owner-execute bit set
91
+ * (stat.mode & 0o100 !== 0). Returns false on any stat error.
92
+ *
93
+ * Uses bit mask rather than equality to 0o755 so that umask-narrowed modes like
94
+ * 0o750 or 0o700 are also accepted — AC10.
95
+ *
96
+ * @param {string} absPath - absolute path to the file
97
+ * @returns {boolean}
98
+ */
99
+ function execBitPresent(absPath) {
100
+ try {
101
+ const stat = fs.statSync(absPath)
102
+ return (stat.mode & 0o100) !== 0
103
+ } catch (_) {
104
+ return false
105
+ }
106
+ }
107
+
108
+ /**
109
+ * scaffoldRemedy returns a ready-to-copy remedy command for a scaffold finding.
110
+ * The message is neutral about blame direction (AC16).
111
+ */
112
+ function scaffoldRemedy(action, relPath) {
113
+ // Lazy-require version to avoid a circular dependency at module load time.
114
+ let ver = 'unknown'
115
+ try {
116
+ const pkg = require('../../package.json')
117
+ ver = pkg.version || 'unknown'
118
+ } catch (_) {}
119
+ return `trackfw update # ${action} ${relPath}: content differs from the template trackfw v${ver} generates; if this project was initialized with a newer binary, update the binary instead`
120
+ }
121
+
122
+ /**
123
+ * scaffoldWrongModeRemedy returns a remedy command for the scaffold-wrong-mode finding.
124
+ * Names the missing execute bit explicitly to distinguish it from content divergence (AC3).
125
+ * The message is runtime-neutral so the parity check can diff Go, Node, and Python outputs
126
+ * byte-for-byte on this finding kind.
127
+ */
128
+ function scaffoldWrongModeRemedy(relPath) {
129
+ return `trackfw update # restore execute bit on ${relPath}: content is correct but the owner-execute bit is missing (mode 0755 required); trackfw update now restores the mode unconditionally on existing files`
130
+ }
131
+
132
+ /**
133
+ * loadProjectConfig reads trackfw.yaml from projectRoot and returns a cfg object
134
+ * with the same keys that buildValidateScript / buildCIWorkflowContent expect.
135
+ * Missing keys default to undefined/null — identical to Go's loadUpdateConfig().
136
+ */
137
+ function loadProjectConfig(projectRoot) {
138
+ try {
139
+ const raw = fs.readFileSync(path.join(projectRoot, 'trackfw.yaml'), 'utf8')
140
+ const parsed = yaml.parse(raw) || {}
141
+ return {
142
+ backend: parsed.backend || null,
143
+ frontend: parsed.frontend || null,
144
+ pkgManager: parsed.pkg_manager || null,
145
+ ci: parsed.ci || null,
146
+ }
147
+ } catch (_) {
148
+ return { backend: null, frontend: null, pkgManager: null, ci: null }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * checkValidateScriptArtifact checks scripts/trackfw-validate.sh using set-membership:
154
+ * accepted if the file content matches EITHER Go/Node's cfg-rendered form OR Python's
155
+ * fixed form (PYTHON_VALIDATE_SCRIPT_FORM). A file matching NO known form is accused.
156
+ * All other scaffold artifacts use checkScaffoldArtifact (single-template equality).
157
+ *
158
+ * After content membership passes, the execute bit is checked unless on Windows (AC5).
159
+ * Content divergence always takes precedence over mode (at most one finding per artifact).
160
+ *
161
+ * @param {string} absPath - absolute path to the file
162
+ * @param {string} relPath - relative path (used as destination in findings)
163
+ * @param {object} cfg - project config from trackfw.yaml
164
+ * @returns {object|null} finding or null
165
+ */
166
+ function checkValidateScriptArtifact(absPath, relPath, cfg) {
167
+ let actual
168
+ try {
169
+ actual = fs.readFileSync(absPath, 'utf8')
170
+ } catch (err) {
171
+ if (err.code === 'ENOENT') {
172
+ return {
173
+ finding: SCAFFOLD_MISSING,
174
+ claim: { kind: '', item: '', target: '', surface: '', scope: '' },
175
+ destination: relPath,
176
+ remedy: scaffoldRemedy('restore', relPath),
177
+ }
178
+ }
179
+ return {
180
+ finding: SCAFFOLD_DIVERGENT,
181
+ claim: { kind: '', item: '', target: '', surface: '', scope: '' },
182
+ destination: relPath,
183
+ remedy: scaffoldRemedy('resync', relPath),
184
+ }
185
+ }
186
+ const goNodeForm = buildValidateScript(cfg)
187
+ if (actual !== goNodeForm && actual !== PYTHON_VALIDATE_SCRIPT_FORM) {
188
+ // Content diverges — scaffold-divergent takes precedence over any mode issue.
189
+ return {
190
+ finding: SCAFFOLD_DIVERGENT,
191
+ claim: { kind: '', item: '', target: '', surface: '', scope: '' },
192
+ destination: relPath,
193
+ remedy: scaffoldRemedy('resync', relPath),
194
+ }
195
+ }
196
+ // Content accepted. Check the execute bit (AC2/AC3).
197
+ // Suppressed on Windows where the bit is not representable (AC5).
198
+ if (_platform !== 'win32' && !execBitPresent(absPath)) {
199
+ return {
200
+ finding: SCAFFOLD_WRONG_MODE,
201
+ claim: { kind: '', item: '', target: '', surface: '', scope: '' },
202
+ destination: relPath,
203
+ remedy: scaffoldWrongModeRemedy(relPath),
204
+ }
205
+ }
206
+ return null
207
+ }
208
+
209
+ /**
210
+ * checkScaffoldArtifact compares the on-disk content at absPath against expected.
211
+ * Returns a finding if the file is divergent or (when reportMissing=true) absent.
212
+ * relPath is used as destination in the finding.
213
+ *
214
+ * @param {string} absPath - absolute path to the file
215
+ * @param {string} relPath - relative path (destination in findings)
216
+ * @param {string} expected - expected file content
217
+ * @param {boolean} reportMissing - if true, absence is a finding
218
+ * @param {boolean} execBit - if true, check the owner-execute bit after content passes
219
+ * true → artifact was written with 0o755; bit absence → SCAFFOLD_WRONG_MODE (AC2/AC3)
220
+ * false → artifact is 0o644 (slash commands, CI workflows); never mode-checked (AC4/AC11)
221
+ * @returns {object|null} finding or null
222
+ */
223
+ function checkScaffoldArtifact(absPath, relPath, expected, reportMissing, execBit) {
224
+ let actual
225
+ try {
226
+ actual = fs.readFileSync(absPath, 'utf8')
227
+ } catch (err) {
228
+ if (err.code === 'ENOENT') {
229
+ if (!reportMissing) return null
230
+ return {
231
+ finding: SCAFFOLD_MISSING,
232
+ claim: { kind: '', item: '', target: '', surface: '', scope: '' },
233
+ destination: relPath,
234
+ remedy: scaffoldRemedy('restore', relPath),
235
+ }
236
+ }
237
+ // Unreadable artifact: report as divergent so the user is informed.
238
+ return {
239
+ finding: SCAFFOLD_DIVERGENT,
240
+ claim: { kind: '', item: '', target: '', surface: '', scope: '' },
241
+ destination: relPath,
242
+ remedy: scaffoldRemedy('resync', relPath),
243
+ }
244
+ }
245
+ if (actual !== expected) {
246
+ // Content diverges — takes precedence over any mode issue (at most one finding
247
+ // per artifact; update fixes both content and mode anyway).
248
+ return {
249
+ finding: SCAFFOLD_DIVERGENT,
250
+ claim: { kind: '', item: '', target: '', surface: '', scope: '' },
251
+ destination: relPath,
252
+ remedy: scaffoldRemedy('resync', relPath),
253
+ }
254
+ }
255
+ // Content matches. Check the execute bit when required (AC2/AC3).
256
+ // Suppressed on Windows where the bit is not representable (AC5).
257
+ if (execBit && _platform !== 'win32' && !execBitPresent(absPath)) {
258
+ return {
259
+ finding: SCAFFOLD_WRONG_MODE,
260
+ claim: { kind: '', item: '', target: '', surface: '', scope: '' },
261
+ destination: relPath,
262
+ remedy: scaffoldWrongModeRemedy(relPath),
263
+ }
264
+ }
265
+ return null
266
+ }
267
+
268
+ /**
269
+ * runScaffoldDoctor compares scaffold artifacts on disk against the templates the
270
+ * currently installed binary would generate (given the project's own trackfw.yaml),
271
+ * and returns findings for any artifact that is divergent or missing.
272
+ *
273
+ * @param {string} projectRoot - absolute path to the project root
274
+ * @returns {Array} findings
275
+ */
276
+ function runScaffoldDoctor(projectRoot) {
277
+ // Eligibility: trackfw.yaml must exist.
278
+ try {
279
+ fs.statSync(path.join(projectRoot, 'trackfw.yaml'))
280
+ } catch (_) {
281
+ return []
282
+ }
283
+
284
+ const cfg = loadProjectConfig(projectRoot)
285
+ const findings = []
286
+
287
+ // --- Scripts (always in scope when trackfw.yaml is present) ---
288
+ //
289
+ // scripts/trackfw-validate.sh uses set-membership (Go/Node form OR Python form).
290
+ // The four remaining scripts use single-template equality via checkScaffoldArtifact.
291
+ // All five have execBit=true: the generator writes them with mode 0o755 (AC11).
292
+ const validateF = checkValidateScriptArtifact(
293
+ path.join(projectRoot, 'scripts/trackfw-validate.sh'),
294
+ 'scripts/trackfw-validate.sh',
295
+ cfg,
296
+ )
297
+ if (validateF) findings.push(validateF)
298
+
299
+ const staticScripts = [
300
+ { relPath: 'scripts/trackfw-attention-signal.sh', expected: SIGNAL_SCRIPT, execBit: true },
301
+ { relPath: 'scripts/trackfw-attention-cleanup.sh', expected: CLEANUP_SCRIPT, execBit: true },
302
+ { relPath: 'scripts/trackfw-credential-guard.sh', expected: CREDENTIAL_GUARD_SCRIPT, execBit: true },
303
+ { relPath: 'scripts/trackfw-git-branch-guard.sh', expected: GIT_BRANCH_GUARD_SCRIPT, execBit: true },
304
+ ]
305
+ for (const { relPath, expected, execBit } of staticScripts) {
306
+ const f = checkScaffoldArtifact(path.join(projectRoot, relPath), relPath, expected, true, execBit)
307
+ if (f) findings.push(f)
308
+ }
309
+
310
+ // --- Slash commands (AC14: only when the directory already exists) ---
311
+ //
312
+ // Slash commands are markdown files written with mode 0644 — execBit=false (AC11).
313
+ const claudeDir = path.join(projectRoot, CLAUDE_COMMANDS_DIR_PATH)
314
+ let claudeDirExists = false
315
+ try {
316
+ fs.statSync(claudeDir)
317
+ claudeDirExists = true
318
+ } catch (_) {}
319
+ if (claudeDirExists) {
320
+ for (const [filename, content] of Object.entries(CLAUDE_COMMANDS)) {
321
+ const relPath = `${CLAUDE_COMMANDS_DIR_PATH}/${filename}`
322
+ const f = checkScaffoldArtifact(path.join(projectRoot, relPath), relPath, content, true, false)
323
+ if (f) findings.push(f)
324
+ }
325
+ }
326
+
327
+ // --- CI workflow (AC13: conditional on ci: in trackfw.yaml) ---
328
+ //
329
+ // CI workflow YAML files are written with mode 0644 — execBit=false (AC11).
330
+ if (cfg.ci === 'github-actions') {
331
+ const relPath = GITHUB_ACTIONS_WORKFLOW_PATH
332
+ const f = checkScaffoldArtifact(
333
+ path.join(projectRoot, relPath),
334
+ relPath,
335
+ buildGitHubActionsWorkflowContent(cfg),
336
+ true,
337
+ false,
338
+ )
339
+ if (f) findings.push(f)
340
+ } else if (cfg.ci === 'gitlab-ci') {
341
+ const relPath = GITLAB_CI_WORKFLOW_PATH
342
+ const f = checkScaffoldArtifact(
343
+ path.join(projectRoot, relPath),
344
+ relPath,
345
+ buildGitLabCIWorkflowContent(cfg),
346
+ true,
347
+ false,
348
+ )
349
+ if (f) findings.push(f)
350
+ }
351
+
352
+ // Deterministic output (AC7): sort by destination.
353
+ findings.sort((a, b) => (a.destination < b.destination ? -1 : a.destination > b.destination ? 1 : 0))
354
+
355
+ return findings
356
+ }
357
+
358
+ module.exports = {
359
+ runScaffoldDoctor,
360
+ checkValidateScriptArtifact,
361
+ SCAFFOLD_DIVERGENT,
362
+ SCAFFOLD_MISSING,
363
+ SCAFFOLD_WRONG_MODE,
364
+ PYTHON_VALIDATE_SCRIPT_FORM,
365
+ _setPlatformForTest,
366
+ }
@@ -94,7 +94,18 @@ function silenceConsole(fn) {
94
94
  * - `missing` (nothing present under any of `relPaths`) never triggers
95
95
  * `apply` unless `installMissing` is true.
96
96
  */
97
- function runFileTarget({ id, path: displayPath, root, relPaths, apply, dryRun, installMissing }) {
97
+ // runFileTarget — computes updated/skipped/missing/failed for a target whose
98
+ // only observable effect is writing under a fixed set of paths (files or
99
+ // directories) relative to `root`, by diffing content hashes before/after
100
+ // invoking `apply(root)`.
101
+ //
102
+ // `seeds` (optional) — extra paths copied into the dry-run sandbox but NOT
103
+ // included in the hash comparison. Used for:
104
+ // - trackfw.yaml: agent-rules reads it for agent_conventions (Gap E)
105
+ // - detection signals for agent-hooks: .windsurfrules, .github/copilot-
106
+ // instructions.md, etc. — InjectHooksDetected checks these to decide
107
+ // which hooks to write; absent → hooks silently omitted (Gap C)
108
+ function runFileTarget({ id, path: displayPath, root, relPaths, seeds = [], apply, dryRun, installMissing }) {
98
109
  const before = relPaths.map(rel => hashPath(path.join(root, rel)))
99
110
  const allMissingBefore = before.every(h => h === null)
100
111
 
@@ -108,6 +119,7 @@ function runFileTarget({ id, path: displayPath, root, relPaths, apply, dryRun, i
108
119
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trackfw-update-'))
109
120
  try {
110
121
  for (const rel of relPaths) copyPath(path.join(root, rel), path.join(tmp, rel))
122
+ for (const rel of seeds) copyPath(path.join(root, rel), path.join(tmp, rel))
111
123
  silenceConsole(() => apply(tmp))
112
124
  after = relPaths.map(rel => hashPath(path.join(tmp, rel)))
113
125
  } finally {
@@ -0,0 +1,223 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * push/runner.js — Core implementation of `trackfw push`.
5
+ *
6
+ * Empurra commits já criados, sem commitar e sem abrir PR/MR.
7
+ * Reusa os helpers de npm/src/ship/runner.js; nunca reimplementa a lógica de governança,
8
+ * detecção de squash-merge ou construção de push args.
9
+ *
10
+ * See ADR-2026-08-22-comandos-de-entrega-separados-push-proprio-e-ship-como-composicao.md.
11
+ */
12
+
13
+ const {
14
+ isShipBranch,
15
+ isGatedShipBranch,
16
+ isGitWriteCmd,
17
+ checkShipGovernance,
18
+ detectPendingSquashMerges,
19
+ defaultCheckPROpen,
20
+ buildPushArgs,
21
+ defaultExecGit,
22
+ } = require('../ship/runner')
23
+
24
+ /**
25
+ * defaultCheckGovernance delegates to checkShipGovernance — the same hard gate used by
26
+ * `trackfw ship` and `trackfw commit`.
27
+ * @returns {string[]}
28
+ */
29
+ function defaultCheckGovernance() {
30
+ return checkShipGovernance()
31
+ }
32
+
33
+ const { resolve: forgeResolve } = require('../forge/resolve')
34
+ const { forgeAdapter } = require('../forge/adapter')
35
+
36
+ // push --force-with-lease refusal messages — "trackfw push" (not "ship") because this command
37
+ // closes the commit→push cycle without opening a PR. See
38
+ // ADR-2026-08-22-comandos-de-entrega-separados-push-proprio-e-ship-como-composicao.md.
39
+ // Defined here (not imported from ship/runner.js) to keep push's user-visible messages
40
+ // independent of ship's contract string.
41
+ const PUSH_FORCE_LEASE_NO_FORGE_CLI_MSG =
42
+ 'trackfw push --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.'
43
+
44
+ function pushForceLeaseNoPROpenMsg(branch) {
45
+ return `trackfw push --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.`
46
+ }
47
+
48
+ function pushForceLeaseCannotVerifyMsg(branch, cliName, errMessage) {
49
+ return `trackfw push --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.`
50
+ }
51
+
52
+ /**
53
+ * runPush implements the push sequence: branch validation, governance, force-with-lease gate,
54
+ * squash-merge detection, and push. Never commits and never opens a PR/MR.
55
+ *
56
+ * @param {{ dryRun?: boolean, forceWithLease?: boolean }} opts
57
+ * @param {{
58
+ * execGit?: function,
59
+ * checkGovernance?: function,
60
+ * writeErr?: function,
61
+ * configForge?: string,
62
+ * repoDir?: string,
63
+ * availFn?: function,
64
+ * checkPROpen?: function,
65
+ * }} deps
66
+ * @returns {number} exit code (0 = success)
67
+ */
68
+ function runPush(opts, deps) {
69
+ opts = opts || {}
70
+ deps = deps || {}
71
+
72
+ const execGit = deps.execGit || defaultExecGit
73
+ const checkGovernanceFn = deps.checkGovernance || defaultCheckGovernance
74
+ // writeln is injectable (mirrors ship/runner.js) so tests can capture stdout without spawning
75
+ // a real process. Production callers omit it and fall back to process.stdout.
76
+ const writeln = deps.writeln || ((s) => process.stdout.write(s + '\n'))
77
+ // "Error: " prefix — mirrors Go exactly: push.go returns messages as errors, and
78
+ // root.go's ExecuteC path emits `cmd.ErrPrefix() + " " + err.Error() + "\n"` —
79
+ // cmd.ErrPrefix() is "Error:", so the combined line is "Error: <message>\n".
80
+ const writeErr = deps.writeErr || ((s) => process.stderr.write(`Error: ${s}\n`))
81
+
82
+ // Inner wrapper: skips write commands in --dry-run mode.
83
+ function git(args) {
84
+ if (opts.dryRun && isGitWriteCmd(args)) {
85
+ writeln(`[dry-run] git ${args.join(' ')}`)
86
+ return { stdout: '', error: null }
87
+ }
88
+ return execGit(args)
89
+ }
90
+
91
+ // ─── Step 1: Branch validation ─────────────────────────────────────────────
92
+ const branchResult = execGit(['symbolic-ref', '--short', 'HEAD'])
93
+ if (branchResult.error) {
94
+ writeErr(`could not determine current branch (are you in a git repo?): ${branchResult.error.message}`)
95
+ return 1
96
+ }
97
+ const branch = branchResult.stdout.trim()
98
+
99
+ // main/master is blocked unconditionally.
100
+ if (branch === 'main' || branch === 'master') {
101
+ writeErr(`trackfw push cannot run on "${branch}" — use a feature branch:\n git checkout -b feat/<slug>`)
102
+ return 1
103
+ }
104
+
105
+ if (!isShipBranch(branch)) {
106
+ writeErr(
107
+ `branch "${branch}" does not match the required pattern feat|fix|refactor|chore|docs/<slug>\n` +
108
+ 'Rename your branch or create a new one:\n git checkout -b feat/<slug>'
109
+ )
110
+ return 1
111
+ }
112
+
113
+ writeln(`Branch: ${branch}`)
114
+
115
+ // ─── Step 2: Governance ────────────────────────────────────────────────────
116
+ // push never reads the index — no doc-only exception. Governance is either
117
+ // skipped (chore/docs) or enforced (feat/fix/refactor), nothing in between.
118
+ if (!isGatedShipBranch(branch)) {
119
+ // chore/docs: housekeeping types already exempted from this gate by
120
+ // `trackfw branch new` and `trackfw commit` — push without it too.
121
+ writeln('Governance: skipped (chore/docs branch)')
122
+ } else {
123
+ const violations = checkGovernanceFn()
124
+ if (violations.length > 0) {
125
+ writeln('\nGovernance check failed:')
126
+ for (const v of violations) {
127
+ writeln(` ${v}`)
128
+ }
129
+ writeln('\nCreate the required artifacts before running push:')
130
+ writeln(' trackfw req new "<title>"')
131
+ writeln(' trackfw roadmap new "<title>"')
132
+ writeln(' trackfw roadmap move <name> wip')
133
+ writeln("\nNote: this governance check is a hard gate — it is not affected by lenient")
134
+ writeln("mode or per-rule severity configured in trackfw.yaml. If 'trackfw validate'")
135
+ writeln("passes but 'trackfw push' aborts here, you likely have lenient mode")
136
+ writeln("configured — push always requires REQ + roadmap in wip/.")
137
+ writeErr(`governance check failed: ${violations.length} violation(s)`)
138
+ return 1
139
+ }
140
+
141
+ writeln('Governance: OK')
142
+ }
143
+
144
+ // ─── Step 2.5: force-with-lease gate ───────────────────────────────────────
145
+ // Runs before any write (push) — a refusal here never leaves the caller unable to push.
146
+ // Read-only, so it runs in --dry-run too.
147
+ if (opts.forceWithLease) {
148
+ const { stdout: gateRemoteURLRaw } = execGit(['remote', 'get-url', 'origin'])
149
+ const gateRemoteURL = (gateRemoteURLRaw || '').trim()
150
+
151
+ let resolution
152
+ try {
153
+ resolution = forgeResolve({
154
+ flagForge: '',
155
+ configForge: deps.configForge || '',
156
+ remoteURL: gateRemoteURL,
157
+ repoDir: deps.repoDir || '',
158
+ })
159
+ } catch (resErr) {
160
+ writeErr(resErr.message)
161
+ return 1
162
+ }
163
+
164
+ const adapter = forgeAdapter(resolution.forge, deps.availFn || undefined)
165
+ if (resolution.forge === 'manual' || !adapter.available) {
166
+ writeErr(PUSH_FORCE_LEASE_NO_FORGE_CLI_MSG)
167
+ return 1
168
+ }
169
+
170
+ const checkPROpen = deps.checkPROpen || defaultCheckPROpen
171
+ let open
172
+ try {
173
+ open = checkPROpen(adapter, branch)
174
+ } catch (prErr) {
175
+ writeErr(pushForceLeaseCannotVerifyMsg(branch, adapter.cliName, prErr.message))
176
+ return 1
177
+ }
178
+ if (!open) {
179
+ writeErr(pushForceLeaseNoPROpenMsg(branch))
180
+ return 1
181
+ }
182
+
183
+ writeln(`force-with-lease: open ${adapter.noun} confirmed for "${branch}" (${resolution.forge}).`)
184
+ }
185
+
186
+ // ─── Step 3: Squash-merge detection ────────────────────────────────────────
187
+ if (opts.dryRun) {
188
+ writeln('[dry-run] git fetch origin --prune')
189
+ } else {
190
+ const { error: fetchErr } = execGit(['fetch', 'origin', '--prune'])
191
+ if (fetchErr) {
192
+ writeln('Warning: could not fetch origin (offline or no remote); skipping squash-merge check.')
193
+ } else {
194
+ detectPendingSquashMerges(branch, execGit, writeln)
195
+ }
196
+ }
197
+
198
+ // ─── Step 4: Push ──────────────────────────────────────────────────────────
199
+ let pushArgs = buildPushArgs(branch, execGit)
200
+ if (opts.forceWithLease) {
201
+ // Fixed position: push --force-with-lease [-u] origin <branch> — identical
202
+ // across the 3 CLIs (the parity gate compares this literally).
203
+ pushArgs = [pushArgs[0], '--force-with-lease', ...pushArgs.slice(1)]
204
+ }
205
+ const { error: pushErr } = git(pushArgs)
206
+ if (pushErr) {
207
+ writeErr(`git push failed: ${pushErr.message}`)
208
+ return 1
209
+ }
210
+
211
+ if (!opts.dryRun) {
212
+ writeln(`Pushed: ${branch} → origin/${branch}`)
213
+ }
214
+
215
+ return 0
216
+ }
217
+
218
+ module.exports = {
219
+ runPush,
220
+ PUSH_FORCE_LEASE_NO_FORGE_CLI_MSG,
221
+ pushForceLeaseNoPROpenMsg,
222
+ pushForceLeaseCannotVerifyMsg,
223
+ }