javi-forge 1.17.0 → 1.18.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.
@@ -316,6 +316,18 @@ function describeRunners(resolved) {
316
316
  }
317
317
  export async function runCI(options, onStep, onGateOutcome) {
318
318
  const { projectDir = process.cwd(), mode = "full", noDocker = false, noGhagga = false, noSecurity = false, timeout = 600, } = options;
319
+ // ── Run-scoped Docker availability (lazy-memoized) ─────────────────────────
320
+ // Computed at most ONCE per run and only when an image gate needs it. The
321
+ // full/quick prologue below assigns its own `isDockerAvailable()` result back
322
+ // into this cache so `runGates` never re-probes; the gates-only path leaves it
323
+ // undefined until an image gate lazily triggers the probe (a native-only
324
+ // gates-only repo never touches Docker — behaves exactly as before slice 3).
325
+ let dockerAvailableCache;
326
+ const dockerAvailable = async () => (dockerAvailableCache ??= await isDockerAvailable());
327
+ const dockerGate = {
328
+ noDocker,
329
+ isAvailable: dockerAvailable,
330
+ };
319
331
  // ── Resolve runners (once — nothing downstream re-detects) ─────────────────
320
332
  const stepDetect = "detect";
321
333
  report(onStep, stepDetect, "Detecting stack", "running");
@@ -343,7 +355,7 @@ export async function runCI(options, onStep, onGateOutcome) {
343
355
  report(onStep, mode, `${mode} mode`, "error", detail);
344
356
  throw new Error(`no runners resolved — ${mode} mode requires at least one runner`);
345
357
  }
346
- await runGates(resolved.gates, projectDir, onStep, onGateOutcome);
358
+ await runGates(resolved.gates, projectDir, onStep, dockerGate, onGateOutcome);
347
359
  return;
348
360
  }
349
361
  // Legacy single-runner view for the zero-config auto path. Keeping this
@@ -409,6 +421,9 @@ export async function runCI(options, onStep, onGateOutcome) {
409
421
  const stepDocker = "docker-check";
410
422
  report(onStep, stepDocker, "Checking Docker", "running");
411
423
  const dockerOk = await isDockerAvailable();
424
+ // Reuse this result for the gate fail-closed check — `runGates` must not
425
+ // run a second `docker info` (memoization seam).
426
+ dockerAvailableCache = dockerOk;
412
427
  if (!dockerOk) {
413
428
  report(onStep, stepDocker, "Docker not available", "error", "Start Docker or use --no-docker");
414
429
  throw new Error("Docker is not available");
@@ -515,7 +530,7 @@ export async function runCI(options, onStep, onGateOutcome) {
515
530
  // already `full` or `quick` here; the guard makes the contract explicit.
516
531
  // `runGates` no-ops on an empty gate list (a v1 repo carries none).
517
532
  if (mode === "full" || mode === "quick") {
518
- await runGates(resolved.gates, projectDir, onStep, onGateOutcome);
533
+ await runGates(resolved.gates, projectDir, onStep, dockerGate, onGateOutcome);
519
534
  }
520
535
  }
521
536
  // =============================================================================
@@ -938,7 +953,10 @@ async function runGateCommand(gate, cmd, projectDir, nativeEnv, containerEnv) {
938
953
  timedOut: result.timedOut,
939
954
  };
940
955
  }
941
- async function runGates(gates, projectDir, onStep, onOutcome) {
956
+ async function runGates(gates, projectDir, onStep,
957
+ // REQUIRED, so it MUST precede the optional `onOutcome` (TS1016: a required
958
+ // parameter cannot follow an optional one). Both call sites pass it positionally.
959
+ docker, onOutcome) {
942
960
  if (gates.length === 0)
943
961
  return;
944
962
  const blockingFailures = [];
@@ -989,6 +1007,33 @@ async function runGates(gates, projectDir, onStep, onOutcome) {
989
1007
  ...extra,
990
1008
  });
991
1009
  report(onStep, stepId, label, "running");
1010
+ // Fail-closed matrix (slice 3): an image gate that cannot reach Docker is
1011
+ // REFUSED — it MUST NOT fall through to native/unpinned execution and MUST
1012
+ // NOT be silently skipped/passed. Blocking → build failure (feeds the
1013
+ // aggregate throw); informative → `warning` (never a false-green). A gate
1014
+ // WITHOUT `image` is unaffected and runs native regardless of --no-docker.
1015
+ //
1016
+ // `isAvailable()` is touched ONLY for an image gate under Docker: the
1017
+ // `noDocker` short-circuit means an image-less set (or a --no-docker run)
1018
+ // never shells out to `docker info`, keeping the native path zero-cost.
1019
+ if (gate.image !== undefined) {
1020
+ if (docker.noDocker || !(await docker.isAvailable())) {
1021
+ const why = docker.noDocker
1022
+ ? "--no-docker set"
1023
+ : "Docker not available";
1024
+ const reason = `gate "${gate.id}" requires image "${gate.image}" but ${why} — refusing (never runs native/unpinned)`;
1025
+ if (blocking) {
1026
+ blockingFailures.push(gate.id);
1027
+ report(onStep, stepId, `${label} failed`, "error", reason);
1028
+ emit("error", { reason });
1029
+ }
1030
+ else {
1031
+ report(onStep, stepId, `${label} failed (informative)`, "warning", reason);
1032
+ emit("warning", { reason });
1033
+ }
1034
+ continue; // NEVER falls through to native execution.
1035
+ }
1036
+ }
992
1037
  // Per-gate env: build the INJECTED allowlist (engine keys + baseline) once,
993
1038
  // then split into two maps (JDB-001):
994
1039
  // - nativeEnv: full host env + injected + gate.env — a spawn env MAP (never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {