javi-forge 1.14.0 → 1.15.1

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.
@@ -80,6 +80,18 @@ export interface ResolveRunnerOptions {
80
80
  */
81
81
  export declare function resolveCIRunners(projectDir: string, options?: ResolveRunnerOptions): Promise<ResolvedRunners>;
82
82
  export declare function runCI(options: CIOptions, onStep: CIStepCallback, onGateOutcome?: (outcome: GateOutcome) => void): Promise<void>;
83
+ /**
84
+ * Result of a single host-native gate command. `code` is the resolved exit code
85
+ * (a timed-out command resolves the `timeout(1)` sentinel 124); `timedOut` is
86
+ * `true` IFF the internal wall-clock `killTimer` fired. The flag exists so a
87
+ * caller can tell a wall-clock timeout apart from a child that itself exits 124
88
+ * (a `curl` op-timeout, a nested `timeout(1)`, a script returning 124) — both
89
+ * carry `code: 124`, but only a real timeout carries `timedOut: true` (R3-004).
90
+ */
91
+ export interface GateRunResult {
92
+ code: number;
93
+ timedOut: boolean;
94
+ }
83
95
  /**
84
96
  * Execute a single gate command HOST-NATIVE via `bash -c`, at the repo root,
85
97
  * with the provided env MAP. Modeled on `runSemgrep`/`runGhagga` (a spawned
@@ -96,10 +108,23 @@ export declare function runCI(options: CIOptions, onStep: CIStepCallback, onGate
96
108
  * eliminate. A null code therefore resolves to a NON-ZERO code using the shell
97
109
  * convention `128 + <signal number>` when the signal is resolvable, else 1.
98
110
  *
111
+ * TIMEOUT (GATE-2): when `timeoutSec` is set, a command still running after that
112
+ * many wall-clock seconds is killed — SIGTERM first, escalated to SIGKILL after a
113
+ * short grace if it ignores SIGTERM. A `timedOut` flag is set BEFORE the SIGTERM
114
+ * lands, and on `close` it OVERRIDES the child's reported code with the timeout
115
+ * sentinel 124 — REGARDLESS of what the child reported. This closes the false-green
116
+ * where a child that TRAPS SIGTERM and exits 0 gracefully (dev servers, watchers,
117
+ * SIGTERM-handling CLIs) would otherwise resolve 0 and PASS a timed-out blocking
118
+ * gate. INVARIANT: `timedOut ⇒ non-zero, ALWAYS` (124, the GNU `timeout(1)`
119
+ * convention — distinct from a signal-death 143/137). Timers are cleared on
120
+ * `close`/`error` so no dangling handle keeps the process alive. Omitting
121
+ * `timeoutSec` preserves the pre-GATE-2 behavior exactly (no timer, runs to
122
+ * completion).
123
+ *
99
124
  * Env values arrive as discrete map entries — never string-spliced into the
100
125
  * `bash -c` command — so metacharacters in a value cannot break out of the shell.
101
126
  */
102
- export declare function runGateNative(cmd: string, cwd: string, env: Record<string, string>): Promise<number>;
127
+ export declare function runGateNative(cmd: string, cwd: string, env: Record<string, string>, timeoutSec?: number): Promise<GateRunResult>;
103
128
  /**
104
129
  * A single gate's structured result, collected for the headless JSON run path.
105
130
  * Mirrors the `{ id, mode, scope, status, blocking, changedFiles?, exitCode? }`
@@ -117,10 +142,12 @@ export interface GateOutcome {
117
142
  /** First non-zero command code for a failed gate. */
118
143
  exitCode?: number;
119
144
  /**
120
- * Human-readable cause of a degrade/skip, surfaced so the headless JSON
121
- * consumer sees the degrade LOUDLY — not just in the Ink stream. Populated for
122
- * the scope:changed skip variants: base ref null, changed-file resolution
123
- * failure (shallow clone / missing ref), and the empty changed-set skip.
145
+ * Human-readable cause of a degrade/skip/timeout, surfaced so the headless
146
+ * JSON consumer sees it LOUDLY — not just in the Ink stream. Populated for the
147
+ * scope:changed skip variants (base ref null, changed-file resolution failure
148
+ * under a shallow clone / missing ref, empty changed-set skip) AND for a gate
149
+ * that timed out (so a 124 wall-clock kill is distinguishable from a command
150
+ * that itself exits 124).
124
151
  */
125
152
  reason?: string;
126
153
  }
@@ -773,26 +773,90 @@ 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. The `timedOut` flag
826
+ // travels with the code so the caller can tell a wall-clock timeout apart
827
+ // from a child that itself exits 124 (both are 124, but only one is a
828
+ // timeout — R3-004 observability).
829
+ resolve({ code: GATE_TIMEOUT_EXIT_CODE, timedOut: true });
830
+ return;
831
+ }
783
832
  if (code !== null) {
784
- resolve(code);
833
+ resolve({ code, timedOut: false });
785
834
  return;
786
835
  }
787
836
  // Signal death: map to a non-zero code so the collector records a
788
837
  // blocking failure. `128 + signum` mirrors the shell; fall back to 1
789
838
  // when the signal name is not resolvable.
790
839
  const signum = signal ? os.constants.signals[signal] : undefined;
791
- resolve(signum !== undefined ? 128 + signum : 1);
840
+ resolve({
841
+ code: signum !== undefined ? 128 + signum : 1,
842
+ timedOut: false,
843
+ });
844
+ });
845
+ proc.on("error", (e) => {
846
+ clearTimers();
847
+ reject(e);
792
848
  });
793
- proc.on("error", reject);
794
849
  });
795
850
  }
851
+ /** Grace between the timeout SIGTERM and the escalated SIGKILL. */
852
+ const GATE_TIMEOUT_GRACE_MS = 2000;
853
+ /**
854
+ * Exit code resolved for a timed-out gate, regardless of how the child died.
855
+ * 124 is the GNU `timeout(1)` convention for "command timed out" — semantically
856
+ * distinct from a signal-death 143 (SIGTERM) / 137 (SIGKILL), and it CANNOT be a
857
+ * false-green because it is non-zero. Enforces `timedOut ⇒ non-zero, always`.
858
+ */
859
+ const GATE_TIMEOUT_EXIT_CODE = 124;
796
860
  /**
797
861
  * Env var carrying a scope:changed gate's newline-joined, root-relative paths.
798
862
  *
@@ -820,7 +884,10 @@ const BASELINE_ENV = "JAVI_FORGE_BASELINE";
820
884
  * contribute to the accumulator, so the exit code stays 0.
821
885
  *
822
886
  * 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.
887
+ * (fail-fast, matching the runner precedent); that first code is reported. An
888
+ * optional per-gate `timeout` (seconds) is applied PER COMMAND: a command that
889
+ * exceeds it is killed and resolves non-zero, so the timed-out gate fails
890
+ * (blocking) or warns (informative) — never a false-green.
824
891
  *
825
892
  * `scope: changed` consumes the injectable `git-diff.ts` engine: the base ref is
826
893
  * resolved and the changed set computed ONCE, then shared. A non-empty set runs
@@ -913,10 +980,16 @@ async function runGates(gates, projectDir, onStep, onOutcome) {
913
980
  Object.assign(gateEnv, gate.env);
914
981
  }
915
982
  let exitCode = 0;
983
+ let timedOut = false;
916
984
  let spawnError;
917
985
  try {
918
986
  for (const cmd of gate.run) {
919
- exitCode = await runGateNative(cmd, projectDir, gateEnv);
987
+ // timeout is per-command (matches the fail-fast model): each command
988
+ // gets its own wall-clock budget. A timed-out command is killed and
989
+ // resolves non-zero, so fail-fast stops the gate here.
990
+ const result = await runGateNative(cmd, projectDir, gateEnv, gate.timeout);
991
+ exitCode = result.code;
992
+ timedOut = result.timedOut;
920
993
  if (exitCode !== 0)
921
994
  break; // fail-fast: skip the remaining commands
922
995
  }
@@ -929,15 +1002,31 @@ async function runGates(gates, projectDir, onStep, onOutcome) {
929
1002
  emit("done", { changedFiles: gateChangedFiles });
930
1003
  continue;
931
1004
  }
1005
+ // R3-004: a timed-out gate carries a `reason` naming the timeout so the
1006
+ // JSON/dashboard consumer can distinguish "this gate timed out (bump the
1007
+ // timeout)" from "the command failed with 124 (fix the command)". A
1008
+ // non-timeout failure leaves `reason` undefined. Never key on the 124 value
1009
+ // itself — that IS the ambiguity; only the real `timedOut` signal disambiguates.
1010
+ const timeoutReason = timedOut
1011
+ ? `timed out after ${gate.timeout}s`
1012
+ : undefined;
932
1013
  const detail = spawnError !== undefined ? String(spawnError) : `exit ${exitCode}`;
933
1014
  if (blocking) {
934
1015
  blockingFailures.push(gate.id);
935
1016
  report(onStep, stepId, `${label} failed`, "error", detail);
936
- emit("error", { changedFiles: gateChangedFiles, exitCode });
1017
+ emit("error", {
1018
+ changedFiles: gateChangedFiles,
1019
+ exitCode,
1020
+ reason: timeoutReason,
1021
+ });
937
1022
  }
938
1023
  else {
939
1024
  report(onStep, stepId, `${label} failed (informative)`, "warning", detail);
940
- emit("warning", { changedFiles: gateChangedFiles, exitCode });
1025
+ emit("warning", {
1026
+ changedFiles: gateChangedFiles,
1027
+ exitCode,
1028
+ reason: timeoutReason,
1029
+ });
941
1030
  }
942
1031
  }
943
1032
  if (blockingFailures.length > 0) {
@@ -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.1",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {