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.
- package/README.md +0 -3
- package/bin/trackfw +38 -1
- package/package.json +1 -1
- package/src/branch/prune.js +450 -0
- package/src/branch/runner.js +33 -20
- package/src/changelog/index.js +114 -0
- package/src/commands/branch.js +59 -9
- package/src/commands/changelog.js +29 -0
- package/src/commands/commit.js +60 -0
- package/src/commands/discover.js +75 -0
- package/src/commands/doctor.js +32 -0
- package/src/commands/index.js +23 -4
- package/src/commands/integrations.js +23 -1
- package/src/commands/serve.js +52 -4
- package/src/commands/ship.js +4 -3
- package/src/commands/thirdparty.js +384 -0
- package/src/commands/update-harness.js +320 -12
- package/src/commands/update.js +3 -1
- package/src/commit/runner.js +352 -0
- package/src/config/index.js +27 -0
- package/src/generators/hooks.js +925 -6
- package/src/generators/init.js +28 -6
- package/src/i18n/locales/en-US.json +0 -21
- package/src/i18n/locales/es-ES.json +0 -21
- package/src/i18n/locales/pt-BR.json +0 -21
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/index.js +19 -2
- package/src/integrations/manager.js +133 -19
- package/src/lib/fatal-error.js +64 -0
- package/src/lib/unknown-command.js +82 -0
- package/src/ship/runner.js +259 -95
- package/src/thirdparty/fetch.js +140 -0
- package/src/thirdparty/markers.js +207 -0
- package/src/thirdparty/provenance.js +114 -0
- package/src/thirdparty/quarantine.js +113 -0
- package/src/thirdparty/references.js +194 -0
- package/src/validator/index.js +1060 -27
- package/src/commands/plugins.js +0 -184
package/src/ship/runner.js
CHANGED
|
@@ -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
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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
|
|
175
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
`
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
-
|
|
327
|
-
if (
|
|
328
|
-
|
|
329
|
-
'
|
|
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
|
-
|
|
339
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|