javi-forge 1.12.0 → 1.13.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/commands/ci.d.ts +24 -1
- package/dist/commands/ci.js +150 -10
- package/dist/ui/CI.js +2 -0
- package/package.json +1 -1
package/dist/commands/ci.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type CIGateConfig } 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 */
|
|
@@ -77,6 +80,26 @@ export interface ResolveRunnerOptions {
|
|
|
77
80
|
*/
|
|
78
81
|
export declare function resolveCIRunners(projectDir: string, options?: ResolveRunnerOptions): Promise<ResolvedRunners>;
|
|
79
82
|
export declare function runCI(options: CIOptions, onStep: CIStepCallback): 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>;
|
|
80
103
|
/**
|
|
81
104
|
* Classification of an existing `.git/hooks/<name>` before anything is written
|
|
82
105
|
* (design D6). Every state has exactly one write policy, so no hook is ever
|
package/dist/commands/ci.js
CHANGED
|
@@ -2,10 +2,11 @@ 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, 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";
|
|
@@ -148,8 +149,12 @@ function freezeRunner(runner) {
|
|
|
148
149
|
requiredTools: Object.freeze([...runner.requiredTools]),
|
|
149
150
|
});
|
|
150
151
|
}
|
|
151
|
-
function freezeRunners(source, runners) {
|
|
152
|
-
return Object.freeze({
|
|
152
|
+
function freezeRunners(source, runners, gates = []) {
|
|
153
|
+
return Object.freeze({
|
|
154
|
+
source,
|
|
155
|
+
runners: Object.freeze(runners),
|
|
156
|
+
gates: Object.freeze([...gates]),
|
|
157
|
+
});
|
|
153
158
|
}
|
|
154
159
|
/** Build-tool heuristic for a known stack in a given directory. */
|
|
155
160
|
async function detectBuildTool(stack, dir) {
|
|
@@ -247,7 +252,7 @@ export async function resolveCIRunners(projectDir, options = {}) {
|
|
|
247
252
|
for (const runnerConfig of ciConfig.runners) {
|
|
248
253
|
runners.push(await resolveConfiguredRunner(projectDir, runnerConfig));
|
|
249
254
|
}
|
|
250
|
-
return freezeRunners("config", runners);
|
|
255
|
+
return freezeRunners("config", runners, ciConfig.gates ?? []);
|
|
251
256
|
}
|
|
252
257
|
// Zero-config default: single auto-detected runner (unchanged behavior).
|
|
253
258
|
const info = await detectCIStack(projectDir);
|
|
@@ -288,11 +293,12 @@ function report(onStep, id, label, status, detail) {
|
|
|
288
293
|
/**
|
|
289
294
|
* Detect-step label: legacy format for auto, explicit otherwise.
|
|
290
295
|
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
* (`
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
+
* RUNNER COUNT: the `auto` and `stack-override` paths each yield exactly one
|
|
297
|
+
* runner, so their branches deref `runners[0]` safely. A gates-only v2 config
|
|
298
|
+
* (version 2, `runners` omitted) reaches here with ZERO runners on the `config`
|
|
299
|
+
* source; that branch never dereferences `runners[0]` — it only reads
|
|
300
|
+
* `runners.length` and maps over the (possibly empty) list — so an empty list
|
|
301
|
+
* is handled without a fallback.
|
|
296
302
|
*/
|
|
297
303
|
function describeRunners(resolved) {
|
|
298
304
|
const first = resolved.runners[0];
|
|
@@ -324,9 +330,24 @@ export async function runCI(options, onStep) {
|
|
|
324
330
|
report(onStep, stepDetect, "Detecting stack", "error", String(e));
|
|
325
331
|
throw e;
|
|
326
332
|
}
|
|
333
|
+
// ── Gates-only v2 repo (zero runners) ──────────────────────────────────────
|
|
334
|
+
// A `version: 2` config MAY declare `gates:` with NO `runners`. Such a repo
|
|
335
|
+
// has no stack to detect, no image to build, and no runner loop, so the
|
|
336
|
+
// runner prologue below (which dereferences `resolved.runners[0]`) must be
|
|
337
|
+
// skipped entirely. `detect`/`shell` have nothing to target → named error;
|
|
338
|
+
// `full`/`quick` jump straight to the gate phase and return.
|
|
339
|
+
if (resolved.runners.length === 0) {
|
|
340
|
+
if (mode === "detect" || mode === "shell") {
|
|
341
|
+
const detail = "no runners resolved — nothing to detect or shell into";
|
|
342
|
+
report(onStep, mode, `${mode} mode`, "error", detail);
|
|
343
|
+
throw new Error(`no runners resolved — ${mode} mode requires at least one runner`);
|
|
344
|
+
}
|
|
345
|
+
await runGates(resolved.gates, projectDir, onStep);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
327
348
|
// Legacy single-runner view for the zero-config auto path. Keeping this
|
|
328
349
|
// shape guarantees single-stack repositories behave exactly as before.
|
|
329
|
-
// `runners[0]` is always present —
|
|
350
|
+
// `runners[0]` is always present here — the zero-runner case returned above.
|
|
330
351
|
// The command lists CAN be empty, so those keep their `?? null`.
|
|
331
352
|
const primary = resolved.runners[0];
|
|
332
353
|
const stackInfo = {
|
|
@@ -487,6 +508,14 @@ export async function runCI(options, onStep) {
|
|
|
487
508
|
report(onStep, stepGhagga, "GHAGGA review", "skipped", "ghagga not installed");
|
|
488
509
|
}
|
|
489
510
|
}
|
|
511
|
+
// ── Gate phase (full || quick — skipped in detect/shell) ────────────────────
|
|
512
|
+
// Gates run on every REAL CI run, including `--quick` (the pre-push path where
|
|
513
|
+
// a blocking gate matters most). `detect`/`shell` return earlier, so mode is
|
|
514
|
+
// already `full` or `quick` here; the guard makes the contract explicit.
|
|
515
|
+
// `runGates` no-ops on an empty gate list (a v1 repo carries none).
|
|
516
|
+
if (mode === "full" || mode === "quick") {
|
|
517
|
+
await runGates(resolved.gates, projectDir, onStep);
|
|
518
|
+
}
|
|
490
519
|
}
|
|
491
520
|
// =============================================================================
|
|
492
521
|
// Step runners
|
|
@@ -716,6 +745,117 @@ async function runGhagga(projectDir) {
|
|
|
716
745
|
});
|
|
717
746
|
}
|
|
718
747
|
// =============================================================================
|
|
748
|
+
// Gate phase (version 2) — host-native, repo-level
|
|
749
|
+
// =============================================================================
|
|
750
|
+
/** Drop `undefined` entries so `process.env` fits the spawn env-map contract. */
|
|
751
|
+
function filterDefinedEnv(env) {
|
|
752
|
+
const out = {};
|
|
753
|
+
for (const [key, value] of Object.entries(env)) {
|
|
754
|
+
if (value !== undefined)
|
|
755
|
+
out[key] = value;
|
|
756
|
+
}
|
|
757
|
+
return out;
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Execute a single gate command HOST-NATIVE via `bash -c`, at the repo root,
|
|
761
|
+
* with the provided env MAP. Modeled on `runSemgrep`/`runGhagga` (a spawned
|
|
762
|
+
* process, NOT `runStep`'s Docker branch — gates have no runner or image).
|
|
763
|
+
*
|
|
764
|
+
* RESOLVES the child exit code (it does NOT throw on a non-zero exit) so the
|
|
765
|
+
* collector and the JSON `exitCode` field are populatable. A spawn error (e.g.
|
|
766
|
+
* `bash` missing) rejects, and the collector treats that as a failure.
|
|
767
|
+
*
|
|
768
|
+
* SIGNAL DEATH IS A FAILURE, NEVER A FALSE-GREEN: when the child is terminated
|
|
769
|
+
* by a signal (OOM kill, SIGSEGV/SIGABRT, external SIGTERM) `close` reports a
|
|
770
|
+
* NULL code. Mapping that to 0 would report a signal-killed blocking gate as
|
|
771
|
+
* `done` and let the build PASS — the exact false-green this phase exists to
|
|
772
|
+
* eliminate. A null code therefore resolves to a NON-ZERO code using the shell
|
|
773
|
+
* convention `128 + <signal number>` when the signal is resolvable, else 1.
|
|
774
|
+
*
|
|
775
|
+
* Env values arrive as discrete map entries — never string-spliced into the
|
|
776
|
+
* `bash -c` command — so metacharacters in a value cannot break out of the shell.
|
|
777
|
+
*/
|
|
778
|
+
export async function runGateNative(cmd, cwd, env) {
|
|
779
|
+
return await new Promise((resolve, reject) => {
|
|
780
|
+
const proc = spawn("bash", ["-c", cmd], { cwd, env, stdio: "inherit" });
|
|
781
|
+
proc.on("close", (code, signal) => {
|
|
782
|
+
if (code !== null) {
|
|
783
|
+
resolve(code);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
// Signal death: map to a non-zero code so the collector records a
|
|
787
|
+
// blocking failure. `128 + signum` mirrors the shell; fall back to 1
|
|
788
|
+
// when the signal name is not resolvable.
|
|
789
|
+
const signum = signal ? os.constants.signals[signal] : undefined;
|
|
790
|
+
resolve(signum !== undefined ? 128 + signum : 1);
|
|
791
|
+
});
|
|
792
|
+
proc.on("error", reject);
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Repo-level gate phase. Each gate runs host-native via `runGateNative` at the
|
|
797
|
+
* repo root. Outcome semantics:
|
|
798
|
+
* - exit 0 → `done`
|
|
799
|
+
* - non-zero/spawn error, blocking → `error`, gate id recorded (NOT re-thrown)
|
|
800
|
+
* - non-zero/spawn error, informative → `warning`, build never fails
|
|
801
|
+
*
|
|
802
|
+
* A blocking failure does NOT abort the phase — every gate reports its own
|
|
803
|
+
* status first. After the loop, if ANY blocking gate failed, ONE aggregate
|
|
804
|
+
* error is thrown so a single blocking failure never hides a later gate's
|
|
805
|
+
* result (the top-level catch yields exit 1). Informative failures never
|
|
806
|
+
* contribute to the accumulator, so the exit code stays 0.
|
|
807
|
+
*
|
|
808
|
+
* 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.
|
|
810
|
+
*
|
|
811
|
+
* Slice 3 wires `mode` and `scope: all` gates with the engine-injected env
|
|
812
|
+
* (`CI=true`). `scope: changed` wiring, `baseline`, and gate `env` injection
|
|
813
|
+
* land in slice 4.
|
|
814
|
+
*/
|
|
815
|
+
async function runGates(gates, projectDir, onStep) {
|
|
816
|
+
if (gates.length === 0)
|
|
817
|
+
return;
|
|
818
|
+
const blockingFailures = [];
|
|
819
|
+
// Engine-injected keys only in slice 3; gate `env` + JAVI_FORGE_CHANGED_FILES
|
|
820
|
+
// are added in slice 4.
|
|
821
|
+
const baseEnv = {
|
|
822
|
+
...filterDefinedEnv(process.env),
|
|
823
|
+
CI: "true",
|
|
824
|
+
};
|
|
825
|
+
for (const gate of gates) {
|
|
826
|
+
const stepId = `gate:${gate.id}`;
|
|
827
|
+
const label = `Gate: ${gate.id}`;
|
|
828
|
+
report(onStep, stepId, label, "running");
|
|
829
|
+
let exitCode = 0;
|
|
830
|
+
let spawnError;
|
|
831
|
+
try {
|
|
832
|
+
for (const cmd of gate.run) {
|
|
833
|
+
exitCode = await runGateNative(cmd, projectDir, baseEnv);
|
|
834
|
+
if (exitCode !== 0)
|
|
835
|
+
break; // fail-fast: skip the remaining commands
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
catch (e) {
|
|
839
|
+
spawnError = e;
|
|
840
|
+
}
|
|
841
|
+
if (spawnError === undefined && exitCode === 0) {
|
|
842
|
+
report(onStep, stepId, `${label} passed`, "done");
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
const detail = spawnError !== undefined ? String(spawnError) : `exit ${exitCode}`;
|
|
846
|
+
if (gate.mode === GATE_MODE.BLOCKING) {
|
|
847
|
+
blockingFailures.push(gate.id);
|
|
848
|
+
report(onStep, stepId, `${label} failed`, "error", detail);
|
|
849
|
+
}
|
|
850
|
+
else {
|
|
851
|
+
report(onStep, stepId, `${label} failed (informative)`, "warning", detail);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (blockingFailures.length > 0) {
|
|
855
|
+
throw new Error(`blocking gate(s) failed: ${blockingFailures.join(", ")}`);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
// =============================================================================
|
|
719
859
|
// CI Hooks Installation
|
|
720
860
|
// =============================================================================
|
|
721
861
|
/**
|
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
|