trackfw 7.0.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/bin/trackfw +38 -1
- package/package.json +1 -1
- package/src/branch/prune.js +450 -0
- package/src/commands/branch.js +47 -0
- package/src/commands/discover.js +6 -0
- package/src/commands/doctor.js +32 -0
- package/src/commands/index.js +1 -0
- package/src/commands/integrations.js +16 -1
- package/src/commands/serve.js +52 -4
- package/src/commands/update-harness.js +317 -11
- package/src/generators/hooks.js +478 -36
- package/src/i18n/locales/en-US.json +0 -3
- package/src/i18n/locales/es-ES.json +0 -3
- package/src/i18n/locales/pt-BR.json +0 -3
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/manager.js +118 -17
- package/src/lib/fatal-error.js +64 -0
- package/src/ship/runner.js +52 -75
- package/src/validator/index.js +426 -92
|
@@ -30,6 +30,14 @@ class IntegrationManager {
|
|
|
30
30
|
constructor({ projectRoot = process.cwd(), homeRoot = os.homedir() } = {}, { onSkip } = {}) {
|
|
31
31
|
this.roots = { project: path.resolve(projectRoot), global: path.resolve(homeRoot) }
|
|
32
32
|
this.onSkip = onSkip
|
|
33
|
+
// _afterManifestPersist, when set, runs immediately after manifests are
|
|
34
|
+
// persisted and before artifact bytes are written during install/update
|
|
35
|
+
// (never during uninstall, which is intentionally not inverted — see the
|
|
36
|
+
// comment in mutate()). It exists only so tests can simulate an
|
|
37
|
+
// interruption exactly at the ADR-2026-08-18 ordering seam. Production
|
|
38
|
+
// code never assigns it. Mirrors internal/integrations/manager.go's
|
|
39
|
+
// afterManifestPersist package var.
|
|
40
|
+
this._afterManifestPersist = undefined
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
manifestPath(scope) { return path.join(this.roots[scope], '.trackfw', 'integrations-manifest.json') }
|
|
@@ -118,7 +126,16 @@ class IntegrationManager {
|
|
|
118
126
|
const file = this.resolve(scope, plan.destination)
|
|
119
127
|
const record = manifests.get(scope).artifacts[file]
|
|
120
128
|
const managed = Boolean(record && record.claims.some(claim => claimKey(claim) === claimKey(plan.claim)))
|
|
121
|
-
|
|
129
|
+
// registered reports whether the manifest has ANY entry for this
|
|
130
|
+
// destination, regardless of claim ownership — unlike managed, which
|
|
131
|
+
// additionally requires this exact claim to own that entry. doctor
|
|
132
|
+
// (ML-2A) needs this distinction: a destination registered under a
|
|
133
|
+
// *different* claim must never be reported as an "unregistered
|
|
134
|
+
// write" — that would be the dominant false-positive doctor exists to
|
|
135
|
+
// avoid. Additive field; mirrors Inspection.Registered
|
|
136
|
+
// (internal/integrations/manager.go).
|
|
137
|
+
const registered = Boolean(record)
|
|
138
|
+
if (!fs.existsSync(file)) return { ...plan, destination: file, state: 'not-installed', managed, registered }
|
|
122
139
|
const actual = sha256(fs.readFileSync(file))
|
|
123
140
|
const desired = sha256(plan.content)
|
|
124
141
|
let state
|
|
@@ -129,7 +146,7 @@ class IntegrationManager {
|
|
|
129
146
|
} else if (actual === desired) state = 'current'
|
|
130
147
|
else if ((plan.legacyHashes || []).includes(actual)) state = 'outdated'
|
|
131
148
|
else state = 'modified'
|
|
132
|
-
return { ...plan, destination: file, state, managed }
|
|
149
|
+
return { ...plan, destination: file, state, managed, registered }
|
|
133
150
|
})
|
|
134
151
|
}
|
|
135
152
|
|
|
@@ -167,9 +184,44 @@ class IntegrationManager {
|
|
|
167
184
|
const snapshots = new Map()
|
|
168
185
|
for (const item of active) this.snapshot(snapshots, item.file)
|
|
169
186
|
for (const scope of manifests.keys()) this.snapshot(snapshots, this.manifestPath(scope))
|
|
170
|
-
|
|
171
|
-
for (const item of active) this.apply(operation, item, manifests.get(item.plan.claim.scope), force)
|
|
187
|
+
const saveManifests = () => {
|
|
172
188
|
for (const [scope, manifest] of [...manifests].sort(([a], [b]) => a.localeCompare(b))) this.saveManifest(scope, manifest)
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
// ADR-2026-08-18: install/update persist the manifest before writing
|
|
192
|
+
// artifact bytes (self-healing direction on interruption); uninstall
|
|
193
|
+
// keeps the original artifacts-before-manifest order. The two are
|
|
194
|
+
// deliberately NOT symmetric.
|
|
195
|
+
if (operation === 'uninstall') {
|
|
196
|
+
// Uninstall is not inverted. Removing bytes first and persisting the
|
|
197
|
+
// manifest last means an interruption leaves the manifest still
|
|
198
|
+
// declaring an artifact whose file is now absent — inspectResolved
|
|
199
|
+
// resolves that as 'not-installed', the same self-healing direction
|
|
200
|
+
// install/update get from the inversion below. Inverting uninstall
|
|
201
|
+
// the same way (drop the manifest entry, then remove bytes) would
|
|
202
|
+
// instead leave, on interruption, a file on disk whose content still
|
|
203
|
+
// matches the catalog template but with no manifest entry at all —
|
|
204
|
+
// reported as an orphaned state='current'/managed=false artifact
|
|
205
|
+
// that looks legitimate and that nothing detects or repairs
|
|
206
|
+
// automatically. That is exactly the "disk ahead of manifest" bad
|
|
207
|
+
// direction the ADR exists to eliminate, so uninstall must not be
|
|
208
|
+
// simetrized with install/update here. Mirrors the comment in
|
|
209
|
+
// internal/integrations/manager.go:mutate.
|
|
210
|
+
for (const item of active) this.applyUninstall(item, manifests.get(item.plan.claim.scope))
|
|
211
|
+
saveManifests()
|
|
212
|
+
} else {
|
|
213
|
+
// Install/Update: compute the manifest update for every active item
|
|
214
|
+
// in memory (no bytes touched yet), persist all manifests, and only
|
|
215
|
+
// then write the artifact bytes.
|
|
216
|
+
const pendingWrites = []
|
|
217
|
+
for (const item of active) {
|
|
218
|
+
const write = this.planArtifactWrite(operation, item, manifests.get(item.plan.claim.scope), force)
|
|
219
|
+
if (write) pendingWrites.push(write)
|
|
220
|
+
}
|
|
221
|
+
saveManifests()
|
|
222
|
+
if (typeof this._afterManifestPersist === 'function') this._afterManifestPersist()
|
|
223
|
+
for (const write of pendingWrites) this.atomicWrite(write.file, write.content, write.mode)
|
|
224
|
+
}
|
|
173
225
|
} catch (error) {
|
|
174
226
|
this.rollback(snapshots)
|
|
175
227
|
throw error
|
|
@@ -186,7 +238,7 @@ class IntegrationManager {
|
|
|
186
238
|
if (status.state === 'modified' && !force) throw new Error(`Artifact is modified; use --force: ${file}`)
|
|
187
239
|
if (status.state === 'outdated' && owned && !force) return true // skip: bytes preserved, batch continues
|
|
188
240
|
} else if (operation === 'update') {
|
|
189
|
-
if (!owned && status.state === 'modified') throw new Error(
|
|
241
|
+
if (!owned && status.state === 'modified') throw new Error(this.unmanagedArtifactError(file, plan.claim))
|
|
190
242
|
if (status.state === 'modified' && !force) throw new Error(`Artifact is modified; use --force: ${file}`)
|
|
191
243
|
} else if (operation === 'uninstall' && owned && status.state === 'modified' && !force) {
|
|
192
244
|
throw new Error(`Artifact is modified; use --force: ${file}`)
|
|
@@ -256,19 +308,42 @@ class IntegrationManager {
|
|
|
256
308
|
return { state: 'modified', managed: false }
|
|
257
309
|
}
|
|
258
310
|
|
|
259
|
-
|
|
311
|
+
// applyUninstall removes ownership of one artifact from the manifest, and —
|
|
312
|
+
// once no claim remains — the artifact's bytes and any empty ancestor
|
|
313
|
+
// directories it managed. It mutates disk directly (not deferred), because
|
|
314
|
+
// uninstall deliberately keeps the pre-ADR-2026-08-18 ordering: see the
|
|
315
|
+
// comment in mutate() for why this is not simetrized with
|
|
316
|
+
// planArtifactWrite. Mirrors internal/integrations/manager.go:applyUninstall.
|
|
317
|
+
applyUninstall({ plan, file }, manifest) {
|
|
318
|
+
const record = manifest.artifacts[file]
|
|
319
|
+
const key = claimKey(plan.claim)
|
|
320
|
+
const owned = Boolean(record && record.claims.some(claim => claimKey(claim) === key))
|
|
321
|
+
if (!owned) return
|
|
322
|
+
record.claims = record.claims.filter(claim => claimKey(claim) !== key)
|
|
323
|
+
if (record.claims.length) return
|
|
324
|
+
if (fs.existsSync(file)) fs.unlinkSync(file)
|
|
325
|
+
delete manifest.artifacts[file]
|
|
326
|
+
this.cleanEmpty(path.dirname(file), this.roots[plan.claim.scope])
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// planArtifactWrite computes the manifest update for one install/update
|
|
330
|
+
// item entirely in memory — it never touches the artifact's bytes on disk.
|
|
331
|
+
// The caller (mutate) persists every manifest in the batch first, and only
|
|
332
|
+
// then applies the returned pending write (ADR-2026-08-18). Returns null
|
|
333
|
+
// when no byte write is needed.
|
|
334
|
+
//
|
|
335
|
+
// The manifest values it stores are deliberately *optimistic* when a write
|
|
336
|
+
// is pending: sha256/catalog_version describe the content this call is
|
|
337
|
+
// about to write, not what is currently on disk. If interrupted before the
|
|
338
|
+
// pending write lands, the manifest already declares the target state and
|
|
339
|
+
// inspectResolved resolves the (absent or stale) file to
|
|
340
|
+
// 'not-installed'/'modified', both self-repairable by a later
|
|
341
|
+
// install/update, never modified+unowned ("unmanaged"). Mirrors
|
|
342
|
+
// internal/integrations/manager.go:planArtifactWrite.
|
|
343
|
+
planArtifactWrite(operation, { plan, file }, manifest, force) {
|
|
260
344
|
let record = manifest.artifacts[file]
|
|
261
345
|
const key = claimKey(plan.claim)
|
|
262
346
|
const owned = Boolean(record && record.claims.some(claim => claimKey(claim) === key))
|
|
263
|
-
if (operation === 'uninstall') {
|
|
264
|
-
if (!owned) return
|
|
265
|
-
record.claims = record.claims.filter(claim => claimKey(claim) !== key)
|
|
266
|
-
if (record.claims.length) return
|
|
267
|
-
if (fs.existsSync(file)) fs.unlinkSync(file)
|
|
268
|
-
delete manifest.artifacts[file]
|
|
269
|
-
this.cleanEmpty(path.dirname(file), this.roots[plan.claim.scope])
|
|
270
|
-
return
|
|
271
|
-
}
|
|
272
347
|
|
|
273
348
|
const exists = fs.existsSync(file)
|
|
274
349
|
let actual = exists ? sha256(fs.readFileSync(file)) : ''
|
|
@@ -278,16 +353,29 @@ class IntegrationManager {
|
|
|
278
353
|
if (exists && !owned) writeDesired = (operation === 'update' && actual !== desired) || (force && actual !== desired)
|
|
279
354
|
else if (exists && owned) writeDesired = actual !== desired
|
|
280
355
|
if (!record) record = { destination: file, sha256: '', catalog_version: '', claims: [] }
|
|
356
|
+
let pending = null
|
|
281
357
|
if (writeDesired) {
|
|
282
|
-
|
|
358
|
+
// Optimistic: bytes have not moved yet, but the manifest must already
|
|
359
|
+
// describe the content we are about to write (see doc comment above).
|
|
283
360
|
actual = desired
|
|
361
|
+
pending = { file, content: plan.content, mode: 0o644 }
|
|
284
362
|
} else if (exists && !owned && actual !== desired && !knownLegacy && !force) {
|
|
285
|
-
|
|
363
|
+
// Defense-in-depth: preflight already rejects this exact case for
|
|
364
|
+
// 'update' (unconditionally) and for 'install' without --force (any
|
|
365
|
+
// state === 'modified' is blocked before an active item ever reaches
|
|
366
|
+
// planArtifactWrite()). This branch is therefore not reachable via
|
|
367
|
+
// install/update today, but stays as a second line of defense in case
|
|
368
|
+
// preflight's guard is ever loosened — hence the identical remediation
|
|
369
|
+
// text, so a user who somehow hits it still gets the same actionable
|
|
370
|
+
// message. No manifest mutation happens on this path. Mirrors
|
|
371
|
+
// internal/integrations/manager.go:planArtifactWrite.
|
|
372
|
+
throw new Error(this.unmanagedArtifactError(file, plan.claim))
|
|
286
373
|
}
|
|
287
374
|
if (!record.claims.some(claim => claimKey(claim) === key)) record.claims.push(cleanClaim(plan.claim))
|
|
288
375
|
record.sha256 = actual
|
|
289
376
|
record.catalog_version = actual === desired ? plan.catalogVersion : 'legacy'
|
|
290
377
|
manifest.artifacts[file] = record
|
|
378
|
+
return pending
|
|
291
379
|
}
|
|
292
380
|
|
|
293
381
|
snapshot(snapshots, file) {
|
|
@@ -305,6 +393,19 @@ class IntegrationManager {
|
|
|
305
393
|
}
|
|
306
394
|
}
|
|
307
395
|
|
|
396
|
+
// unmanagedArtifactError — mensagem de erro para update/uninstall (ou,
|
|
397
|
+
// defensivamente, install) recusando bytes que o trackfw não escreveu.
|
|
398
|
+
// Nomeia o remédio — trackfw did not write these bytes, então a única forma
|
|
399
|
+
// segura de trazer o artefato sob gestão é `<kind> install --force`, que
|
|
400
|
+
// explicitamente autoriza adotar/substituir conteúdo unmanaged — com as
|
|
401
|
+
// flags exatas para reproduzir o claim deste plano (item, target, scope),
|
|
402
|
+
// prontas para copiar e colar. Espelha
|
|
403
|
+
// internal/integrations/manager.go:unmanagedArtifactError — fonte canônica
|
|
404
|
+
// do texto, byte a byte idêntica nos 3 CLIs.
|
|
405
|
+
unmanagedArtifactError(file, claim) {
|
|
406
|
+
return `unmanaged artifact "${file}" does not match a trackfw template — trackfw did not write these bytes.\nAdopt it with: trackfw ${claim.kind} install --force --items ${claim.item} --targets ${claim.target} --scope ${claim.scope}`
|
|
407
|
+
}
|
|
408
|
+
|
|
308
409
|
// tildeAbbrev — retorna o caminho de exibição abreviado para uso em mensagens
|
|
309
410
|
// de aviso de skip. Espelha internal/integrations/manager.go:tildeAbbrev.
|
|
310
411
|
// - scope global: substitui homeRoot por '~' (via tildeify, com salvaguarda
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs')
|
|
4
|
+
|
|
5
|
+
// fatal-error.js — global backstop for errors that escape every commander
|
|
6
|
+
// action (sync or async, since npm/bin/trackfw drives the program via
|
|
7
|
+
// parseAsync()). Installed ONCE at the entrypoint, never per-command:
|
|
8
|
+
// the real leak pattern (REQ-2026-08-16-erro-nao-tratado-no-cli-node-vaza-
|
|
9
|
+
// stack-trace-caminhos-absolutos-e-versao-do-runtime) was per-command —
|
|
10
|
+
// commands that catch internally (e.g. `roadmap move`) already print a
|
|
11
|
+
// clean message via console.error + process.exitCode, but any command that
|
|
12
|
+
// doesn't catch let Node's own uncaught-exception/unhandled-rejection
|
|
13
|
+
// printer dump the full stack, absolute install path and runtime version.
|
|
14
|
+
// A single handler at the entrypoint closes every one of those gaps at
|
|
15
|
+
// once instead of relying on every future command remembering a try/catch.
|
|
16
|
+
//
|
|
17
|
+
// TRACKFW_DEBUG=1 restores the full stack — this must never blind someone
|
|
18
|
+
// who genuinely needs to debug a crash.
|
|
19
|
+
//
|
|
20
|
+
// Writes with fs.writeSync(2, ...) — NOT process.stderr.write() — on
|
|
21
|
+
// purpose: installGlobalHandlers() below calls process.exit() right after
|
|
22
|
+
// this returns, and when stderr is a pipe (exactly what every spawnSync-
|
|
23
|
+
// based test in this repo uses, and what a piped CI log is) Node's stream
|
|
24
|
+
// writes are asynchronous; process.exit() can drop a still-pending write
|
|
25
|
+
// before the OS ever sees it, silently eating the message (failure mode
|
|
26
|
+
// #2 in the REQ: "engolir a mensagem" — worse than the leak this handler
|
|
27
|
+
// exists to fix). fs.writeSync is a raw, synchronous syscall — it cannot
|
|
28
|
+
// race process.exit().
|
|
29
|
+
function reportFatalError(err) {
|
|
30
|
+
// TRACKFW_DEBUG=1: print the full stack (its first line already is
|
|
31
|
+
// "Error: <message>", so this is the debug-only superset of the clean
|
|
32
|
+
// path below, not a duplicate of it).
|
|
33
|
+
if (process.env.TRACKFW_DEBUG === '1' && err && err.stack) {
|
|
34
|
+
fs.writeSync(2, `${err.stack}\n`)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
const message = err && err.message !== undefined ? err.message : String(err)
|
|
38
|
+
fs.writeSync(2, `Error: ${message}\n`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// installGlobalHandlers — defense in depth beyond the parseAsync().catch()
|
|
42
|
+
// in bin/trackfw. Covers errors that reach the process outside that single
|
|
43
|
+
// promise chain (e.g. a rejection from a detached/floating promise started
|
|
44
|
+
// by a command, or a genuine uncaughtException). Per Node's own contract,
|
|
45
|
+
// once a listener is attached Node no longer prints its default trace and
|
|
46
|
+
// no longer auto-exits — the listener owns both the message and the exit.
|
|
47
|
+
// Both listeners call process.exit() immediately after reportFatalError()
|
|
48
|
+
// returns — safe here specifically because reportFatalError writes
|
|
49
|
+
// synchronously (see comment above); process.exitCode (which waits for the
|
|
50
|
+
// event loop to drain) would be wrong here regardless, since long-running
|
|
51
|
+
// commands (e.g. `serve`) hold the loop open and the process would hang
|
|
52
|
+
// instead of exiting on a genuinely fatal error.
|
|
53
|
+
function installGlobalHandlers() {
|
|
54
|
+
process.on('unhandledRejection', (err) => {
|
|
55
|
+
reportFatalError(err)
|
|
56
|
+
process.exit(1)
|
|
57
|
+
})
|
|
58
|
+
process.on('uncaughtException', (err) => {
|
|
59
|
+
reportFatalError(err)
|
|
60
|
+
process.exit(1)
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { reportFatalError, installGlobalHandlers }
|
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.
|
|
@@ -77,66 +77,25 @@ function defaultCheckGovernance() {
|
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
79
|
* checkShipGovernance — hard gate (bypasses config/baseline/lenient).
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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.
|
|
83
92
|
* @returns {string[]} violation messages
|
|
84
93
|
*/
|
|
85
94
|
function checkShipGovernance() {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const wipDir = path.join(roadmapDir, 'wip')
|
|
91
|
-
|
|
92
|
-
// Get branch name
|
|
93
|
-
const branchResult = defaultExecGit(['symbolic-ref', '--short', 'HEAD'])
|
|
94
|
-
const branch = branchResult.error ? '' : branchResult.stdout.trim()
|
|
95
|
-
|
|
96
|
-
// Check branch has matching roadmap in wip/ — gated types only (feat/fix/refactor); chore/docs
|
|
97
|
-
// are exempted by the outer runShip Step 2 skip and never reach this function in practice, but
|
|
98
|
-
// isGatedShipBranch keeps this check correct in isolation too.
|
|
99
|
-
if (branch && isGatedShipBranch(branch)) {
|
|
100
|
-
const slug = normalizeBranchSlug(branch.split('/').slice(1).join('/'))
|
|
101
|
-
let hasMatch = false
|
|
102
|
-
let wipFiles = []
|
|
103
|
-
|
|
104
|
-
if (fs.existsSync(wipDir)) {
|
|
105
|
-
wipFiles = fs.readdirSync(wipDir).filter(f => f.endsWith('.md'))
|
|
106
|
-
for (const f of wipFiles) {
|
|
107
|
-
if (normalizeBranchSlug(f).includes(slug)) {
|
|
108
|
-
hasMatch = true
|
|
109
|
-
break
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (wipFiles.length === 0) {
|
|
115
|
-
violations.push(
|
|
116
|
-
`branch "${branch}" is a feat/fix/refactor branch but no roadmap is in wip/ — create governance artifacts first:\n` +
|
|
117
|
-
' trackfw req new "<title>"\n' +
|
|
118
|
-
' trackfw roadmap new "<title>"\n' +
|
|
119
|
-
' trackfw roadmap move <name> wip'
|
|
120
|
-
)
|
|
121
|
-
} else if (!hasMatch) {
|
|
122
|
-
violations.push(
|
|
123
|
-
`branch "${branch}" has no matching roadmap in wip/ (found: ${wipFiles.join(', ')}) — ` +
|
|
124
|
-
'include the branch slug in the roadmap filename'
|
|
125
|
-
)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Check WIP roadmaps have a linked REQ
|
|
129
|
-
if (hasMatch && fs.existsSync(wipDir)) {
|
|
130
|
-
for (const f of wipFiles) {
|
|
131
|
-
const content = fs.readFileSync(path.join(wipDir, f), 'utf8')
|
|
132
|
-
if (!content.includes('REQ:') && !content.includes('req:')) {
|
|
133
|
-
violations.push(`roadmap "${f}" is in wip but has no linked REQ`)
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return violations
|
|
95
|
+
return [
|
|
96
|
+
...validator.validateBranchHasWIPRoadmap(),
|
|
97
|
+
...validator.validateWIPHasREQ(),
|
|
98
|
+
]
|
|
140
99
|
}
|
|
141
100
|
|
|
142
101
|
/**
|
|
@@ -172,8 +131,17 @@ function normalizeBranchSlug(value) {
|
|
|
172
131
|
}
|
|
173
132
|
|
|
174
133
|
/**
|
|
175
|
-
* detectPendingSquashMerges warns about remote branches with
|
|
176
|
-
* 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).
|
|
177
145
|
* @param {string} currentBranch
|
|
178
146
|
* @param {function} execGit
|
|
179
147
|
* @param {function} writeln
|
|
@@ -188,9 +156,8 @@ function detectPendingSquashMerges(currentBranch, execGit, writeln) {
|
|
|
188
156
|
const shortName = candidate.replace(/^origin\//, '')
|
|
189
157
|
if (shortName === currentBranch) continue
|
|
190
158
|
|
|
191
|
-
const
|
|
192
|
-
if (
|
|
193
|
-
if (diff.trim()) {
|
|
159
|
+
const evalResult = evaluateBranchIntegration(candidate, execGit)
|
|
160
|
+
if (evalResult.decision === BRANCH_PRUNE_DECISION.PENDING_WORK) {
|
|
194
161
|
writeln(`Warning: branch "${shortName}" appears to have unmerged changes vs origin/main.`)
|
|
195
162
|
}
|
|
196
163
|
}
|
|
@@ -396,6 +363,15 @@ function runShip(opts, deps = {}) {
|
|
|
396
363
|
const execGit = deps.execGit || defaultExecGit
|
|
397
364
|
const checkGovernanceFn = deps.checkGovernance || defaultCheckGovernance
|
|
398
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`))
|
|
399
375
|
|
|
400
376
|
// Inner git wrapper: skips write commands in dry-run mode.
|
|
401
377
|
function git(args) {
|
|
@@ -416,20 +392,20 @@ function runShip(opts, deps = {}) {
|
|
|
416
392
|
// ─── Step 1: Branch validation ─────────────────────────────────────────────
|
|
417
393
|
const branchResult = execGit(['symbolic-ref', '--short', 'HEAD'])
|
|
418
394
|
if (branchResult.error) {
|
|
419
|
-
|
|
395
|
+
writeErr(`could not determine current branch (are you in a git repo?): ${branchResult.error.message}`)
|
|
420
396
|
return 1
|
|
421
397
|
}
|
|
422
398
|
const branch = branchResult.stdout.trim()
|
|
423
399
|
|
|
424
400
|
// main/master is blocked unconditionally — the doc-only exception never applies here.
|
|
425
401
|
if (branch === 'main' || branch === 'master') {
|
|
426
|
-
|
|
402
|
+
writeErr(`trackfw ship cannot run on "${branch}" — use a feature branch:\n git checkout -b feat/<slug>`)
|
|
427
403
|
return 1
|
|
428
404
|
}
|
|
429
405
|
|
|
430
406
|
if (!docOnly && !isShipBranch(branch)) {
|
|
431
|
-
|
|
432
|
-
`
|
|
407
|
+
writeErr(
|
|
408
|
+
`branch "${branch}" does not match the required pattern feat|fix|refactor|chore|docs/<slug>\n` +
|
|
433
409
|
'Rename your branch or create a new one:\n git checkout -b feat/<slug>'
|
|
434
410
|
)
|
|
435
411
|
return 1
|
|
@@ -461,7 +437,7 @@ function runShip(opts, deps = {}) {
|
|
|
461
437
|
writeln("mode or per-rule severity configured in trackfw.yaml. If 'trackfw validate'")
|
|
462
438
|
writeln("passes but 'trackfw ship' aborts here, you likely have lenient mode")
|
|
463
439
|
writeln("configured — ship always requires REQ + roadmap in wip/.")
|
|
464
|
-
|
|
440
|
+
writeErr(`governance check failed: ${violations.length} violation(s)`)
|
|
465
441
|
return 1
|
|
466
442
|
}
|
|
467
443
|
|
|
@@ -491,8 +467,8 @@ function runShip(opts, deps = {}) {
|
|
|
491
467
|
|
|
492
468
|
// Reuses stagedFiles read at the top of the function (Step 0) — never re-query git here.
|
|
493
469
|
if (stagedFiles.length === 0) {
|
|
494
|
-
|
|
495
|
-
'
|
|
470
|
+
writeErr(
|
|
471
|
+
'nothing is staged — stage your files explicitly before running ship:\n' +
|
|
496
472
|
' git add <file1> <file2> ...\n' +
|
|
497
473
|
"Never use 'git add .' or 'git add -A'"
|
|
498
474
|
)
|
|
@@ -501,8 +477,8 @@ function runShip(opts, deps = {}) {
|
|
|
501
477
|
|
|
502
478
|
// ─── Step 5: Commit ────────────────────────────────────────────────────────
|
|
503
479
|
if (!opts.message) {
|
|
504
|
-
|
|
505
|
-
'
|
|
480
|
+
writeErr(
|
|
481
|
+
'commit message is required — use -m:\n' +
|
|
506
482
|
' trackfw ship -m "feat(<scope>): <description>"'
|
|
507
483
|
)
|
|
508
484
|
return 1
|
|
@@ -510,7 +486,7 @@ function runShip(opts, deps = {}) {
|
|
|
510
486
|
|
|
511
487
|
const { error: commitErr } = git(['commit', '-m', opts.message])
|
|
512
488
|
if (commitErr) {
|
|
513
|
-
|
|
489
|
+
writeErr(`git commit failed: ${commitErr.message}`)
|
|
514
490
|
return 1
|
|
515
491
|
}
|
|
516
492
|
|
|
@@ -522,7 +498,7 @@ function runShip(opts, deps = {}) {
|
|
|
522
498
|
const pushArgs = buildPushArgs(branch, execGit)
|
|
523
499
|
const { error: pushErr } = git(pushArgs)
|
|
524
500
|
if (pushErr) {
|
|
525
|
-
|
|
501
|
+
writeErr(`git push failed: ${pushErr.message}`)
|
|
526
502
|
return 1
|
|
527
503
|
}
|
|
528
504
|
|
|
@@ -638,4 +614,5 @@ module.exports = {
|
|
|
638
614
|
gitCommitsSince,
|
|
639
615
|
buildPRBody,
|
|
640
616
|
COMMIT_MESSAGE_SEP,
|
|
617
|
+
detectPendingSquashMerges,
|
|
641
618
|
}
|