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.
- package/dist/cli/dispatch/ci.js +27 -0
- package/dist/commands/ci.d.ts +69 -2
- package/dist/commands/ci.js +269 -11
- package/dist/lib/git-diff.d.ts +4 -1
- package/dist/lib/git-diff.js +14 -2
- package/dist/ui/CI.js +2 -0
- 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,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
|
package/dist/commands/ci.js
CHANGED
|
@@ -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({
|
|
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
|
-
*
|
|
292
|
-
*
|
|
293
|
-
* (`
|
|
294
|
-
*
|
|
295
|
-
*
|
|
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 —
|
|
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
|
/**
|
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
|
}
|
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
|