javi-forge 1.12.0 → 1.14.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,3 +1,4 @@
1
+ import { type CIGateConfig, type GateMode, type GateScope } from "../lib/ci-config.js";
1
2
  import type { Stack } from "../types/index.js";
2
3
  export type CIMode = "full" | "quick" | "shell" | "detect";
3
4
  export interface CIOptions {
@@ -16,7 +17,7 @@ export interface CIOptions {
16
17
  /** Explicit single-stack override (--stack). Insufficient for hybrid repos */
17
18
  stack?: string;
18
19
  }
19
- export type CIStepStatus = "pending" | "running" | "done" | "error" | "skipped";
20
+ export type CIStepStatus = "pending" | "running" | "done" | "error" | "skipped" | "warning";
20
21
  export interface CIStep {
21
22
  id: string;
22
23
  label: string;
@@ -60,6 +61,8 @@ export type RunnerSource = "auto" | "config" | "stack-override";
60
61
  export interface ResolvedRunners {
61
62
  readonly source: RunnerSource;
62
63
  readonly runners: readonly ResolvedRunner[];
64
+ /** Declared quality gates (version 2 only); empty for auto/stack-override. */
65
+ readonly gates: readonly CIGateConfig[];
63
66
  }
64
67
  export interface ResolveRunnerOptions {
65
68
  /** Explicit config path (--config). Wins over default discovery */
@@ -76,7 +79,71 @@ export interface ResolveRunnerOptions {
76
79
  * 3. otherwise → single auto-detected runner (zero-config default)
77
80
  */
78
81
  export declare function resolveCIRunners(projectDir: string, options?: ResolveRunnerOptions): Promise<ResolvedRunners>;
79
- 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
+ /**
84
+ * Execute a single gate command HOST-NATIVE via `bash -c`, at the repo root,
85
+ * with the provided env MAP. Modeled on `runSemgrep`/`runGhagga` (a spawned
86
+ * process, NOT `runStep`'s Docker branch — gates have no runner or image).
87
+ *
88
+ * RESOLVES the child exit code (it does NOT throw on a non-zero exit) so the
89
+ * collector and the JSON `exitCode` field are populatable. A spawn error (e.g.
90
+ * `bash` missing) rejects, and the collector treats that as a failure.
91
+ *
92
+ * SIGNAL DEATH IS A FAILURE, NEVER A FALSE-GREEN: when the child is terminated
93
+ * by a signal (OOM kill, SIGSEGV/SIGABRT, external SIGTERM) `close` reports a
94
+ * NULL code. Mapping that to 0 would report a signal-killed blocking gate as
95
+ * `done` and let the build PASS — the exact false-green this phase exists to
96
+ * eliminate. A null code therefore resolves to a NON-ZERO code using the shell
97
+ * convention `128 + <signal number>` when the signal is resolvable, else 1.
98
+ *
99
+ * Env values arrive as discrete map entries — never string-spliced into the
100
+ * `bash -c` command — so metacharacters in a value cannot break out of the shell.
101
+ */
102
+ export declare function runGateNative(cmd: string, cwd: string, env: Record<string, string>): Promise<number>;
103
+ /**
104
+ * A single gate's structured result, collected for the headless JSON run path.
105
+ * Mirrors the `{ id, mode, scope, status, blocking, changedFiles?, exitCode? }`
106
+ * JSON shape.
107
+ */
108
+ export interface GateOutcome {
109
+ id: string;
110
+ mode: GateMode;
111
+ scope: GateScope;
112
+ status: CIStepStatus;
113
+ /** `true` when `mode === blocking` — an errored blocking gate drives `ok:false`. */
114
+ blocking: boolean;
115
+ /** The changed-file set a scope:changed gate saw (present only when resolved). */
116
+ changedFiles?: string[];
117
+ /** First non-zero command code for a failed gate. */
118
+ exitCode?: number;
119
+ /**
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.
124
+ */
125
+ reason?: string;
126
+ }
127
+ /** Structured result of a headless (`--json`) gate run. */
128
+ export interface HeadlessGateResult {
129
+ /** `false` iff a BLOCKING gate errored; informative failures keep it `true`. */
130
+ ok: boolean;
131
+ gates: GateOutcome[];
132
+ /** The process exit code to set explicitly (1 on a blocking failure or crash). */
133
+ exitCode: number;
134
+ }
135
+ /**
136
+ * Drive `runCI` headlessly (no Ink render), collecting each gate's structured
137
+ * outcome for the `--json` run path. `runCI` throws on a blocking gate failure
138
+ * (and on any non-gate error); the outcomes are captured regardless via the
139
+ * `onOutcome` callback, so the JSON is always complete.
140
+ *
141
+ * `ok` is `false` iff a BLOCKING gate errored (spec contract); informative
142
+ * failures keep `ok:true`. `exitCode` is `1` when a blocking gate errored OR
143
+ * `runCI` threw for any other reason (a real crash still exits non-zero), else
144
+ * `0`. The caller (dispatch) prints `{ ok, gates }` and sets `process.exitCode`.
145
+ */
146
+ export declare function collectGateOutcomes(options: CIOptions): Promise<HeadlessGateResult>;
80
147
  /**
81
148
  * Classification of an existing `.git/hooks/<name>` before anything is written
82
149
  * (design D6). Every state has exactly one write policy, so no hook is ever
@@ -2,13 +2,15 @@ import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { constants } from "node:fs";
4
4
  import fsp from "node:fs/promises";
5
+ import os from "node:os";
5
6
  import path from "node:path";
6
7
  import fs from "fs-extra";
7
8
  import { HOOK_ASSETS_DIR } from "../constants.js";
8
- import { CI_STACKS, findCIConfig, loadCIConfig, } from "../lib/ci-config.js";
9
+ import { CI_STACKS, findCIConfig, GATE_MODE, GATE_SCOPE, loadCIConfig, } from "../lib/ci-config.js";
9
10
  import { refreshContextDir } from "../lib/context.js";
10
11
  import { ensureImage, isDockerAvailable, openShell, runInContainer, } from "../lib/docker.js";
11
12
  import { execFileAsync } from "../lib/exec.js";
13
+ import { changedFiles, resolveBaseRef } from "../lib/git-diff.js";
12
14
  // =============================================================================
13
15
  // Stack detection
14
16
  // =============================================================================
@@ -148,8 +150,12 @@ function freezeRunner(runner) {
148
150
  requiredTools: Object.freeze([...runner.requiredTools]),
149
151
  });
150
152
  }
151
- function freezeRunners(source, runners) {
152
- return Object.freeze({ source, runners: Object.freeze(runners) });
153
+ function freezeRunners(source, runners, gates = []) {
154
+ return Object.freeze({
155
+ source,
156
+ runners: Object.freeze(runners),
157
+ gates: Object.freeze([...gates]),
158
+ });
153
159
  }
154
160
  /** Build-tool heuristic for a known stack in a given directory. */
155
161
  async function detectBuildTool(stack, dir) {
@@ -247,7 +253,7 @@ export async function resolveCIRunners(projectDir, options = {}) {
247
253
  for (const runnerConfig of ciConfig.runners) {
248
254
  runners.push(await resolveConfiguredRunner(projectDir, runnerConfig));
249
255
  }
250
- return freezeRunners("config", runners);
256
+ return freezeRunners("config", runners, ciConfig.gates ?? []);
251
257
  }
252
258
  // Zero-config default: single auto-detected runner (unchanged behavior).
253
259
  const info = await detectCIStack(projectDir);
@@ -288,11 +294,12 @@ function report(onStep, id, label, status, detail) {
288
294
  /**
289
295
  * Detect-step label: legacy format for auto, explicit otherwise.
290
296
  *
291
- * INVARIANT (holds for every `ResolvedRunners` value): `runners` is never
292
- * empty. The config path rejects an empty list before resolving
293
- * (`src/lib/ci-config.ts` "runners is required and must be a non-empty
294
- * list"), and the `auto` and `stack-override` paths each yield exactly one
295
- * runner. `runners[0]` therefore needs no fallback.
297
+ * RUNNER COUNT: the `auto` and `stack-override` paths each yield exactly one
298
+ * runner, so their branches deref `runners[0]` safely. A gates-only v2 config
299
+ * (version 2, `runners` omitted) reaches here with ZERO runners on the `config`
300
+ * source; that branch never dereferences `runners[0]` it only reads
301
+ * `runners.length` and maps over the (possibly empty) list — so an empty list
302
+ * is handled without a fallback.
296
303
  */
297
304
  function describeRunners(resolved) {
298
305
  const first = resolved.runners[0];
@@ -307,7 +314,7 @@ function describeRunners(resolved) {
307
314
  .join(", ");
308
315
  return `Config: ${resolved.runners.length} runner(s) — ${summary}`;
309
316
  }
310
- export async function runCI(options, onStep) {
317
+ export async function runCI(options, onStep, onGateOutcome) {
311
318
  const { projectDir = process.cwd(), mode = "full", noDocker = false, noGhagga = false, noSecurity = false, timeout = 600, } = options;
312
319
  // ── Resolve runners (once — nothing downstream re-detects) ─────────────────
313
320
  const stepDetect = "detect";
@@ -324,9 +331,24 @@ export async function runCI(options, onStep) {
324
331
  report(onStep, stepDetect, "Detecting stack", "error", String(e));
325
332
  throw e;
326
333
  }
334
+ // ── Gates-only v2 repo (zero runners) ──────────────────────────────────────
335
+ // A `version: 2` config MAY declare `gates:` with NO `runners`. Such a repo
336
+ // has no stack to detect, no image to build, and no runner loop, so the
337
+ // runner prologue below (which dereferences `resolved.runners[0]`) must be
338
+ // skipped entirely. `detect`/`shell` have nothing to target → named error;
339
+ // `full`/`quick` jump straight to the gate phase and return.
340
+ if (resolved.runners.length === 0) {
341
+ if (mode === "detect" || mode === "shell") {
342
+ const detail = "no runners resolved — nothing to detect or shell into";
343
+ report(onStep, mode, `${mode} mode`, "error", detail);
344
+ throw new Error(`no runners resolved — ${mode} mode requires at least one runner`);
345
+ }
346
+ await runGates(resolved.gates, projectDir, onStep, onGateOutcome);
347
+ return;
348
+ }
327
349
  // Legacy single-runner view for the zero-config auto path. Keeping this
328
350
  // shape guarantees single-stack repositories behave exactly as before.
329
- // `runners[0]` is always present — see the invariant on `describeRunners`.
351
+ // `runners[0]` is always present here — the zero-runner case returned above.
330
352
  // The command lists CAN be empty, so those keep their `?? null`.
331
353
  const primary = resolved.runners[0];
332
354
  const stackInfo = {
@@ -487,6 +509,14 @@ export async function runCI(options, onStep) {
487
509
  report(onStep, stepGhagga, "GHAGGA review", "skipped", "ghagga not installed");
488
510
  }
489
511
  }
512
+ // ── Gate phase (full || quick — skipped in detect/shell) ────────────────────
513
+ // Gates run on every REAL CI run, including `--quick` (the pre-push path where
514
+ // a blocking gate matters most). `detect`/`shell` return earlier, so mode is
515
+ // already `full` or `quick` here; the guard makes the contract explicit.
516
+ // `runGates` no-ops on an empty gate list (a v1 repo carries none).
517
+ if (mode === "full" || mode === "quick") {
518
+ await runGates(resolved.gates, projectDir, onStep, onGateOutcome);
519
+ }
490
520
  }
491
521
  // =============================================================================
492
522
  // Step runners
@@ -716,6 +746,234 @@ async function runGhagga(projectDir) {
716
746
  });
717
747
  }
718
748
  // =============================================================================
749
+ // Gate phase (version 2) — host-native, repo-level
750
+ // =============================================================================
751
+ /** Drop `undefined` entries so `process.env` fits the spawn env-map contract. */
752
+ function filterDefinedEnv(env) {
753
+ const out = {};
754
+ for (const [key, value] of Object.entries(env)) {
755
+ if (value !== undefined)
756
+ out[key] = value;
757
+ }
758
+ return out;
759
+ }
760
+ /**
761
+ * Execute a single gate command HOST-NATIVE via `bash -c`, at the repo root,
762
+ * with the provided env MAP. Modeled on `runSemgrep`/`runGhagga` (a spawned
763
+ * process, NOT `runStep`'s Docker branch — gates have no runner or image).
764
+ *
765
+ * RESOLVES the child exit code (it does NOT throw on a non-zero exit) so the
766
+ * collector and the JSON `exitCode` field are populatable. A spawn error (e.g.
767
+ * `bash` missing) rejects, and the collector treats that as a failure.
768
+ *
769
+ * SIGNAL DEATH IS A FAILURE, NEVER A FALSE-GREEN: when the child is terminated
770
+ * by a signal (OOM kill, SIGSEGV/SIGABRT, external SIGTERM) `close` reports a
771
+ * NULL code. Mapping that to 0 would report a signal-killed blocking gate as
772
+ * `done` and let the build PASS — the exact false-green this phase exists to
773
+ * eliminate. A null code therefore resolves to a NON-ZERO code using the shell
774
+ * convention `128 + <signal number>` when the signal is resolvable, else 1.
775
+ *
776
+ * Env values arrive as discrete map entries — never string-spliced into the
777
+ * `bash -c` command — so metacharacters in a value cannot break out of the shell.
778
+ */
779
+ export async function runGateNative(cmd, cwd, env) {
780
+ return await new Promise((resolve, reject) => {
781
+ const proc = spawn("bash", ["-c", cmd], { cwd, env, stdio: "inherit" });
782
+ proc.on("close", (code, signal) => {
783
+ if (code !== null) {
784
+ resolve(code);
785
+ return;
786
+ }
787
+ // Signal death: map to a non-zero code so the collector records a
788
+ // blocking failure. `128 + signum` mirrors the shell; fall back to 1
789
+ // when the signal name is not resolvable.
790
+ const signum = signal ? os.constants.signals[signal] : undefined;
791
+ resolve(signum !== undefined ? 128 + signum : 1);
792
+ });
793
+ proc.on("error", reject);
794
+ });
795
+ }
796
+ /**
797
+ * Env var carrying a scope:changed gate's newline-joined, root-relative paths.
798
+ *
799
+ * KNOWN LIMITATION (JDB-103): the list is newline-joined, so a path that itself
800
+ * contains a literal `\n` (git can emit such a path when `core.quotePath` is off)
801
+ * would corrupt line-based parsing on the gate side. This is a low-likelihood
802
+ * edge — repo paths with embedded newlines are pathological — and is accepted as
803
+ * a documented caveat rather than switched to NUL-joining, which would force
804
+ * every gate consumer to change its parser.
805
+ */
806
+ const CHANGED_FILES_ENV = "JAVI_FORGE_CHANGED_FILES";
807
+ /** Env var carrying a gate's optional baseline artifact path. */
808
+ const BASELINE_ENV = "JAVI_FORGE_BASELINE";
809
+ /**
810
+ * Repo-level gate phase. Each gate runs host-native via `runGateNative` at the
811
+ * repo root. Outcome semantics:
812
+ * - exit 0 → `done`
813
+ * - non-zero/spawn error, blocking → `error`, gate id recorded (NOT re-thrown)
814
+ * - non-zero/spawn error, informative → `warning`, build never fails
815
+ *
816
+ * A blocking failure does NOT abort the phase — every gate reports its own
817
+ * status first. After the loop, if ANY blocking gate failed, ONE aggregate
818
+ * error is thrown so a single blocking failure never hides a later gate's
819
+ * result (the top-level catch yields exit 1). Informative failures never
820
+ * contribute to the accumulator, so the exit code stays 0.
821
+ *
822
+ * 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.
824
+ *
825
+ * `scope: changed` consumes the injectable `git-diff.ts` engine: the base ref is
826
+ * resolved and the changed set computed ONCE, then shared. A non-empty set runs
827
+ * the gate with `$JAVI_FORGE_CHANGED_FILES` (newline-joined, root-relative); an
828
+ * empty set skips the gate; a null base OR a `changedFiles` throw skips every
829
+ * scope:changed gate with a named warning (loud-degrade, never widens, never
830
+ * crashes). `baseline` is injected as `$JAVI_FORGE_BASELINE`. Gate `env` spreads
831
+ * LAST (documented last-wins over the engine-injected keys).
832
+ *
833
+ * `onOutcome` (optional) receives each gate's structured result for the headless
834
+ * JSON run path.
835
+ */
836
+ async function runGates(gates, projectDir, onStep, onOutcome) {
837
+ if (gates.length === 0)
838
+ return;
839
+ const blockingFailures = [];
840
+ const baseEnv = {
841
+ ...filterDefinedEnv(process.env),
842
+ CI: "true",
843
+ };
844
+ // Lazily resolved and memoized — computed only when a scope:changed gate
845
+ // exists, and only once (shared across all scope:changed gates).
846
+ let changedScope = null;
847
+ const resolveChangedScope = async () => {
848
+ if (changedScope !== null)
849
+ return changedScope;
850
+ const base = await resolveBaseRef(process.env, projectDir);
851
+ if (base === null) {
852
+ changedScope = {
853
+ kind: "skip",
854
+ reason: "no base ref resolved — skipping scope:changed",
855
+ };
856
+ return changedScope;
857
+ }
858
+ try {
859
+ changedScope = {
860
+ kind: "files",
861
+ files: await changedFiles(base, projectDir),
862
+ };
863
+ }
864
+ catch {
865
+ // Shallow clone / base sha absent from local history: caught here so the
866
+ // throw never aborts the phase and the gate never widens to scope:all.
867
+ changedScope = {
868
+ kind: "skip",
869
+ reason: "changed-file diff failed (shallow clone / missing ref) — skipping scope:changed",
870
+ };
871
+ }
872
+ return changedScope;
873
+ };
874
+ for (const gate of gates) {
875
+ const stepId = `gate:${gate.id}`;
876
+ const label = `Gate: ${gate.id}`;
877
+ const blocking = gate.mode === GATE_MODE.BLOCKING;
878
+ const emit = (status, extra) => onOutcome?.({
879
+ id: gate.id,
880
+ mode: gate.mode,
881
+ scope: gate.scope,
882
+ status,
883
+ blocking,
884
+ ...extra,
885
+ });
886
+ report(onStep, stepId, label, "running");
887
+ // Per-gate env map: engine keys, then baseline, then gate.env LAST (last-wins).
888
+ const gateEnv = { ...baseEnv };
889
+ let gateChangedFiles;
890
+ if (gate.scope === GATE_SCOPE.CHANGED) {
891
+ const scope = await resolveChangedScope();
892
+ if (scope.kind === "skip") {
893
+ report(onStep, stepId, `${label} skipped`, "skipped", scope.reason);
894
+ // Loud-degrade: carry the named reason into the JSON outcome too, not
895
+ // only the Ink `onStep` stream (which is a no-op under --json).
896
+ emit("skipped", { reason: scope.reason });
897
+ continue;
898
+ }
899
+ if (scope.files.length === 0) {
900
+ const noChanges = "no changed files";
901
+ report(onStep, stepId, `${label} skipped`, "skipped", noChanges);
902
+ emit("skipped", { changedFiles: [], reason: noChanges });
903
+ continue;
904
+ }
905
+ gateChangedFiles = scope.files;
906
+ gateEnv[CHANGED_FILES_ENV] = scope.files.join("\n");
907
+ }
908
+ if (gate.baseline !== undefined) {
909
+ gateEnv[BASELINE_ENV] = gate.baseline;
910
+ }
911
+ if (gate.env !== undefined) {
912
+ // Gate env spreads LAST — a gate MAY override CI / CHANGED_FILES / BASELINE.
913
+ Object.assign(gateEnv, gate.env);
914
+ }
915
+ let exitCode = 0;
916
+ let spawnError;
917
+ try {
918
+ for (const cmd of gate.run) {
919
+ exitCode = await runGateNative(cmd, projectDir, gateEnv);
920
+ if (exitCode !== 0)
921
+ break; // fail-fast: skip the remaining commands
922
+ }
923
+ }
924
+ catch (e) {
925
+ spawnError = e;
926
+ }
927
+ if (spawnError === undefined && exitCode === 0) {
928
+ report(onStep, stepId, `${label} passed`, "done");
929
+ emit("done", { changedFiles: gateChangedFiles });
930
+ continue;
931
+ }
932
+ const detail = spawnError !== undefined ? String(spawnError) : `exit ${exitCode}`;
933
+ if (blocking) {
934
+ blockingFailures.push(gate.id);
935
+ report(onStep, stepId, `${label} failed`, "error", detail);
936
+ emit("error", { changedFiles: gateChangedFiles, exitCode });
937
+ }
938
+ else {
939
+ report(onStep, stepId, `${label} failed (informative)`, "warning", detail);
940
+ emit("warning", { changedFiles: gateChangedFiles, exitCode });
941
+ }
942
+ }
943
+ if (blockingFailures.length > 0) {
944
+ throw new Error(`blocking gate(s) failed: ${blockingFailures.join(", ")}`);
945
+ }
946
+ }
947
+ /**
948
+ * Drive `runCI` headlessly (no Ink render), collecting each gate's structured
949
+ * outcome for the `--json` run path. `runCI` throws on a blocking gate failure
950
+ * (and on any non-gate error); the outcomes are captured regardless via the
951
+ * `onOutcome` callback, so the JSON is always complete.
952
+ *
953
+ * `ok` is `false` iff a BLOCKING gate errored (spec contract); informative
954
+ * failures keep `ok:true`. `exitCode` is `1` when a blocking gate errored OR
955
+ * `runCI` threw for any other reason (a real crash still exits non-zero), else
956
+ * `0`. The caller (dispatch) prints `{ ok, gates }` and sets `process.exitCode`.
957
+ */
958
+ export async function collectGateOutcomes(options) {
959
+ const gates = [];
960
+ let threw = false;
961
+ try {
962
+ await runCI(options, () => { }, (outcome) => gates.push(outcome));
963
+ }
964
+ catch {
965
+ // Blocking failure (aggregate throw) or a non-gate error — outcomes are
966
+ // already captured; the headless path never propagates the throw.
967
+ threw = true;
968
+ }
969
+ const blockingErrored = gates.some((g) => g.blocking && g.status === "error");
970
+ return {
971
+ ok: !blockingErrored,
972
+ gates,
973
+ exitCode: blockingErrored || threw ? 1 : 0,
974
+ };
975
+ }
976
+ // =============================================================================
719
977
  // CI Hooks Installation
720
978
  // =============================================================================
721
979
  /**
@@ -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/dist/ui/CI.js CHANGED
@@ -13,6 +13,7 @@ const STATUS_ICON = {
13
13
  done: "✓",
14
14
  error: "✗",
15
15
  skipped: "–",
16
+ warning: "⚠",
16
17
  };
17
18
  const STATUS_COLOR = {
18
19
  pending: theme.muted,
@@ -20,6 +21,7 @@ const STATUS_COLOR = {
20
21
  done: theme.success,
21
22
  error: theme.error,
22
23
  skipped: theme.muted,
24
+ warning: theme.warning,
23
25
  };
24
26
  // =============================================================================
25
27
  // Component
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {