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.
@@ -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
@@ -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({ source, runners: Object.freeze(runners) });
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
- * 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.
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 — see the invariant on `describeRunners`.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {