trackfw 6.10.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -3
- package/package.json +1 -1
- package/src/branch/runner.js +33 -20
- package/src/changelog/index.js +114 -0
- package/src/commands/branch.js +12 -9
- package/src/commands/changelog.js +29 -0
- package/src/commands/commit.js +60 -0
- package/src/commands/discover.js +69 -0
- package/src/commands/index.js +22 -4
- package/src/commands/integrations.js +7 -0
- package/src/commands/ship.js +4 -3
- package/src/commands/thirdparty.js +384 -0
- package/src/commands/update-harness.js +3 -1
- 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 +479 -2
- package/src/generators/init.js +28 -6
- package/src/i18n/locales/en-US.json +1 -19
- package/src/i18n/locales/es-ES.json +1 -19
- package/src/i18n/locales/pt-BR.json +1 -19
- package/src/integrations/index.js +19 -2
- package/src/integrations/manager.js +15 -2
- package/src/lib/unknown-command.js +82 -0
- package/src/ship/runner.js +213 -26
- 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 +715 -16
- package/src/commands/plugins.js +0 -184
|
@@ -115,22 +115,6 @@
|
|
|
115
115
|
"tail": "Number of recent transitions to show",
|
|
116
116
|
"header": "── trackfw log ─────────────────────────"
|
|
117
117
|
},
|
|
118
|
-
"plugins": {
|
|
119
|
-
"description": "Manage trackfw plugins",
|
|
120
|
-
"list": {
|
|
121
|
-
"description": "List installed plugins",
|
|
122
|
-
"empty": "No plugins installed. Use `trackfw plugins add <user/repo>` to install one."
|
|
123
|
-
},
|
|
124
|
-
"add": {
|
|
125
|
-
"description": "Install a plugin from GitHub Releases (user/repo or user/repo@tag)",
|
|
126
|
-
"installing": "Installing plugin from {{repo}}...",
|
|
127
|
-
"success": "Plugin \"{{name}}\" installed successfully."
|
|
128
|
-
},
|
|
129
|
-
"remove": {
|
|
130
|
-
"description": "Remove an installed plugin",
|
|
131
|
-
"success": "Plugin \"{{name}}\" removed."
|
|
132
|
-
}
|
|
133
|
-
},
|
|
134
118
|
"metrics": {
|
|
135
119
|
"description": "Show delivery metrics",
|
|
136
120
|
"no_data": "No transitions recorded yet.",
|
|
@@ -138,8 +122,6 @@
|
|
|
138
122
|
"export": "Export metrics to CSV file"
|
|
139
123
|
},
|
|
140
124
|
"errors": {
|
|
141
|
-
"notFound": "Not found: {{path}}"
|
|
142
|
-
"downloadFailed": "download failed: HTTP {{status}} for {{url}}",
|
|
143
|
-
"pluginNotFound": "plugin \"{{name}}\" not found"
|
|
125
|
+
"notFound": "Not found: {{path}}"
|
|
144
126
|
}
|
|
145
127
|
}
|
|
@@ -115,22 +115,6 @@
|
|
|
115
115
|
"tail": "Número de transiciones recientes a mostrar",
|
|
116
116
|
"header": "── trackfw log ─────────────────────────"
|
|
117
117
|
},
|
|
118
|
-
"plugins": {
|
|
119
|
-
"description": "Gestionar plugins de trackfw",
|
|
120
|
-
"list": {
|
|
121
|
-
"description": "Listar plugins instalados",
|
|
122
|
-
"empty": "No hay plugins instalados. Usa `trackfw plugins add <user/repo>` para instalar uno."
|
|
123
|
-
},
|
|
124
|
-
"add": {
|
|
125
|
-
"description": "Instalar un plugin desde GitHub Releases (user/repo o user/repo@tag)",
|
|
126
|
-
"installing": "Instalando plugin desde {{repo}}...",
|
|
127
|
-
"success": "Plugin \"{{name}}\" instalado correctamente."
|
|
128
|
-
},
|
|
129
|
-
"remove": {
|
|
130
|
-
"description": "Eliminar un plugin instalado",
|
|
131
|
-
"success": "Plugin \"{{name}}\" eliminado."
|
|
132
|
-
}
|
|
133
|
-
},
|
|
134
118
|
"metrics": {
|
|
135
119
|
"description": "Muestra métricas de entrega",
|
|
136
120
|
"no_data": "No hay transiciones registradas aún.",
|
|
@@ -138,8 +122,6 @@
|
|
|
138
122
|
"export": "Exportar métricas a archivo CSV"
|
|
139
123
|
},
|
|
140
124
|
"errors": {
|
|
141
|
-
"notFound": "No encontrado: {{path}}"
|
|
142
|
-
"downloadFailed": "fallo en la descarga: HTTP {{status}} para {{url}}",
|
|
143
|
-
"pluginNotFound": "plugin \"{{name}}\" no encontrado"
|
|
125
|
+
"notFound": "No encontrado: {{path}}"
|
|
144
126
|
}
|
|
145
127
|
}
|
|
@@ -115,22 +115,6 @@
|
|
|
115
115
|
"tail": "Número de transições recentes a exibir",
|
|
116
116
|
"header": "── trackfw log ─────────────────────────"
|
|
117
117
|
},
|
|
118
|
-
"plugins": {
|
|
119
|
-
"description": "Gerenciar plugins do trackfw",
|
|
120
|
-
"list": {
|
|
121
|
-
"description": "Listar plugins instalados",
|
|
122
|
-
"empty": "Nenhum plugin instalado. Use `trackfw plugins add <user/repo>` para instalar um."
|
|
123
|
-
},
|
|
124
|
-
"add": {
|
|
125
|
-
"description": "Instalar um plugin do GitHub Releases (user/repo ou user/repo@tag)",
|
|
126
|
-
"installing": "Instalando plugin de {{repo}}...",
|
|
127
|
-
"success": "Plugin \"{{name}}\" instalado com sucesso."
|
|
128
|
-
},
|
|
129
|
-
"remove": {
|
|
130
|
-
"description": "Remover um plugin instalado",
|
|
131
|
-
"success": "Plugin \"{{name}}\" removido."
|
|
132
|
-
}
|
|
133
|
-
},
|
|
134
118
|
"metrics": {
|
|
135
119
|
"description": "Exibe métricas de delivery",
|
|
136
120
|
"no_data": "Nenhuma transição registrada ainda.",
|
|
@@ -138,8 +122,6 @@
|
|
|
138
122
|
"export": "Exportar métricas para arquivo CSV"
|
|
139
123
|
},
|
|
140
124
|
"errors": {
|
|
141
|
-
"notFound": "Não encontrado: {{path}}"
|
|
142
|
-
"downloadFailed": "falha no download: HTTP {{status}} para {{url}}",
|
|
143
|
-
"pluginNotFound": "plugin \"{{name}}\" não encontrado"
|
|
125
|
+
"notFound": "Não encontrado: {{path}}"
|
|
144
126
|
}
|
|
145
127
|
}
|
|
@@ -8,6 +8,7 @@ const { IntegrationManager } = require('./manager')
|
|
|
8
8
|
const { legacyHashes } = require('./legacy')
|
|
9
9
|
const identityStore = require('../identity')
|
|
10
10
|
const { injectRulesForTool } = require('../generators/init')
|
|
11
|
+
const { applyThirdPartyReferences } = require('../thirdparty/references')
|
|
11
12
|
|
|
12
13
|
function parseSurfaces(values = []) {
|
|
13
14
|
const result = {}
|
|
@@ -55,7 +56,17 @@ function buildPlans(kind, options = {}) {
|
|
|
55
56
|
for (const item of selected.itemEntries) {
|
|
56
57
|
for (const installPath of paths) {
|
|
57
58
|
const destination = installPath.path.replace('{{id}}', item.id)
|
|
58
|
-
|
|
59
|
+
let content = render({ target: targetEntry.id, kind, item, content: readAsset(item), capability, destination, identity: identityConfig })
|
|
60
|
+
// D5/D9 extension point: reproduce any persisted third-party
|
|
61
|
+
// reference block so regenerating this exact artifact (e.g. a
|
|
62
|
+
// later `trackfw agents update`) settles at state "current"
|
|
63
|
+
// instead of treating the attachment as drift. See the doc
|
|
64
|
+
// comment on applyThirdPartyReferences (../thirdparty/references.js)
|
|
65
|
+
// for why this cannot live inside render() itself. Mirrors
|
|
66
|
+
// internal/integrations/plan.go:BuildPlans.
|
|
67
|
+
if (kind === 'agents' && options.projectRoot) {
|
|
68
|
+
content = applyThirdPartyReferences(options.projectRoot, content, targetEntry.id, item.id)
|
|
69
|
+
}
|
|
59
70
|
const claim = { target: targetEntry.id, surface: surface.id, scope, kind, item: item.id }
|
|
60
71
|
plans.push({
|
|
61
72
|
claim,
|
|
@@ -100,8 +111,14 @@ function execute(kind, operation, options = {}, roots = {}) {
|
|
|
100
111
|
// executeIntegrationMutation/executeIntegrationList).
|
|
101
112
|
const homeRoot = roots.homeRoot || os.homedir()
|
|
102
113
|
const identityConfig = options.identity !== undefined ? options.identity : identityStore.load(homeRoot)
|
|
103
|
-
const plans = buildPlans(kind, { ...options, identity: identityConfig })
|
|
104
114
|
const manager = new IntegrationManager(roots, { onSkip: options.onSkip })
|
|
115
|
+
// D5/D9: manager.roots.project lets buildPlans reproduce any persisted
|
|
116
|
+
// third-party reference block (see the projectRoot doc comment above the
|
|
117
|
+
// applyThirdPartyReferences call in buildPlans) so a plain `agents update`
|
|
118
|
+
// settles at state "current" after a third-party attachment instead of
|
|
119
|
+
// reporting drift. Mirrors internal/commands/integrations_flags.go passing
|
|
120
|
+
// manager.ProjectRoot to BuildPlans at both call sites.
|
|
121
|
+
const plans = buildPlans(kind, { ...options, identity: identityConfig, projectRoot: options.projectRoot || manager.roots.project })
|
|
105
122
|
let statuses
|
|
106
123
|
if (operation === 'list') statuses = manager.inspect(plans)
|
|
107
124
|
else if (operation === 'install') statuses = manager.install(plans, { force: options.force })
|
|
@@ -10,8 +10,21 @@ const { tildeify } = require('../lib/update-engine')
|
|
|
10
10
|
|
|
11
11
|
const SCHEMA_VERSION = 1
|
|
12
12
|
const sha256 = content => crypto.createHash('sha256').update(content).digest('hex')
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// claimKey includes origin (ADR-2026-08-15 D11) so a catalog claim and a
|
|
14
|
+
// third-party claim that happen to share target/surface/scope/kind/item are
|
|
15
|
+
// never treated as the same ownership record — mirrors Go's Claim struct
|
|
16
|
+
// equality (`==`), field-by-field, which already includes Origin
|
|
17
|
+
// (internal/integrations/manager.go's claimOwned/appendClaim/removeClaim).
|
|
18
|
+
const claimKey = claim => [claim.target, claim.surface, claim.scope, claim.kind, claim.item, claim.origin || ''].join('\u0000')
|
|
19
|
+
// cleanClaim omits origin entirely when falsy/absent, mirroring Go's
|
|
20
|
+
// `json:"origin,omitempty"` — a catalog claim (origin "") must serialize
|
|
21
|
+
// with NO "origin" key at all, byte-identical to a manifest written before
|
|
22
|
+
// this field existed (retrocompatibility, D11).
|
|
23
|
+
const cleanClaim = claim => {
|
|
24
|
+
const out = { target: claim.target, surface: claim.surface, scope: claim.scope, kind: claim.kind, item: claim.item }
|
|
25
|
+
if (claim.origin) out.origin = claim.origin
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
15
28
|
|
|
16
29
|
class IntegrationManager {
|
|
17
30
|
constructor({ projectRoot = process.cwd(), homeRoot = os.homedir() } = {}, { onSkip } = {}) {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// unknown-command.js — canonical cross-CLI "unknown command" message and the
|
|
4
|
+
// shared suggestion algorithm.
|
|
5
|
+
//
|
|
6
|
+
// ADR-2026-08-15-remocao-do-subsistema-de-plugins-em-vez-de-gate-de-binario-de-
|
|
7
|
+
// terceiro.md (D3): argument dispatch to an external `trackfw-<name>` binary was
|
|
8
|
+
// removed together with the plugin subsystem. An unrecognized top-level command
|
|
9
|
+
// now always produces the message below instead — in all three CLIs.
|
|
10
|
+
//
|
|
11
|
+
// The plain (no-transposition) Levenshtein distance and the suggestion-picking
|
|
12
|
+
// rule here are reimplemented IDENTICALLY in Go (internal/commands/root.go,
|
|
13
|
+
// suggestCommand/levenshteinDistance) and Python (pypi/trackfw/cli.py,
|
|
14
|
+
// _suggest_command/_levenshtein_distance) — deliberately NOT delegated to each
|
|
15
|
+
// framework's own suggestion helper (cobra's Command.SuggestionsFor, commander's
|
|
16
|
+
// suggestSimilar), which use different distance functions/thresholds and would
|
|
17
|
+
// make the three CLIs disagree on whether/what to suggest for the same typo.
|
|
18
|
+
// Parity is enforced by scripts/check-unknown-command-parity.sh.
|
|
19
|
+
|
|
20
|
+
function levenshteinDistance(a, b) {
|
|
21
|
+
const la = a.length
|
|
22
|
+
const lb = b.length
|
|
23
|
+
const d = []
|
|
24
|
+
for (let i = 0; i <= la; i++) {
|
|
25
|
+
d.push(new Array(lb + 1).fill(0))
|
|
26
|
+
d[i][0] = i
|
|
27
|
+
}
|
|
28
|
+
for (let j = 0; j <= lb; j++) {
|
|
29
|
+
d[0][j] = j
|
|
30
|
+
}
|
|
31
|
+
for (let i = 1; i <= la; i++) {
|
|
32
|
+
for (let j = 1; j <= lb; j++) {
|
|
33
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
34
|
+
d[i][j] = Math.min(
|
|
35
|
+
d[i - 1][j] + 1, // deletion
|
|
36
|
+
d[i][j - 1] + 1, // insertion
|
|
37
|
+
d[i - 1][j - 1] + cost, // substitution
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return d[la][lb]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// suggestCommand — a candidate is eligible when its case-insensitive
|
|
45
|
+
// Levenshtein distance to `typed` is <= 2, OR it is a case-insensitive prefix
|
|
46
|
+
// match (candidate starts with the typed text). Among eligible candidates the
|
|
47
|
+
// winner is the one with the lowest distance, alphabetical tie-break, so the
|
|
48
|
+
// pick is deterministic and single — matching Go/Python exactly.
|
|
49
|
+
function suggestCommand(typed, candidates) {
|
|
50
|
+
const lowerTyped = typed.toLowerCase()
|
|
51
|
+
let bestDist = -1
|
|
52
|
+
let best = ''
|
|
53
|
+
for (const candidate of candidates) {
|
|
54
|
+
const lowerC = candidate.toLowerCase()
|
|
55
|
+
const dist = levenshteinDistance(lowerTyped, lowerC)
|
|
56
|
+
const prefixMatch = lowerTyped.length > 0 && lowerC.startsWith(lowerTyped)
|
|
57
|
+
if (dist > 2 && !prefixMatch) continue
|
|
58
|
+
if (bestDist === -1 || dist < bestDist || (dist === bestDist && candidate < best)) {
|
|
59
|
+
bestDist = dist
|
|
60
|
+
best = candidate
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return bestDist === -1 ? null : best
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// formatUnknownCommandError — canonical message, byte-identical across the
|
|
67
|
+
// three CLIs modulo the typed name and the suggestion (present only when
|
|
68
|
+
// suggestCommand finds an eligible candidate):
|
|
69
|
+
// Error: unknown command "x" for "trackfw"
|
|
70
|
+
// Did you mean "validate"?
|
|
71
|
+
// Run 'trackfw --help' for usage.
|
|
72
|
+
function formatUnknownCommandError(typed, candidates, cmdPath) {
|
|
73
|
+
const lines = [`Error: unknown command "${typed}" for "${cmdPath}"`]
|
|
74
|
+
const suggestion = suggestCommand(typed, candidates)
|
|
75
|
+
if (suggestion) {
|
|
76
|
+
lines.push(`Did you mean "${suggestion}"?`)
|
|
77
|
+
}
|
|
78
|
+
lines.push(`Run '${cmdPath} --help' for usage.`)
|
|
79
|
+
return lines.join('\n')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { levenshteinDistance, suggestCommand, formatUnknownCommandError }
|
package/src/ship/runner.js
CHANGED
|
@@ -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
|
|
|
@@ -78,8 +93,10 @@ function checkShipGovernance() {
|
|
|
78
93
|
const branchResult = defaultExecGit(['symbolic-ref', '--short', 'HEAD'])
|
|
79
94
|
const branch = branchResult.error ? '' : branchResult.stdout.trim()
|
|
80
95
|
|
|
81
|
-
// Check branch has matching roadmap in wip/
|
|
82
|
-
|
|
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)) {
|
|
83
100
|
const slug = normalizeBranchSlug(branch.split('/').slice(1).join('/'))
|
|
84
101
|
let hasMatch = false
|
|
85
102
|
let wipFiles = []
|
|
@@ -217,6 +234,137 @@ function firstLine(s) {
|
|
|
217
234
|
return idx >= 0 ? s.slice(0, idx) : s
|
|
218
235
|
}
|
|
219
236
|
|
|
237
|
+
/**
|
|
238
|
+
* splitNonEmptyLines splits git output (e.g. `diff --cached --name-only`) into an array
|
|
239
|
+
* of trimmed, non-empty lines.
|
|
240
|
+
* @param {string} s
|
|
241
|
+
* @returns {string[]}
|
|
242
|
+
*/
|
|
243
|
+
function splitNonEmptyLines(s) {
|
|
244
|
+
const trimmed = (s || '').trim()
|
|
245
|
+
if (!trimmed) return []
|
|
246
|
+
return trimmed
|
|
247
|
+
.split('\n')
|
|
248
|
+
.map((line) => line.trim())
|
|
249
|
+
.filter((line) => line !== '')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* allDocOnly returns true when there is at least one staged file and every staged file is
|
|
254
|
+
* doc-only: under docs/ or vault/ (path prefix), or has a .md extension. A single file
|
|
255
|
+
* outside that criterion makes it return false. Mirrors the doc-only exception documented
|
|
256
|
+
* in CLAUDE.md §7 ("Alteração doc-only (markdown, comentários)").
|
|
257
|
+
* @param {string[]} files
|
|
258
|
+
* @returns {boolean}
|
|
259
|
+
*/
|
|
260
|
+
function allDocOnly(files) {
|
|
261
|
+
if (!files || files.length === 0) return false
|
|
262
|
+
for (const f of files) {
|
|
263
|
+
if (f.startsWith('docs/') || f.startsWith('vault/') || f.endsWith('.md')) continue
|
|
264
|
+
return false
|
|
265
|
+
}
|
|
266
|
+
return true
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* commitMessageSep delimits full commit messages (%B) in the output of gitCommitsSince's
|
|
271
|
+
* `git log --format=%B<sep>`. Same non-printable separator used by the Go implementation
|
|
272
|
+
* (internal/commands/ship.go) — cannot appear in a real commit message.
|
|
273
|
+
*/
|
|
274
|
+
const COMMIT_MESSAGE_SEP = '\x1e'
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 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
|
|
280
|
+
* yields nothing.
|
|
281
|
+
* @param {function} execGit
|
|
282
|
+
* @returns {string}
|
|
283
|
+
*/
|
|
284
|
+
function defaultBaseBranch(execGit) {
|
|
285
|
+
const { stdout, error } = execGit(['symbolic-ref', 'refs/remotes/origin/HEAD'])
|
|
286
|
+
if (error) return 'main'
|
|
287
|
+
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)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* gitCommitsSince returns the full message (subject + body) of every non-merge commit in
|
|
295
|
+
* base..HEAD, most-recent-first (git log's natural order). Returns [] on any git error or
|
|
296
|
+
* when the range is empty.
|
|
297
|
+
* @param {string} base
|
|
298
|
+
* @param {function} execGit
|
|
299
|
+
* @returns {string[]}
|
|
300
|
+
*/
|
|
301
|
+
function gitCommitsSince(base, execGit) {
|
|
302
|
+
const { stdout, error } = execGit(['log', `${base}..HEAD`, '--no-merges', `--format=%B${COMMIT_MESSAGE_SEP}`])
|
|
303
|
+
if (error) return []
|
|
304
|
+
const out = (stdout || '').trim()
|
|
305
|
+
if (!out) return []
|
|
306
|
+
const parts = out.split(COMMIT_MESSAGE_SEP)
|
|
307
|
+
const commits = []
|
|
308
|
+
for (let p of parts) {
|
|
309
|
+
p = p.replace(/^\n+|\n+$/g, '')
|
|
310
|
+
if (p.trim() === '') continue
|
|
311
|
+
commits.push(p)
|
|
312
|
+
}
|
|
313
|
+
return commits
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* buildPRBody constructs the PR/MR body. With 0 or 1 non-merge commit on the branch (the
|
|
318
|
+
* trivial case — just the commit `ship` itself made), it keeps the original minimal body,
|
|
319
|
+
* not a regression. With 2+ commits, it aggregates the branch's commit history:
|
|
320
|
+
*
|
|
321
|
+
* ## Commits
|
|
322
|
+
* - <subject of commit 1>
|
|
323
|
+
* - <subject of commit 2>
|
|
324
|
+
*
|
|
325
|
+
* ## Detalhes
|
|
326
|
+
* <full body of each commit that has one, in blocks>
|
|
327
|
+
*
|
|
328
|
+
* ---
|
|
329
|
+
* Branch: <branch>
|
|
330
|
+
*
|
|
331
|
+
* @param {string} branch
|
|
332
|
+
* @param {string[]} commits
|
|
333
|
+
* @returns {string}
|
|
334
|
+
*/
|
|
335
|
+
function buildPRBody(branch, commits) {
|
|
336
|
+
if (!commits || commits.length <= 1) {
|
|
337
|
+
return `Branch: ${branch}\n\nCreated by trackfw ship.`
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const subjects = []
|
|
341
|
+
const details = []
|
|
342
|
+
for (const c of commits) {
|
|
343
|
+
const nlIdx = c.indexOf('\n')
|
|
344
|
+
const subject = (nlIdx >= 0 ? c.slice(0, nlIdx) : c).trim()
|
|
345
|
+
if (subject === '') continue
|
|
346
|
+
subjects.push(subject)
|
|
347
|
+
if (nlIdx >= 0) {
|
|
348
|
+
const bodyText = c.slice(nlIdx + 1).trim()
|
|
349
|
+
if (bodyText !== '') {
|
|
350
|
+
details.push(`**${subject}**\n\n${bodyText}`)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
let b = '## Commits\n\n'
|
|
356
|
+
for (const s of subjects) {
|
|
357
|
+
b += `- ${s}\n`
|
|
358
|
+
}
|
|
359
|
+
if (details.length > 0) {
|
|
360
|
+
b += '\n## Detalhes\n\n'
|
|
361
|
+
b += details.join('\n\n')
|
|
362
|
+
b += '\n'
|
|
363
|
+
}
|
|
364
|
+
b += `\n---\nBranch: ${branch}\n`
|
|
365
|
+
return b
|
|
366
|
+
}
|
|
367
|
+
|
|
220
368
|
/**
|
|
221
369
|
* buildForgeCreateArgs appends --title and --body (or --description for azure)
|
|
222
370
|
* to a copy of adapter.cliArgs. Never mutates the original array.
|
|
@@ -258,6 +406,13 @@ function runShip(opts, deps = {}) {
|
|
|
258
406
|
return execGit(args)
|
|
259
407
|
}
|
|
260
408
|
|
|
409
|
+
// ─── Step 0: staged files ───────────────────────────────────────────────────
|
|
410
|
+
// Read once, up front, so Steps 1 and 2 can grant a doc-only exception before they run —
|
|
411
|
+
// and so Step 4 below reuses the same read instead of querying git twice.
|
|
412
|
+
const { stdout: stagedOut } = execGit(['diff', '--cached', '--name-only'])
|
|
413
|
+
const stagedFiles = splitNonEmptyLines(stagedOut)
|
|
414
|
+
const docOnly = allDocOnly(stagedFiles)
|
|
415
|
+
|
|
261
416
|
// ─── Step 1: Branch validation ─────────────────────────────────────────────
|
|
262
417
|
const branchResult = execGit(['symbolic-ref', '--short', 'HEAD'])
|
|
263
418
|
if (branchResult.error) {
|
|
@@ -266,14 +421,15 @@ function runShip(opts, deps = {}) {
|
|
|
266
421
|
}
|
|
267
422
|
const branch = branchResult.stdout.trim()
|
|
268
423
|
|
|
424
|
+
// main/master is blocked unconditionally — the doc-only exception never applies here.
|
|
269
425
|
if (branch === 'main' || branch === 'master') {
|
|
270
426
|
writeln(`error: trackfw ship cannot run on "${branch}" — use a feature branch:\n git checkout -b feat/<slug>`)
|
|
271
427
|
return 1
|
|
272
428
|
}
|
|
273
429
|
|
|
274
|
-
if (!isShipBranch(branch)) {
|
|
430
|
+
if (!docOnly && !isShipBranch(branch)) {
|
|
275
431
|
writeln(
|
|
276
|
-
`error: branch "${branch}" does not match the required pattern feat|fix|refactor/<slug>\n` +
|
|
432
|
+
`error: branch "${branch}" does not match the required pattern feat|fix|refactor|chore|docs/<slug>\n` +
|
|
277
433
|
'Rename your branch or create a new one:\n git checkout -b feat/<slug>'
|
|
278
434
|
)
|
|
279
435
|
return 1
|
|
@@ -282,25 +438,35 @@ function runShip(opts, deps = {}) {
|
|
|
282
438
|
writeln(`Branch: ${branch}`)
|
|
283
439
|
|
|
284
440
|
// ─── Step 2: Governance ────────────────────────────────────────────────────
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
441
|
+
// Doc-only changes (all staged files under docs/, vault/, or *.md) are exempt from
|
|
442
|
+
// REQ+roadmap governance — mirrors the CLAUDE.md §7 exception for doc-only changes.
|
|
443
|
+
if (docOnly) {
|
|
444
|
+
writeln('Governance: skipped (doc-only change)')
|
|
445
|
+
} else if (isShipBranch(branch) && !isGatedShipBranch(branch)) {
|
|
446
|
+
// chore/docs: housekeeping types already exempted from this gate by
|
|
447
|
+
// `trackfw branch new` and `trackfw commit` — ship without it too.
|
|
448
|
+
writeln('Governance: skipped (chore/docs branch)')
|
|
449
|
+
} else {
|
|
450
|
+
const violations = checkGovernanceFn()
|
|
451
|
+
if (violations.length > 0) {
|
|
452
|
+
writeln('\nGovernance check failed:')
|
|
453
|
+
for (const v of violations) {
|
|
454
|
+
writeln(` ${v}`)
|
|
455
|
+
}
|
|
456
|
+
writeln('\nCreate the required artifacts before running ship:')
|
|
457
|
+
writeln(' trackfw req new "<title>"')
|
|
458
|
+
writeln(' trackfw roadmap new "<title>"')
|
|
459
|
+
writeln(' trackfw roadmap move <name> wip')
|
|
460
|
+
writeln("\nNote: this governance check is a hard gate — it is not affected by lenient")
|
|
461
|
+
writeln("mode or per-rule severity configured in trackfw.yaml. If 'trackfw validate'")
|
|
462
|
+
writeln("passes but 'trackfw ship' aborts here, you likely have lenient mode")
|
|
463
|
+
writeln("configured — ship always requires REQ + roadmap in wip/.")
|
|
464
|
+
writeln(`\nerror: governance check failed: ${violations.length} violation(s)`)
|
|
465
|
+
return 1
|
|
290
466
|
}
|
|
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
467
|
|
|
303
|
-
|
|
468
|
+
writeln('Governance: OK')
|
|
469
|
+
}
|
|
304
470
|
|
|
305
471
|
// ─── Step 3: Squash-merge detection ────────────────────────────────────────
|
|
306
472
|
if (opts.dryRun) {
|
|
@@ -323,8 +489,8 @@ function runShip(opts, deps = {}) {
|
|
|
323
489
|
if (diffStatOut) writeln(diffStatOut)
|
|
324
490
|
writeln('────────────────────────────────────────────────────────\n')
|
|
325
491
|
|
|
326
|
-
|
|
327
|
-
if (
|
|
492
|
+
// Reuses stagedFiles read at the top of the function (Step 0) — never re-query git here.
|
|
493
|
+
if (stagedFiles.length === 0) {
|
|
328
494
|
writeln(
|
|
329
495
|
'error: nothing is staged — stage your files explicitly before running ship:\n' +
|
|
330
496
|
' git add <file1> <file2> ...\n' +
|
|
@@ -391,7 +557,23 @@ function runShip(opts, deps = {}) {
|
|
|
391
557
|
return 0
|
|
392
558
|
}
|
|
393
559
|
|
|
560
|
+
// Title/body computed once for every remaining branch below (dry-run and real CLI
|
|
561
|
+
// invocation alike). git log/diff are read-only — they run in --dry-run mode too, same
|
|
562
|
+
// as the staged-files read in Step 0.
|
|
563
|
+
//
|
|
564
|
+
// Design decision (documented per roadmap ML-1A, mirrored from the Go implementation):
|
|
565
|
+
// the title is always firstLine(opts.message), the -m message passed to this very `ship`
|
|
566
|
+
// call, even when the branch carries multiple prior commits. Deriving a distinct "PR
|
|
567
|
+
// title" from N unrelated commit subjects would need a heuristic with no unambiguous
|
|
568
|
+
// answer — the simplest, least surprising rule is that -m is the PR's summary.
|
|
569
|
+
const base = defaultBaseBranch(execGit)
|
|
570
|
+
const commits = gitCommitsSince(base, execGit)
|
|
571
|
+
const title = firstLine(opts.message || '')
|
|
572
|
+
const body = buildPRBody(branch, commits)
|
|
573
|
+
|
|
394
574
|
if (opts.dryRun) {
|
|
575
|
+
writeln(`[dry-run] Title: ${title}`)
|
|
576
|
+
writeln(`[dry-run] Body:\n${body}`)
|
|
395
577
|
if (!adapter.available && resolution.forge !== 'manual') {
|
|
396
578
|
const url = adapter.fallbackURL(remoteURL, branch)
|
|
397
579
|
if (url) {
|
|
@@ -423,8 +605,6 @@ function runShip(opts, deps = {}) {
|
|
|
423
605
|
}
|
|
424
606
|
|
|
425
607
|
// CLI is available — invoke it.
|
|
426
|
-
const title = firstLine(opts.message || '')
|
|
427
|
-
const body = `Branch: ${branch}\n\nCreated by trackfw ship.`
|
|
428
608
|
const cliArgs = buildForgeCreateArgs(adapter, title, body)
|
|
429
609
|
const execForgeCLI = deps.execForgeCLI || defaultExecForgeCLI
|
|
430
610
|
const cliErr = execForgeCLI(adapter.cliName, cliArgs)
|
|
@@ -443,6 +623,7 @@ function runShip(opts, deps = {}) {
|
|
|
443
623
|
module.exports = {
|
|
444
624
|
runShip,
|
|
445
625
|
isShipBranch,
|
|
626
|
+
isGatedShipBranch,
|
|
446
627
|
isGitWriteCmd,
|
|
447
628
|
normalizeBranchSlug,
|
|
448
629
|
checkShipGovernance,
|
|
@@ -451,4 +632,10 @@ module.exports = {
|
|
|
451
632
|
GIT_WRITE_COMMANDS,
|
|
452
633
|
buildForgeCreateArgs,
|
|
453
634
|
firstLine,
|
|
635
|
+
allDocOnly,
|
|
636
|
+
splitNonEmptyLines,
|
|
637
|
+
defaultBaseBranch,
|
|
638
|
+
gitCommitsSince,
|
|
639
|
+
buildPRBody,
|
|
640
|
+
COMMIT_MESSAGE_SEP,
|
|
454
641
|
}
|