javi-forge 1.13.0 → 1.15.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.
@@ -103,6 +103,33 @@ export async function handleCi(cli, ctx) {
103
103
  : cli.flags.quick
104
104
  ? "quick"
105
105
  : "full";
106
+ // Headless gate-run JSON (slice 4): `--json` on the RUN path is a NEW branch,
107
+ // NOT flag reuse — the flag is otherwise consumed only by `ci validate`. It
108
+ // bypasses the Ink render, drives the gate phase collecting structured
109
+ // outcomes, prints `{ ok, gates }`, and sets the process exit code EXPLICITLY
110
+ // (CI.tsx's error boundary is unreachable without a render, so this branch
111
+ // owns its exit code). `ok` is false iff a BLOCKING gate errored.
112
+ //
113
+ // JDA-A-002 / JDB-101: `ok` is deliberately scoped to blocking GATES (spec
114
+ // contract), so a blocking RUNNER/phase failure makes runCI throw yet leaves
115
+ // `ok:true`. A consumer keying on the object alone would misread that as
116
+ // success. Surfacing the top-level `exitCode` (non-zero on ANY run failure,
117
+ // including a crash) closes that gap without reinterpreting `ok`.
118
+ if (cli.flags.json) {
119
+ const { collectGateOutcomes } = await import("../../commands/ci.js");
120
+ const result = await collectGateOutcomes({
121
+ projectDir: process.cwd(),
122
+ mode: ciMode,
123
+ noDocker: !cli.flags.docker,
124
+ noGhagga: !cli.flags.ciGhagga,
125
+ noSecurity: !cli.flags.security,
126
+ timeout: cli.flags.timeout,
127
+ config: cli.flags.config || undefined,
128
+ stack: cli.flags.stack || undefined,
129
+ });
130
+ console.log(JSON.stringify({ ok: result.ok, exitCode: result.exitCode, gates: result.gates }, null, 2));
131
+ process.exit(result.exitCode);
132
+ }
106
133
  render(React.createElement(CIContextProvider, { isCI: true },
107
134
  React.createElement(CI, { projectDir: process.cwd(), mode: ciMode, noDocker: !cli.flags.docker, noGhagga: !cli.flags.ciGhagga, noSecurity: !cli.flags.security, timeout: cli.flags.timeout, config: cli.flags.config || undefined, stack: cli.flags.stack || undefined })), { stdin: ctx.inkStdin });
108
135
  }
@@ -1,4 +1,4 @@
1
- import { type CIGateConfig } from "../lib/ci-config.js";
1
+ import { type CIGateConfig, type GateMode, type GateScope } from "../lib/ci-config.js";
2
2
  import type { Stack } from "../types/index.js";
3
3
  export type CIMode = "full" | "quick" | "shell" | "detect";
