forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6
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/AGENTS.md +18 -7
- package/CHANGELOG.md +79 -1
- package/CLAUDE.md +0 -12
- package/CODING_STANDARDS.md +72 -0
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +28 -375
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +8 -5
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/INSTALL.md +4 -0
- package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/github-accounts.md +134 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +114 -35
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +359 -144
- package/lib/audit-evidence.js +71 -110
- package/lib/base-remote.js +138 -0
- package/lib/beta5-compatibility-evidence.js +1093 -0
- package/lib/bun-lockfile-proof.js +413 -0
- package/lib/bun-workflow-pins.js +461 -0
- package/lib/capabilities/index.js +9 -0
- package/lib/capabilities/model.js +141 -0
- package/lib/capabilities/probes.js +347 -0
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/codex-skills.js +2 -2
- package/lib/commands/_manifest.js +1 -0
- package/lib/commands/_registry.js +50 -20
- package/lib/commands/clean.js +252 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/doctor.js +37 -6
- package/lib/commands/gate.js +197 -27
- package/lib/commands/github.js +215 -0
- package/lib/commands/hooks.js +276 -30
- package/lib/commands/insights.js +8 -3
- package/lib/commands/memory.js +66 -2
- package/lib/commands/merge.js +1265 -58
- package/lib/commands/plan.js +33 -2
- package/lib/commands/pr.js +3 -1
- package/lib/commands/preflight.js +21 -4
- package/lib/commands/prime.js +21 -8
- package/lib/commands/push.js +146 -54
- package/lib/commands/recall.js +127 -49
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +39 -3
- package/lib/commands/remember.js +28 -4
- package/lib/commands/serve.js +26 -9
- package/lib/commands/setup.js +323 -98
- package/lib/commands/shepherd.js +591 -73
- package/lib/commands/ship.js +36 -91
- package/lib/commands/skill.js +127 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/team.js +47 -8
- package/lib/commands/test.js +187 -38
- package/lib/commands/validate.js +65 -21
- package/lib/commands/worktree.js +359 -45
- package/lib/core/runtime-graph.js +1 -1
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
- package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
- package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
- package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
- package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
- package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
- package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
- package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +134 -32
- package/lib/gate-events.js +98 -10
- package/lib/git-defaults.js +56 -0
- package/lib/github-context.js +308 -0
- package/lib/global-flags.js +1 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +122 -5
- package/lib/insights.js +96 -80
- package/lib/issue-render.js +19 -0
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +739 -31
- package/lib/kernel/claim-reconciler.js +238 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/lease-enforcer.js +9 -4
- package/lib/kernel/legacy-claim-repair.js +442 -0
- package/lib/kernel/live-claim-projection.js +26 -0
- package/lib/kernel/migrations.js +118 -3
- package/lib/kernel/readiness-model.js +184 -12
- package/lib/kernel/schema.js +49 -1
- package/lib/kernel/sqlite-driver.js +3435 -172
- package/lib/kernel/taxonomy-validator.js +4 -1
- package/lib/kernel/windows-private-acl.js +239 -0
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/hygiene.js +191 -0
- package/lib/memory/router.js +110 -28
- package/lib/memory/usage-evidence.js +4 -0
- package/lib/memory-digest.js +106 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +71 -10
- package/lib/merge-rules.js +143 -21
- package/lib/npm-publish-workflow.js +465 -0
- package/lib/orientation.js +68 -43
- package/lib/package-root.js +2 -0
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/auto-actions.js +169 -28
- package/lib/pr-monitor/differ.js +110 -4
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/flow-monitor.js +1424 -0
- package/lib/pr-monitor/gather.js +251 -44
- package/lib/pr-monitor/journal.js +18 -39
- package/lib/pr-monitor/monitor.js +117 -10
- package/lib/pr-monitor/process-identity.js +117 -0
- package/lib/pr-monitor/reconcile-executor.js +1129 -470
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +293 -0
- package/lib/pr-monitor/review-preflight.js +269 -0
- package/lib/pr-monitor/shepherd-lease.js +38 -20
- package/lib/pr-monitor/verdict.js +438 -0
- package/lib/pr-monitor/watch-lifecycle.js +145 -27
- package/lib/pr-monitor/watch-owner.js +1414 -0
- package/lib/pr-monitor/watch.js +129 -58
- package/lib/pr-pull.js +33 -14
- package/lib/pr-shepherd.js +51 -11
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +178 -4
- package/lib/protected-state-authority.js +1100 -0
- package/lib/protected-state-surfaces.js +243 -45
- package/lib/release-readiness.js +53 -7
- package/lib/review-adapter.js +65 -0
- package/lib/shell-utils.js +1 -1
- package/lib/skills-sync.js +71 -35
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/lib/validation/risk-manifest.js +339 -0
- package/lib/workflow/enforce-stage.js +44 -0
- package/lib/workflow/plan-authority.js +225 -0
- package/package.json +12 -9
- package/scripts/commitlint.js +13 -15
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/generate-risk-manifest.js +91 -0
- package/scripts/github-context-bridge.sh +10 -0
- package/scripts/legacy-claim-repair.js +145 -0
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +457 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +773 -0
- package/scripts/protected-state-check.js +479 -31
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-agent-skills.js +333 -34
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +935 -37
- package/scripts/test-profile.js +13 -3
- package/scripts/test.js +271 -57
- package/skills/coverage.json +1 -0
- package/skills/review/SKILL.md +6 -11
- package/skills/review/evals/scorecard.json +4 -4
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/setup/SKILL.md +18 -0
- package/skills/setup/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +39 -16
- package/skills/shepherd/evals/scorecard.json +4 -4
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/validate/SKILL.md +3 -0
- package/skills/validate/evals/scorecard.json +1 -1
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
package/lib/commands/plan.js
CHANGED
|
@@ -13,6 +13,10 @@ const path = require('node:path');
|
|
|
13
13
|
const { execFileSync } = require('node:child_process');
|
|
14
14
|
const { resolveIssueBackend } = require('../issue-backend');
|
|
15
15
|
const { runIssueOperation } = require('../forge-issues');
|
|
16
|
+
const {
|
|
17
|
+
findPlanWorkFolder,
|
|
18
|
+
reconcilePlanAuthority,
|
|
19
|
+
} = require('../workflow/plan-authority');
|
|
16
20
|
|
|
17
21
|
// Constants for security
|
|
18
22
|
// Note: cwd is resolved at call-time via getExecOptions() to avoid stale require-time snapshots
|
|
@@ -483,7 +487,7 @@ async function registerBranchIssueLinkage(options, branch, issueId) {
|
|
|
483
487
|
branch,
|
|
484
488
|
actor: null,
|
|
485
489
|
issue_id: issueId,
|
|
486
|
-
work_folder: null,
|
|
490
|
+
work_folder: options.workFolder || null,
|
|
487
491
|
registered_at: new Date().toISOString(),
|
|
488
492
|
state: 'active',
|
|
489
493
|
});
|
|
@@ -976,11 +980,38 @@ async function executePlan(featureName, options = {}) { // NOSONAR S3776
|
|
|
976
980
|
};
|
|
977
981
|
}
|
|
978
982
|
|
|
983
|
+
const workFolder = options.workFolder || findPlanWorkFolder(options.projectRoot || process.cwd(), featureSlug);
|
|
984
|
+
|
|
979
985
|
// F1: persist the branch->issue linkage so a plan-created branch resolves
|
|
980
986
|
// to its issue for kernel-authoritative stage state (dev/validate/ship).
|
|
981
987
|
// Kernel-only, best-effort.
|
|
982
988
|
if (issueBackend === 'kernel') {
|
|
983
|
-
await registerBranchIssueLinkage({ ...options, issueBackend }, branch.branchName, issue.issueId);
|
|
989
|
+
await registerBranchIssueLinkage({ ...options, issueBackend, workFolder }, branch.branchName, issue.issueId);
|
|
990
|
+
await withPlanDriver(options, driver => {
|
|
991
|
+
if (!driver || typeof driver.loadPlanSnapshot !== 'function') return null;
|
|
992
|
+
let existing;
|
|
993
|
+
try {
|
|
994
|
+
existing = driver.loadPlanSnapshot({ issue_id: issue.issueId }, {});
|
|
995
|
+
} catch (error) {
|
|
996
|
+
// Injectable issue runners used by embedding callers may not persist into
|
|
997
|
+
// this driver's store. With no repository artifacts there is nothing to
|
|
998
|
+
// reconcile; preserve that legacy embedding contract.
|
|
999
|
+
if (/^Issue .* not found in the kernel$/i.test(error.message)) {
|
|
1000
|
+
if (!workFolder) return null;
|
|
1001
|
+
throw new Error(`Issue ${issue.issueId} is not available for plan authority persistence`);
|
|
1002
|
+
}
|
|
1003
|
+
throw error;
|
|
1004
|
+
}
|
|
1005
|
+
if (!existing && !workFolder) return null;
|
|
1006
|
+
return reconcilePlanAuthority({
|
|
1007
|
+
driver,
|
|
1008
|
+
issueId: issue.issueId,
|
|
1009
|
+
projectRoot: options.projectRoot || process.cwd(),
|
|
1010
|
+
workFolder,
|
|
1011
|
+
mode: 'plan',
|
|
1012
|
+
repairCommand: `forge plan ${JSON.stringify(featureName)} --issue ${issue.issueId}`,
|
|
1013
|
+
});
|
|
1014
|
+
});
|
|
984
1015
|
}
|
|
985
1016
|
|
|
986
1017
|
// Build result summary
|
package/lib/commands/pr.js
CHANGED
|
@@ -33,7 +33,7 @@ const SUBCOMMANDS = {
|
|
|
33
33
|
},
|
|
34
34
|
merge: {
|
|
35
35
|
module: merge,
|
|
36
|
-
summary: 'Opt-in
|
|
36
|
+
summary: 'Opt-in guarded merge, OFF by default (= forge merge --auto <pr> --expect-head <sha> --issue <id>)',
|
|
37
37
|
},
|
|
38
38
|
};
|
|
39
39
|
|
|
@@ -81,6 +81,8 @@ async function handler(args, flags, projectRoot, opts) {
|
|
|
81
81
|
|
|
82
82
|
module.exports = {
|
|
83
83
|
name: 'pr',
|
|
84
|
+
githubAuth: (args = []) => ['ship', 'merge', 'shepherd']
|
|
85
|
+
.includes(stripGlobalFlags(args).find(arg => !arg.startsWith('-'))),
|
|
84
86
|
description:
|
|
85
87
|
'Unified pull-request surface: forge pr ship|preflight|shepherd|merge (wraps ship/preflight/shepherd/merge)',
|
|
86
88
|
usage,
|
|
@@ -88,11 +88,19 @@ function resolveBaseRef(exec) {
|
|
|
88
88
|
* @param {{ runAll?: boolean }} [opts]
|
|
89
89
|
* @returns {{ resolved: boolean, changedFiles: string[], reason?: string, baseRef?: string }}
|
|
90
90
|
*/
|
|
91
|
-
function resolveChangeSet(exec = execFileSync, { runAll = false } = {}) {
|
|
91
|
+
function resolveChangeSet(exec = execFileSync, { runAll = false, baseRef: explicitBaseRef = null } = {}) {
|
|
92
92
|
if (runAll) {
|
|
93
93
|
return { resolved: true, changedFiles: [], reason: 'whole-tree scope (--all)' };
|
|
94
94
|
}
|
|
95
|
-
const baseRef = resolveBaseRef(exec);
|
|
95
|
+
const baseRef = explicitBaseRef || resolveBaseRef(exec);
|
|
96
|
+
if (explicitBaseRef && !gitVerifyRef(exec, explicitBaseRef)) {
|
|
97
|
+
return {
|
|
98
|
+
resolved: false,
|
|
99
|
+
baseRef: explicitBaseRef,
|
|
100
|
+
changedFiles: [],
|
|
101
|
+
reason: `explicit base branch ${explicitBaseRef} is unavailable`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
96
104
|
if (!baseRef) {
|
|
97
105
|
return {
|
|
98
106
|
resolved: false,
|
|
@@ -103,7 +111,16 @@ function resolveChangeSet(exec = execFileSync, { runAll = false } = {}) {
|
|
|
103
111
|
// Diff against the SAME base we just resolved. getChangedFiles() re-resolves
|
|
104
112
|
// its own diff ref (and can fall back to a different default branch), so use
|
|
105
113
|
// baseRef directly to keep the file list consistent with the resolved base.
|
|
106
|
-
|
|
114
|
+
const changedFiles = changedFilesForBase(exec, baseRef);
|
|
115
|
+
if (changedFiles == null) {
|
|
116
|
+
return {
|
|
117
|
+
resolved: false,
|
|
118
|
+
baseRef,
|
|
119
|
+
changedFiles: [],
|
|
120
|
+
reason: `git diff failed or timed out for ${baseRef}`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return { resolved: true, baseRef, changedFiles };
|
|
107
124
|
}
|
|
108
125
|
|
|
109
126
|
/** Files changed between the merge-base of HEAD and `baseRef` and HEAD. */
|
|
@@ -111,7 +128,7 @@ function changedFilesForBase(exec, baseRef) {
|
|
|
111
128
|
const mergeBase = gitTryOut(exec, ['merge-base', 'HEAD', baseRef]);
|
|
112
129
|
const range = mergeBase ? `${mergeBase}...HEAD` : `${baseRef}...HEAD`;
|
|
113
130
|
const out = gitTryOut(exec, ['diff', '--name-only', range]);
|
|
114
|
-
if (out == null) return
|
|
131
|
+
if (out == null) return null;
|
|
115
132
|
return out.split(/\r?\n/).filter(Boolean);
|
|
116
133
|
}
|
|
117
134
|
|
package/lib/commands/prime.js
CHANGED
|
@@ -6,15 +6,28 @@ const {
|
|
|
6
6
|
runOrientationCommand,
|
|
7
7
|
} = require('../orientation');
|
|
8
8
|
|
|
9
|
+
const DEPRECATION_NOTICE =
|
|
10
|
+
'forge prime is deprecated — run `forge status -v` for the same briefing.\n';
|
|
11
|
+
|
|
12
|
+
// The single briefing renderer. `forge status -v` calls this so the full
|
|
13
|
+
// session-entry briefing has exactly one implementation.
|
|
14
|
+
// Async: the briefing leads with LIVE state (stage / claims / ready / gates / one
|
|
15
|
+
// adoption nudge), which needs a best-effort (non-throwing) kernel read before the
|
|
16
|
+
// synchronous build assembles it into the bounded orientation.
|
|
17
|
+
async function renderBriefing(args, projectRoot) {
|
|
18
|
+
const liveState = await collectPrimeLiveState(projectRoot);
|
|
19
|
+
return runOrientationCommand(buildPrime, args, projectRoot, { liveState });
|
|
20
|
+
}
|
|
21
|
+
|
|
9
22
|
module.exports = {
|
|
10
23
|
name: 'prime',
|
|
11
|
-
description: '
|
|
12
|
-
usage: 'Usage: forge prime [--budget N] [--json]',
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return runOrientationCommand(buildPrime, args, projectRoot, { liveState });
|
|
24
|
+
description: 'Deprecated alias for `forge status -v`: session-entry bounded orientation',
|
|
25
|
+
usage: 'Usage: forge prime [--budget N] [--json] (deprecated — use `forge status -v`)',
|
|
26
|
+
// Session-start hooks in consumer repos call this and consume stdout as context,
|
|
27
|
+
// so the notice goes to stderr and stdout stays byte-identical to `status -v`.
|
|
28
|
+
handler: async (args, _flags, projectRoot, options = {}) => {
|
|
29
|
+
(options.stderr || process.stderr).write(DEPRECATION_NOTICE);
|
|
30
|
+
return renderBriefing(args, projectRoot);
|
|
19
31
|
},
|
|
32
|
+
renderBriefing,
|
|
20
33
|
};
|
package/lib/commands/push.js
CHANGED
|
@@ -4,8 +4,8 @@ const { execFileSync, spawnSync } = require('node:child_process');
|
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
const forgeToken = require('../../scripts/check-forge-token');
|
|
7
|
-
const {
|
|
8
|
-
const {
|
|
7
|
+
const { QUICK_LANE_ENV_VAR, QUICK_LANE_VALUE, resolveFullSuiteTimeoutMs } = require('../../scripts/test');
|
|
8
|
+
const { fireAndForget } = require('../pr-monitor/reconcile-executor');
|
|
9
9
|
|
|
10
10
|
const isWindows = process.platform === 'win32';
|
|
11
11
|
|
|
@@ -113,54 +113,49 @@ async function autoFileBackingIssueForPush(projectRoot, execFn, deps = {}) {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
116
|
+
* Best-effort wake of the repository-wide singleton after a successful push.
|
|
117
|
+
* The shared trigger owns all containment and gate checks and never selects a
|
|
118
|
+
* per-PR watcher here.
|
|
119
119
|
*
|
|
120
|
-
* @param {
|
|
121
|
-
* @returns {
|
|
120
|
+
* @param {object} params
|
|
121
|
+
* @returns {{ armed: boolean, reason?: string }}
|
|
122
122
|
*/
|
|
123
|
-
function
|
|
123
|
+
function maybeTriggerShepherdAfterPush({
|
|
124
|
+
projectRoot,
|
|
125
|
+
fireAndForget: trigger = fireAndForget,
|
|
126
|
+
}) {
|
|
124
127
|
try {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
return Number.isInteger(n) && n > 0 ? n : null;
|
|
130
|
-
} catch (_err) { /* intentional: no open PR / gh missing → arm nothing */ // NOSONAR S2486
|
|
131
|
-
return null;
|
|
128
|
+
trigger({ projectRoot });
|
|
129
|
+
return { armed: true };
|
|
130
|
+
} catch {
|
|
131
|
+
return { armed: false, reason: 'shepherd-launch-failed' };
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
/**
|
|
136
|
-
*
|
|
137
|
-
* push, when an OPEN PR exists for the current branch. This closes the gap where
|
|
138
|
-
* PRs not born from `forge ship` (gh pr create, the GitHub UI, an earlier push)
|
|
139
|
-
* never got a watcher. Gated by the default-ON `rail.auto_shepherd`, idempotent
|
|
140
|
-
* via the watch loop's own PID/journal lock, and reusing the same
|
|
141
|
-
* `startPrWatcherDetached` as ship. MUST NEVER throw into or fail the push: a
|
|
142
|
-
* disabled rail, no PR, a gh error, or a spawn error all degrade to
|
|
143
|
-
* `{ armed: false }`.
|
|
136
|
+
* Build the environment for the spawned `git push`, declaring the push lane.
|
|
144
137
|
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
138
|
+
* `--quick` skips the test step push.js controls, but the `git push` it spawns
|
|
139
|
+
* still fires the lefthook pre-push hook, whose tests job runs scripts/test.js —
|
|
140
|
+
* so --quick was quick only up to the push. This declares the lane to that hook
|
|
141
|
+
* so the quick lane is lint-only end to end.
|
|
142
|
+
*
|
|
143
|
+
* The declaration is set on the child env only, and is explicitly REMOVED for a
|
|
144
|
+
* full push so an inherited value from an outer shell can never silently drop
|
|
145
|
+
* the tests from a `forge push`.
|
|
146
|
+
*
|
|
147
|
+
* @param {boolean} quickMode - Whether this is a --quick push
|
|
148
|
+
* @param {NodeJS.ProcessEnv} [baseEnv=process.env] - Environment to derive from
|
|
149
|
+
* @returns {NodeJS.ProcessEnv} Environment for the git push child process
|
|
147
150
|
*/
|
|
148
|
-
function
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}) {
|
|
155
|
-
try {
|
|
156
|
-
if (!railEnabled(projectRoot)) return { armed: false, reason: 'rail.auto_shepherd disabled' };
|
|
157
|
-
const prNumber = prLookup(execFn);
|
|
158
|
-
if (!prNumber) return { armed: false, reason: 'no-open-pr' };
|
|
159
|
-
const res = startWatcher({ prNumber, cwd: projectRoot });
|
|
160
|
-
return { armed: !!(res && res.started), reason: res && res.reason, prNumber };
|
|
161
|
-
} catch (err) {
|
|
162
|
-
return { armed: false, reason: err.message };
|
|
151
|
+
function buildPushEnv(quickMode, baseEnv = process.env) {
|
|
152
|
+
const env = { ...baseEnv };
|
|
153
|
+
if (quickMode) {
|
|
154
|
+
env[QUICK_LANE_ENV_VAR] = QUICK_LANE_VALUE;
|
|
155
|
+
} else {
|
|
156
|
+
delete env[QUICK_LANE_ENV_VAR];
|
|
163
157
|
}
|
|
158
|
+
return env;
|
|
164
159
|
}
|
|
165
160
|
|
|
166
161
|
/**
|
|
@@ -196,6 +191,94 @@ function runLint(spawnFn, pkgManager, projectRoot) {
|
|
|
196
191
|
return lintResult.status === 0;
|
|
197
192
|
}
|
|
198
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Reports the kill signal a timed-out test run is terminated with. Shared by
|
|
196
|
+
* the spawn options and the timeout detector so the two cannot drift.
|
|
197
|
+
*/
|
|
198
|
+
const TEST_RUN_KILL_SIGNAL = 'SIGKILL';
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Reads the terminating signal under either field name.
|
|
202
|
+
*
|
|
203
|
+
* `node:child_process.spawnSync` reports `signal`; native `Bun.spawnSync`
|
|
204
|
+
* reports `signalCode`. Read both rather than assuming one.
|
|
205
|
+
*
|
|
206
|
+
* @param {Object} testResult - spawnSync result object
|
|
207
|
+
* @returns {string|null} Signal name, or null when none was reported
|
|
208
|
+
*/
|
|
209
|
+
function terminationSignalOf(testResult) {
|
|
210
|
+
return testResult.signal || testResult.signalCode || null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Reports whether a test run was killed by its own wall-clock budget.
|
|
215
|
+
*
|
|
216
|
+
* Requires AFFIRMATIVE evidence. Verified against pinned Bun 1.3.12 on Windows:
|
|
217
|
+
* a `node:child_process` spawnSync timeout returns `status: null`,
|
|
218
|
+
* `signal: <killSignal>`, AND an error whose `code` is `ETIMEDOUT`. Native
|
|
219
|
+
* `Bun.spawnSync` instead sets `exitedDueToTimeout: true` with `signalCode`.
|
|
220
|
+
* Those two markers are the only proof of a timeout, and they are checked
|
|
221
|
+
* BEFORE the generic error branch — testing `error` first swallowed the timeout
|
|
222
|
+
* diagnostic in exactly the case it was written for.
|
|
223
|
+
*
|
|
224
|
+
* A bare kill signal with no marker is deliberately NOT treated as a timeout:
|
|
225
|
+
* an OOM kill or an operator `kill -9` produces the identical status/signal
|
|
226
|
+
* shape, and since a genuine budget kill always carries a marker, inferring
|
|
227
|
+
* from the signal alone buys nothing and costs a misdiagnosis (it would claim
|
|
228
|
+
* the budget elapsed and send the user to raise FORGE_TEST_TIMEOUT_MS).
|
|
229
|
+
*
|
|
230
|
+
* @param {Object} testResult - spawnSync result object
|
|
231
|
+
* @returns {boolean} True when the run hit its budget
|
|
232
|
+
*/
|
|
233
|
+
function isTimeoutTermination(testResult) {
|
|
234
|
+
if (testResult.exitedDueToTimeout === true) return true;
|
|
235
|
+
return Boolean(testResult.error) && testResult.error.code === 'ETIMEDOUT';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Explain a test run that never produced an exit status.
|
|
240
|
+
*
|
|
241
|
+
* `spawnSync` reports `status: null` when the child was killed by a signal
|
|
242
|
+
* (timeout kill included) or never started at all. Without this the push
|
|
243
|
+
* aborted with no summary at all, which is indistinguishable from a genuine
|
|
244
|
+
* test failure.
|
|
245
|
+
*
|
|
246
|
+
* @param {Object} testResult - spawnSync result object
|
|
247
|
+
* @param {number} timeoutMs - Wall-clock budget applied to the run
|
|
248
|
+
* @param {function} log - Logger function
|
|
249
|
+
*/
|
|
250
|
+
function reportTestRunTermination(testResult, timeoutMs, log) {
|
|
251
|
+
const signal = terminationSignalOf(testResult);
|
|
252
|
+
|
|
253
|
+
if (isTimeoutTermination(testResult)) {
|
|
254
|
+
log(
|
|
255
|
+
`Test run timed out after ${Math.round(timeoutMs / 1000)}s `
|
|
256
|
+
+ `(killed by ${signal || 'timeout'}) — push aborted.`,
|
|
257
|
+
);
|
|
258
|
+
log('Raise the budget with FORGE_TEST_TIMEOUT_MS if this machine is slower than the default.');
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (testResult.error) {
|
|
263
|
+
log(`Test run could not complete: ${testResult.error.message}`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (signal) {
|
|
268
|
+
// Signalled without timeout evidence: report the fact and the likely
|
|
269
|
+
// causes. Do NOT blame the budget — that sends the user after the wrong
|
|
270
|
+
// lever and hides the real failure.
|
|
271
|
+
log(
|
|
272
|
+
`Test run was terminated by ${signal} before finishing — push aborted. `
|
|
273
|
+
+ `The run had a ${Math.round(timeoutMs / 1000)}s budget but reported no timeout, `
|
|
274
|
+
+ 'so this is most likely an external kill or an out-of-memory kill.',
|
|
275
|
+
);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
log('Test run ended without an exit status — push aborted.');
|
|
280
|
+
}
|
|
281
|
+
|
|
199
282
|
/**
|
|
200
283
|
* Run tests unless in quick mode, logging warnings for first push.
|
|
201
284
|
* @param {function} spawnFn - spawnSync or mock
|
|
@@ -204,9 +287,10 @@ function runLint(spawnFn, pkgManager, projectRoot) {
|
|
|
204
287
|
* @param {string} projectRoot - Absolute path to project root
|
|
205
288
|
* @param {boolean} quickMode - Whether to skip tests
|
|
206
289
|
* @param {function} log - Logger function
|
|
290
|
+
* @param {NodeJS.ProcessEnv} [env] - Environment used to resolve the test budget
|
|
207
291
|
* @returns {boolean|undefined} True if tests passed, undefined if skipped
|
|
208
292
|
*/
|
|
209
|
-
function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log) {
|
|
293
|
+
function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log, env = process.env) {
|
|
210
294
|
if (quickMode) {
|
|
211
295
|
log('Tests skipped (--quick) — CI will run full suite on GitHub');
|
|
212
296
|
const branch = getCurrentBranch(execFn);
|
|
@@ -216,12 +300,25 @@ function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log) {
|
|
|
216
300
|
return undefined;
|
|
217
301
|
}
|
|
218
302
|
|
|
303
|
+
// `forge push` runs the package-level `test` script, i.e. the whole suite.
|
|
304
|
+
// Budget it from the shared full-suite budget in scripts/test.js — which is
|
|
305
|
+
// sized as ~2x the measured healthy full-suite runtime and overridable with
|
|
306
|
+
// FORGE_TEST_TIMEOUT_MS — instead of an arbitrary local cap: the old fixed
|
|
307
|
+
// 120s killed a healthy full run mid-suite and pushed nothing. Never
|
|
308
|
+
// reintroduce a local constant here; change the shared budget with a fresh
|
|
309
|
+
// measurement instead.
|
|
310
|
+
const timeoutMs = resolveFullSuiteTimeoutMs(env);
|
|
219
311
|
const testResult = spawnFn(pkgManager, ['run', 'test'], {
|
|
220
312
|
stdio: 'inherit',
|
|
221
313
|
shell: isWindows,
|
|
222
314
|
cwd: projectRoot,
|
|
223
|
-
timeout:
|
|
315
|
+
timeout: timeoutMs,
|
|
316
|
+
killSignal: TEST_RUN_KILL_SIGNAL,
|
|
224
317
|
});
|
|
318
|
+
if (testResult.status === null || testResult.status === undefined) {
|
|
319
|
+
reportTestRunTermination(testResult, timeoutMs, log);
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
225
322
|
return testResult.status === 0;
|
|
226
323
|
}
|
|
227
324
|
|
|
@@ -271,7 +368,7 @@ module.exports = {
|
|
|
271
368
|
}
|
|
272
369
|
|
|
273
370
|
// Step 3: Tests (skip in quick mode)
|
|
274
|
-
const testsPassed = runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log);
|
|
371
|
+
const testsPassed = runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log, deps?.env || process.env);
|
|
275
372
|
if (!quickMode && !testsPassed) {
|
|
276
373
|
return { success: false, quickMode, lintPassed: true, testsPassed: false, pushed: false };
|
|
277
374
|
}
|
|
@@ -288,7 +385,7 @@ module.exports = {
|
|
|
288
385
|
// Step 5: git push with passthrough args
|
|
289
386
|
const gitArgs = args.filter(a => a !== '--quick');
|
|
290
387
|
try {
|
|
291
|
-
execFn('git', ['push', ...gitArgs], { stdio: 'inherit' });
|
|
388
|
+
execFn('git', ['push', ...gitArgs], { stdio: 'inherit', env: buildPushEnv(quickMode) });
|
|
292
389
|
} catch (_pushErr) { // NOSONAR S2486
|
|
293
390
|
log('git push failed.');
|
|
294
391
|
return {
|
|
@@ -300,15 +397,10 @@ module.exports = {
|
|
|
300
397
|
};
|
|
301
398
|
}
|
|
302
399
|
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
// from `forge ship`.
|
|
306
|
-
maybeArmWatcherAfterPush({
|
|
400
|
+
// Wake the repository-wide singleton after a successful push.
|
|
401
|
+
maybeTriggerShepherdAfterPush({
|
|
307
402
|
projectRoot,
|
|
308
|
-
|
|
309
|
-
startWatcher: deps?.startWatcher,
|
|
310
|
-
railEnabled: deps?.railEnabled,
|
|
311
|
-
prLookup: deps?.prLookup,
|
|
403
|
+
fireAndForget: deps?.fireAndForget,
|
|
312
404
|
});
|
|
313
405
|
|
|
314
406
|
return {
|
|
@@ -323,7 +415,7 @@ module.exports = {
|
|
|
323
415
|
// Exposed for unit tests; not part of the CLI surface.
|
|
324
416
|
_internal: {
|
|
325
417
|
autoFileBackingIssueForPush,
|
|
326
|
-
|
|
327
|
-
|
|
418
|
+
buildPushEnv,
|
|
419
|
+
maybeTriggerShepherdAfterPush,
|
|
328
420
|
},
|
|
329
421
|
};
|