javi-forge 1.13.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.
@@ -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
  }
@@ -1,4 +1,4 @@
1
- import { type CIGateConfig } from "../lib/ci-config.js";
1
+ import { type CIGateConfig, type GateMode, type GateScope } from "../lib/ci-config.js";
2
2
  import type { Stack } from "../types/index.js";
3
3
  export type CIMode = "full" | "quick" | "shell" | "detect";
4
4
  export interface CIOptions {
@@ -79,7 +79,7 @@ export interface ResolveRunnerOptions {
79
79
  * 3. otherwise → single auto-detected runner (zero-config default)
80
80
  */
81
81
  export declare function resolveCIRunners(projectDir: string, options?: ResolveRunnerOptions): Promise<ResolvedRunners>;
82
- 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
83
  /**
84
84
  * Execute a single gate command HOST-NATIVE via `bash -c`, at the repo root,
85
85
  * with the provided env MAP. Modeled on `runSemgrep`/`runGhagga` (a spawned
@@ -100,6 +100,50 @@ export declare function runCI(options: CIOptions, onStep: CIStepCallback): Promi
100
100
  * `bash -c` command — so metacharacters in a value cannot break out of the shell.
101
101
  */
102
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>;
103
147
  /**
104
148
  * Classification of an existing `.git/hooks/<name>` before anything is written
105
149
  * (design D6). Every state has exactly one write policy, so no hook is ever
@@ -6,10 +6,11 @@ import os from "node:os";
6
6
  import path from "node:path";
7
7
  import fs from "fs-extra";
8
8
  import { HOOK_ASSETS_DIR } from "../constants.js";
9
- import { CI_STACKS, findCIConfig, GATE_MODE, loadCIConfig, } from "../lib/ci-config.js";
9
+ import { CI_STACKS, findCIConfig, GATE_MODE, GATE_SCOPE, loadCIConfig, } from "../lib/ci-config.js";
10
10
  import { refreshContextDir } from "../lib/context.js";
11
11
  import { ensureImage, isDockerAvailable, openShell, runInContainer, } from "../lib/docker.js";
12
12
  import { execFileAsync } from "../lib/exec.js";
13
+ import { changedFiles, resolveBaseRef } from "../lib/git-diff.js";
13
14
  // =============================================================================
14
15
  // Stack detection
15
16
  // =============================================================================
@@ -313,7 +314,7 @@ function describeRunners(resolved) {
313
314
  .join(", ");
314
315
  return `Config: ${resolved.runners.length} runner(s) — ${summary}`;
315
316
  }
316
- export async function runCI(options, onStep) {
317
+ export async function runCI(options, onStep, onGateOutcome) {
317
318
  const { projectDir = process.cwd(), mode = "full", noDocker = false, noGhagga = false, noSecurity = false, timeout = 600, } = options;
318
319
  // ── Resolve runners (once — nothing downstream re-detects) ─────────────────
319
320
  const stepDetect = "detect";
@@ -342,7 +343,7 @@ export async function runCI(options, onStep) {
342
343
  report(onStep, mode, `${mode} mode`, "error", detail);
343
344
  throw new Error(`no runners resolved — ${mode} mode requires at least one runner`);
344
345
  }
345
- await runGates(resolved.gates, projectDir, onStep);
346
+ await runGates(resolved.gates, projectDir, onStep, onGateOutcome);
346
347
  return;
347
348
  }
348
349
  // Legacy single-runner view for the zero-config auto path. Keeping this
@@ -514,7 +515,7 @@ export async function runCI(options, onStep) {
514
515
  // already `full` or `quick` here; the guard makes the contract explicit.
515
516
  // `runGates` no-ops on an empty gate list (a v1 repo carries none).
516
517
  if (mode === "full" || mode === "quick") {
517
- await runGates(resolved.gates, projectDir, onStep);
518
+ await runGates(resolved.gates, projectDir, onStep, onGateOutcome);
518
519
  }
519
520
  }
520
521
  // =============================================================================
@@ -792,6 +793,19 @@ export async function runGateNative(cmd, cwd, env) {
792
793
  proc.on("error", reject);
793
794
  });
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";
795
809
  /**
796
810
  * Repo-level gate phase. Each gate runs host-native via `runGateNative` at the
797
811
  * repo root. Outcome semantics:
@@ -808,29 +822,101 @@ export async function runGateNative(cmd, cwd, env) {
808
822
  * Multi-command gates run in order and STOP at the first non-zero exit
809
823
  * (fail-fast, matching the runner precedent); that first code is reported.
810
824
  *
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.
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.
814
835
  */
815
- async function runGates(gates, projectDir, onStep) {
836
+ async function runGates(gates, projectDir, onStep, onOutcome) {
816
837
  if (gates.length === 0)
817
838
  return;
818
839
  const blockingFailures = [];
819
- // Engine-injected keys only in slice 3; gate `env` + JAVI_FORGE_CHANGED_FILES
820
- // are added in slice 4.
821
840
  const baseEnv = {
822
841
  ...filterDefinedEnv(process.env),
823
842
  CI: "true",
824
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
+ };
825
874
  for (const gate of gates) {
826
875
  const stepId = `gate:${gate.id}`;
827
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
+ });
828
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
+ }
829
915
  let exitCode = 0;
830
916
  let spawnError;
831
917
  try {
832
918
  for (const cmd of gate.run) {
833
- exitCode = await runGateNative(cmd, projectDir, baseEnv);
919
+ exitCode = await runGateNative(cmd, projectDir, gateEnv);
834
920
  if (exitCode !== 0)
835
921
  break; // fail-fast: skip the remaining commands
836
922
  }
@@ -840,21 +926,53 @@ async function runGates(gates, projectDir, onStep) {
840
926
  }
841
927
  if (spawnError === undefined && exitCode === 0) {
842
928
  report(onStep, stepId, `${label} passed`, "done");
929
+ emit("done", { changedFiles: gateChangedFiles });
843
930
  continue;
844
931
  }
845
932
  const detail = spawnError !== undefined ? String(spawnError) : `exit ${exitCode}`;
846
- if (gate.mode === GATE_MODE.BLOCKING) {
933
+ if (blocking) {
847
934
  blockingFailures.push(gate.id);
848
935
  report(onStep, stepId, `${label} failed`, "error", detail);
936
+ emit("error", { changedFiles: gateChangedFiles, exitCode });
849
937
  }
850
938
  else {
851
939
  report(onStep, stepId, `${label} failed (informative)`, "warning", detail);
940
+ emit("warning", { changedFiles: gateChangedFiles, exitCode });
852
941
  }
853
942
  }
854
943
  if (blockingFailures.length > 0) {
855
944
  throw new Error(`blocking gate(s) failed: ${blockingFailures.join(", ")}`);
856
945
  }
857
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
+ }
858
976
  // =============================================================================
859
977
  // CI Hooks Installation
860
978
  // =============================================================================
@@ -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).
@@ -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); a push has no base ref and falls back to GITHUB_SHA.
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {