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
@@ -5,6 +5,12 @@ const fs = require('fs');
5
5
  const os = require('os');
6
6
  const path = require('path');
7
7
  const identityStore = require('../identity');
8
+ const { runFileTarget, validateTargets, buildDocument, humanReport, silenceConsole } = require('../lib/update-engine');
9
+
10
+ // `trackfw update` is project-scoped only — see docs/cli-parity.md,
11
+ // "`trackfw update` vs `trackfw update harness`". It must NEVER touch global
12
+ // state (~/.claude, ~/.codex, etc.). Global artifacts moved to
13
+ // `trackfw update harness` (update-harness.js).
8
14
 
9
15
  function readUpdateConfig(rootDir) {
10
16
  const yaml = path.join(rootDir, 'trackfw.yaml');
@@ -50,99 +56,227 @@ function updateHooksSurgical(cfg, rootDir) {
50
56
  }
51
57
  }
52
58
 
53
- const cmd = new Command('update');
54
- cmd.description('Update trackfw-managed artifacts to the current version');
55
- cmd.action(() => {
56
- const cwd = process.cwd();
57
- const yaml = path.join(cwd, 'trackfw.yaml');
58
- if (!fs.existsSync(yaml)) {
59
- console.error('✗ trackfw.yaml não encontrado — execute trackfw init primeiro');
60
- process.exit(1);
59
+ // codexProjectAgentsTarget — installs/reports the project-scoped Codex
60
+ // agents/skills bundle (catalog-based). Uses IntegrationManager.inspect,
61
+ // which is read-only, to compute state under --dry-run too; only the
62
+ // mutating manager.update() call is skipped when dryRun is true. Kept
63
+ // separate from runFileTarget because IntegrationManager already owns a
64
+ // correct, manifest-aware not-installed/current/outdated/modified state
65
+ // machine — re-deriving it via directory hashing would risk diverging from
66
+ // that source of truth (and would need the .trackfw manifest copied into
67
+ // the simulation, which is unnecessary complexity here).
68
+ function codexProjectAgentsTarget(cwd, identityConfig, { dryRun, installMissing }) {
69
+ const id = 'codex-project-agents';
70
+ const displayPath = '.codex/agents, .agents/skills';
71
+ const detected = fs.existsSync(path.join(cwd, 'AGENTS.md')) || fs.existsSync(path.join(cwd, '.codex'));
72
+ if (!detected) return { id, state: 'missing', path: displayPath };
73
+
74
+ try {
75
+ const { buildPlans, IntegrationManager } = require('../integrations');
76
+ const manager = new IntegrationManager({ projectRoot: cwd });
77
+ let wroteAny = false;
78
+ for (const kind of ['agents', 'skills']) {
79
+ const plans = buildPlans(kind, { targets: ['codex'], scope: 'project', identity: identityConfig });
80
+ const statuses = manager.inspect(plans);
81
+ const toWrite = plans.filter((_, index) => {
82
+ const state = statuses[index].state;
83
+ return state === 'outdated' || (installMissing && state === 'not-installed');
84
+ });
85
+ if (toWrite.length) {
86
+ wroteAny = true;
87
+ if (!dryRun) manager.update(toWrite);
88
+ }
89
+ }
90
+ return { id, state: wroteAny ? 'updated' : 'skipped', path: displayPath };
91
+ } catch (e) {
92
+ return { id, state: 'failed', path: displayPath, message: e.message };
61
93
  }
94
+ }
62
95
 
63
- const cfg = readUpdateConfig(cwd);
96
+ // PROJECT_TARGET_IDS — the fixed declared order for `trackfw update`
97
+ // targets. `ci-workflow` and `git-hooks` only appear when the project's
98
+ // trackfw.yaml opted into a CI system / hook framework — see ambiguity
99
+ // note in the ML-6C handoff report about config-conditional target lists.
100
+ const PROJECT_TARGET_IDS = [
101
+ 'agent-rules',
102
+ 'agent-hooks',
103
+ 'codex-project-agents',
104
+ 'validate-script',
105
+ 'ci-workflow',
106
+ 'git-hooks',
107
+ 'claude-commands',
108
+ ];
109
+
110
+ // buildProjectTargets — `wanted` (nullable) restricts which targets are
111
+ // even computed/applied. This must be enforced HERE, before any apply()
112
+ // runs, not as a post-hoc filter on the returned array: every target's
113
+ // apply() is a real filesystem side effect (outside --dry-run), so
114
+ // filtering afterwards would still have written every unrequested target.
115
+ function buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing }, wanted) {
64
116
  const generators = require('../generators/init');
65
117
  const discover = require('./discover');
118
+ const hooksGen = require('../generators/hooks');
119
+ const include = (id) => !wanted || wanted.includes(id);
66
120
 
67
- // A identidade é carregada uma única vez, fora de qualquer try/catch —
68
- // um identity.json corrompido deve abortar o comando inteiro, nunca cair
69
- // silenciosamente para os nomes neutros default.
70
- const identityConfig = identityStore.load(os.homedir());
121
+ const targets = [];
71
122
 
72
- console.log('trackfw update — re-aplicando templates atuais...\n');
123
+ if (include('agent-rules')) targets.push(runFileTarget({
124
+ id: 'agent-rules',
125
+ path: 'CLAUDE.md, AGENTS.md, GEMINI.md, .github/copilot-instructions.md, .windsurfrules, .amazonq/developer/guidelines.md, .cursor/rules/trackfw.mdc',
126
+ root: cwd,
127
+ relPaths: ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md', '.github/copilot-instructions.md', '.windsurfrules', '.amazonq/developer/guidelines.md', '.cursor/rules/trackfw.mdc'],
128
+ apply: (root) => generators.injectRulesDetected(root),
129
+ dryRun,
130
+ installMissing,
131
+ }));
73
132
 
74
- // 1. Agent rules (marker-delimited, idempotent)
75
- try {
76
- generators.injectRulesDetected(cwd);
77
- console.log(' ✓ agent rules atualizadas');
78
- } catch (e) {
79
- console.log(` ⚠ agent rules: ${e.message}`);
80
- }
81
- if (fs.existsSync(path.join(cwd, 'AGENTS.md')) || fs.existsSync(path.join(cwd, '.codex'))) {
82
- try {
83
- const { buildPlans, IntegrationManager } = require('../integrations');
84
- const roots = { projectRoot: cwd };
85
- const manager = new IntegrationManager(roots);
86
- for (const kind of ['agents', 'skills']) {
87
- const plans = buildPlans(kind, { targets: ['codex'], scope: 'project', identity: identityConfig });
88
- const statuses = manager.inspect(plans);
89
- const existing = plans.filter((_, index) => statuses[index].state !== 'not-installed');
90
- manager.update(existing);
91
- }
92
- } catch (e) {
93
- console.warn(` ⚠ Codex integration: ${e.message}`);
94
- }
95
- }
133
+ if (include('agent-hooks')) targets.push(runFileTarget({
134
+ id: 'agent-hooks',
135
+ path: '.claude/settings.json, .codex/hooks.json, .gemini/settings.json, .kiro/hooks/trackfw-attention.json, .github/hooks/trackfw-attention.json, .cursor/hooks.json, scripts/trackfw-attention-*.sh',
136
+ root: cwd,
137
+ relPaths: [
138
+ '.claude/settings.json',
139
+ '.codex/hooks.json',
140
+ '.gemini/settings.json',
141
+ '.kiro/hooks/trackfw-attention.json',
142
+ '.github/hooks/trackfw-attention.json',
143
+ '.cursor/hooks.json',
144
+ 'scripts/trackfw-attention-signal.sh',
145
+ 'scripts/trackfw-attention-cleanup.sh',
146
+ ],
147
+ apply: (root) => {
148
+ hooksGen.injectHooksDetected(root);
149
+ hooksGen.generateAttentionScripts(cfg, root);
150
+ },
151
+ dryRun,
152
+ installMissing,
153
+ }));
96
154
 
97
- // 1b. Agent hooks (attention signal/cleanup)
98
- try {
99
- const { injectHooksDetected, generateAttentionScripts } = require('../generators/hooks');
100
- injectHooksDetected(cwd);
101
- generateAttentionScripts(cfg, cwd);
102
- console.log(' ✓ agent hooks atualizados');
103
- } catch (e) {
104
- console.warn(` ⚠ agent hooks: ${e.message}`);
155
+ if (include('codex-project-agents')) targets.push(codexProjectAgentsTarget(cwd, identityConfig, { dryRun, installMissing }));
156
+
157
+ if (include('validate-script')) targets.push(runFileTarget({
158
+ id: 'validate-script',
159
+ path: 'scripts/trackfw-validate.sh',
160
+ root: cwd,
161
+ relPaths: ['scripts/trackfw-validate.sh'],
162
+ // Reuses generators/init.js's generateValidateScript — the SAME
163
+ // generator `trackfw init` uses to write this file — not
164
+ // discover.js's writeValidateScript, which produces a different
165
+ // (simpler, non-per-backend) script and made every `update` re-run
166
+ // report "updated" against a project actually already current
167
+ // (ML-6H fix). readUpdateConfig returns raw trackfw.yaml keys
168
+ // (snake_case, e.g. "pkg_manager"); buildValidateScript expects the
169
+ // camelCase shape used by the rest of the init generators.
170
+ apply: (root) => generators.generateValidateScript({
171
+ backend: cfg.backend,
172
+ frontend: cfg.frontend,
173
+ pkgManager: cfg.pkg_manager,
174
+ }, root),
175
+ dryRun,
176
+ installMissing,
177
+ }));
178
+
179
+ if (include('ci-workflow') && (cfg.ci === 'github-actions' || cfg.ci === 'github_actions')) {
180
+ targets.push(runFileTarget({
181
+ id: 'ci-workflow',
182
+ path: '.github/workflows/trackfw-validate.yml',
183
+ root: cwd,
184
+ relPaths: ['.github/workflows/trackfw-validate.yml'],
185
+ apply: (root) => discover.writeCIWorkflowForce(root),
186
+ dryRun,
187
+ installMissing,
188
+ }));
105
189
  }
106
190
 
107
- // 2. Validate script (trackfw-owned, overwrite)
108
- try {
109
- discover.writeValidateScript(cwd);
110
- console.log(' ✓ scripts/trackfw-validate.sh atualizado');
111
- } catch (e) {
112
- console.log(` ⚠ validate script: ${e.message}`);
191
+ if (include('git-hooks') && (cfg.hooks === 'husky' || cfg.hooks === 'lefthook')) {
192
+ const relPath = cfg.hooks === 'husky' ? '.husky/pre-commit' : 'lefthook.yml';
193
+ targets.push(runFileTarget({
194
+ id: 'git-hooks',
195
+ path: relPath,
196
+ root: cwd,
197
+ relPaths: [relPath],
198
+ apply: (root) => updateHooksSurgical(cfg, root),
199
+ dryRun,
200
+ installMissing,
201
+ }));
113
202
  }
114
203
 
115
- // 3. CI workflow (trackfw-owned, overwrite)
116
- if (cfg.ci === 'github-actions' || cfg.ci === 'github_actions') {
117
- try {
118
- discover.writeCIWorkflowForce(cwd);
119
- console.log(' ✓ .github/workflows/trackfw-validate.yml atualizado');
120
- } catch (e) {
121
- console.log(` ⚠ CI workflow: ${e.message}`);
122
- }
204
+ if (include('claude-commands')) targets.push(runFileTarget({
205
+ id: 'claude-commands',
206
+ path: '.claude/commands/trackfw',
207
+ root: cwd,
208
+ relPaths: ['.claude/commands/trackfw'],
209
+ apply: (root) => generators.generateClaudeCommandsForce(root),
210
+ dryRun,
211
+ installMissing,
212
+ }));
213
+
214
+ return targets;
215
+ }
216
+
217
+ const cmd = new Command('update');
218
+ cmd.description('Update trackfw-managed artifacts. Bare form is project-scoped (never touches global state); `update harness` updates the global harness instead.');
219
+ // `[mode]` is a plain positional argument, not a nested commander.Command —
220
+ // see the long comment in update-harness.js:run for why: a real subcommand
221
+ // redeclaring the same --json/--dry-run/--targets/--install-missing flags
222
+ // as this parent silently drops them (commander@12 quirk, confirmed by
223
+ // reproduction). One Command, one Option per flag, branch on `mode` inside
224
+ // a single action — this is the only structure that parses correctly.
225
+ cmd.argument('[mode]', 'Pass "harness" to update the global harness instead of the current project');
226
+ cmd.option('--dry-run', 'Compute and report states without writing anything');
227
+ cmd.option('--json', 'Emit the result document as JSON');
228
+ cmd.option('--targets <ids>', 'Comma-separated subset of target ids');
229
+ cmd.option('--install-missing', 'Allow missing targets to be installed');
230
+ cmd.action((mode, options) => {
231
+ if (mode === 'harness') {
232
+ return require('./update-harness').run(options);
233
+ }
234
+ if (mode) {
235
+ console.error(`✗ Unknown update mode: ${mode} (expected "harness" or no argument)`);
236
+ process.exit(1);
123
237
  }
124
238
 
125
- // 4. Git hooks (surgical)
126
- updateHooksSurgical(cfg, cwd);
239
+ const cwd = process.cwd();
240
+ const yaml = path.join(cwd, 'trackfw.yaml');
241
+ if (!fs.existsSync(yaml)) {
242
+ console.error('✗ trackfw.yaml não encontrado — execute trackfw init primeiro');
243
+ process.exit(1);
244
+ }
127
245
 
128
- // 5. Claude commands (force overwrite)
246
+ let wanted;
129
247
  try {
130
- generators.generateClaudeCommandsForce(cwd);
131
- console.log(' ✓ .claude/commands/trackfw/ atualizado');
248
+ const requested = options.targets ? String(options.targets).split(',').map((s) => s.trim()).filter(Boolean) : [];
249
+ wanted = validateTargets(PROJECT_TARGET_IDS, requested);
132
250
  } catch (e) {
133
- console.log(` ⚠ Claude commands: ${e.message}`);
251
+ console.error(`✗ ${e.message}`);
252
+ process.exit(1);
134
253
  }
135
254
 
136
- // 6. Global skill (force overwrite)
137
- try {
138
- generators.installSkillsForce(cwd);
139
- console.log(' ✓ skill global atualizada');
140
- } catch (e) {
141
- console.log(` ⚠ skills: ${e.message}`);
255
+ const cfg = readUpdateConfig(cwd);
256
+ const dryRun = Boolean(options.dryRun);
257
+ const installMissing = Boolean(options.installMissing);
258
+
259
+ // A identidade é carregada uma única vez, fora de qualquer try/catch —
260
+ // um identity.json corrompido deve abortar o comando inteiro, nunca cair
261
+ // silenciosamente para os nomes neutros default.
262
+ const identityConfig = identityStore.load(os.homedir());
263
+
264
+ // With --json, stdout must carry only the result document — apply()
265
+ // functions log human progress lines as a side effect; silence them so a
266
+ // consumer parsing --json output never has to skip preamble noise.
267
+ const targets = options.json
268
+ ? silenceConsole(() => buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing }, wanted))
269
+ : buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing }, wanted);
270
+
271
+ const doc = buildDocument('project', dryRun, targets);
272
+
273
+ if (options.json) {
274
+ console.log(JSON.stringify(doc, null, 2));
275
+ } else {
276
+ console.log(humanReport('project', dryRun, targets));
142
277
  }
143
278
 
144
- console.log('\n✓ trackfw update concluído');
145
- require('../generators/init').printArchitectNextSteps(process.cwd())
279
+ if (doc.summary.failed > 0) process.exitCode = 1;
146
280
  });
147
281
 
148
282
  module.exports = cmd;
@@ -26,9 +26,11 @@ function defaults() {
26
26
  lenientUntil: '',
27
27
  wipLimit: 1,
28
28
  wipBySquad: false,
29
+ staleWipDays: 7,
29
30
  requireReqInCommit: false,
30
31
  strictCiPaths: false,
31
32
  traceIdField: '',
33
+ forge: '',
32
34
  // NOVOS campos:
33
35
  linkFields: {
34
36
  req: ['REQ:'],
@@ -42,7 +44,7 @@ function defaults() {
42
44
  wip_limit: 'error',
43
45
  stale_wip: 'warning',
44
46
  adr_orphan: 'warning',
45
- ref_targets_exist: 'warning',
47
+ ref_targets_exist: 'error',
46
48
  folder_status: 'warning',
47
49
  filename_uniqueness: 'error',
48
50
  blocked_by_draft_adr: 'error',
@@ -191,9 +193,11 @@ function parse(content, cfg) {
191
193
  case 'lenient_until': cfg.lenientUntil = val; break;
192
194
  case 'wip_limit': { const n = parseInt(val, 10); if (n > 0) cfg.wipLimit = n; break; }
193
195
  case 'wip_by_squad': cfg.wipBySquad = val === 'true'; break;
196
+ case 'stale_wip_days': { const n = parseInt(val, 10); if (n > 0) cfg.staleWipDays = n; break; }
194
197
  case 'require_req_in_commit': cfg.requireReqInCommit = val === 'true'; break;
195
198
  case 'strict_ci_paths': cfg.strictCiPaths = val === 'true'; break;
196
199
  case 'trace_id_field': cfg.traceIdField = val.replace(/^["']|["']$/g, ''); break;
200
+ case 'forge': cfg.forge = val.replace(/^["']|["']$/g, ''); break;
197
201
  case 'link_fields': inLinkFields = true; break;
198
202
  case 'acceptance_markers': inAcceptanceMarkers = true; acceptanceMarkers = []; break;
199
203
  case 'rules': inRules = true; rules = {}; break;
@@ -208,4 +212,3 @@ const NAMESPACING_FLAT = 'flat';
208
212
  const NAMESPACING_BY_AGENT = 'by_agent';
209
213
 
210
214
  module.exports = { load, reset, defaults, expandPath, NAMESPACING_FLAT, NAMESPACING_BY_AGENT };
211
-
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Verificação de disponibilidade de CLI — scan puro no PATH sem subprocess
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /**
11
+ * Verifica se um executável está disponível no PATH, respeitando
12
+ * TRACKFW_DISABLE_EXTERNAL_COMMANDS (mesmo padrão de discover.go).
13
+ * @param {string} name
14
+ * @returns {boolean}
15
+ */
16
+ function _defaultAvailFn(name) {
17
+ if (!name) return false;
18
+ if (process.env.TRACKFW_DISABLE_EXTERNAL_COMMANDS === '1') return false;
19
+
20
+ const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
21
+ const isWindows = process.platform === 'win32';
22
+ const exts = isWindows
23
+ ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';')
24
+ : [''];
25
+
26
+ for (const dir of dirs) {
27
+ for (const ext of exts) {
28
+ try {
29
+ const candidate = path.join(dir, name + ext);
30
+ fs.accessSync(candidate, fs.constants.F_OK | fs.constants.X_OK);
31
+ return true;
32
+ } catch (_) {
33
+ // continua
34
+ }
35
+ }
36
+ }
37
+ return false;
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Conversão de remote URL → base HTTPS
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * Converte qualquer formato de remote git (git@, ssh://, https://, http://)
46
+ * para URL base HTTPS sem .git.
47
+ *
48
+ * Normalização especial para Azure SSH:
49
+ * git@ssh.dev.azure.com:v3/org/project/repo → https://dev.azure.com/org/project/_git/repo
50
+ *
51
+ * @param {string} rawURL
52
+ * @param {string} forge
53
+ * @returns {string}
54
+ */
55
+ function remoteHTTPSBase(rawURL, forge) {
56
+ if (!rawURL) return '';
57
+ rawURL = rawURL.trim();
58
+ if (!rawURL) return '';
59
+
60
+ let host = '';
61
+ let pathStr = '';
62
+
63
+ if (rawURL.startsWith('git@')) {
64
+ // git@host:path
65
+ const rest = rawURL.slice(4);
66
+ const colonIdx = rest.indexOf(':');
67
+ if (colonIdx < 0) return '';
68
+ host = rest.slice(0, colonIdx).toLowerCase();
69
+ pathStr = rest.slice(colonIdx + 1);
70
+
71
+ } else if (rawURL.startsWith('ssh://')) {
72
+ // ssh://[user@]host/path
73
+ let rest = rawURL.slice(6);
74
+ const atIdx = rest.indexOf('@');
75
+ if (atIdx >= 0) rest = rest.slice(atIdx + 1);
76
+ const slashIdx = rest.indexOf('/');
77
+ if (slashIdx < 0) {
78
+ host = rest.toLowerCase();
79
+ } else {
80
+ host = rest.slice(0, slashIdx).toLowerCase();
81
+ pathStr = rest.slice(slashIdx + 1);
82
+ }
83
+
84
+ } else if (rawURL.startsWith('https://') || rawURL.startsWith('http://')) {
85
+ let rest = rawURL.replace(/^https?:\/\//, '');
86
+ const atIdx = rest.indexOf('@');
87
+ if (atIdx >= 0) rest = rest.slice(atIdx + 1);
88
+ const slashIdx = rest.indexOf('/');
89
+ if (slashIdx < 0) {
90
+ host = rest.toLowerCase();
91
+ } else {
92
+ host = rest.slice(0, slashIdx).toLowerCase();
93
+ pathStr = rest.slice(slashIdx + 1);
94
+ }
95
+
96
+ } else {
97
+ return '';
98
+ }
99
+
100
+ pathStr = pathStr.replace(/\.git$/, '').replace(/^\/|\/$/g, '');
101
+
102
+ // Normalização Azure SSH
103
+ if (forge === 'azure' && host === 'ssh.dev.azure.com') {
104
+ host = 'dev.azure.com';
105
+ pathStr = pathStr.replace(/^v3\//, '');
106
+ const parts = pathStr.split('/');
107
+ if (parts.length === 3) {
108
+ pathStr = `${parts[0]}/${parts[1]}/_git/${parts[2]}`;
109
+ }
110
+ }
111
+
112
+ if (!pathStr) return `https://${host}`;
113
+ return `https://${host}/${pathStr}`;
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Adapter
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /**
121
+ * @typedef {Object} Adapter
122
+ * @property {string} forge
123
+ * @property {string} noun - "Pull Request" ou "Merge Request"
124
+ * @property {string} cliName - nome do executável (vazio = sem CLI)
125
+ * @property {string[]} cliArgs - argumentos para criar PR/MR
126
+ * @property {boolean} available - false quando CLI ausente
127
+ * @property {function(string, string): string} fallbackURL
128
+ */
129
+
130
+ /**
131
+ * Retorna o Adapter para o forge informado.
132
+ *
133
+ * @param {string} forge
134
+ * @param {function(string): boolean} [availFn] - injeta verificação de CLI (para testes)
135
+ * @returns {Adapter}
136
+ */
137
+ function forgeAdapter(forge, availFn) {
138
+ if (typeof availFn !== 'function') {
139
+ availFn = _defaultAvailFn;
140
+ }
141
+
142
+ let noun = 'Pull Request';
143
+ let cliName = '';
144
+ let cliArgs = [];
145
+ let available = false;
146
+
147
+ switch (forge) {
148
+ case 'github':
149
+ cliName = 'gh';
150
+ cliArgs = ['pr', 'create'];
151
+ available = availFn('gh');
152
+ break;
153
+ case 'gitlab':
154
+ noun = 'Merge Request';
155
+ cliName = 'glab';
156
+ cliArgs = ['mr', 'create'];
157
+ available = availFn('glab');
158
+ break;
159
+ case 'azure':
160
+ cliName = 'az';
161
+ cliArgs = ['repos', 'pr', 'create'];
162
+ available = availFn('az');
163
+ break;
164
+ case 'bitbucket':
165
+ // Bitbucket não possui CLI — nunca chama availFn
166
+ break;
167
+ default:
168
+ // forge desconhecido: sem CLI, sem disponibilidade
169
+ break;
170
+ }
171
+
172
+ const adapter = { forge, noun, cliName, cliArgs, available };
173
+
174
+ adapter.fallbackURL = function (remoteURL, branch) {
175
+ const base = remoteHTTPSBase(remoteURL, forge);
176
+ if (!base) return '';
177
+ switch (forge) {
178
+ case 'github': return `${base}/compare/${branch}?expand=1`;
179
+ case 'gitlab': return `${base}/-/merge_requests/new?merge_request[source_branch]=${branch}`;
180
+ case 'bitbucket': return `${base}/pull-requests/new?source=${branch}`;
181
+ case 'azure': return `${base}/pullrequestcreate?sourceRef=${branch}`;
182
+ default: return '';
183
+ }
184
+ };
185
+
186
+ return adapter;
187
+ }
188
+
189
+ module.exports = { forgeAdapter, remoteHTTPSBase };