trackfw 3.0.0 → 4.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.
Files changed (59) hide show
  1. package/README.md +94 -3
  2. package/package.json +1 -1
  3. package/src/commands/barrier.js +358 -0
  4. package/src/commands/discover.js +18 -0
  5. package/src/commands/help.js +99 -9
  6. package/src/commands/index.js +3 -0
  7. package/src/commands/init.js +40 -2
  8. package/src/commands/log.js +2 -1
  9. package/src/commands/note.js +24 -0
  10. package/src/commands/req.js +7 -1
  11. package/src/commands/roadmap.js +4 -3
  12. package/src/commands/ship.js +49 -0
  13. package/src/commands/update-harness.js +208 -0
  14. package/src/commands/update.js +206 -72
  15. package/src/config/index.js +5 -2
  16. package/src/forge/adapter.js +189 -0
  17. package/src/forge/resolve.js +191 -0
  18. package/src/generators/adr.js +13 -4
  19. package/src/generators/date.js +23 -0
  20. package/src/generators/init.js +229 -39
  21. package/src/generators/note.js +154 -0
  22. package/src/generators/req.js +88 -5
  23. package/src/generators/roadmap.js +96 -20
  24. package/src/i18n/locales/en-US.json +2 -1
  25. package/src/i18n/locales/es-ES.json +2 -1
  26. package/src/i18n/locales/pt-BR.json +2 -1
  27. package/src/identity/preset.js +22 -0
  28. package/src/integrations/assets/agents/architect.md +35 -0
  29. package/src/integrations/assets/agents/backend.md +32 -0
  30. package/src/integrations/assets/agents/code-quality.md +32 -0
  31. package/src/integrations/assets/agents/data.md +32 -0
  32. package/src/integrations/assets/agents/dba.md +32 -0
  33. package/src/integrations/assets/agents/frontend.md +32 -0
  34. package/src/integrations/assets/agents/iac.md +46 -0
  35. package/src/integrations/assets/agents/infra.md +34 -0
  36. package/src/integrations/assets/agents/qa.md +32 -0
  37. package/src/integrations/assets/agents/security.md +32 -0
  38. package/src/integrations/assets/agents/tooling.md +47 -0
  39. package/src/integrations/assets/agents/ux.md +32 -0
  40. package/src/integrations/assets/catalog.json +16 -2
  41. package/src/integrations/assets/skills/architecture.md +66 -0
  42. package/src/integrations/assets/skills/backend.md +67 -0
  43. package/src/integrations/assets/skills/code-quality.md +78 -0
  44. package/src/integrations/assets/skills/data.md +75 -0
  45. package/src/integrations/assets/skills/dba.md +77 -0
  46. package/src/integrations/assets/skills/frontend.md +70 -0
  47. package/src/integrations/assets/skills/iac.md +90 -0
  48. package/src/integrations/assets/skills/infra.md +62 -0
  49. package/src/integrations/assets/skills/qa.md +63 -0
  50. package/src/integrations/assets/skills/security.md +74 -0
  51. package/src/integrations/assets/skills/tooling.md +80 -0
  52. package/src/integrations/assets/skills/ux.md +70 -0
  53. package/src/integrations/catalog.js +25 -1
  54. package/src/integrations/index.js +20 -2
  55. package/src/integrations/render.js +57 -6
  56. package/src/lib/update-engine.js +217 -0
  57. package/src/ship/runner.js +454 -0
  58. package/src/validator/index.js +323 -111
  59. package/src/validator/traceid.js +1 -1
@@ -0,0 +1,454 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * ship/runner.js — Core implementation of `trackfw ship`.
5
+ *
6
+ * All git write operations are injectable for testability.
7
+ * Never passes "add ." or "add -A" to any git executor.
8
+ */
9
+
10
+ const { spawnSync } = require('child_process')
11
+ const fs = require('fs')
12
+ const path = require('path')
13
+ const { load: loadConfig, reset: resetConfig } = require('../config')
14
+ const { resolve: forgeResolve } = require('../forge/resolve')
15
+ const { forgeAdapter } = require('../forge/adapter')
16
+
17
+ // Git subcommands that modify local or remote state.
18
+ // In --dry-run mode these are printed but not executed.
19
+ const GIT_WRITE_COMMANDS = new Set(['commit', 'push', 'fetch'])
20
+
21
+ /**
22
+ * isGitWriteCmd returns true when the first arg is a write-mode git subcommand.
23
+ * @param {string[]} args
24
+ * @returns {boolean}
25
+ */
26
+ function isGitWriteCmd(args) {
27
+ return args.length > 0 && GIT_WRITE_COMMANDS.has(args[0])
28
+ }
29
+
30
+ /**
31
+ * isShipBranch returns true when branch matches feat|fix|refactor/<slug>.
32
+ * @param {string} branch
33
+ * @returns {boolean}
34
+ */
35
+ function isShipBranch(branch) {
36
+ return /^(feat|fix|refactor)\/.+/.test(branch)
37
+ }
38
+
39
+ /**
40
+ * defaultExecGit runs git with the provided args and returns { stdout, error }.
41
+ * @param {string[]} args
42
+ * @returns {{ stdout: string, error: Error|null }}
43
+ */
44
+ function defaultExecGit(args) {
45
+ const result = spawnSync('git', args, { encoding: 'utf8' })
46
+ if (result.status !== 0) {
47
+ const msg = (result.stderr || '').trim() || `git ${args.join(' ')} exited with ${result.status}`
48
+ return { stdout: '', error: new Error(msg) }
49
+ }
50
+ return { stdout: (result.stdout || '').trim(), error: null }
51
+ }
52
+
53
+ /**
54
+ * defaultCheckGovernance returns violation messages from the governance gate.
55
+ * Uses validator equivalent: looks for wip roadmap and req link in the filesystem.
56
+ * Returns [] when governance passes.
57
+ * @returns {string[]}
58
+ */
59
+ function defaultCheckGovernance() {
60
+ return checkShipGovernance()
61
+ }
62
+
63
+ /**
64
+ * 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
68
+ * @returns {string[]} violation messages
69
+ */
70
+ 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
123
+ }
124
+
125
+ /**
126
+ * resolveRoadmapDir delegates to config.load() — single source of truth for roadmap_dir.
127
+ * Accepts an optional cwd for testability (passed through to config.load).
128
+ * Default when no trackfw.yaml is present: docs/roadmaps.
129
+ * @param {string} [cwd]
130
+ * @returns {string}
131
+ */
132
+ function resolveRoadmapDir(cwd) {
133
+ return loadConfig(cwd).roadmapDir
134
+ }
135
+
136
+ /**
137
+ * normalizeBranchSlug converts a string to a lowercase dash-only slug
138
+ * (same algorithm as Go's normalizeBranchSlug).
139
+ * @param {string} value
140
+ * @returns {string}
141
+ */
142
+ function normalizeBranchSlug(value) {
143
+ let out = ''
144
+ let lastDash = false
145
+ for (const ch of value.toLowerCase()) {
146
+ if (/[a-z0-9]/.test(ch)) {
147
+ out += ch
148
+ lastDash = false
149
+ } else if (!lastDash) {
150
+ out += '-'
151
+ lastDash = true
152
+ }
153
+ }
154
+ return out.replace(/^-|-$/g, '')
155
+ }
156
+
157
+ /**
158
+ * detectPendingSquashMerges warns about remote branches with non-empty diffs vs origin/main.
159
+ * Non-blocking.
160
+ * @param {string} currentBranch
161
+ * @param {function} execGit
162
+ * @param {function} writeln
163
+ */
164
+ function detectPendingSquashMerges(currentBranch, execGit, writeln) {
165
+ const { stdout, error } = execGit(['branch', '-r', '--no-merged', 'origin/main'])
166
+ if (error || !stdout.trim()) return
167
+
168
+ for (const raw of stdout.split('\n')) {
169
+ const candidate = raw.trim()
170
+ if (!candidate || candidate.includes('HEAD')) continue
171
+ const shortName = candidate.replace(/^origin\//, '')
172
+ if (shortName === currentBranch) continue
173
+
174
+ const { stdout: diff, error: derr } = execGit(['diff', 'origin/main', candidate, '--stat'])
175
+ if (derr) continue
176
+ if (diff.trim()) {
177
+ writeln(`Warning: branch "${shortName}" appears to have unmerged changes vs origin/main.`)
178
+ }
179
+ }
180
+ }
181
+
182
+ /**
183
+ * buildPushArgs returns the push args, adding -u if no upstream is configured.
184
+ * @param {string} branch
185
+ * @param {function} execGit
186
+ * @returns {string[]}
187
+ */
188
+ function buildPushArgs(branch, execGit) {
189
+ const { error } = execGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])
190
+ if (error) {
191
+ return ['push', '-u', 'origin', branch]
192
+ }
193
+ return ['push', 'origin', branch]
194
+ }
195
+
196
+ /**
197
+ * defaultExecForgeCLI invokes a forge CLI (gh, glab, az) inheriting stdio.
198
+ * @param {string} name
199
+ * @param {string[]} args
200
+ * @returns {Error|null}
201
+ */
202
+ function defaultExecForgeCLI(name, args) {
203
+ const result = spawnSync(name, args, { stdio: 'inherit' })
204
+ if (result.status !== 0) {
205
+ return new Error(`${name} exited with ${result.status}`)
206
+ }
207
+ return null
208
+ }
209
+
210
+ /**
211
+ * firstLine returns only the first line of s.
212
+ * @param {string} s
213
+ * @returns {string}
214
+ */
215
+ function firstLine(s) {
216
+ const idx = s.indexOf('\n')
217
+ return idx >= 0 ? s.slice(0, idx) : s
218
+ }
219
+
220
+ /**
221
+ * buildForgeCreateArgs appends --title and --body (or --description for azure)
222
+ * to a copy of adapter.cliArgs. Never mutates the original array.
223
+ * @param {object} adapter
224
+ * @param {string} title
225
+ * @param {string} body
226
+ * @returns {string[]}
227
+ */
228
+ function buildForgeCreateArgs(adapter, title, body) {
229
+ const args = [...adapter.cliArgs, '--title', title]
230
+ if (adapter.forge === 'azure') {
231
+ args.push('--description', body)
232
+ } else {
233
+ args.push('--body', body)
234
+ }
235
+ return args
236
+ }
237
+
238
+ /**
239
+ * runShip executes the seven-step ship sequence.
240
+ *
241
+ * @param {{ message: string, dryRun: boolean, noPR?: boolean, forge?: string }} opts
242
+ * @param {{ execGit?: function, checkGovernance?: function, writeln?: function,
243
+ * configForge?: string, repoDir?: string,
244
+ * availFn?: function, execForgeCLI?: function }} deps
245
+ * @returns {number} exit code (0 = success)
246
+ */
247
+ function runShip(opts, deps = {}) {
248
+ const execGit = deps.execGit || defaultExecGit
249
+ const checkGovernanceFn = deps.checkGovernance || defaultCheckGovernance
250
+ const writeln = deps.writeln || ((s) => process.stdout.write(s + '\n'))
251
+
252
+ // Inner git wrapper: skips write commands in dry-run mode.
253
+ function git(args) {
254
+ if (opts.dryRun && isGitWriteCmd(args)) {
255
+ writeln(`[dry-run] git ${args.join(' ')}`)
256
+ return { stdout: '', error: null }
257
+ }
258
+ return execGit(args)
259
+ }
260
+
261
+ // ─── Step 1: Branch validation ─────────────────────────────────────────────
262
+ const branchResult = execGit(['symbolic-ref', '--short', 'HEAD'])
263
+ if (branchResult.error) {
264
+ writeln(`error: could not determine current branch (are you in a git repo?): ${branchResult.error.message}`)
265
+ return 1
266
+ }
267
+ const branch = branchResult.stdout.trim()
268
+
269
+ if (branch === 'main' || branch === 'master') {
270
+ writeln(`error: trackfw ship cannot run on "${branch}" — use a feature branch:\n git checkout -b feat/<slug>`)
271
+ return 1
272
+ }
273
+
274
+ if (!isShipBranch(branch)) {
275
+ writeln(
276
+ `error: branch "${branch}" does not match the required pattern feat|fix|refactor/<slug>\n` +
277
+ 'Rename your branch or create a new one:\n git checkout -b feat/<slug>'
278
+ )
279
+ return 1
280
+ }
281
+
282
+ writeln(`Branch: ${branch}`)
283
+
284
+ // ─── 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}`)
290
+ }
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
+
303
+ writeln('Governance: OK')
304
+
305
+ // ─── Step 3: Squash-merge detection ────────────────────────────────────────
306
+ if (opts.dryRun) {
307
+ writeln('[dry-run] git fetch origin --prune')
308
+ } else {
309
+ const { error: fetchErr } = execGit(['fetch', 'origin', '--prune'])
310
+ if (fetchErr) {
311
+ writeln('Warning: could not fetch origin (offline or no remote); skipping squash-merge check.')
312
+ } else {
313
+ detectPendingSquashMerges(branch, execGit, writeln)
314
+ }
315
+ }
316
+
317
+ // ─── Step 4: Review staged ─────────────────────────────────────────────────
318
+ const { stdout: statusOut } = execGit(['status', '--short'])
319
+ const { stdout: diffStatOut } = execGit(['diff', '--cached', '--stat'])
320
+
321
+ writeln('\n── Staged changes ──────────────────────────────────────')
322
+ if (statusOut) writeln(statusOut)
323
+ if (diffStatOut) writeln(diffStatOut)
324
+ writeln('────────────────────────────────────────────────────────\n')
325
+
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' +
330
+ ' git add <file1> <file2> ...\n' +
331
+ "Never use 'git add .' or 'git add -A'"
332
+ )
333
+ return 1
334
+ }
335
+
336
+ // ─── Step 5: Commit ────────────────────────────────────────────────────────
337
+ if (!opts.message) {
338
+ writeln(
339
+ 'error: commit message is required — use -m:\n' +
340
+ ' trackfw ship -m "feat(<scope>): <description>"'
341
+ )
342
+ return 1
343
+ }
344
+
345
+ const { error: commitErr } = git(['commit', '-m', opts.message])
346
+ if (commitErr) {
347
+ writeln(`error: git commit failed: ${commitErr.message}`)
348
+ return 1
349
+ }
350
+
351
+ if (!opts.dryRun) {
352
+ writeln(`Committed: ${opts.message}`)
353
+ }
354
+
355
+ // ─── Step 6: Push ──────────────────────────────────────────────────────────
356
+ const pushArgs = buildPushArgs(branch, execGit)
357
+ const { error: pushErr } = git(pushArgs)
358
+ if (pushErr) {
359
+ writeln(`error: git push failed: ${pushErr.message}`)
360
+ return 1
361
+ }
362
+
363
+ if (!opts.dryRun) {
364
+ writeln(`Pushed: ${branch} → origin/${branch}`)
365
+ }
366
+
367
+ // ─── Step 7: Open PR/MR ────────────────────────────────────────────────────
368
+ const { stdout: remoteURLRaw } = execGit(['remote', 'get-url', 'origin'])
369
+ const remoteURL = (remoteURLRaw || '').trim()
370
+
371
+ let resolution
372
+ try {
373
+ resolution = forgeResolve({
374
+ flagForge: opts.forge || '',
375
+ configForge: deps.configForge || '',
376
+ remoteURL,
377
+ repoDir: deps.repoDir || '',
378
+ })
379
+ } catch (resErr) {
380
+ writeln(`Warning: forge resolution error: ${resErr.message} — open PR/MR manually.`)
381
+ writeln('\nship complete.')
382
+ return 0
383
+ }
384
+
385
+ const adapter = forgeAdapter(resolution.forge, deps.availFn || undefined)
386
+ writeln(`Forge: ${resolution.forge} (source: ${resolution.source})`)
387
+
388
+ if (opts.noPR) {
389
+ writeln(`--no-pr: skipping ${adapter.noun} creation.`)
390
+ writeln('\nship complete.')
391
+ return 0
392
+ }
393
+
394
+ if (opts.dryRun) {
395
+ if (!adapter.available && resolution.forge !== 'manual') {
396
+ const url = adapter.fallbackURL(remoteURL, branch)
397
+ if (url) {
398
+ writeln(`[dry-run] ${adapter.noun} CLI (${adapter.cliName}) not available — would open in browser:\n ${url}`)
399
+ } else {
400
+ writeln(`[dry-run] ${adapter.noun} CLI (${adapter.cliName}) not available — would open ${adapter.noun} manually`)
401
+ }
402
+ } else {
403
+ writeln(`[dry-run] would open ${adapter.noun} via ${resolution.forge} CLI`)
404
+ }
405
+ return 0
406
+ }
407
+
408
+ if (resolution.forge === 'manual') {
409
+ writeln(`\nOpen your ${adapter.noun} manually at:\n ${remoteURL}`)
410
+ writeln('\nship complete.')
411
+ return 0
412
+ }
413
+
414
+ if (!adapter.available) {
415
+ const url = adapter.fallbackURL(remoteURL, branch)
416
+ if (url) {
417
+ writeln(`${adapter.noun} CLI (${adapter.cliName}) not available — open in browser:\n ${url}`)
418
+ } else {
419
+ writeln(`${adapter.noun} CLI (${adapter.cliName}) not available — open ${adapter.noun} manually.`)
420
+ }
421
+ writeln('\nship complete.')
422
+ return 0
423
+ }
424
+
425
+ // CLI is available — invoke it.
426
+ const title = firstLine(opts.message || '')
427
+ const body = `Branch: ${branch}\n\nCreated by trackfw ship.`
428
+ const cliArgs = buildForgeCreateArgs(adapter, title, body)
429
+ const execForgeCLI = deps.execForgeCLI || defaultExecForgeCLI
430
+ const cliErr = execForgeCLI(adapter.cliName, cliArgs)
431
+ if (cliErr) {
432
+ const url = adapter.fallbackURL(remoteURL, branch)
433
+ writeln(`Warning: ${adapter.noun} CLI failed (${cliErr.message}).`)
434
+ if (url) writeln(`Open in browser:\n ${url}`)
435
+ } else {
436
+ writeln(`${adapter.noun} created.`)
437
+ }
438
+
439
+ writeln('\nship complete.')
440
+ return 0
441
+ }
442
+
443
+ module.exports = {
444
+ runShip,
445
+ isShipBranch,
446
+ isGitWriteCmd,
447
+ normalizeBranchSlug,
448
+ checkShipGovernance,
449
+ resolveRoadmapDir,
450
+ resetConfig,
451
+ GIT_WRITE_COMMANDS,
452
+ buildForgeCreateArgs,
453
+ firstLine,
454
+ }