javi-forge 1.11.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
  /**
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Resolve the base ref to diff HEAD against, forge-agnostic. Precedence:
3
+ * 1. `$CI_MERGE_REQUEST_DIFF_BASE_SHA` (GitLab MR) when non-empty.
4
+ * 2. `$CI_COMMIT_BEFORE_SHA` (GitLab push) when non-empty AND not the
5
+ * all-zeros new-branch sentinel.
6
+ * 3. `$GITHUB_BASE_REF` (GitHub Actions PR) → `git merge-base origin/<ref> HEAD`.
7
+ * 4. Local fallback: `git merge-base <candidate> HEAD` over
8
+ * `origin/main`, `origin/master`, `main`, `master` — first that resolves.
9
+ * 5. Nothing resolves → `null` (caller loud-degrades).
10
+ */
11
+ export declare function resolveBaseRef(env: Record<string, string | undefined>, cwd: string): Promise<string | null>;
12
+ /**
13
+ * The union (deduped) of files changed relative to `base`:
14
+ * - committed: `git diff --name-only --diff-filter=ACMR <base>...HEAD`
15
+ * (three-dot; ACMR keeps Added/Copied/Modified/Renamed, drops deletions)
16
+ * - unstaged: `git diff --name-only`
17
+ * - staged: `git diff --name-only --cached`
18
+ *
19
+ * Invoked as `execFileAsync("git", [...], { cwd })` — an argv array, never a
20
+ * shell string.
21
+ *
22
+ * THROWS if any git invocation fails. A base sha absent from local history
23
+ * (CI shallow clone / bad object) makes the committed diff error; that failure
24
+ * MUST propagate so the caller can skip the scope:changed gate with a named
25
+ * warning. It MUST NOT be swallowed into an empty set — an empty set means
26
+ * "no changed files" and would silently pass a scope:changed gate.
27
+ */
28
+ export declare function changedFiles(base: string, cwd: string): Promise<string[]>;
29
+ //# sourceMappingURL=git-diff.d.ts.map
@@ -0,0 +1,132 @@
1
+ import { execFileAsync } from "./exec.js";
2
+ /**
3
+ * Forge-agnostic changed-file diff engine for `scope: changed` gates.
4
+ *
5
+ * Two injectable functions:
6
+ * - {@link resolveBaseRef} — resolve the base commit to diff HEAD against,
7
+ * following a forge-agnostic precedence chain (GitLab MR / GitLab push /
8
+ * GitHub PR / local merge-base). Returns `null` when nothing resolves so the
9
+ * caller can loud-degrade (skip the scope:changed gate with a named warning).
10
+ * - {@link changedFiles} — the union of committed (Added/Copied/Modified/Renamed,
11
+ * deletions dropped), unstaged, and staged changes. THROWS on a git failure
12
+ * (e.g. a base sha absent from local history under a CI shallow clone) so the
13
+ * caller can skip-with-warning; it MUST NOT swallow the failure into an empty
14
+ * set (that would look like "no changes" and silently pass a scope gate).
15
+ *
16
+ * This module is UNWIRED: nothing in the run path imports it yet. The gate
17
+ * phase consumes it in a later slice.
18
+ */
19
+ /** The all-zeros sha git emits for a brand-new branch's "before" ref. */
20
+ const NEW_BRANCH_SENTINEL = "0".repeat(40);
21
+ /**
22
+ * Local base-ref candidates, tried in order. The first whose `git merge-base
23
+ * <candidate> HEAD` resolves wins.
24
+ */
25
+ const LOCAL_BASE_CANDIDATES = [
26
+ "origin/main",
27
+ "origin/master",
28
+ "main",
29
+ "master",
30
+ ];
31
+ function isNonEmpty(value) {
32
+ return typeof value === "string" && value.length > 0;
33
+ }
34
+ /**
35
+ * Compute `git merge-base <ref> HEAD` in `cwd`, returning the resolved sha or
36
+ * `null` when the ref does not exist / has no common ancestor.
37
+ */
38
+ async function tryMergeBase(ref, cwd) {
39
+ try {
40
+ const { stdout } = await execFileAsync("git", ["merge-base", ref, "HEAD"], {
41
+ cwd,
42
+ });
43
+ const sha = stdout.trim();
44
+ return sha.length > 0 ? sha : null;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /**
51
+ * Resolve the base ref to diff HEAD against, forge-agnostic. Precedence:
52
+ * 1. `$CI_MERGE_REQUEST_DIFF_BASE_SHA` (GitLab MR) when non-empty.
53
+ * 2. `$CI_COMMIT_BEFORE_SHA` (GitLab push) when non-empty AND not the
54
+ * all-zeros new-branch sentinel.
55
+ * 3. `$GITHUB_BASE_REF` (GitHub Actions PR) → `git merge-base origin/<ref> HEAD`.
56
+ * 4. Local fallback: `git merge-base <candidate> HEAD` over
57
+ * `origin/main`, `origin/master`, `main`, `master` — first that resolves.
58
+ * 5. Nothing resolves → `null` (caller loud-degrades).
59
+ */
60
+ export async function resolveBaseRef(env, cwd) {
61
+ // 1. GitLab merge request — the base sha is provided directly.
62
+ if (isNonEmpty(env.CI_MERGE_REQUEST_DIFF_BASE_SHA)) {
63
+ return env.CI_MERGE_REQUEST_DIFF_BASE_SHA;
64
+ }
65
+ // 2. GitLab push — the previous sha, unless it is the new-branch sentinel.
66
+ if (isNonEmpty(env.CI_COMMIT_BEFORE_SHA) &&
67
+ env.CI_COMMIT_BEFORE_SHA !== NEW_BRANCH_SENTINEL) {
68
+ return env.CI_COMMIT_BEFORE_SHA;
69
+ }
70
+ // 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.
72
+ if (isNonEmpty(env.GITHUB_BASE_REF)) {
73
+ const base = await tryMergeBase(`origin/${env.GITHUB_BASE_REF}`, cwd);
74
+ if (base !== null)
75
+ return base;
76
+ }
77
+ else if (isNonEmpty(env.GITHUB_SHA)) {
78
+ return env.GITHUB_SHA;
79
+ }
80
+ // 4. Local fallback — first candidate whose merge-base resolves.
81
+ for (const candidate of LOCAL_BASE_CANDIDATES) {
82
+ const base = await tryMergeBase(candidate, cwd);
83
+ if (base !== null)
84
+ return base;
85
+ }
86
+ // 5. Nothing resolved.
87
+ return null;
88
+ }
89
+ /**
90
+ * Parse `git diff --name-only` stdout into a list of repo-root-relative paths,
91
+ * dropping blank lines.
92
+ */
93
+ function parseNameOnly(stdout) {
94
+ return stdout
95
+ .split("\n")
96
+ .map((line) => line.trim())
97
+ .filter((line) => line.length > 0);
98
+ }
99
+ /**
100
+ * The union (deduped) of files changed relative to `base`:
101
+ * - committed: `git diff --name-only --diff-filter=ACMR <base>...HEAD`
102
+ * (three-dot; ACMR keeps Added/Copied/Modified/Renamed, drops deletions)
103
+ * - unstaged: `git diff --name-only`
104
+ * - staged: `git diff --name-only --cached`
105
+ *
106
+ * Invoked as `execFileAsync("git", [...], { cwd })` — an argv array, never a
107
+ * shell string.
108
+ *
109
+ * THROWS if any git invocation fails. A base sha absent from local history
110
+ * (CI shallow clone / bad object) makes the committed diff error; that failure
111
+ * MUST propagate so the caller can skip the scope:changed gate with a named
112
+ * warning. It MUST NOT be swallowed into an empty set — an empty set means
113
+ * "no changed files" and would silently pass a scope:changed gate.
114
+ */
115
+ export async function changedFiles(base, cwd) {
116
+ const invocations = [
117
+ ["diff", "--name-only", "--diff-filter=ACMR", `${base}...HEAD`],
118
+ ["diff", "--name-only"],
119
+ ["diff", "--name-only", "--cached"],
120
+ ];
121
+ const seen = new Set();
122
+ for (const args of invocations) {
123
+ // Deliberately NOT wrapped in try/catch: a git failure here (shallow
124
+ // clone / missing base object) must surface to the caller.
125
+ const { stdout } = await execFileAsync("git", args, { cwd });
126
+ for (const file of parseNameOnly(stdout)) {
127
+ seen.add(file);
128
+ }
129
+ }
130
+ return [...seen];
131
+ }
132
+ //# sourceMappingURL=git-diff.js.map
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.11.0",
3
+ "version": "1.13.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {