javi-forge 1.14.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.
@@ -96,10 +96,23 @@ export declare function runCI(options: CIOptions, onStep: CIStepCallback, onGate
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>;
103
116
  /**
104
117
  * A single gate's structured result, collected for the headless JSON run path.
105
118
  * Mirrors the `{ id, mode, scope, status, blocking, changedFiles?, exitCode? }`
@@ -773,13 +773,59 @@ function filterDefinedEnv(env) {
773
773
  * eliminate. A null code therefore resolves to a NON-ZERO code using the shell
774
774
  * convention `128 + <signal number>` when the signal is resolvable, else 1.
775
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
+ *
776
789
  * Env values arrive as discrete map entries — never string-spliced into the
777
790
  * `bash -c` command — so metacharacters in a value cannot break out of the shell.
778
791
  */
779
- export async function runGateNative(cmd, cwd, env) {
792
+ export async function runGateNative(cmd, cwd, env, timeoutSec) {
780
793
  return await new Promise((resolve, reject) => {
781
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
+ }
782
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
+ }
783
829
  if (code !== null) {
784
830
  resolve(code);
785
831
  return;
@@ -790,9 +836,21 @@ export async function runGateNative(cmd, cwd, env) {
790
836
  const signum = signal ? os.constants.signals[signal] : undefined;
791
837
  resolve(signum !== undefined ? 128 + signum : 1);
792
838
  });
793
- proc.on("error", reject);
839
+ proc.on("error", (e) => {
840
+ clearTimers();
841
+ reject(e);
842
+ });
794
843
  });
795
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;
796
854
  /**
797
855
  * Env var carrying a scope:changed gate's newline-joined, root-relative paths.
798
856
  *
@@ -820,7 +878,10 @@ const BASELINE_ENV = "JAVI_FORGE_BASELINE";
820
878
  * contribute to the accumulator, so the exit code stays 0.
821
879
  *
822
880
  * Multi-command gates run in order and STOP at the first non-zero exit
823
- * (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.
824
885
  *
825
886
  * `scope: changed` consumes the injectable `git-diff.ts` engine: the base ref is
826
887
  * resolved and the changed set computed ONCE, then shared. A non-empty set runs
@@ -916,7 +977,10 @@ async function runGates(gates, projectDir, onStep, onOutcome) {
916
977
  let spawnError;
917
978
  try {
918
979
  for (const cmd of gate.run) {
919
- exitCode = await runGateNative(cmd, projectDir, gateEnv);
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);
920
984
  if (exitCode !== 0)
921
985
  break; // fail-fast: skip the remaining commands
922
986
  }
@@ -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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {