javi-forge 1.10.1 → 1.11.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.
@@ -33,14 +33,26 @@ export async function handleCi(cli, ctx) {
33
33
  }
34
34
  }
35
35
  else if (cli.flags.json) {
36
- console.log(JSON.stringify({ ok: true, runners: result.runners }, null, 2));
36
+ const gates = result.gates ?? [];
37
+ console.log(JSON.stringify({
38
+ ok: true,
39
+ runners: result.runners,
40
+ ...(gates.length > 0 ? { gates } : {}),
41
+ }, null, 2));
37
42
  }
38
43
  else {
44
+ const gates = result.gates ?? [];
39
45
  console.log(`✓ CI config valid: ${result.configPath}`);
40
46
  console.log(` ${result.runners.length} runner(s):`);
41
47
  for (const runner of result.runners) {
42
48
  console.log(` - ${runner.name} (${runner.stack})`);
43
49
  }
50
+ if (gates.length > 0) {
51
+ console.log(` ${gates.length} gate(s):`);
52
+ for (const gate of gates) {
53
+ console.log(` - ${gate.id} (${gate.mode}, scope: ${gate.scope})`);
54
+ }
55
+ }
44
56
  }
45
57
  process.exit(0);
46
58
  }
@@ -12,6 +12,12 @@ export interface CIValidateRunnerSummary {
12
12
  name: string;
13
13
  stack: string;
14
14
  }
15
+ /** One validated gate, reduced to what the report shows. */
16
+ export interface CIValidateGateSummary {
17
+ id: string;
18
+ mode: string;
19
+ scope: string;
20
+ }
15
21
  export interface CIValidateOk {
16
22
  ok: true;
17
23
  /**
@@ -24,6 +30,8 @@ export interface CIValidateOk {
24
30
  /** Resolved config path, or null in auto-detect mode. */
25
31
  configPath: string | null;
26
32
  runners: CIValidateRunnerSummary[];
33
+ /** Declared gates (version 2). Empty when none are declared. */
34
+ gates: CIValidateGateSummary[];
27
35
  }
28
36
  export interface CIValidateErr {
29
37
  ok: false;
@@ -35,7 +35,13 @@ export async function validateCIConfig(projectDir, config) {
35
35
  else {
36
36
  const discovered = await findCIConfig(projectDir);
37
37
  if (!discovered) {
38
- return { ok: true, mode: "auto-detect", configPath: null, runners: [] };
38
+ return {
39
+ ok: true,
40
+ mode: "auto-detect",
41
+ configPath: null,
42
+ runners: [],
43
+ gates: [],
44
+ };
39
45
  }
40
46
  configPath = discovered;
41
47
  }
@@ -49,6 +55,11 @@ export async function validateCIConfig(projectDir, config) {
49
55
  name: r.name,
50
56
  stack: r.stack,
51
57
  })),
58
+ gates: (ciConfig.gates ?? []).map((g) => ({
59
+ id: g.id,
60
+ mode: g.mode,
61
+ scope: g.scope,
62
+ })),
52
63
  };
53
64
  }
54
65
  catch (e) {
@@ -349,10 +349,26 @@ export async function runCI(options, onStep) {
349
349
  report(onStep, "docker-image", "Building Docker image", "running");
350
350
  let shellImage;
351
351
  try {
352
- shellImage = await ensureImage({
353
- stack: stackInfo.stackType,
354
- javaVersion: stackInfo.javaVersion,
355
- });
352
+ // B2: honor a per-runner pinned image / build context the same way the
353
+ // runner loop does, instead of always deriving the stack default. An
354
+ // explicit image passes through verbatim; a build context is built with
355
+ // the deterministic per-runner tag; otherwise fall back to the stack image.
356
+ if (primary.image) {
357
+ shellImage = primary.image;
358
+ }
359
+ else if (primary.buildContext) {
360
+ shellImage = await ensureImage({
361
+ stack: primary.stack,
362
+ buildContext: path.resolve(projectDir, primary.buildContext),
363
+ imageTag: `javi-forge-ci-${primary.name}`,
364
+ });
365
+ }
366
+ else {
367
+ shellImage = await ensureImage({
368
+ stack: stackInfo.stackType,
369
+ javaVersion: stackInfo.javaVersion,
370
+ });
371
+ }
356
372
  report(onStep, "docker-image", "Docker image ready", "done");
357
373
  }
358
374
  catch (e) {
@@ -411,10 +427,16 @@ export async function runCI(options, onStep) {
411
427
  report(onStep, stepContext, "Refresh .context/ directory", "error", String(e));
412
428
  }
413
429
  // ── Runner execution ─────────────────────────────────────────────────────────
414
- // ONE executor for every resolution source. Naming is a function of
415
- // `resolved.source` alone — never of `resolved.runners.length`, which would
416
- // silently rename a single-runner CONFIG.
417
- const naming = resolved.source === "auto" ? NAMING_MODE.BARE : NAMING_MODE.SUFFIXED;
430
+ // ONE executor for every resolution source. Naming is a function of whether
431
+ // the runner NAME is IMPLICIT or EXPLICIT — never of `resolved.runners.length`,
432
+ // which would silently rename a single-runner CONFIG (B1):
433
+ // - IMPLICIT name BARE ids: `auto` (zero-config) OR `stack-override`
434
+ // (`--stack`, the user never named the runner).
435
+ // - EXPLICIT name → SUFFIXED ids: `config` (a runner named in ci.yaml).
436
+ const implicitName = resolved.source === "auto" || resolved.source === "stack-override";
437
+ const naming = implicitName
438
+ ? NAMING_MODE.BARE
439
+ : NAMING_MODE.SUFFIXED;
418
440
  for (const runner of resolved.runners) {
419
441
  await runRunner(runner, {
420
442
  projectDir,
@@ -499,7 +521,10 @@ async function runRunner(runner, ctx) {
499
521
  imageName = ctx.preresolvedImage;
500
522
  }
501
523
  else {
502
- const stepImage = `docker-image:${runner.name}`;
524
+ // Under an IMPLICIT name (bare) the image step id is unsuffixed too, so a
525
+ // `--stack` run reads identically to zero-config auto. A CONFIG runner
526
+ // (suffixed) keeps `docker-image:<name>` (the R3 guard).
527
+ const stepImage = bare ? "docker-image" : `docker-image:${runner.name}`;
503
528
  try {
504
529
  if (runner.buildContext) {
505
530
  report(onStep, stepImage, `Building image for ${runner.name} from ${runner.buildContext}`, "running");
@@ -10,6 +10,20 @@
10
10
  */
11
11
  import type { Stack } from "../types/index.js";
12
12
  export declare const CI_CONFIG_VERSION = 1;
13
+ /** Config schema versions this loader accepts. v2 is additive (unlocks gates). */
14
+ export declare const CI_CONFIG_VERSIONS: readonly number[];
15
+ /** Gate outcome mode: a blocking gate fails the build, informative degrades. */
16
+ export declare const GATE_MODE: {
17
+ readonly BLOCKING: "blocking";
18
+ readonly INFORMATIVE: "informative";
19
+ };
20
+ export type GateMode = (typeof GATE_MODE)[keyof typeof GATE_MODE];
21
+ /** Gate file scope: all files, or only the changed set (slice 4 wiring). */
22
+ export declare const GATE_SCOPE: {
23
+ readonly ALL: "all";
24
+ readonly CHANGED: "changed";
25
+ };
26
+ export type GateScope = (typeof GATE_SCOPE)[keyof typeof GATE_SCOPE];
13
27
  /** Default config locations, in discovery order, relative to the project root. */
14
28
  export declare const CI_CONFIG_CANDIDATES: readonly [".javi-forge/ci.yaml", ".javi-forge/ci.yml"];
15
29
  export interface CIRunnerConfig {
@@ -32,9 +46,26 @@ export interface CIRunnerConfig {
32
46
  /** Tools that must exist in the runner environment (validated fail-closed) */
33
47
  requires: string[];
34
48
  }
49
+ /** A declarable named quality gate (version 2 only). Runs host-native. */
50
+ export interface CIGateConfig {
51
+ /** Unique, tag-safe identifier (reuses RUNNER_NAME_RE). */
52
+ id: string;
53
+ /** Command(s) to run — string or list, normalized to a list. */
54
+ run: string[];
55
+ /** blocking (default) fails the build; informative degrades to a warning. */
56
+ mode: GateMode;
57
+ /** all (default) or changed — the file scope the gate cares about. */
58
+ scope: GateScope;
59
+ /** Optional baseline artifact path (slice 4). */
60
+ baseline?: string;
61
+ /** Optional env injected via the child-process env map (slice 4). */
62
+ env?: Record<string, string>;
63
+ }
35
64
  export interface CIConfig {
36
65
  version: number;
37
66
  runners: CIRunnerConfig[];
67
+ /** Present only under version 2 when `gates:` is declared. */
68
+ gates?: CIGateConfig[];
38
69
  }
39
70
  export interface CIConfigValidationError {
40
71
  path: string;
@@ -46,6 +77,12 @@ export declare class CIConfigError extends Error {
46
77
  }
47
78
  /** Stacks accepted by the CI config schema and the --stack override. */
48
79
  export declare const CI_STACKS: readonly string[];
80
+ /**
81
+ * Validate the `gates:` block (version 2 only). Every schema error names the
82
+ * offending field; a duplicate id is reported once. Returns the parsed gates
83
+ * (the caller discards them all if `errors` is non-empty — fail closed).
84
+ */
85
+ export declare function validateGates(raw: unknown, errors: CIConfigValidationError[]): CIGateConfig[];
49
86
  /**
50
87
  * Parse and validate CI config YAML text. Throws CIConfigError listing every
51
88
  * validation problem; never returns a partially valid config.
@@ -15,6 +15,18 @@ import YAML from "yaml";
15
15
  // Types
16
16
  // =============================================================================
17
17
  export const CI_CONFIG_VERSION = 1;
18
+ /** Config schema versions this loader accepts. v2 is additive (unlocks gates). */
19
+ export const CI_CONFIG_VERSIONS = [1, 2];
20
+ /** Gate outcome mode: a blocking gate fails the build, informative degrades. */
21
+ export const GATE_MODE = {
22
+ BLOCKING: "blocking",
23
+ INFORMATIVE: "informative",
24
+ };
25
+ /** Gate file scope: all files, or only the changed set (slice 4 wiring). */
26
+ export const GATE_SCOPE = {
27
+ ALL: "all",
28
+ CHANGED: "changed",
29
+ };
18
30
  /** Default config locations, in discovery order, relative to the project root. */
19
31
  export const CI_CONFIG_CANDIDATES = [
20
32
  ".javi-forge/ci.yaml",
@@ -202,6 +214,144 @@ function validateRunner(raw, index, errors) {
202
214
  requires,
203
215
  };
204
216
  }
217
+ const GATE_FIELDS = new Set(["id", "run", "mode", "scope", "baseline", "env"]);
218
+ function validateGate(raw, index, errors) {
219
+ const base = `gates[${index}]`;
220
+ if (!isRecord(raw)) {
221
+ errors.push({ path: base, message: "gate must be an object" });
222
+ return null;
223
+ }
224
+ for (const key of Object.keys(raw)) {
225
+ if (!GATE_FIELDS.has(key)) {
226
+ errors.push({
227
+ path: `${base}.${key}`,
228
+ message: `unknown field "${key}"`,
229
+ });
230
+ }
231
+ }
232
+ const id = raw.id;
233
+ if (typeof id !== "string" || !id.trim()) {
234
+ errors.push({
235
+ path: `${base}.id`,
236
+ message: "id is required and must be a non-empty string",
237
+ });
238
+ }
239
+ else if (!RUNNER_NAME_RE.test(id.trim())) {
240
+ errors.push({
241
+ path: `${base}.id`,
242
+ message: "id must start with a letter, digit or underscore and contain only [a-zA-Z0-9._-] (it is used as a tag)",
243
+ });
244
+ }
245
+ const run = normalizeCommands(raw.run, `${base}.run`, errors);
246
+ if (raw.run === undefined) {
247
+ errors.push({
248
+ path: `${base}.run`,
249
+ message: "run is required and must be a non-empty string or a list of non-empty strings",
250
+ });
251
+ }
252
+ else if (run.length === 0 &&
253
+ !errors.some((e) => e.path === `${base}.run`)) {
254
+ errors.push({
255
+ path: `${base}.run`,
256
+ message: "run must be a non-empty string or a list of non-empty strings",
257
+ });
258
+ }
259
+ let mode = GATE_MODE.BLOCKING;
260
+ if (raw.mode !== undefined) {
261
+ if (raw.mode === GATE_MODE.BLOCKING || raw.mode === GATE_MODE.INFORMATIVE) {
262
+ mode = raw.mode;
263
+ }
264
+ else {
265
+ errors.push({
266
+ path: `${base}.mode`,
267
+ message: `mode must be one of: ${GATE_MODE.BLOCKING}, ${GATE_MODE.INFORMATIVE} (got "${String(raw.mode)}")`,
268
+ });
269
+ }
270
+ }
271
+ let scope = GATE_SCOPE.ALL;
272
+ if (raw.scope !== undefined) {
273
+ if (raw.scope === GATE_SCOPE.ALL || raw.scope === GATE_SCOPE.CHANGED) {
274
+ scope = raw.scope;
275
+ }
276
+ else {
277
+ errors.push({
278
+ path: `${base}.scope`,
279
+ message: `scope must be one of: ${GATE_SCOPE.ALL}, ${GATE_SCOPE.CHANGED} (got "${String(raw.scope)}")`,
280
+ });
281
+ }
282
+ }
283
+ let baseline;
284
+ if (raw.baseline !== undefined) {
285
+ if (typeof raw.baseline !== "string" || !raw.baseline.trim()) {
286
+ errors.push({
287
+ path: `${base}.baseline`,
288
+ message: "baseline must be a non-empty string (path)",
289
+ });
290
+ }
291
+ else {
292
+ baseline = raw.baseline;
293
+ }
294
+ }
295
+ let env;
296
+ if (raw.env !== undefined) {
297
+ if (!isRecord(raw.env)) {
298
+ errors.push({
299
+ path: `${base}.env`,
300
+ message: "env must be a mapping of string keys to string values",
301
+ });
302
+ }
303
+ else if (Object.values(raw.env).some((v) => typeof v !== "string")) {
304
+ errors.push({
305
+ path: `${base}.env`,
306
+ message: "env values must all be strings",
307
+ });
308
+ }
309
+ else {
310
+ env = raw.env;
311
+ }
312
+ }
313
+ return {
314
+ id: typeof id === "string" ? id.trim() : "",
315
+ run,
316
+ mode,
317
+ scope,
318
+ baseline,
319
+ env,
320
+ };
321
+ }
322
+ /**
323
+ * Validate the `gates:` block (version 2 only). Every schema error names the
324
+ * offending field; a duplicate id is reported once. Returns the parsed gates
325
+ * (the caller discards them all if `errors` is non-empty — fail closed).
326
+ */
327
+ export function validateGates(raw, errors) {
328
+ if (!Array.isArray(raw)) {
329
+ errors.push({ path: "gates", message: "gates must be a non-empty list" });
330
+ return [];
331
+ }
332
+ if (raw.length === 0) {
333
+ errors.push({ path: "gates", message: "gates must be a non-empty list" });
334
+ return [];
335
+ }
336
+ const gates = [];
337
+ const seen = new Set();
338
+ raw.forEach((item, index) => {
339
+ const gate = validateGate(item, index, errors);
340
+ if (!gate)
341
+ return;
342
+ if (gate.id) {
343
+ if (seen.has(gate.id)) {
344
+ errors.push({
345
+ path: `gates[${index}].id`,
346
+ message: `duplicate gate id "${gate.id}"`,
347
+ });
348
+ }
349
+ seen.add(gate.id);
350
+ }
351
+ gates.push(gate);
352
+ });
353
+ return gates;
354
+ }
205
355
  // =============================================================================
206
356
  // Public API
207
357
  // =============================================================================
@@ -226,23 +376,54 @@ export function parseCIConfig(rawYaml, source) {
226
376
  if (!isRecord(doc)) {
227
377
  throw new CIConfigError([{ path: "<document>", message: "config must be a YAML object/mapping" }], source);
228
378
  }
229
- for (const key of Object.keys(doc)) {
230
- if (!TOP_LEVEL_FIELDS.has(key)) {
231
- errors.push({ path: key, message: `unknown field "${key}"` });
232
- }
233
- }
234
- if (doc.version !== CI_CONFIG_VERSION) {
379
+ // Read the version FIRST: the allowed-key set is a function of the accepted
380
+ // version, so `gates` under v1 reports the named "gates require version: 2"
381
+ // error, never the generic unknown-field error (JDA-006).
382
+ const version = typeof doc.version === "number" && CI_CONFIG_VERSIONS.includes(doc.version)
383
+ ? doc.version
384
+ : undefined;
385
+ if (version === undefined) {
235
386
  errors.push({
236
387
  path: "version",
237
- message: `version is required and must be the number ${CI_CONFIG_VERSION}`,
388
+ message: `version is required and must be one of: ${CI_CONFIG_VERSIONS.join(", ")}`,
238
389
  });
239
390
  }
240
- if (!Array.isArray(doc.runners) || doc.runners.length === 0) {
391
+ const isV2 = version === 2;
392
+ const hasGates = doc.gates !== undefined;
393
+ for (const key of Object.keys(doc)) {
394
+ if (TOP_LEVEL_FIELDS.has(key))
395
+ continue;
396
+ if (key === "gates") {
397
+ // `gates` is only a known key under v2; under any other version it is a
398
+ // named schema error that takes precedence over the generic path.
399
+ if (!isV2) {
400
+ errors.push({ path: "gates", message: "gates require version: 2" });
401
+ }
402
+ continue;
403
+ }
404
+ errors.push({ path: key, message: `unknown field "${key}"` });
405
+ }
406
+ if (isV2) {
407
+ // v2: runners OPTIONAL when gates present; NEITHER runners nor gates fails
408
+ // closed (nothing to run).
409
+ if (!hasGates &&
410
+ (!Array.isArray(doc.runners) || doc.runners.length === 0)) {
411
+ errors.push({
412
+ path: "runners",
413
+ message: "a version 2 config must declare runners or gates (nothing to run otherwise)",
414
+ });
415
+ }
416
+ }
417
+ else if (!Array.isArray(doc.runners) || doc.runners.length === 0) {
418
+ // v1 (and invalid-version) still hard-require a non-empty runners list.
241
419
  errors.push({
242
420
  path: "runners",
243
421
  message: "runners is required and must be a non-empty list",
244
422
  });
245
423
  }
424
+ // Gates are validated ONLY under v2 — a v1+gates config is already rejected
425
+ // above and must not surface a second, confusing wave of gate-field errors.
426
+ const gates = isV2 && hasGates ? validateGates(doc.gates, errors) : [];
246
427
  const runners = [];
247
428
  if (Array.isArray(doc.runners)) {
248
429
  doc.runners.forEach((raw, index) => {
@@ -266,7 +447,12 @@ export function parseCIConfig(rawYaml, source) {
266
447
  if (errors.length > 0) {
267
448
  throw new CIConfigError(errors, source);
268
449
  }
269
- return { version: CI_CONFIG_VERSION, runners };
450
+ // `version` is defined here (an undefined version pushed an error above and
451
+ // would have thrown). A v1 config carries NO gates key — byte-identical shape.
452
+ const config = { version: version ?? CI_CONFIG_VERSION, runners };
453
+ if (gates.length > 0)
454
+ config.gates = gates;
455
+ return config;
270
456
  }
271
457
  /**
272
458
  * Load and validate a CI config file. Fails closed: a missing file or any
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.10.1",
3
+ "version": "1.11.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {