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.
- package/dist/cli/dispatch/ci.js +27 -0
- package/dist/commands/ci.d.ts +60 -3
- package/dist/commands/ci.js +197 -15
- package/dist/lib/ci-config.d.ts +6 -0
- package/dist/lib/ci-config.js +24 -1
- package/dist/lib/git-diff.d.ts +4 -1
- package/dist/lib/git-diff.js +14 -2
- package/package.json +1 -1
package/dist/cli/dispatch/ci.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/ci.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/dist/commands/ci.js
CHANGED
|
@@ -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",
|
|
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
|
-
*
|
|
812
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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
|
// =============================================================================
|
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
|
/**
|
package/dist/lib/git-diff.d.ts
CHANGED
|
@@ -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).
|
package/dist/lib/git-diff.js
CHANGED
|
@@ -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)
|
|
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
|
}
|