javi-forge 1.15.0 → 1.16.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.
@@ -50,7 +50,8 @@ export async function handleCi(cli, ctx) {
50
50
  if (gates.length > 0) {
51
51
  console.log(` ${gates.length} gate(s):`);
52
52
  for (const gate of gates) {
53
- console.log(` - ${gate.id} (${gate.mode}, scope: ${gate.scope})`);
53
+ const image = gate.image !== undefined ? `, image: ${gate.image}` : "";
54
+ console.log(` - ${gate.id} (${gate.mode}, scope: ${gate.scope}${image})`);
54
55
  }
55
56
  }
56
57
  }
@@ -17,6 +17,8 @@ export interface CIValidateGateSummary {
17
17
  id: string;
18
18
  mode: string;
19
19
  scope: string;
20
+ /** Container image ref, present ONLY when the gate declares one. */
21
+ image?: string;
20
22
  }
21
23
  export interface CIValidateOk {
22
24
  ok: true;
@@ -59,6 +59,9 @@ export async function validateCIConfig(projectDir, config) {
59
59
  id: g.id,
60
60
  mode: g.mode,
61
61
  scope: g.scope,
62
+ // Surface `image` ONLY when declared, so an image-less gate summary
63
+ // stays byte-identical to today (no `image` key).
64
+ ...(g.image !== undefined ? { image: g.image } : {}),
62
65
  })),
63
66
  };
64
67
  }
@@ -80,6 +80,18 @@ export interface ResolveRunnerOptions {
80
80
  */
81
81
  export declare function resolveCIRunners(projectDir: string, options?: ResolveRunnerOptions): Promise<ResolvedRunners>;
82
82
  export declare function runCI(options: CIOptions, onStep: CIStepCallback, onGateOutcome?: (outcome: GateOutcome) => void): Promise<void>;
83
+ /**
84
+ * Result of a single host-native gate command. `code` is the resolved exit code
85
+ * (a timed-out command resolves the `timeout(1)` sentinel 124); `timedOut` is
86
+ * `true` IFF the internal wall-clock `killTimer` fired. The flag exists so a
87
+ * caller can tell a wall-clock timeout apart from a child that itself exits 124
88
+ * (a `curl` op-timeout, a nested `timeout(1)`, a script returning 124) — both
89
+ * carry `code: 124`, but only a real timeout carries `timedOut: true` (R3-004).
90
+ */
91
+ export interface GateRunResult {
92
+ code: number;
93
+ timedOut: boolean;
94
+ }
83
95
  /**
84
96
  * Execute a single gate command HOST-NATIVE via `bash -c`, at the repo root,
85
97
  * with the provided env MAP. Modeled on `runSemgrep`/`runGhagga` (a spawned
@@ -112,7 +124,7 @@ export declare function runCI(options: CIOptions, onStep: CIStepCallback, onGate
112
124
  * Env values arrive as discrete map entries — never string-spliced into the
113
125
  * `bash -c` command — so metacharacters in a value cannot break out of the shell.
114
126
  */
115
- export declare function runGateNative(cmd: string, cwd: string, env: Record<string, string>, timeoutSec?: number): Promise<number>;
127
+ export declare function runGateNative(cmd: string, cwd: string, env: Record<string, string>, timeoutSec?: number): Promise<GateRunResult>;
116
128
  /**
117
129
  * A single gate's structured result, collected for the headless JSON run path.
118
130
  * Mirrors the `{ id, mode, scope, status, blocking, changedFiles?, exitCode? }`
@@ -130,10 +142,12 @@ export interface GateOutcome {
130
142
  /** First non-zero command code for a failed gate. */
131
143
  exitCode?: number;
132
144
  /**
133
- * Human-readable cause of a degrade/skip, surfaced so the headless JSON
134
- * consumer sees the degrade LOUDLY — not just in the Ink stream. Populated for
135
- * the scope:changed skip variants: base ref null, changed-file resolution
136
- * failure (shallow clone / missing ref), and the empty changed-set skip.
145
+ * Human-readable cause of a degrade/skip/timeout, surfaced so the headless
146
+ * JSON consumer sees it LOUDLY — not just in the Ink stream. Populated for the
147
+ * scope:changed skip variants (base ref null, changed-file resolution failure
148
+ * under a shallow clone / missing ref, empty changed-set skip) AND for a gate
149
+ * that timed out (so a 124 wall-clock kill is distinguishable from a command
150
+ * that itself exits 124).
137
151
  */
138
152
  reason?: string;
139
153
  }
@@ -822,19 +822,25 @@ export async function runGateNative(cmd, cwd, env, timeoutSec) {
822
822
  // INVARIANT: timedOut ⇒ non-zero, ALWAYS. Even a child that trapped the
823
823
  // SIGTERM and exited 0 before the SIGKILL escalation is a timeout, not a
824
824
  // pass. Resolve the GNU `timeout(1)` sentinel 124 ("command timed out"),
825
- // semantically distinct from a signal-death 143/137.
826
- resolve(GATE_TIMEOUT_EXIT_CODE);
825
+ // semantically distinct from a signal-death 143/137. The `timedOut` flag
826
+ // travels with the code so the caller can tell a wall-clock timeout apart
827
+ // from a child that itself exits 124 (both are 124, but only one is a
828
+ // timeout — R3-004 observability).
829
+ resolve({ code: GATE_TIMEOUT_EXIT_CODE, timedOut: true });
827
830
  return;
828
831
  }
829
832
  if (code !== null) {
830
- resolve(code);
833
+ resolve({ code, timedOut: false });
831
834
  return;
832
835
  }
833
836
  // Signal death: map to a non-zero code so the collector records a
834
837
  // blocking failure. `128 + signum` mirrors the shell; fall back to 1
835
838
  // when the signal name is not resolvable.
836
839
  const signum = signal ? os.constants.signals[signal] : undefined;
837
- resolve(signum !== undefined ? 128 + signum : 1);
840
+ resolve({
841
+ code: signum !== undefined ? 128 + signum : 1,
842
+ timedOut: false,
843
+ });
838
844
  });
839
845
  proc.on("error", (e) => {
840
846
  clearTimers();
@@ -974,13 +980,16 @@ async function runGates(gates, projectDir, onStep, onOutcome) {
974
980
  Object.assign(gateEnv, gate.env);
975
981
  }
976
982
  let exitCode = 0;
983
+ let timedOut = false;
977
984
  let spawnError;
978
985
  try {
979
986
  for (const cmd of gate.run) {
980
987
  // timeout is per-command (matches the fail-fast model): each command
981
988
  // gets its own wall-clock budget. A timed-out command is killed and
982
989
  // resolves non-zero, so fail-fast stops the gate here.
983
- exitCode = await runGateNative(cmd, projectDir, gateEnv, gate.timeout);
990
+ const result = await runGateNative(cmd, projectDir, gateEnv, gate.timeout);
991
+ exitCode = result.code;
992
+ timedOut = result.timedOut;
984
993
  if (exitCode !== 0)
985
994
  break; // fail-fast: skip the remaining commands
986
995
  }
@@ -993,15 +1002,31 @@ async function runGates(gates, projectDir, onStep, onOutcome) {
993
1002
  emit("done", { changedFiles: gateChangedFiles });
994
1003
  continue;
995
1004
  }
1005
+ // R3-004: a timed-out gate carries a `reason` naming the timeout so the
1006
+ // JSON/dashboard consumer can distinguish "this gate timed out (bump the
1007
+ // timeout)" from "the command failed with 124 (fix the command)". A
1008
+ // non-timeout failure leaves `reason` undefined. Never key on the 124 value
1009
+ // itself — that IS the ambiguity; only the real `timedOut` signal disambiguates.
1010
+ const timeoutReason = timedOut
1011
+ ? `timed out after ${gate.timeout}s`
1012
+ : undefined;
996
1013
  const detail = spawnError !== undefined ? String(spawnError) : `exit ${exitCode}`;
997
1014
  if (blocking) {
998
1015
  blockingFailures.push(gate.id);
999
1016
  report(onStep, stepId, `${label} failed`, "error", detail);
1000
- emit("error", { changedFiles: gateChangedFiles, exitCode });
1017
+ emit("error", {
1018
+ changedFiles: gateChangedFiles,
1019
+ exitCode,
1020
+ reason: timeoutReason,
1021
+ });
1001
1022
  }
1002
1023
  else {
1003
1024
  report(onStep, stepId, `${label} failed (informative)`, "warning", detail);
1004
- emit("warning", { changedFiles: gateChangedFiles, exitCode });
1025
+ emit("warning", {
1026
+ changedFiles: gateChangedFiles,
1027
+ exitCode,
1028
+ reason: timeoutReason,
1029
+ });
1005
1030
  }
1006
1031
  }
1007
1032
  if (blockingFailures.length > 0) {
@@ -60,6 +60,13 @@ export interface CIGateConfig {
60
60
  baseline?: string;
61
61
  /** Optional env injected via the child-process env map (slice 4). */
62
62
  env?: Record<string, string>;
63
+ /**
64
+ * Optional container image ref (containerized-gates). A plain,
65
+ * digest-pinnable image ref; when present the gate runs inside this image
66
+ * instead of host-native. Omitted → host-native (unchanged). Execution
67
+ * routing lands in a later slice; this slice validates the schema only.
68
+ */
69
+ image?: string;
63
70
  /**
64
71
  * Optional per-command wall-clock timeout in seconds (GATE-2). When set, a
65
72
  * command exceeding it is killed and the gate FAILS (non-zero). Omitted →
@@ -221,6 +221,7 @@ const GATE_FIELDS = new Set([
221
221
  "scope",
222
222
  "baseline",
223
223
  "env",
224
+ "image",
224
225
  "timeout",
225
226
  ]);
226
227
  function validateGate(raw, index, errors) {
@@ -318,6 +319,28 @@ function validateGate(raw, index, errors) {
318
319
  env = raw.env;
319
320
  }
320
321
  }
322
+ let image;
323
+ if (raw.image !== undefined) {
324
+ if (typeof raw.image !== "string" || !raw.image.trim()) {
325
+ errors.push({
326
+ path: `${base}.image`,
327
+ message: "image must be a non-empty string",
328
+ });
329
+ }
330
+ else if (raw.image.trim().startsWith("-")) {
331
+ // Harden against docker-flag injection: an image ref like "--privileged"
332
+ // or "-v /:/host" would be parsed by `docker run` as a FLAG, not an
333
+ // image argument, if it reached argv. Reject leading-dash refs at
334
+ // validation with a named error (JDB-004).
335
+ errors.push({
336
+ path: `${base}.image`,
337
+ message: "image must not start with '-' (would be parsed as a docker flag)",
338
+ });
339
+ }
340
+ else {
341
+ image = raw.image;
342
+ }
343
+ }
321
344
  let timeout;
322
345
  if (raw.timeout !== undefined) {
323
346
  if (typeof raw.timeout !== "number" ||
@@ -339,6 +362,7 @@ function validateGate(raw, index, errors) {
339
362
  scope,
340
363
  baseline,
341
364
  env,
365
+ image,
342
366
  timeout,
343
367
  };
344
368
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {