forge-workflow 0.1.0-beta.7 → 0.1.0-beta.8

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.
@@ -1,12 +1,28 @@
1
1
  'use strict';
2
2
 
3
- const { execFileSync, spawnSync } = require('node:child_process');
3
+ const { randomUUID } = require('node:crypto');
4
+ const { execFileSync, spawn, spawnSync } = require('node:child_process');
4
5
  const fs = require('node:fs');
5
6
  const path = require('node:path');
6
7
  const forgeToken = require('../../scripts/check-forge-token');
7
- const { QUICK_LANE_ENV_VAR, QUICK_LANE_VALUE, resolveFullSuiteTimeoutMs } = require('../../scripts/test');
8
+ const {
9
+ QUICK_LANE_ENV_VAR,
10
+ QUICK_LANE_VALUE,
11
+ TIMEOUT_EXIT_CODE,
12
+ resolveFullSuiteTimeoutMs,
13
+ runCommand,
14
+ } = require('../../scripts/test');
15
+ const {
16
+ INSTANCE_ENV,
17
+ MANIFEST_ENV,
18
+ TOKEN_ENV,
19
+ createProcessTree,
20
+ } = require('../../scripts/process-tree');
8
21
  const { fireAndForget } = require('../pr-monitor/reconcile-executor');
9
- const { verifyValidationReceipt } = require('../validation-receipt');
22
+ const validationReceipt = require('../validation-receipt');
23
+
24
+ const { verifyValidationReceipt } = validationReceipt;
25
+ const PUSH_NONCE_ENV_VAR = forgeToken._internal.NONCE_ENV_VAR;
10
26
 
11
27
  const isWindows = process.platform === 'win32';
12
28
 
@@ -25,6 +41,19 @@ function detectPackageManager(projectRoot, existsSync = fs.existsSync) {
25
41
  return 'npm';
26
42
  }
27
43
 
44
+ function isForgeCheckout(projectRoot, readFileSync = fs.readFileSync, existsSync = fs.existsSync) {
45
+ try {
46
+ const manifest = JSON.parse(readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
47
+ return manifest.name === 'forge-workflow'
48
+ && manifest.bin?.forge === 'bin/forge.js'
49
+ && manifest.scripts?.['test:full:parallel'] === 'node scripts/test-full-suite.js'
50
+ && existsSync(path.join(projectRoot, 'scripts', 'test-full-suite.js'));
51
+ } catch (error) {
52
+ if (error instanceof SyntaxError || error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
53
+ throw error;
54
+ }
55
+ }
56
+
28
57
  /**
29
58
  * Get the current git branch name.
30
59
  *
@@ -146,19 +175,26 @@ function maybeTriggerShepherdAfterPush({
146
175
  * the tests from a `forge push`.
147
176
  *
148
177
  * @param {boolean} quickMode - Whether this is a --quick push
149
- * @param {NodeJS.ProcessEnv} [baseEnv=process.env] - Environment to derive from
178
+ * @param {NodeJS.ProcessEnv} [baseEnv] - Environment to derive from
179
+ * @param {string} [nonce] - Signed invocation proof locator for the Git child
150
180
  * @returns {NodeJS.ProcessEnv} Environment for the git push child process
151
181
  */
152
- function buildPushEnv(quickMode, baseEnv = process.env) {
153
- const env = { ...baseEnv };
182
+ function buildPushEnv(quickMode, baseEnv, nonce) {
183
+ const env = { ...(baseEnv || process.env) };
184
+ delete env[PUSH_NONCE_ENV_VAR];
154
185
  if (quickMode) {
155
186
  env[QUICK_LANE_ENV_VAR] = QUICK_LANE_VALUE;
156
187
  } else {
157
188
  delete env[QUICK_LANE_ENV_VAR];
158
189
  }
190
+ if (nonce) env[PUSH_NONCE_ENV_VAR] = nonce;
159
191
  return env;
160
192
  }
161
193
 
194
+ function buildGateEnv(baseEnv = process.env) {
195
+ return buildPushEnv(false, baseEnv);
196
+ }
197
+
162
198
  /**
163
199
  * Run branch protection check as a subprocess.
164
200
  * @param {function} execFn - execFileSync or mock
@@ -166,9 +202,9 @@ function buildPushEnv(quickMode, baseEnv = process.env) {
166
202
  * @param {function} log - Logger function
167
203
  * @returns {boolean} True if branch protection passed
168
204
  */
169
- function runBranchProtection(execFn, projectRoot, log) {
205
+ function runBranchProtection(execFn, projectRoot, log, env) {
170
206
  try {
171
- execFn('node', [path.join(projectRoot, 'scripts', 'branch-protection.js')]);
207
+ execFn('node', [path.join(projectRoot, 'scripts', 'branch-protection.js')], { env });
172
208
  return true;
173
209
  } catch (_err) { /* intentional: fallback to failure */ // NOSONAR S2486
174
210
  log('Branch protection check failed — push aborted.');
@@ -183,11 +219,12 @@ function runBranchProtection(execFn, projectRoot, log) {
183
219
  * @param {string} projectRoot - Absolute path to project root
184
220
  * @returns {boolean} True if lint passed
185
221
  */
186
- function runLint(spawnFn, pkgManager, projectRoot) {
222
+ function runLint(spawnFn, pkgManager, projectRoot, env) {
187
223
  const lintResult = spawnFn(pkgManager, ['run', 'lint'], {
188
224
  stdio: 'inherit',
189
225
  shell: isWindows,
190
226
  cwd: projectRoot,
227
+ env,
191
228
  });
192
229
  return lintResult.status === 0;
193
230
  }
@@ -280,18 +317,104 @@ function reportTestRunTermination(testResult, timeoutMs, log) {
280
317
  log('Test run ended without an exit status — push aborted.');
281
318
  }
282
319
 
320
+ async function runSupervisedTestCommand({
321
+ testCommand,
322
+ testArgs,
323
+ projectRoot,
324
+ env,
325
+ timeoutMs,
326
+ log,
327
+ spawnAsyncFn,
328
+ createProcessTreeFn,
329
+ }) {
330
+ const isolatedEnv = { ...env };
331
+ delete isolatedEnv[MANIFEST_ENV];
332
+ delete isolatedEnv[TOKEN_ENV];
333
+ delete isolatedEnv[INSTANCE_ENV];
334
+ // A unique manifest prevents timeout cleanup from touching an outer
335
+ // validation run while allInstances reaches this runner's nested shards.
336
+ const processTree = createProcessTreeFn({
337
+ env: isolatedEnv,
338
+ token: randomUUID(),
339
+ allInstances: true,
340
+ reconcile: false,
341
+ });
342
+ let supervisedEnv = null;
343
+ try {
344
+ supervisedEnv = processTree.envFor(isolatedEnv);
345
+ } catch { /* handled by the fail-closed check below */ }
346
+ const supervisionMarker = supervisedEnv?.[MANIFEST_ENV];
347
+ if (typeof supervisionMarker !== 'string' || supervisionMarker.length === 0) {
348
+ log('Test supervision unavailable — push aborted.');
349
+ processTree.cleanup('SIGKILL');
350
+ return false;
351
+ }
352
+ let cancellationSignal = null;
353
+ const removeSignalHandlers = processTree.installSignalHandlers((signal) => {
354
+ cancellationSignal = signal;
355
+ processTree.cleanup('SIGKILL');
356
+ });
357
+ let terminationSignal = null;
358
+ const observingSpawn = (...spawnArgs) => {
359
+ const child = spawnAsyncFn(...spawnArgs);
360
+ child.once?.('close', (_status, signal) => {
361
+ terminationSignal = signal;
362
+ });
363
+ return child;
364
+ };
365
+ try {
366
+ if (cancellationSignal) return false;
367
+ const status = await runCommand(testCommand, testArgs, {
368
+ cwd: projectRoot,
369
+ detached: !isWindows,
370
+ env: supervisedEnv,
371
+ killSignal: TEST_RUN_KILL_SIGNAL,
372
+ processTree,
373
+ timeout: timeoutMs,
374
+ windowsHide: true,
375
+ }, observingSpawn);
376
+ if (cancellationSignal) {
377
+ reportTestRunTermination({ status: null, signal: cancellationSignal }, timeoutMs, log);
378
+ return false;
379
+ }
380
+ if (status === TIMEOUT_EXIT_CODE) {
381
+ reportTestRunTermination({
382
+ status: null,
383
+ signal: TEST_RUN_KILL_SIGNAL,
384
+ error: Object.assign(new Error('test run timed out'), { code: 'ETIMEDOUT' }),
385
+ }, timeoutMs, log);
386
+ }
387
+ if (terminationSignal) {
388
+ reportTestRunTermination({ status: null, signal: terminationSignal }, timeoutMs, log);
389
+ return false;
390
+ }
391
+ return status === 0;
392
+ } catch (error) {
393
+ reportTestRunTermination({ status: null, signal: null, error }, timeoutMs, log);
394
+ return false;
395
+ } finally {
396
+ removeSignalHandlers();
397
+ processTree.cleanup(cancellationSignal ? 'SIGKILL' : 'SIGTERM');
398
+ }
399
+ }
400
+
283
401
  /**
284
402
  * Run tests unless in quick mode, logging warnings for first push.
285
- * @param {function} spawnFn - spawnSync or mock
286
- * @param {function} execFn - execFileSync or mock
287
- * @param {string} pkgManager - Package manager command
288
- * @param {string} projectRoot - Absolute path to project root
289
- * @param {boolean} quickMode - Whether to skip tests
290
- * @param {function} log - Logger function
291
- * @param {NodeJS.ProcessEnv} [env] - Environment used to resolve the test budget
292
- * @returns {boolean|undefined} True if tests passed, undefined if skipped
403
+ * @param {object} options - Test execution dependencies and state
404
+ * @returns {Promise<boolean|undefined>} True if tests passed, undefined if skipped
293
405
  */
294
- function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log, env = process.env) {
406
+ async function runTests({
407
+ spawnAsyncFn,
408
+ execFn,
409
+ readFileFn,
410
+ existsFn,
411
+ pkgManager,
412
+ projectRoot,
413
+ quickMode,
414
+ log,
415
+ env = process.env,
416
+ createProcessTreeFn = createProcessTree,
417
+ }) {
295
418
  if (quickMode) {
296
419
  log('Tests skipped (--quick) — CI will run full suite on GitHub');
297
420
  const branch = getCurrentBranch(execFn);
@@ -301,7 +424,10 @@ function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log, env
301
424
  return undefined;
302
425
  }
303
426
 
304
- // `forge push` runs the package-level `test` script, i.e. the whole suite.
427
+ const useForgeRunner = isForgeCheckout(projectRoot, readFileFn, existsFn);
428
+ const testCommand = useForgeRunner ? 'node' : pkgManager;
429
+ const testArgs = useForgeRunner ? ['scripts/test-full-suite.js'] : ['run', 'test'];
430
+
305
431
  // Budget it from the shared full-suite budget in scripts/test.js — which is
306
432
  // sized as ~2x the measured healthy full-suite runtime and overridable with
307
433
  // FORGE_TEST_TIMEOUT_MS — instead of an arbitrary local cap: the old fixed
@@ -309,18 +435,108 @@ function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log, env
309
435
  // reintroduce a local constant here; change the shared budget with a fresh
310
436
  // measurement instead.
311
437
  const timeoutMs = resolveFullSuiteTimeoutMs(env);
312
- const testResult = spawnFn(pkgManager, ['run', 'test'], {
313
- stdio: 'inherit',
314
- shell: isWindows,
315
- cwd: projectRoot,
316
- timeout: timeoutMs,
317
- killSignal: TEST_RUN_KILL_SIGNAL,
438
+ return runSupervisedTestCommand({
439
+ testCommand,
440
+ testArgs,
441
+ projectRoot,
442
+ env,
443
+ timeoutMs,
444
+ log,
445
+ spawnAsyncFn,
446
+ createProcessTreeFn,
318
447
  });
319
- if (testResult.status === null || testResult.status === undefined) {
320
- reportTestRunTermination(testResult, timeoutMs, log);
448
+ }
449
+
450
+ /**
451
+ * Consume Forge-owned push arguments while preserving Git-owned arguments.
452
+ * Only the first `--` is a Forge delimiter; everything after it belongs to Git.
453
+ *
454
+ * @param {string[]} args - Raw command arguments
455
+ * @param {object} flags - Parsed Forge flags
456
+ * @returns {{ quickMode: boolean, gitArgs: string[] }} Parsed push arguments
457
+ */
458
+ function parsePushArguments(args, flags) {
459
+ const delimiterIndex = args.indexOf('--');
460
+ const forgeArgs = delimiterIndex === -1 ? args : args.slice(0, delimiterIndex);
461
+ const gitArgsAfterDelimiter = delimiterIndex === -1 ? [] : args.slice(delimiterIndex + 1);
462
+ const quickMode = delimiterIndex === -1
463
+ ? Boolean(flags && (flags['--quick'] || flags.quick))
464
+ : forgeArgs.some(arg => arg === '--quick' || arg === '-q');
465
+ return {
466
+ quickMode,
467
+ gitArgs: [
468
+ ...forgeArgs.filter(arg => arg !== '--quick' && arg !== '-q'),
469
+ ...gitArgsAfterDelimiter,
470
+ ],
471
+ };
472
+ }
473
+
474
+ /**
475
+ * Bind full-mode authorization to either the reused receipt or this fresh run.
476
+ * Quick mode has no test evidence and must carry no receipt identity.
477
+ */
478
+ function receiptIdentityForPush(quickMode, validationReused, receipt) {
479
+ if (quickMode) return null;
480
+ if (validationReused) return receipt.identity;
481
+ return 'fresh-tests';
482
+ }
483
+
484
+ /**
485
+ * Write the short-lived pre-push authorization after all required gates pass.
486
+ * Failure is non-fatal because Lefthook then executes its gates normally.
487
+ */
488
+ function writePushAuthorization({
489
+ tokenWrite,
490
+ worktreeRoot,
491
+ proofSnapshot,
492
+ quickMode,
493
+ completedGates,
494
+ validationReused,
495
+ receipt,
496
+ gateEnv,
497
+ log,
498
+ }) {
499
+ try {
500
+ return tokenWrite(worktreeRoot, {
501
+ snapshot: proofSnapshot,
502
+ mode: quickMode ? 'quick' : 'full',
503
+ gates: completedGates,
504
+ receiptIdentity: receiptIdentityForPush(quickMode, validationReused, receipt),
505
+ env: gateEnv,
506
+ });
507
+ } catch (_err) { /* intentional: non-fatal, lefthook will re-run hooks */ // NOSONAR S2486
508
+ log('Warning: Could not write forge push token — lefthook hooks will run again');
509
+ return null;
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Execute Git push and revoke this invocation's proof regardless of outcome.
515
+ */
516
+ function executeGitPush({
517
+ execFn,
518
+ gitArgs,
519
+ quickMode,
520
+ baseEnv,
521
+ authorization,
522
+ tokenConsume,
523
+ worktreeRoot,
524
+ log,
525
+ }) {
526
+ try {
527
+ execFn('git', ['push', ...gitArgs], {
528
+ stdio: 'inherit',
529
+ env: buildPushEnv(quickMode, baseEnv, authorization?.nonce),
530
+ });
531
+ return true;
532
+ } catch (_pushErr) { // NOSONAR S2486
533
+ log('git push failed.');
321
534
  return false;
535
+ } finally {
536
+ if (authorization?.nonce) {
537
+ try { tokenConsume(worktreeRoot, { nonce: authorization.nonce }); } catch { /* best effort revocation */ }
538
+ }
322
539
  }
323
- return testResult.status === 0;
324
540
  }
325
541
 
326
542
  module.exports = {
@@ -344,59 +560,94 @@ module.exports = {
344
560
  handler: async (args, flags, projectRoot, deps) => {
345
561
  const execFn = deps?.execFileSync || execFileSync;
346
562
  const spawnFn = deps?.spawnSync || spawnSync;
563
+ const spawnAsyncFn = deps?.spawn || spawn;
347
564
  const existsFn = deps?.existsSync || fs.existsSync;
565
+ const readFileFn = deps?.readFileSync || fs.readFileSync;
348
566
  const log = deps?.log || console.log;
349
567
  const tokenWrite = deps?.writeForgeToken || forgeToken.write;
568
+ const tokenConsume = deps?.consumeForgeToken || forgeToken.consume;
569
+ const beginPushProof = deps?.beginPushProof || validationReceipt._pushProof.begin;
350
570
  const verifyReceipt = deps?.verifyValidationReceipt || verifyValidationReceipt;
351
571
 
352
- const quickMode = !!(flags && (flags['--quick'] || flags.quick));
572
+ const { quickMode, gitArgs } = parsePushArguments(args, flags);
353
573
  const pkgManager = detectPackageManager(projectRoot, existsFn);
574
+ const worktreeRoot = resolveGitToplevel(execFn, projectRoot);
575
+ const gateEnv = buildGateEnv(deps?.env || process.env);
576
+ let proofSnapshot = null;
577
+ try {
578
+ proofSnapshot = beginPushProof(worktreeRoot, { env: gateEnv });
579
+ } catch { /* invalid snapshot falls back to ordinary hooks */ }
580
+ const completedGates = [];
354
581
 
355
582
  // Step 1: Branch protection
356
- const branchOk = runBranchProtection(execFn, projectRoot, log);
583
+ const branchOk = runBranchProtection(execFn, projectRoot, log, gateEnv);
357
584
  if (!branchOk) {
358
585
  return { success: false, quickMode, lintPassed: false, pushed: false };
359
586
  }
587
+ completedGates.push('branch-protection');
360
588
 
361
589
  // Auto-file rail (non-blocking): ensure the branch being pushed has a backing
362
590
  // Kernel issue so started work never goes unfiled. Never aborts the push.
363
591
  await autoFileBackingIssueForPush(projectRoot, execFn, deps || {});
364
592
 
365
593
  // Step 2: Lint
366
- const lintPassed = runLint(spawnFn, pkgManager, projectRoot);
594
+ const lintPassed = runLint(spawnFn, pkgManager, projectRoot, gateEnv);
367
595
  if (!lintPassed) {
368
596
  log('Lint failed — push aborted.');
369
597
  return { success: false, quickMode, lintPassed: false, pushed: false };
370
598
  }
599
+ completedGates.push('lint');
371
600
 
372
601
  // Step 3: Tests (skip in quick mode)
373
602
  const receipt = quickMode
374
603
  ? { valid: false }
375
- : verifyReceipt(resolveGitToplevel(execFn, projectRoot));
604
+ : verifyReceipt(worktreeRoot);
376
605
  const validationReused = receipt.valid === true;
377
606
  if (validationReused) log(`Tests reused from full validation at ${receipt.head || 'current HEAD'}`);
378
607
  const testsPassed = validationReused
379
608
  ? true
380
- : runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log, deps?.env || process.env);
609
+ : await runTests({
610
+ spawnAsyncFn,
611
+ execFn,
612
+ readFileFn,
613
+ existsFn,
614
+ pkgManager,
615
+ projectRoot,
616
+ quickMode,
617
+ log,
618
+ env: gateEnv,
619
+ createProcessTreeFn: deps?.createProcessTree || createProcessTree,
620
+ });
381
621
  if (!quickMode && !testsPassed) {
382
622
  return { success: false, quickMode, lintPassed: true, testsPassed: false, validationReused, pushed: false };
383
623
  }
624
+ if (!quickMode) completedGates.push('tests');
384
625
 
385
- // Step 4: Write nonce token so lefthook skips pre-push hooks.
386
- // Write to the git worktree top-level (where the hook checks), not the
387
- // inherited projectRoot, so the skip works from linked worktrees too.
388
- try {
389
- tokenWrite(resolveGitToplevel(execFn, projectRoot));
390
- } catch (_err) { /* intentional: non-fatal, lefthook will re-run hooks */ // NOSONAR S2486
391
- log('Warning: Could not write forge push token — lefthook hooks will run again');
392
- }
626
+ // Step 4: Write invocation proof under worktree-private Git metadata.
627
+ const authorization = writePushAuthorization({
628
+ tokenWrite,
629
+ worktreeRoot,
630
+ proofSnapshot,
631
+ quickMode,
632
+ completedGates,
633
+ validationReused,
634
+ receipt,
635
+ gateEnv,
636
+ log,
637
+ });
393
638
 
394
639
  // Step 5: git push with passthrough args
395
- const gitArgs = args.filter(a => a !== '--quick');
396
- try {
397
- execFn('git', ['push', ...gitArgs], { stdio: 'inherit', env: buildPushEnv(quickMode) });
398
- } catch (_pushErr) { // NOSONAR S2486
399
- log('git push failed.');
640
+ const pushed = executeGitPush({
641
+ execFn,
642
+ gitArgs,
643
+ quickMode,
644
+ baseEnv: deps?.env || process.env,
645
+ authorization,
646
+ tokenConsume,
647
+ worktreeRoot,
648
+ log,
649
+ });
650
+ if (!pushed) {
400
651
  return {
401
652
  success: false,
402
653
  quickMode,
@@ -424,6 +675,7 @@ module.exports = {
424
675
  // Exposed for unit tests; not part of the CLI surface.
425
676
  _internal: {
426
677
  autoFileBackingIssueForPush,
678
+ buildGateEnv,
427
679
  buildPushEnv,
428
680
  maybeTriggerShepherdAfterPush,
429
681
  },
@@ -9,13 +9,14 @@ const {
9
9
  const { runIssueOperation: defaultRunIssueOperation } = require('../forge-issues');
10
10
  const { normalizeArgs, normalizeIssueResult, withResolvedIssueBackend } = require('./_issue');
11
11
  const { generateNpmPublishWorkflow } = require('../npm-publish-workflow');
12
+ const { generateTestWorkflow } = require('../test-workflow');
12
13
  const { updateBunWorkflowPins } = require('../bun-workflow-pins');
13
14
 
14
15
  // `forge release <id>` releases a claimed issue; `forge release check` runs the
15
16
  // release-readiness gate. The two share the top-level verb, so this command
16
17
  // dispatches `check` to the gate and routes everything else through the shared
17
18
  // issue dispatch (resolve backend → runIssueOperation('release') → normalize).
18
- const usage = 'Usage: forge release <id> | forge release check --target 0.1.0 [--json] | forge release regen-audit | forge release generate-npm-workflow --expect-head <full-sha> | forge release update-bun-pins --expect-head <full-sha>';
19
+ const usage = 'Usage: forge release <id> | forge release check --target 0.1.0 [--json] | forge release regen-audit | forge release generate-npm-workflow --expect-head <full-sha> | forge release generate-test-workflow --expect-head <full-sha> | forge release update-bun-pins --expect-head <full-sha>';
19
20
 
20
21
  async function runReleaseIssue(args, projectRoot, opts = {}) {
21
22
  const resolved = withResolvedIssueBackend(projectRoot, opts);
@@ -59,6 +60,22 @@ function parseReleaseArgs(args = []) {
59
60
 
60
61
  async function handler(args, _flags, projectRoot, opts = {}) {
61
62
  const parsed = parseReleaseArgs(args);
63
+ if (parsed.subcommand === 'generate-test-workflow') {
64
+ const generator = opts.generateTestWorkflow || generateTestWorkflow;
65
+ const generated = await generator(projectRoot, {
66
+ env: opts.env,
67
+ kernelDeps: opts.kernelDeps,
68
+ expectedHead: parsed.expectedHead,
69
+ resolveHead: opts.resolveHead,
70
+ });
71
+ return generated.success
72
+ ? {
73
+ success: true,
74
+ generated,
75
+ output: `Generated ${generated.path} (${generated.contentHash}).\n`,
76
+ }
77
+ : generated;
78
+ }
62
79
  if (parsed.subcommand === 'update-bun-pins') {
63
80
  const updater = opts.updateBunWorkflowPins || updateBunWorkflowPins;
64
81
  const updated = await updater(projectRoot, {
@@ -28,6 +28,8 @@ const LOCKFILE_MAP = [
28
28
 
29
29
  const DEFAULT_TIMEOUT = 120000;
30
30
  const DIRECT_TEST_CANDIDATES = Object.freeze({
31
+ 'lib/test-workflow.js': ['test/test-workflow.test.js'],
32
+ 'lib/workflow-templates/test.yml': ['test/test-workflow.test.js'],
31
33
  'bin/forge.js': [
32
34
  'test/cli-flags.test.js',
33
35
  'test/forge-cli-registry.test.js',
@@ -86,7 +88,13 @@ const DIRECT_TEST_CANDIDATES = Object.freeze({
86
88
  'test/github-launcher.test.js',
87
89
  'test/integration/github-account-context.test.js',
88
90
  ],
91
+ 'bin/forge-gh-proxy.js': ['test/bin/forge-gh-proxy.test.js', 'test/github-launcher.test.js'],
92
+ 'bin/forge-github-credential.js': ['test/bin/forge-github-credential.test.js', 'test/github-launcher.test.js'],
89
93
  'lib/github-context.js': ['test/github-context.test.js', 'test/integration/github-account-context.test.js'],
94
+ 'lib/github-credential.js': ['test/github-credential.test.js', 'test/github-launcher.test.js'],
95
+ 'lib/github-router.js': ['test/github-router.test.js', 'test/commands/github.test.js'],
96
+ 'lib/gh-proxy.js': ['test/gh-proxy.test.js', 'test/github-launcher.test.js'],
97
+ 'lib/native-gh.js': ['test/native-gh.test.js', 'test/integration/github-account-context.test.js'],
90
98
  'lib/commands/_registry.js': [
91
99
  'test/commands/_registry.test.js',
92
100
  'test/commands/_registry-github-context.test.js',