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.
- package/dist/commands/ci.d.ts +32 -5
- package/dist/commands/ci.js +97 -8
- package/dist/lib/ci-config.d.ts +6 -0
- package/dist/lib/ci-config.js +24 -1
- package/package.json +1 -1
package/dist/commands/ci.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
121
|
-
* consumer sees
|
|
122
|
-
*
|
|
123
|
-
*
|
|
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
|
}
|
package/dist/commands/ci.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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", {
|
|
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", {
|
|
1025
|
+
emit("warning", {
|
|
1026
|
+
changedFiles: gateChangedFiles,
|
|
1027
|
+
exitCode,
|
|
1028
|
+
reason: timeoutReason,
|
|
1029
|
+
});
|
|
941
1030
|
}
|
|
942
1031
|
}
|
|
943
1032
|
if (blockingFailures.length > 0) {
|
package/dist/lib/ci-config.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/ci-config.js
CHANGED
|
@@ -214,7 +214,15 @@ function validateRunner(raw, index, errors) {
|
|
|
214
214
|
requires,
|
|
215
215
|
};
|
|
216
216
|
}
|
|
217
|
-
const GATE_FIELDS = new Set([
|
|
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
|
/**
|