trackfw 6.10.0 → 7.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.
Files changed (38) hide show
  1. package/README.md +0 -3
  2. package/bin/trackfw +38 -1
  3. package/package.json +1 -1
  4. package/src/branch/prune.js +450 -0
  5. package/src/branch/runner.js +33 -20
  6. package/src/changelog/index.js +114 -0
  7. package/src/commands/branch.js +59 -9
  8. package/src/commands/changelog.js +29 -0
  9. package/src/commands/commit.js +60 -0
  10. package/src/commands/discover.js +75 -0
  11. package/src/commands/doctor.js +32 -0
  12. package/src/commands/index.js +23 -4
  13. package/src/commands/integrations.js +23 -1
  14. package/src/commands/serve.js +52 -4
  15. package/src/commands/ship.js +4 -3
  16. package/src/commands/thirdparty.js +384 -0
  17. package/src/commands/update-harness.js +320 -12
  18. package/src/commands/update.js +3 -1
  19. package/src/commit/runner.js +352 -0
  20. package/src/config/index.js +27 -0
  21. package/src/generators/hooks.js +925 -6
  22. package/src/generators/init.js +28 -6
  23. package/src/i18n/locales/en-US.json +0 -21
  24. package/src/i18n/locales/es-ES.json +0 -21
  25. package/src/i18n/locales/pt-BR.json +0 -21
  26. package/src/integrations/doctor.js +0 -0
  27. package/src/integrations/index.js +19 -2
  28. package/src/integrations/manager.js +133 -19
  29. package/src/lib/fatal-error.js +64 -0
  30. package/src/lib/unknown-command.js +82 -0
  31. package/src/ship/runner.js +259 -95
  32. package/src/thirdparty/fetch.js +140 -0
  33. package/src/thirdparty/markers.js +207 -0
  34. package/src/thirdparty/provenance.js +114 -0
  35. package/src/thirdparty/quarantine.js +113 -0
  36. package/src/thirdparty/references.js +194 -0
  37. package/src/validator/index.js +1060 -27
  38. package/src/commands/plugins.js +0 -184
@@ -8,11 +8,11 @@
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.
@@ -28,11 +28,26 @@ function isGitWriteCmd(args) {
28
28
  }
29
29
 
30
30
  /**
31
- * isShipBranch returns true when branch matches feat|fix|refactor/<slug>.
31
+ * isShipBranch returns true when branch matches feat|fix|refactor|chore|docs/<slug> — the full
32
+ * vocabulary `trackfw ship` accepts on the branch name. feat/fix/refactor are gated on Step 2's
33
+ * branch_has_wip_roadmap governance check (a hard gate not affected by lenient mode); chore/docs
34
+ * are housekeeping types — already exempted from that gate by `trackfw branch new` and
35
+ * `trackfw commit` — and ship without it too.
32
36
  * @param {string} branch
33
37
  * @returns {boolean}
34
38
  */
35
39
  function isShipBranch(branch) {
40
+ return /^(feat|fix|refactor|chore|docs)\/.+/.test(branch)
41
+ }
42
+
43
+ /**
44
+ * isGatedShipBranch returns true when branch matches feat|fix|refactor/<slug> — the subset of
45
+ * isShipBranch's vocabulary that requires Step 2's branch_has_wip_roadmap governance check.
46
+ * chore/docs branches satisfy isShipBranch but return false here.
47
+ * @param {string} branch
48
+ * @returns {boolean}
49
+ */
50
+ function isGatedShipBranch(branch) {
36
51
  return /^(feat|fix|refactor)\/.+/.test(branch)
37
52
  }
38
53
 
@@ -62,64 +77,25 @@ function defaultCheckGovernance() {
62
77
 
63
78
  /**
64
79
  * checkShipGovernance — hard gate (bypasses config/baseline/lenient).
65
- * Checks:
66
- * 1. Current branch has a matching roadmap in wip/
67
- * 2. WIP roadmaps have a linked REQ
80
+ *
81
+ * Delegates entirely to the shared validator functions already used by `trackfw validate`,
82
+ * `trackfw branch new` and `trackfw commit` — never reimplement this logic locally. Byte-
83
+ * identical to Go's CheckShipGovernance (internal/validator/validator.go), which has the same
84
+ * no-args shape and the same two checks:
85
+ * 1. validateBranchHasWIPRoadmap — current branch (feat/fix/refactor only; re-derived
86
+ * internally from TRACKFW_BRANCH/git, same as Go) has a matching roadmap in wip/ OR done/
87
+ * 2. validateWIPHasREQ — every roadmap in wip/ has a linked REQ
88
+ * Before this ML, this function reimplemented both checks locally with its own wording (no
89
+ * "nor done/" clause, no done/ directory scan at all) — see
90
+ * vault/notes/ship-checkgovernance-error-stream-wording-divergence-2026-08-16.md. Duplicating
91
+ * the check was the actual root cause of the drift, not just the wording.
68
92
  * @returns {string[]} violation messages
69
93
  */
70
94
  function checkShipGovernance() {
71
- const violations = []
72
-
73
- // Resolve roadmap dir via config module (single source of truth; default: docs/roadmaps)
74
- const roadmapDir = resolveRoadmapDir()
75
- const wipDir = path.join(roadmapDir, 'wip')
76
-
77
- // Get branch name
78
- const branchResult = defaultExecGit(['symbolic-ref', '--short', 'HEAD'])
79
- const branch = branchResult.error ? '' : branchResult.stdout.trim()
80
-
81
- // Check branch has matching roadmap in wip/
82
- if (branch && isShipBranch(branch)) {
83
- const slug = normalizeBranchSlug(branch.split('/').slice(1).join('/'))
84
- let hasMatch = false
85
- let wipFiles = []
86
-
87
- if (fs.existsSync(wipDir)) {
88
- wipFiles = fs.readdirSync(wipDir).filter(f => f.endsWith('.md'))
89
- for (const f of wipFiles) {
90
- if (normalizeBranchSlug(f).includes(slug)) {
91
- hasMatch = true
92
- break
93
- }
94
- }
95
- }
96
-
97
- if (wipFiles.length === 0) {
98
- violations.push(
99
- `branch "${branch}" is a feat/fix/refactor branch but no roadmap is in wip/ — create governance artifacts first:\n` +
100
- ' trackfw req new "<title>"\n' +
101
- ' trackfw roadmap new "<title>"\n' +
102
- ' trackfw roadmap move <name> wip'
103
- )
104
- } else if (!hasMatch) {
105
- violations.push(
106
- `branch "${branch}" has no matching roadmap in wip/ (found: ${wipFiles.join(', ')}) — ` +
107
- 'include the branch slug in the roadmap filename'
108
- )
109
- }
110
-
111
- // Check WIP roadmaps have a linked REQ
112
- if (hasMatch && fs.existsSync(wipDir)) {
113
- for (const f of wipFiles) {
114
- const content = fs.readFileSync(path.join(wipDir, f), 'utf8')
115
- if (!content.includes('REQ:') && !content.includes('req:')) {
116
- violations.push(`roadmap "${f}" is in wip but has no linked REQ`)
117
- }
118
- }
119
- }
120
- }
121
-
122
- return violations
95
+ return [
96
+ ...validator.validateBranchHasWIPRoadmap(),
97
+ ...validator.validateWIPHasREQ(),
98
+ ]
123
99
  }
124
100
 
125
101
  /**
@@ -155,8 +131,17 @@ function normalizeBranchSlug(value) {
155
131
  }
156
132
 
157
133
  /**
158
- * detectPendingSquashMerges warns about remote branches with non-empty diffs vs origin/main.
159
- * Non-blocking.
134
+ * detectPendingSquashMerges warns about remote branches with genuinely pending work vs
135
+ * origin/main. Non-blocking.
136
+ *
137
+ * Reuses evaluateBranchIntegration (branch/prune.js) — the same touched-files heuristic
138
+ * `trackfw branch prune` uses — instead of a naive bidirectional `git diff origin/main <branch>
139
+ * --stat`. The naive check false-positives on a branch that was squash-merged and is now merely
140
+ * stale (main advanced further afterwards): it always shows a non-empty diff even though nothing
141
+ * from the branch is actually missing from main. Only DECISION.PENDING_WORK — genuine,
142
+ * unintegrated work — surfaces this warning; every other decision (no_own_work,
143
+ * content_identical, review_doc_config, no_merge_base, eval_error) stays silent, the same
144
+ * posture the naive check had on error (skip, no warning).
160
145
  * @param {string} currentBranch
161
146
  * @param {function} execGit
162
147
  * @param {function} writeln
@@ -171,9 +156,8 @@ function detectPendingSquashMerges(currentBranch, execGit, writeln) {
171
156
  const shortName = candidate.replace(/^origin\//, '')
172
157
  if (shortName === currentBranch) continue
173
158
 
174
- const { stdout: diff, error: derr } = execGit(['diff', 'origin/main', candidate, '--stat'])
175
- if (derr) continue
176
- if (diff.trim()) {
159
+ const evalResult = evaluateBranchIntegration(candidate, execGit)
160
+ if (evalResult.decision === BRANCH_PRUNE_DECISION.PENDING_WORK) {
177
161
  writeln(`Warning: branch "${shortName}" appears to have unmerged changes vs origin/main.`)
178
162
  }
179
163
  }
@@ -217,6 +201,137 @@ function firstLine(s) {
217
201
  return idx >= 0 ? s.slice(0, idx) : s
218
202
  }
219
203
 
204
+ /**
205
+ * splitNonEmptyLines splits git output (e.g. `diff --cached --name-only`) into an array
206
+ * of trimmed, non-empty lines.
207
+ * @param {string} s
208
+ * @returns {string[]}
209
+ */
210
+ function splitNonEmptyLines(s) {
211
+ const trimmed = (s || '').trim()
212
+ if (!trimmed) return []
213
+ return trimmed
214
+ .split('\n')
215
+ .map((line) => line.trim())
216
+ .filter((line) => line !== '')
217
+ }
218
+
219
+ /**
220
+ * allDocOnly returns true when there is at least one staged file and every staged file is
221
+ * doc-only: under docs/ or vault/ (path prefix), or has a .md extension. A single file
222
+ * outside that criterion makes it return false. Mirrors the doc-only exception documented
223
+ * in CLAUDE.md §7 ("Alteração doc-only (markdown, comentários)").
224
+ * @param {string[]} files
225
+ * @returns {boolean}
226
+ */
227
+ function allDocOnly(files) {
228
+ if (!files || files.length === 0) return false
229
+ for (const f of files) {
230
+ if (f.startsWith('docs/') || f.startsWith('vault/') || f.endsWith('.md')) continue
231
+ return false
232
+ }
233
+ return true
234
+ }
235
+
236
+ /**
237
+ * commitMessageSep delimits full commit messages (%B) in the output of gitCommitsSince's
238
+ * `git log --format=%B<sep>`. Same non-printable separator used by the Go implementation
239
+ * (internal/commands/ship.go) — cannot appear in a real commit message.
240
+ */
241
+ const COMMIT_MESSAGE_SEP = '\x1e'
242
+
243
+ /**
244
+ * defaultBaseBranch resolves the repository's default branch for `git log <base>..HEAD`.
245
+ * Tries `git symbolic-ref refs/remotes/origin/HEAD` (format "refs/remotes/origin/main" —
246
+ * only the name after the last slash is kept) and falls back to "main" when that fails or
247
+ * yields nothing.
248
+ * @param {function} execGit
249
+ * @returns {string}
250
+ */
251
+ function defaultBaseBranch(execGit) {
252
+ const { stdout, error } = execGit(['symbolic-ref', 'refs/remotes/origin/HEAD'])
253
+ if (error) return 'main'
254
+ const out = (stdout || '').trim()
255
+ const idx = out.lastIndexOf('/')
256
+ if (idx < 0 || idx + 1 >= out.length) return 'main'
257
+ return out.slice(idx + 1)
258
+ }
259
+
260
+ /**
261
+ * gitCommitsSince returns the full message (subject + body) of every non-merge commit in
262
+ * base..HEAD, most-recent-first (git log's natural order). Returns [] on any git error or
263
+ * when the range is empty.
264
+ * @param {string} base
265
+ * @param {function} execGit
266
+ * @returns {string[]}
267
+ */
268
+ function gitCommitsSince(base, execGit) {
269
+ const { stdout, error } = execGit(['log', `${base}..HEAD`, '--no-merges', `--format=%B${COMMIT_MESSAGE_SEP}`])
270
+ if (error) return []
271
+ const out = (stdout || '').trim()
272
+ if (!out) return []
273
+ const parts = out.split(COMMIT_MESSAGE_SEP)
274
+ const commits = []
275
+ for (let p of parts) {
276
+ p = p.replace(/^\n+|\n+$/g, '')
277
+ if (p.trim() === '') continue
278
+ commits.push(p)
279
+ }
280
+ return commits
281
+ }
282
+
283
+ /**
284
+ * buildPRBody constructs the PR/MR body. With 0 or 1 non-merge commit on the branch (the
285
+ * trivial case — just the commit `ship` itself made), it keeps the original minimal body,
286
+ * not a regression. With 2+ commits, it aggregates the branch's commit history:
287
+ *
288
+ * ## Commits
289
+ * - <subject of commit 1>
290
+ * - <subject of commit 2>
291
+ *
292
+ * ## Detalhes
293
+ * <full body of each commit that has one, in blocks>
294
+ *
295
+ * ---
296
+ * Branch: <branch>
297
+ *
298
+ * @param {string} branch
299
+ * @param {string[]} commits
300
+ * @returns {string}
301
+ */
302
+ function buildPRBody(branch, commits) {
303
+ if (!commits || commits.length <= 1) {
304
+ return `Branch: ${branch}\n\nCreated by trackfw ship.`
305
+ }
306
+
307
+ const subjects = []
308
+ const details = []
309
+ for (const c of commits) {
310
+ const nlIdx = c.indexOf('\n')
311
+ const subject = (nlIdx >= 0 ? c.slice(0, nlIdx) : c).trim()
312
+ if (subject === '') continue
313
+ subjects.push(subject)
314
+ if (nlIdx >= 0) {
315
+ const bodyText = c.slice(nlIdx + 1).trim()
316
+ if (bodyText !== '') {
317
+ details.push(`**${subject}**\n\n${bodyText}`)
318
+ }
319
+ }
320
+ }
321
+
322
+ let b = '## Commits\n\n'
323
+ for (const s of subjects) {
324
+ b += `- ${s}\n`
325
+ }
326
+ if (details.length > 0) {
327
+ b += '\n## Detalhes\n\n'
328
+ b += details.join('\n\n')
329
+ b += '\n'
330
+ }
331
+ b += `\n---\nBranch: ${branch}\n`
332
+ return b
333
+ }
334
+
220
335
  /**
221
336
  * buildForgeCreateArgs appends --title and --body (or --description for azure)
222
337
  * to a copy of adapter.cliArgs. Never mutates the original array.
@@ -248,6 +363,15 @@ function runShip(opts, deps = {}) {
248
363
  const execGit = deps.execGit || defaultExecGit
249
364
  const checkGovernanceFn = deps.checkGovernance || defaultCheckGovernance
250
365
  const writeln = deps.writeln || ((s) => process.stdout.write(s + '\n'))
366
+ // writeErr — the terminal error line for every abort path below, written to STDERR with the
367
+ // "Error: " prefix. Mirrors Go exactly: ship.go returns these same messages as a bare
368
+ // fmt.Errorf(...), and internal/commands/root.go's Execute() wrapper prints them to stderr as
369
+ // `fmt.Fprintln(os.Stderr, cmd.ErrPrefix(), err.Error())` — cmd.ErrPrefix() is "Error:", and
370
+ // Fprintln inserts exactly one space between operands, so the wire format is
371
+ // "Error: <message>\n". Every multi-line detail printed BEFORE the abort (violation lists,
372
+ // remediation hints, "Note: ..." blocks) stays on stdout via writeln, same as Go's deps.out —
373
+ // only this final one-line (or one-block) summary moves to stderr.
374
+ const writeErr = deps.writeErr || ((s) => process.stderr.write(`Error: ${s}\n`))
251
375
 
252
376
  // Inner git wrapper: skips write commands in dry-run mode.
253
377
  function git(args) {
@@ -258,22 +382,30 @@ function runShip(opts, deps = {}) {
258
382
  return execGit(args)
259
383
  }
260
384
 
385
+ // ─── Step 0: staged files ───────────────────────────────────────────────────
386
+ // Read once, up front, so Steps 1 and 2 can grant a doc-only exception before they run —
387
+ // and so Step 4 below reuses the same read instead of querying git twice.
388
+ const { stdout: stagedOut } = execGit(['diff', '--cached', '--name-only'])
389
+ const stagedFiles = splitNonEmptyLines(stagedOut)
390
+ const docOnly = allDocOnly(stagedFiles)
391
+
261
392
  // ─── Step 1: Branch validation ─────────────────────────────────────────────
262
393
  const branchResult = execGit(['symbolic-ref', '--short', 'HEAD'])
263
394
  if (branchResult.error) {
264
- writeln(`error: could not determine current branch (are you in a git repo?): ${branchResult.error.message}`)
395
+ writeErr(`could not determine current branch (are you in a git repo?): ${branchResult.error.message}`)
265
396
  return 1
266
397
  }
267
398
  const branch = branchResult.stdout.trim()
268
399
 
400
+ // main/master is blocked unconditionally — the doc-only exception never applies here.
269
401
  if (branch === 'main' || branch === 'master') {
270
- writeln(`error: trackfw ship cannot run on "${branch}" — use a feature branch:\n git checkout -b feat/<slug>`)
402
+ writeErr(`trackfw ship cannot run on "${branch}" — use a feature branch:\n git checkout -b feat/<slug>`)
271
403
  return 1
272
404
  }
273
405
 
274
- if (!isShipBranch(branch)) {
275
- writeln(
276
- `error: branch "${branch}" does not match the required pattern feat|fix|refactor/<slug>\n` +
406
+ if (!docOnly && !isShipBranch(branch)) {
407
+ writeErr(
408
+ `branch "${branch}" does not match the required pattern feat|fix|refactor|chore|docs/<slug>\n` +
277
409
  'Rename your branch or create a new one:\n git checkout -b feat/<slug>'
278
410
  )
279
411
  return 1
@@ -282,25 +414,35 @@ function runShip(opts, deps = {}) {
282
414
  writeln(`Branch: ${branch}`)
283
415
 
284
416
  // ─── Step 2: Governance ────────────────────────────────────────────────────
285
- const violations = checkGovernanceFn()
286
- if (violations.length > 0) {
287
- writeln('\nGovernance check failed:')
288
- for (const v of violations) {
289
- writeln(` ${v}`)
417
+ // Doc-only changes (all staged files under docs/, vault/, or *.md) are exempt from
418
+ // REQ+roadmap governance — mirrors the CLAUDE.md §7 exception for doc-only changes.
419
+ if (docOnly) {
420
+ writeln('Governance: skipped (doc-only change)')
421
+ } else if (isShipBranch(branch) && !isGatedShipBranch(branch)) {
422
+ // chore/docs: housekeeping types already exempted from this gate by
423
+ // `trackfw branch new` and `trackfw commit` — ship without it too.
424
+ writeln('Governance: skipped (chore/docs branch)')
425
+ } else {
426
+ const violations = checkGovernanceFn()
427
+ if (violations.length > 0) {
428
+ writeln('\nGovernance check failed:')
429
+ for (const v of violations) {
430
+ writeln(` ${v}`)
431
+ }
432
+ writeln('\nCreate the required artifacts before running ship:')
433
+ writeln(' trackfw req new "<title>"')
434
+ writeln(' trackfw roadmap new "<title>"')
435
+ writeln(' trackfw roadmap move <name> wip')
436
+ writeln("\nNote: this governance check is a hard gate — it is not affected by lenient")
437
+ writeln("mode or per-rule severity configured in trackfw.yaml. If 'trackfw validate'")
438
+ writeln("passes but 'trackfw ship' aborts here, you likely have lenient mode")
439
+ writeln("configured — ship always requires REQ + roadmap in wip/.")
440
+ writeErr(`governance check failed: ${violations.length} violation(s)`)
441
+ return 1
290
442
  }
291
- writeln('\nCreate the required artifacts before running ship:')
292
- writeln(' trackfw req new "<title>"')
293
- writeln(' trackfw roadmap new "<title>"')
294
- writeln(' trackfw roadmap move <name> wip')
295
- writeln("\nNote: this governance check is a hard gate — it is not affected by lenient")
296
- writeln("mode or per-rule severity configured in trackfw.yaml. If 'trackfw validate'")
297
- writeln("passes but 'trackfw ship' aborts here, you likely have lenient mode")
298
- writeln("configured — ship always requires REQ + roadmap in wip/.")
299
- writeln(`\nerror: governance check failed: ${violations.length} violation(s)`)
300
- return 1
301
- }
302
443
 
303
- writeln('Governance: OK')
444
+ writeln('Governance: OK')
445
+ }
304
446
 
305
447
  // ─── Step 3: Squash-merge detection ────────────────────────────────────────
306
448
  if (opts.dryRun) {
@@ -323,10 +465,10 @@ function runShip(opts, deps = {}) {
323
465
  if (diffStatOut) writeln(diffStatOut)
324
466
  writeln('────────────────────────────────────────────────────────\n')
325
467
 
326
- const { stdout: cachedFiles } = execGit(['diff', '--cached', '--name-only'])
327
- if (!cachedFiles.trim()) {
328
- writeln(
329
- 'error: nothing is staged — stage your files explicitly before running ship:\n' +
468
+ // Reuses stagedFiles read at the top of the function (Step 0) — never re-query git here.
469
+ if (stagedFiles.length === 0) {
470
+ writeErr(
471
+ 'nothing is staged — stage your files explicitly before running ship:\n' +
330
472
  ' git add <file1> <file2> ...\n' +
331
473
  "Never use 'git add .' or 'git add -A'"
332
474
  )
@@ -335,8 +477,8 @@ function runShip(opts, deps = {}) {
335
477
 
336
478
  // ─── Step 5: Commit ────────────────────────────────────────────────────────
337
479
  if (!opts.message) {
338
- writeln(
339
- 'error: commit message is required — use -m:\n' +
480
+ writeErr(
481
+ 'commit message is required — use -m:\n' +
340
482
  ' trackfw ship -m "feat(<scope>): <description>"'
341
483
  )
342
484
  return 1
@@ -344,7 +486,7 @@ function runShip(opts, deps = {}) {
344
486
 
345
487
  const { error: commitErr } = git(['commit', '-m', opts.message])
346
488
  if (commitErr) {
347
- writeln(`error: git commit failed: ${commitErr.message}`)
489
+ writeErr(`git commit failed: ${commitErr.message}`)
348
490
  return 1
349
491
  }
350
492
 
@@ -356,7 +498,7 @@ function runShip(opts, deps = {}) {
356
498
  const pushArgs = buildPushArgs(branch, execGit)
357
499
  const { error: pushErr } = git(pushArgs)
358
500
  if (pushErr) {
359
- writeln(`error: git push failed: ${pushErr.message}`)
501
+ writeErr(`git push failed: ${pushErr.message}`)
360
502
  return 1
361
503
  }
362
504
 
@@ -391,7 +533,23 @@ function runShip(opts, deps = {}) {
391
533
  return 0
392
534
  }
393
535
 
536
+ // Title/body computed once for every remaining branch below (dry-run and real CLI
537
+ // invocation alike). git log/diff are read-only — they run in --dry-run mode too, same
538
+ // as the staged-files read in Step 0.
539
+ //
540
+ // Design decision (documented per roadmap ML-1A, mirrored from the Go implementation):
541
+ // the title is always firstLine(opts.message), the -m message passed to this very `ship`
542
+ // call, even when the branch carries multiple prior commits. Deriving a distinct "PR
543
+ // title" from N unrelated commit subjects would need a heuristic with no unambiguous
544
+ // answer — the simplest, least surprising rule is that -m is the PR's summary.
545
+ const base = defaultBaseBranch(execGit)
546
+ const commits = gitCommitsSince(base, execGit)
547
+ const title = firstLine(opts.message || '')
548
+ const body = buildPRBody(branch, commits)
549
+
394
550
  if (opts.dryRun) {
551
+ writeln(`[dry-run] Title: ${title}`)
552
+ writeln(`[dry-run] Body:\n${body}`)
395
553
  if (!adapter.available && resolution.forge !== 'manual') {
396
554
  const url = adapter.fallbackURL(remoteURL, branch)
397
555
  if (url) {
@@ -423,8 +581,6 @@ function runShip(opts, deps = {}) {
423
581
  }
424
582
 
425
583
  // CLI is available — invoke it.
426
- const title = firstLine(opts.message || '')
427
- const body = `Branch: ${branch}\n\nCreated by trackfw ship.`
428
584
  const cliArgs = buildForgeCreateArgs(adapter, title, body)
429
585
  const execForgeCLI = deps.execForgeCLI || defaultExecForgeCLI
430
586
  const cliErr = execForgeCLI(adapter.cliName, cliArgs)
@@ -443,6 +599,7 @@ function runShip(opts, deps = {}) {
443
599
  module.exports = {
444
600
  runShip,
445
601
  isShipBranch,
602
+ isGatedShipBranch,
446
603
  isGitWriteCmd,
447
604
  normalizeBranchSlug,
448
605
  checkShipGovernance,
@@ -451,4 +608,11 @@ module.exports = {
451
608
  GIT_WRITE_COMMANDS,
452
609
  buildForgeCreateArgs,
453
610
  firstLine,
611
+ allDocOnly,
612
+ splitNonEmptyLines,
613
+ defaultBaseBranch,
614
+ gitCommitsSince,
615
+ buildPRBody,
616
+ COMMIT_MESSAGE_SEP,
617
+ detectPendingSquashMerges,
454
618
  }
@@ -0,0 +1,140 @@
1
+ 'use strict'
2
+
3
+ // Node port of internal/thirdparty/fetch.go — the network fetch primitive
4
+ // used by the two-phase third-party artifact gate (skills/agents installed
5
+ // from a URL). See:
6
+ // docs/adr/ADR-2026-08-15-gate-de-duas-fases-para-artefatos-de-terceiro-quarentena-parecer-vinculado-por-checksum-e-deteccao-por-proveniencia-versionada.md
7
+ // (D7).
8
+
9
+ const https = require('node:https')
10
+ const { URL } = require('node:url')
11
+
12
+ // MAX_CONTENT_SIZE is the network fetch cap for third-party text artifacts
13
+ // (D7): 2 MiB — mirrors internal/thirdparty/fetch.go:maxContentSize.
14
+ const MAX_CONTENT_SIZE = 2 * 1024 * 1024
15
+
16
+ // MAX_REDIRECTS mirrors internal/thirdparty/fetch.go:maxRedirects (3). Note
17
+ // the Go reference's own net/http.Client.CheckRedirect semantics: the check
18
+ // runs BEFORE following a redirect and compares the count of requests
19
+ // ALREADY COMPLETED (via.length, which includes the request that just
20
+ // returned the redirect) against maxRedirects — so in practice only
21
+ // (maxRedirects - 1) redirects are ever followed before the next one is
22
+ // refused. Reproduced here byte-for-byte via requestsCompleted, not
23
+ // "fixed" to what the doc comment alone might suggest.
24
+ const MAX_REDIRECTS = 3
25
+
26
+ const TIMEOUT_MS = 30 * 1000
27
+
28
+ // ALLOWED_CONTENT_TYPES mirrors internal/thirdparty/fetch.go:allowedContentTypes.
29
+ const ALLOWED_CONTENT_TYPES = new Set(['text/plain', 'text/markdown', 'text/x-markdown'])
30
+
31
+ // contentTypeAllowed mirrors internal/thirdparty/fetch.go:contentTypeAllowed
32
+ // — tolerates an optional "; charset=..." (or any other parameter) suffix.
33
+ function contentTypeAllowed(contentType) {
34
+ const raw = String(contentType || '')
35
+ const semi = raw.indexOf(';')
36
+ const base = (semi >= 0 ? raw.slice(0, semi) : raw).trim().toLowerCase()
37
+ return ALLOWED_CONTENT_TYPES.has(base)
38
+ }
39
+
40
+ // api is the module.exports object itself, referenced internally via
41
+ // `api.requestOnce(...)` so tests can substitute the single HTTP round
42
+ // trip without a real TLS socket — mirrors the thirdPartyFetch
43
+ // package-var substitution pattern already used at the command layer
44
+ // (npm/src/commands/thirdparty.js). Added in ML-4C to close a coverage
45
+ // gap hefesto-tf found: the resp.statusCode !== 200 branch below existed
46
+ // but was never exercised by any test.
47
+ const api = {}
48
+
49
+ function requestOnce(rawURL) {
50
+ return new Promise((resolve, reject) => {
51
+ const req = https.get(rawURL, { timeout: TIMEOUT_MS }, res => {
52
+ const chunks = []
53
+ let total = 0
54
+ let tooLarge = false
55
+ res.on('data', chunk => {
56
+ if (tooLarge) return
57
+ total += chunk.length
58
+ if (total > MAX_CONTENT_SIZE) {
59
+ tooLarge = true
60
+ res.destroy()
61
+ return
62
+ }
63
+ chunks.push(chunk)
64
+ })
65
+ res.on('end', () => {
66
+ resolve({ statusCode: res.statusCode, headers: res.headers, body: Buffer.concat(chunks), tooLarge })
67
+ })
68
+ res.on('error', err => reject(new Error(`failed to read response body: ${err.message}`)))
69
+ })
70
+ req.on('timeout', () => req.destroy(new Error('fetch failed: request timed out after 30s')))
71
+ req.on('error', err => reject(new Error(`fetch failed: ${err.message}`)))
72
+ })
73
+ }
74
+
75
+ // fetchOnce follows redirects with the same accounting as Go's
76
+ // fetchClient.CheckRedirect (see MAX_REDIRECTS doc above). requestsCompleted
77
+ // starts at 0 and is incremented after every completed HTTP request.
78
+ async function fetchOnce(rawURL, requestsCompleted) {
79
+ let parsed
80
+ try {
81
+ parsed = new URL(rawURL)
82
+ } catch (err) {
83
+ throw new Error(`invalid URL "${rawURL}": ${err.message}`)
84
+ }
85
+ if (parsed.protocol !== 'https:') {
86
+ throw new Error(`refused: URL scheme must be https, got "${parsed.protocol.replace(/:$/, '')}"`)
87
+ }
88
+
89
+ const res = await api.requestOnce(rawURL)
90
+ requestsCompleted += 1
91
+
92
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
93
+ if (requestsCompleted >= MAX_REDIRECTS) {
94
+ throw new Error(`fetch failed: stopped after ${MAX_REDIRECTS} redirects`)
95
+ }
96
+ let next
97
+ try {
98
+ next = new URL(res.headers.location, rawURL).toString()
99
+ } catch (err) {
100
+ throw new Error(`invalid redirect location: ${err.message}`)
101
+ }
102
+ const nextScheme = new URL(next).protocol
103
+ if (nextScheme !== 'https:') {
104
+ throw new Error(`redirect to non-https URL refused: ${next}`)
105
+ }
106
+ return fetchOnce(next, requestsCompleted)
107
+ }
108
+
109
+ if (res.statusCode !== 200) {
110
+ throw new Error(`fetch failed: HTTP ${res.statusCode} for ${rawURL}`)
111
+ }
112
+
113
+ const contentType = res.headers['content-type']
114
+ if (!contentTypeAllowed(contentType)) {
115
+ throw new Error(`refused: unsupported Content-Type "${contentType || ''}" (allowed: text/plain, text/markdown, text/x-markdown)`)
116
+ }
117
+
118
+ if (res.tooLarge) {
119
+ throw new Error(`refused: content exceeds ${MAX_CONTENT_SIZE} bytes`)
120
+ }
121
+
122
+ return res.body
123
+ }
124
+
125
+ // fetch downloads the content at rawURL under the D7 network policy:
126
+ // https-only (validated before the first request), a 30s timeout, at most
127
+ // (MAX_REDIRECTS - 1) redirect hops (each revalidated for https), a 2 MiB
128
+ // size cap, and a Content-Type allowlist. Mirrors
129
+ // internal/thirdparty/fetch.go:Fetch.
130
+ async function fetch(rawURL) {
131
+ return fetchOnce(rawURL, 0)
132
+ }
133
+
134
+ api.fetch = fetch
135
+ api.contentTypeAllowed = contentTypeAllowed
136
+ api.requestOnce = requestOnce
137
+ api.MAX_CONTENT_SIZE = MAX_CONTENT_SIZE
138
+ api.MAX_REDIRECTS = MAX_REDIRECTS
139
+
140
+ module.exports = api