4
4
  export interface CIOptions {
@@ -79,7 +79,7 @@ export interface ResolveRunnerOptions {
79
79
  * 3. otherwise → single auto-detected runner (zero-config default)
80
80
  */
81
81
  export declare function resolveCIRunners(projectDir: string, options?: ResolveRunnerOptions): Promise<ResolvedRunners>;
82
- export declare function runCI(options: CIOptions, onStep: CIStepCallback): Promise<void>;
82
+ export declare function runCI(options: CIOptions, onStep: CIStepCallback, onGateOutcome?: (outcome: GateOutcome) => void): Promise<void>;
83
83
  /**
84
84
  * Execute a single gate command HOST-NATIVE via `bash -c`, at the repo root,
85
85
  * with the provided env MAP. Modeled on `runSemgrep`/`runGhagga` (a spawned
@@ -96,10 +96,67 @@ export declare function runCI(options: CIOptions, onStep: CIStepCallback): Promi
96
96
  * eliminate. A null code therefore resolves to a NON-ZERO code using the shell
97
97
  * convention `128 + <signal number>` when the signal is resolvable, else 1.
98
98
  *
99
+ * TIMEOUT (GATE-2): when `timeoutSec` is set, a command still running after that
100
+ * many wall-clock seconds is killed — SIGTERM first, escalated to SIGKILL after a
101
+ * short grace if it ignores SIGTERM. A `timedOut` flag is set BEFORE the SIGTERM
102
+ * lands, and on `close` it OVERRIDES the child's reported code with the timeout
103
+ * sentinel 124 — REGARDLESS of what the child reported. This closes the false-green
104
+ * where a child that TRAPS SIGTERM and exits 0 gracefully (dev servers, watchers,
105
+ * SIGTERM-handling CLIs) would otherwise resolve 0 and PASS a timed-out blocking
106
+ * gate. INVARIANT: `timedOut ⇒ non-zero, ALWAYS` (124, the GNU `timeout(1)`
107
+ * convention — distinct from a signal-death 143/137). Timers are cleared on
108
+ * `close`/`error` so no dangling handle keeps the process alive. Omitting
109
+ * `timeoutSec` preserves the pre-GATE-2 behavior exactly (no timer, runs to
110
+ * completion).
111
+ *
99
112
  * Env values arrive as discrete map entries — never string-spliced into the
100
113
  * `bash -c` command — so metacharacters in a value cannot break out of the shell.
101
114
  */
102
- export declare function runGateNative(cmd: string, cwd: string, env: Record<string, string>): Promise<number>;
115
+ export declare function runGateNative(cmd: string, cwd: string, env: Record<string, string>, timeoutSec?: number): Promise<number>;
116
+ /**
117
+ * A single gate's structured result, collected for the headless JSON run path.
118
+ * Mirrors the `{ id, mode, scope, status, blocking, changedFiles?, exitCode? }`
119
+ * JSON shape.
120
+ */
121
+ export interface GateOutcome {
122
+ id: string;
123
+ mode: GateMode;
124
+ scope: GateScope;
125
+ status: CIStepStatus;
126
+ /** `true` when `mode === blocking` — an errored blocking gate drives `ok:false`. */
127
+ blocking: boolean;
128
+ /** The changed-file set a scope:changed gate saw (present only when resolved). */
129
+ changedFiles?: string[];
130
+ /** First non-zero command code for a failed gate. */
131
+ exitCode?: number;
132
+ /**
133
+ * Human-readable cause of a degrade/skip, surfaced so the headless JSON
134
+ * consumer sees the degrade LOUDLY — not just in the Ink stream. Populated for
135
+ * the scope:changed skip variants: base ref null, changed-file resolution
136
+ * failure (shallow clone / missing ref), and the empty changed-set skip.
137
+ */
138
+ reason?: string;
139
+ }
140
+ /** Structured result of a headless (`--json`) gate run. */
141
+ export interface HeadlessGateResult {
142
+ /** `false` iff a BLOCKING gate errored; informative failures keep it `true`. */
143
+ ok: boolean;
144
+ gates: GateOutcome[];
145
+ /** The process exit code to set explicitly (1 on a blocking failure or crash). */
146
+ exitCode: number;
147
+ }
148
+ /**
149
+ * Drive `runCI` headlessly (no Ink render), collecting each gate's structured
150
+ * outcome for the `--json` run path. `runCI` throws on a blocking gate failure
151
+ * (and on any non-gate error); the outcomes are captured regardless via the
152
+ * `onOutcome` callback, so the JSON is always complete.
153
+ *
154
+ * `ok` is `false` iff a BLOCKING gate errored (spec contract); informative
155
+ * failures keep `ok:true`. `exitCode` is `1` when a blocking gate errored OR
156
+ * `runCI` threw for any other reason (a real crash still exits non-zero), else
157
+ * `0`. The caller (dispatch) prints `{ ok, gates }` and sets `process.exitCode`.
158
+ */
159
+ export declare function collectGateOutcomes(options: CIOptions): Promise<HeadlessGateResult>;
103
160
  /**
104
161
  * Classification of an existing `.git/hooks/<name>` before anything is written
105
162
  * (design D6). Every state has exactly one write policy, so no hook is ever
@@ -6,10 +6,11 @@ import os from "node:os";
6
6
  import path from "node:path";
7
7
  import fs from "fs-extra";
8
8
  import { HOOK_ASSETS_DIR } from "../constants.js";
9
- import { CI_STACKS, findCIConfig, GATE_MODE, loadCIConfig, } from "../lib/ci-config.js";
9
+ import { CI_STACKS, findCIConfig, GATE_MODE, GATE_SCOPE, loadCIConfig, } from "../lib/ci-config.js";
10
10
  import { refreshContextDir } from "../lib/context.js";
11
11
  import { ensureImage, isDockerAvailable, openShell, runInContainer, } from "../lib/docker.js";
12
12
  import { execFileAsync } from "../lib/exec.js";
13
+ import { changedFiles, resolveBaseRef } from "../lib/git-diff.js";
13
14
  // =============================================================================
14
15
  // Stack detection
15
16
  // =============================================================================
@@ -313,7 +314,7 @@ function describeRunners(resolved) {
313
314
  .join(", ");
314
315
  return `Config: ${resolved.runners.length} runner(s) — ${summary}`;
315
316
  }
316
- export async function runCI(options, onStep) {
317
+ export async function runCI(options, onStep, onGateOutcome) {
317
318
  const { projectDir = process.cwd(), mode = "full", noDocker = false, noGhagga = false, noSecurity = false, timeout = 600, } = options;
318
319
  // ── Resolve runners (once — nothing downstream re-detects) ─────────────────
319
320
  const stepDetect = "detect";
@@ -342,7 +343,7 @@ export async function runCI(options, onStep) {
342
343
  report(onStep, mode, `${mode} mode`, "error", detail);
343
344
  throw new Error(`no runners resolved — ${mode} mode requires at least one runner`);
344
345
  }
345
- await runGates(resolved.gates, projectDir, onStep);
346
+ await runGates(resolved.gates, projectDir, onStep, onGateOutcome);
346
347
  return;
347
348
  }
348
349
  // Legacy single-runner view for the zero-config auto path. Keeping this
@@ -514,7 +515,7 @@ export async function runCI(options, onStep) {
514
515
  // already `full` or `quick` here; the guard makes the contract explicit.
515
516
  // `runGates` no-ops on an empty gate list (a v1 repo carries none).
516
517
  if (mode === "full" || mode === "quick") {
517
- await runGates(resolved.gates, projectDir, onStep);
518
+ await runGates(resolved.gates, projectDir, onStep, onGateOutcome);
518
519
  }
519
520
  }
520
521
  // =============================================================================
@@ -772,13 +773,59 @@ function filterDefinedEnv(env) {
772
773
  * eliminate. A null code therefore resolves to a NON-ZERO code using the shell
773
774
  * convention `128 + <signal number>` when the signal is resolvable, else 1.
774
775
  *
776
+ * TIMEOUT (GATE-2): when `timeoutSec` is set, a command still running after that
777
+ * many wall-clock seconds is killed — SIGTERM first, escalated to SIGKILL after a
778
+ * short grace if it ignores SIGTERM. A `timedOut` flag is set BEFORE the SIGTERM
779
+ * lands, and on `close` it OVERRIDES the child's reported code with the timeout
780
+ * sentinel 124 — REGARDLESS of what the child reported. This closes the false-green
781
+ * where a child that TRAPS SIGTERM and exits 0 gracefully (dev servers, watchers,
782
+ * SIGTERM-handling CLIs) would otherwise resolve 0 and PASS a timed-out blocking
783
+ * gate. INVARIANT: `timedOut ⇒ non-zero, ALWAYS` (124, the GNU `timeout(1)`
784
+ * convention — distinct from a signal-death 143/137). Timers are cleared on
785
+ * `close`/`error` so no dangling handle keeps the process alive. Omitting
786
+ * `timeoutSec` preserves the pre-GATE-2 behavior exactly (no timer, runs to
787
+ * completion).
788
+ *
775
789
  * Env values arrive as discrete map entries — never string-spliced into the
776
790
  * `bash -c` command — so metacharacters in a value cannot break out of the shell.
777
791
  */
778
- export async function runGateNative(cmd, cwd, env) {
792
+ export async function runGateNative(cmd, cwd, env, timeoutSec) {
779
793
  return await new Promise((resolve, reject) => {
780
794
  const proc = spawn("bash", ["-c", cmd], { cwd, env, stdio: "inherit" });
795
+ let killTimer;
796
+ let graceTimer;
797
+ let timedOut = false;
798
+ const clearTimers = () => {
799
+ if (killTimer !== undefined)
800
+ clearTimeout(killTimer);
801
+ if (graceTimer !== undefined)
802
+ clearTimeout(graceTimer);
803
+ };
804
+ if (timeoutSec !== undefined) {
805
+ killTimer = setTimeout(() => {
806
+ // The wall-clock budget expired: mark the run as timed out BEFORE the
807
+ // SIGTERM lands. A child that traps SIGTERM and exits 0 gracefully would
808
+ // otherwise report code=0 → a false-green; the `timedOut` flag overrides
809
+ // that below so a timed-out gate is ALWAYS a FAILURE.
810
+ timedOut = true;
811
+ // Ask politely, then force: SIGKILL if the child is still alive after
812
+ // the grace window.
813
+ proc.kill("SIGTERM");
814
+ graceTimer = setTimeout(() => {
815
+ proc.kill("SIGKILL");
816
+ }, GATE_TIMEOUT_GRACE_MS);
817
+ }, timeoutSec * 1000);
818
+ }
781
819
  proc.on("close", (code, signal) => {
820
+ clearTimers();
821
+ if (timedOut) {
822
+ // INVARIANT: timedOut ⇒ non-zero, ALWAYS. Even a child that trapped the
823
+ // SIGTERM and exited 0 before the SIGKILL escalation is a timeout, not a
824
+ // pass. Resolve the GNU `timeout(1)` sentinel 124 ("command timed out"),
825
+ // semantically distinct from a signal-death 143/137.
826
+ resolve(GATE_TIMEOUT_EXIT_CODE);
827
+ return;
828
+ }
782
829
  if (code !== null) {
783
830
  resolve(code);
784
831
  return;
@@ -789,9 +836,34 @@ export async function runGateNative(cmd, cwd, env) {
789
836
  const signum = signal ? os.constants.signals[signal] : undefined;
790
837
  resolve(signum !== undefined ? 128 + signum : 1);
791
838
  });
792
- proc.on("error", reject);
839
+ proc.on("error", (e) => {
840
+ clearTimers();
841
+ reject(e);
842
+ });
793
843
  });
794
844
  }
845
+ /** Grace between the timeout SIGTERM and the escalated SIGKILL. */
846
+ const GATE_TIMEOUT_GRACE_MS = 2000;
847
+ /**
848
+ * Exit code resolved for a timed-out gate, regardless of how the child died.
849
+ * 124 is the GNU `timeout(1)` convention for "command timed out" — semantically
850
+ * distinct from a signal-death 143 (SIGTERM) / 137 (SIGKILL), and it CANNOT be a
851
+ * false-green because it is non-zero. Enforces `timedOut ⇒ non-zero, always`.
852
+ */
853
+ const GATE_TIMEOUT_EXIT_CODE = 124;
854
+ /**
855
+ * Env var carrying a scope:changed gate's newline-joined, root-relative paths.
856
+ *
857
+ * KNOWN LIMITATION (JDB-103): the list is newline-joined, so a path that itself
858
+ * contains a literal `\n` (git can emit such a path when `core.quotePath` is off)
859
+ * would corrupt line-based parsing on the gate side. This is a low-likelihood
860
+ * edge — repo paths with embedded newlines are pathological — and is accepted as
861
+ * a documented caveat rather than switched to NUL-joining, which would force
862
+ * every gate consumer to change its parser.
863
+ */
864
+ const CHANGED_FILES_ENV = "JAVI_FORGE_CHANGED_FILES";
865
+ /** Env var carrying a gate's optional baseline artifact path. */
866
+ const BASELINE_ENV = "JAVI_FORGE_BASELINE";
795
867
  /**
796
868
  * Repo-level gate phase. Each gate runs host-native via `runGateNative` at the
797
869
  * repo root. Outcome semantics:
@@ -806,31 +878,109 @@ export async function runGateNative(cmd, cwd, env) {
806
878
  * contribute to the accumulator, so the exit code stays 0.
807
879
  *
808
880
  * Multi-command gates run in order and STOP at the first non-zero exit
809
- * (fail-fast, matching the runner precedent); that first code is reported.
881
+ * (fail-fast, matching the runner precedent); that first code is reported. An
882
+ * optional per-gate `timeout` (seconds) is applied PER COMMAND: a command that
883
+ * exceeds it is killed and resolves non-zero, so the timed-out gate fails
884
+ * (blocking) or warns (informative) — never a false-green.
885
+ *
886
+ * `scope: changed` consumes the injectable `git-diff.ts` engine: the base ref is
887
+ * resolved and the changed set computed ONCE, then shared. A non-empty set runs
888
+ * the gate with `$JAVI_FORGE_CHANGED_FILES` (newline-joined, root-relative); an
889
+ * empty set skips the gate; a null base OR a `changedFiles` throw skips every
890
+ * scope:changed gate with a named warning (loud-degrade, never widens, never
891
+ * crashes). `baseline` is injected as `$JAVI_FORGE_BASELINE`. Gate `env` spreads
892
+ * LAST (documented last-wins over the engine-injected keys).
810
893
  *
811
- * Slice 3 wires `mode` and `scope: all` gates with the engine-injected env
812
- * (`CI=true`). `scope: changed` wiring, `baseline`, and gate `env` injection
813
- * land in slice 4.
894
+ * `onOutcome` (optional) receives each gate's structured result for the headless
895
+ * JSON run path.
814
896
  */
815
- async function runGates(gates, projectDir, onStep) {
897
+ async function runGates(gates, projectDir, onStep, onOutcome) {
816
898
  if (gates.length === 0)
817
899
  return;
818
900
  const blockingFailures = [];
819
- // Engine-injected keys only in slice 3; gate `env` + JAVI_FORGE_CHANGED_FILES
820
- // are added in slice 4.
821
901
  const baseEnv = {
822
902
  ...filterDefinedEnv(process.env),
823
903
  CI: "true",
824
904
  };
905
+ // Lazily resolved and memoized — computed only when a scope:changed gate
906
+ // exists, and only once (shared across all scope:changed gates).
907
+ let changedScope = null;
908
+ const resolveChangedScope = async () => {
909
+ if (changedScope !== null)
910
+ return changedScope;
911
+ const base = await resolveBaseRef(process.env, projectDir);
912
+ if (base === null) {
913
+ changedScope = {
914
+ kind: "skip",
915
+ reason: "no base ref resolved — skipping scope:changed",
916
+ };
917
+ return changedScope;
918
+ }
919
+ try {
920
+ changedScope = {
921
+ kind: "files",
922
+ files: await changedFiles(base, projectDir),
923
+ };
924
+ }
925
+ catch {
926
+ // Shallow clone / base sha absent from local history: caught here so the
927
+ // throw never aborts the phase and the gate never widens to scope:all.
928
+ changedScope = {
929
+ kind: "skip",
930
+ reason: "changed-file diff failed (shallow clone / missing ref) — skipping scope:changed",
931
+ };
932
+ }
933
+ return changedScope;
934
+ };
825
935
  for (const gate of gates) {
826
936
  const stepId = `gate:${gate.id}`;
827
937
  const label = `Gate: ${gate.id}`;
938
+ const blocking = gate.mode === GATE_MODE.BLOCKING;
939
+ const emit = (status, extra) => onOutcome?.({
940
+ id: gate.id,
941
+ mode: gate.mode,
942
+ scope: gate.scope,
943
+ status,
944
+ blocking,
945
+ ...extra,
946
+ });
828
947
  report(onStep, stepId, label, "running");
948
+ // Per-gate env map: engine keys, then baseline, then gate.env LAST (last-wins).
949
+ const gateEnv = { ...baseEnv };
950
+ let gateChangedFiles;
951
+ if (gate.scope === GATE_SCOPE.CHANGED) {
952
+ const scope = await resolveChangedScope();
953
+ if (scope.kind === "skip") {
954
+ report(onStep, stepId, `${label} skipped`, "skipped", scope.reason);
955
+ // Loud-degrade: carry the named reason into the JSON outcome too, not
956
+ // only the Ink `onStep` stream (which is a no-op under --json).
957
+ emit("skipped", { reason: scope.reason });
958
+ continue;
959
+ }
960
+ if (scope.files.length === 0) {
961
+ const noChanges = "no changed files";
962
+ report(onStep, stepId, `${label} skipped`, "skipped", noChanges);
963
+ emit("skipped", { changedFiles: [], reason: noChanges });
964
+ continue;
965
+ }
966
+ gateChangedFiles = scope.files;
967
+ gateEnv[CHANGED_FILES_ENV] = scope.files.join("\n");
968
+ }
969
+ if (gate.baseline !== undefined) {
970
+ gateEnv[BASELINE_ENV] = gate.baseline;
971
+ }
972
+ if (gate.env !== undefined) {
973
+ // Gate env spreads LAST — a gate MAY override CI / CHANGED_FILES / BASELINE.
974
+ Object.assign(gateEnv, gate.env);
975
+ }
829
976
  let exitCode = 0;
830
977
  let spawnError;
831
978
  try {
832
979
  for (const cmd of gate.run) {
833
- exitCode = await runGateNative(cmd, projectDir, baseEnv);
980
+ // timeout is per-command (matches the fail-fast model): each command
981
+ // gets its own wall-clock budget. A timed-out command is killed and
982
+ // resolves non-zero, so fail-fast stops the gate here.
983
+ exitCode = await runGateNative(cmd, projectDir, gateEnv, gate.timeout);
834
984
  if (exitCode !== 0)
835
985
  break; // fail-fast: skip the remaining commands
836
986
  }
@@ -840,21 +990,53 @@ async function runGates(gates, projectDir, onStep) {
840
990
  }
841
991
  if (spawnError === undefined && exitCode === 0) {
842
992
  report(onStep, stepId, `${label} passed`, "done");
993
+ emit("done", { changedFiles: gateChangedFiles });
843
994
  continue;
844
995
  }
845
996
  const detail = spawnError !== undefined ? String(spawnError) : `exit ${exitCode}`;
846
- if (gate.mode === GATE_MODE.BLOCKING) {
997
+ if (blocking) {
847
998
  blockingFailures.push(gate.id);
848
999
  report(onStep, stepId, `${label} failed`, "error", detail);
1000
+ emit("error", { changedFiles: gateChangedFiles, exitCode });
849
1001
  }
850
1002
  else {
851
1003
  report(onStep, stepId, `${label} failed (informative)`, "warning", detail);
1004
+ emit("warning", { changedFiles: gateChangedFiles, exitCode });
852
1005
  }
853
1006
  }
854
1007
  if (blockingFailures.length > 0) {
855
1008
  throw new Error(`blocking gate(s) failed: ${blockingFailures.join(", ")}`);
856
1009
  }
857
1010
  }
1011
+ /**
1012
+ * Drive `runCI` headlessly (no Ink render), collecting each gate's structured
1013
+ * outcome for the `--json` run path. `runCI` throws on a blocking gate failure
1014
+ * (and on any non-gate error); the outcomes are captured regardless via the
1015
+ * `onOutcome` callback, so the JSON is always complete.
1016
+ *
1017
+ * `ok` is `false` iff a BLOCKING gate errored (spec contract); informative
1018
+ * failures keep `ok:true`. `exitCode` is `1` when a blocking gate errored OR
1019
+ * `runCI` threw for any other reason (a real crash still exits non-zero), else
1020
+ * `0`. The caller (dispatch) prints `{ ok, gates }` and sets `process.exitCode`.
1021
+ */
1022
+ export async function collectGateOutcomes(options) {
1023
+ const gates = [];
1024
+ let threw = false;
1025
+ try {
1026
+ await runCI(options, () => { }, (outcome) => gates.push(outcome));
1027
+ }
1028
+ catch {
1029
+ // Blocking failure (aggregate throw) or a non-gate error — outcomes are
1030
+ // already captured; the headless path never propagates the throw.
1031
+ threw = true;
1032
+ }
1033
+ const blockingErrored = gates.some((g) => g.blocking && g.status === "error");
1034
+ return {
1035
+ ok: !blockingErrored,
1036
+ gates,
1037
+ exitCode: blockingErrored || threw ? 1 : 0,
1038
+ };
1039
+ }
858
1040
  // =============================================================================
859
1041
  // CI Hooks Installation
860
1042
  // =============================================================================
@@ -60,6 +60,12 @@ export interface CIGateConfig {
60
60
  baseline?: string;
61
61
  /** Optional env injected via the child-process env map (slice 4). */
62
62
  env?: Record<string, string>;
63
+ /**
64
+ * Optional per-command wall-clock timeout in seconds (GATE-2). When set, a
65
+ * command exceeding it is killed and the gate FAILS (non-zero). Omitted →
66
+ * no timeout (unchanged behavior).
67
+ */
68
+ timeout?: number;
63
69
  }
64
70
  export interface CIConfig {
65
71
  version: number;
@@ -214,7 +214,15 @@ function validateRunner(raw, index, errors) {
214
214
  requires,
215
215
  };
216
216
  }
217
- const GATE_FIELDS = new Set(["id", "run", "mode", "scope", "baseline", "env"]);
217
+ const GATE_FIELDS = new Set([
218
+ "id",
219
+ "run",
220
+ "mode",
221
+ "scope",
222
+ "baseline",
223
+ "env",
224
+ "timeout",
225
+ ]);
218
226
  function validateGate(raw, index, errors) {
219
227
  const base = `gates[${index}]`;
220
228
  if (!isRecord(raw)) {
@@ -310,6 +318,20 @@ function validateGate(raw, index, errors) {
310
318
  env = raw.env;
311
319
  }
312
320
  }
321
+ let timeout;
322
+ if (raw.timeout !== undefined) {
323
+ if (typeof raw.timeout !== "number" ||
324
+ !Number.isFinite(raw.timeout) ||
325
+ raw.timeout <= 0) {
326
+ errors.push({
327
+ path: `${base}.timeout`,
328
+ message: `timeout must be a positive number of seconds (got "${String(raw.timeout)}")`,
329
+ });
330
+ }
331
+ else {
332
+ timeout = raw.timeout;
333
+ }
334
+ }
313
335
  return {
314
336
  id: typeof id === "string" ? id.trim() : "",
315
337
  run,
@@ -317,6 +339,7 @@ function validateGate(raw, index, errors) {
317
339
  scope,
318
340
  baseline,
319
341
  env,
342
+ timeout,
320
343
  };
321
344
  }
322
345
  /**
@@ -3,7 +3,10 @@
3
3
  * 1. `$CI_MERGE_REQUEST_DIFF_BASE_SHA` (GitLab MR) when non-empty.
4
4
  * 2. `$CI_COMMIT_BEFORE_SHA` (GitLab push) when non-empty AND not the
5
5
  * all-zeros new-branch sentinel.
6
- * 3. `$GITHUB_BASE_REF` (GitHub Actions PR) → `git merge-base origin/<ref> HEAD`.
6
+ * 3. `$GITHUB_BASE_REF` (GitHub Actions PR) → `git merge-base origin/<ref> HEAD`;
7
+ * else on a push, `$GITHUB_EVENT_BEFORE` (github.event.before — the real push
8
+ * base, unless the all-zeros new-branch sentinel) before falling to
9
+ * `$GITHUB_SHA` (which equals HEAD after actions/checkout → empty diff).
7
10
  * 4. Local fallback: `git merge-base <candidate> HEAD` over
8
11
  * `origin/main`, `origin/master`, `main`, `master` — first that resolves.
9
12
  * 5. Nothing resolves → `null` (caller loud-degrades).
@@ -52,7 +52,10 @@ async function tryMergeBase(ref, cwd) {
52
52
  * 1. `$CI_MERGE_REQUEST_DIFF_BASE_SHA` (GitLab MR) when non-empty.
53
53
  * 2. `$CI_COMMIT_BEFORE_SHA` (GitLab push) when non-empty AND not the
54
54
  * all-zeros new-branch sentinel.
55
- * 3. `$GITHUB_BASE_REF` (GitHub Actions PR) → `git merge-base origin/<ref> HEAD`.
55
+ * 3. `$GITHUB_BASE_REF` (GitHub Actions PR) → `git merge-base origin/<ref> HEAD`;
56
+ * else on a push, `$GITHUB_EVENT_BEFORE` (github.event.before — the real push
57
+ * base, unless the all-zeros new-branch sentinel) before falling to
58
+ * `$GITHUB_SHA` (which equals HEAD after actions/checkout → empty diff).
56
59
  * 4. Local fallback: `git merge-base <candidate> HEAD` over
57
60
  * `origin/main`, `origin/master`, `main`, `master` — first that resolves.
58
61
  * 5. Nothing resolves → `null` (caller loud-degrades).
@@ -68,12 +71,21 @@ export async function resolveBaseRef(env, cwd) {
68
71
  return env.CI_COMMIT_BEFORE_SHA;
69
72
  }
70
73
  // 3. GitHub Actions — a PR sets GITHUB_BASE_REF (merge-base against the
71
- // target branch); a push has no base ref and falls back to GITHUB_SHA.
74
+ // target branch). A push has no base ref: prefer github.event.before
75
+ // (GITHUB_EVENT_BEFORE — the commit the branch pointed at before the push,
76
+ // the real diff base), unless it is the all-zeros new-branch sentinel, and
77
+ // only then fall back to GITHUB_SHA (which equals HEAD after
78
+ // actions/checkout → an empty diff → scope:changed gates would silently
79
+ // skip; a visible skip, not a false-green — the before-sha avoids it).
72
80
  if (isNonEmpty(env.GITHUB_BASE_REF)) {
73
81
  const base = await tryMergeBase(`origin/${env.GITHUB_BASE_REF}`, cwd);
74
82
  if (base !== null)
75
83
  return base;
76
84
  }
85
+ else if (isNonEmpty(env.GITHUB_EVENT_BEFORE) &&
86
+ env.GITHUB_EVENT_BEFORE !== NEW_BRANCH_SENTINEL) {
87
+ return env.GITHUB_EVENT_BEFORE;
88
+ }
77
89
  else if (isNonEmpty(env.GITHUB_SHA)) {
78
90
  return env.GITHUB_SHA;
79
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {