From 27e47be9c2208b1aa784b2a5ebf9feb7a978751b Mon Sep 17 00:00:00 2001 From: dbrosio3 Date: Wed, 1 Jul 2026 11:57:29 -0300 Subject: [PATCH] Add fail_fast option to policies and update related configurations --- bin/pushgate.mjs | 48 +++++++++++---- docs/reference/configuration.md | 8 ++- schemas/pushgate-config-v2.schema.json | 10 ++++ src/config/normalize.ts | 2 + src/config/types.ts | 6 ++ src/generated/pushgate-config-v2-validator.ts | 46 +++++++++++---- src/runner/deterministic-plan.ts | 5 +- templates/base.yml | 2 + test/config.test.ts | 16 +++++ test/deterministic-runner.test.ts | 59 +++++++++++++++++++ test/fixtures/config/valid.yml | 1 + 11 files changed, 178 insertions(+), 25 deletions(-) diff --git a/bin/pushgate.mjs b/bin/pushgate.mjs index 4d56a57..6c9f249 100755 --- a/bin/pushgate.mjs +++ b/bin/pushgate.mjs @@ -7953,13 +7953,15 @@ function normalizePolicies(rawConfig) { ...policies.diff_size ? { diff_size: { max_changed_lines: policies.diff_size.max_changed_lines, - mode: policies.diff_size.mode ?? "blocking" + mode: policies.diff_size.mode ?? "blocking", + fail_fast: policies.diff_size.fail_fast ?? true } } : {}, ...policies.forbidden_paths ? { forbidden_paths: { patterns: [...policies.forbidden_paths.patterns], - mode: policies.forbidden_paths.mode ?? "blocking" + mode: policies.forbidden_paths.mode ?? "blocking", + fail_fast: policies.forbidden_paths.fail_fast ?? true } } : {} }; @@ -8011,7 +8013,7 @@ function validate12(data, { instancePath = "", parentData, parentDataProperty, r errors++; } for (const key0 in data) { - if (!(key0 === "max_changed_lines" || key0 === "mode")) { + if (!(key0 === "max_changed_lines" || key0 === "mode" || key0 === "fail_fast")) { const err1 = { instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }; if (vErrors === null) { vErrors = [err1]; @@ -8065,12 +8067,23 @@ function validate12(data, { instancePath = "", parentData, parentDataProperty, r errors++; } } + if (data.fail_fast !== void 0) { + if (typeof data.fail_fast !== "boolean") { + const err6 = { instancePath: instancePath + "/fail_fast", schemaPath: "#/properties/fail_fast/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }; + if (vErrors === null) { + vErrors = [err6]; + } else { + vErrors.push(err6); + } + errors++; + } + } } else { - const err6 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + const err7 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; if (vErrors === null) { - vErrors = [err6]; + vErrors = [err7]; } else { - vErrors.push(err6); + vErrors.push(err7); } errors++; } @@ -8091,7 +8104,7 @@ function validate14(data, { instancePath = "", parentData, parentDataProperty, r errors++; } for (const key0 in data) { - if (!(key0 === "patterns" || key0 === "mode")) { + if (!(key0 === "patterns" || key0 === "mode" || key0 === "fail_fast")) { const err1 = { instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }; if (vErrors === null) { vErrors = [err1]; @@ -8167,12 +8180,23 @@ function validate14(data, { instancePath = "", parentData, parentDataProperty, r errors++; } } + if (data.fail_fast !== void 0) { + if (typeof data.fail_fast !== "boolean") { + const err8 = { instancePath: instancePath + "/fail_fast", schemaPath: "#/properties/fail_fast/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }; + if (vErrors === null) { + vErrors = [err8]; + } else { + vErrors.push(err8); + } + errors++; + } + } } else { - const err8 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + const err9 = { instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }; if (vErrors === null) { - vErrors = [err8]; + vErrors = [err9]; } else { - vErrors.push(err8); + vErrors.push(err9); } errors++; } @@ -27401,6 +27425,7 @@ function buildBuiltInPolicyEntries(policies) { policies: { diff_size: policies.diff_size }, + failFast: policies.diff_size.fail_fast, resultName: "policy:diff_size", transformDetail: formatDiffSizeDisplayDetail }) @@ -27413,6 +27438,7 @@ function buildBuiltInPolicyEntries(policies) { policies: { forbidden_paths: policies.forbidden_paths }, + failFast: policies.forbidden_paths.fail_fast, resultName: "policy:forbidden_paths" }) ); @@ -27424,7 +27450,7 @@ function buildBuiltInPolicyEntry(options) { display: { label: options.label }, - failFast: false, + failFast: options.failFast, async run(context) { const result = runBuiltInPolicies( options.policies, diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 85cd4a4..7f3e395 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -27,11 +27,13 @@ policies: diff_size: max_changed_lines: 500 mode: warning + fail_fast: false forbidden_paths: patterns: - ".env" - "secrets/**" mode: blocking + fail_fast: true plugins: gitleaks: @@ -84,7 +86,9 @@ extension point for provider-specific nested settings. | `tools[].run` | `changed_files` | | `tools[].fail_fast` | `true` | | `policies.diff_size.mode` | `blocking` | +| `policies.diff_size.fail_fast` | `true` | | `policies.forbidden_paths.mode` | `blocking` | +| `policies.forbidden_paths.fail_fast` | `true` | | `plugins.gitleaks.enabled` | `true` | | `plugins.gitleaks.command` | `gitleaks` | | `plugins.gitleaks.timeout_seconds` | `60` | @@ -132,8 +136,8 @@ commands. They run before plugins and configured tools. | `diff_size` | Counts added plus deleted text lines in the normalized changed-file list. Binary diffs do not contribute. | | `forbidden_paths` | Matches gitignore-like patterns against live changed paths after `ignore_paths` filtering. Deleted files are ignored. | -Policy `mode` uses the same `blocking` or `warning` behavior as configured -tools. +Policy `mode` and `fail_fast` use the same blocking, warning, and fail-fast +behavior as configured tools. ## Plugins diff --git a/schemas/pushgate-config-v2.schema.json b/schemas/pushgate-config-v2.schema.json index 11afcbe..594bd7e 100644 --- a/schemas/pushgate-config-v2.schema.json +++ b/schemas/pushgate-config-v2.schema.json @@ -145,6 +145,11 @@ }, "mode": { "$ref": "#/definitions/policyMode" + }, + "fail_fast": { + "description": "Whether a blocking diff-size violation stops later deterministic checks.", + "type": "boolean", + "default": true } } }, @@ -164,6 +169,11 @@ }, "mode": { "$ref": "#/definitions/policyMode" + }, + "fail_fast": { + "description": "Whether a blocking forbidden-path violation stops later deterministic checks.", + "type": "boolean", + "default": true } } }, diff --git a/src/config/normalize.ts b/src/config/normalize.ts index 33fe817..37ac0a3 100644 --- a/src/config/normalize.ts +++ b/src/config/normalize.ts @@ -87,6 +87,7 @@ function normalizePolicies( diff_size: { max_changed_lines: policies.diff_size.max_changed_lines, mode: policies.diff_size.mode ?? "blocking", + fail_fast: policies.diff_size.fail_fast ?? true, }, } : {}), @@ -95,6 +96,7 @@ function normalizePolicies( forbidden_paths: { patterns: [...policies.forbidden_paths.patterns], mode: policies.forbidden_paths.mode ?? "blocking", + fail_fast: policies.forbidden_paths.fail_fast ?? true, }, } : {}), diff --git a/src/config/types.ts b/src/config/types.ts index 799e3c6..f627d73 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -44,6 +44,8 @@ export interface DiffSizePolicyConfig { max_changed_lines: number; /** Whether a policy violation blocks the push or only warns locally. */ mode: BuiltInPolicyMode; + /** Whether a blocking violation stops later deterministic checks. */ + fail_fast: boolean; } /** Built-in forbidden-path policy configuration. */ @@ -52,6 +54,8 @@ export interface ForbiddenPathsPolicyConfig { patterns: string[]; /** Whether a policy violation blocks the push or only warns locally. */ mode: BuiltInPolicyMode; + /** Whether a blocking violation stops later deterministic checks. */ + fail_fast: boolean; } /** Optional built-in deterministic policies. */ @@ -157,12 +161,14 @@ export interface RawToolConfig { export interface RawDiffSizePolicyConfig { max_changed_lines: number; mode?: BuiltInPolicyMode; + fail_fast?: boolean; } /** Raw built-in forbidden-path policy shape before defaults are normalized. */ export interface RawForbiddenPathsPolicyConfig { patterns: string[]; mode?: BuiltInPolicyMode; + fail_fast?: boolean; } /** Raw built-in policy config before optional policy modes are normalized. */ diff --git a/src/generated/pushgate-config-v2-validator.ts b/src/generated/pushgate-config-v2-validator.ts index dcf3d62..56c81be 100644 --- a/src/generated/pushgate-config-v2-validator.ts +++ b/src/generated/pushgate-config-v2-validator.ts @@ -40,12 +40,12 @@ function ucs2length(str) { return length; } -const schema11 = {"$schema":"http://json-schema.org/draft-07/schema#","$id":"https://github.com/rootstrap/ai-pushgate/schemas/pushgate-config-v2.schema.json","title":"Pushgate v2 config","description":"Versioned project config for .pushgate.yml.","type":"object","additionalProperties":false,"required":["version"],"properties":{"version":{"description":"Pushgate config schema version.","const":2},"review":{"$ref":"#/definitions/review"},"tools":{"description":"Deterministic checks for the later command runner.","type":"array","default":[],"items":{"$ref":"#/definitions/tool"}},"policies":{"$ref":"#/definitions/policies"},"plugins":{"$ref":"#/definitions/plugins"},"ai":{"$ref":"#/definitions/ai"},"ignore_paths":{"description":"Gitignore-like repo-relative changed-file paths omitted by later Pushgate layers.","type":"array","default":[],"items":{"type":"string","minLength":1}}},"definitions":{"review":{"type":"object","additionalProperties":false,"properties":{"target_branch":{"type":"string","minLength":1,"default":"main"},"context_lines":{"type":"integer","minimum":0,"default":10},"max_lines_for_full_file":{"type":"integer","minimum":1,"default":300}}},"tool":{"type":"object","additionalProperties":false,"required":["name","command"],"properties":{"name":{"type":"string","minLength":1},"command":{"description":"Argv tokens for deterministic command execution.","type":"array","minItems":1,"items":{"type":"string","minLength":1}},"extensions":{"type":"array","items":{"type":"string","minLength":1}},"timeout_seconds":{"description":"Maximum runtime before the deterministic command is treated as timed out.","type":"integer","minimum":1,"default":60},"mode":{"description":"Whether command failures block the push or only warn locally.","type":"string","enum":["blocking","warning"],"default":"blocking"},"run":{"description":"Whether the command requires matching live changed files or always runs.","type":"string","enum":["changed_files","always"],"default":"changed_files"},"fail_fast":{"description":"Whether a blocking failure stops later deterministic command checks.","type":"boolean","default":true}}},"policies":{"description":"Optional built-in deterministic policy checks.","type":"object","additionalProperties":false,"default":{},"properties":{"diff_size":{"$ref":"#/definitions/diffSizePolicy"},"forbidden_paths":{"$ref":"#/definitions/forbiddenPathsPolicy"}}},"policyMode":{"description":"Whether a built-in policy violation blocks the push or only warns locally.","type":"string","enum":["blocking","warning"],"default":"blocking"},"diffSizePolicy":{"type":"object","additionalProperties":false,"required":["max_changed_lines"],"properties":{"max_changed_lines":{"description":"Maximum total added plus deleted text lines allowed in the changed diff.","type":"integer","minimum":1},"mode":{"$ref":"#/definitions/policyMode"}}},"forbiddenPathsPolicy":{"type":"object","additionalProperties":false,"required":["patterns"],"properties":{"patterns":{"description":"Gitignore-like repo-relative path patterns that must not be pushed.","type":"array","minItems":1,"items":{"type":"string","minLength":1}},"mode":{"$ref":"#/definitions/policyMode"}}},"plugins":{"description":"Optional external plugin adapters managed by Pushgate.","type":"object","additionalProperties":false,"default":{},"properties":{"gitleaks":{"$ref":"#/definitions/gitleaksPlugin"}}},"gitleaksPlugin":{"description":"Gitleaks secret-scanner plugin adapter.","type":"object","additionalProperties":false,"properties":{"enabled":{"description":"Whether the configured plugin should run.","type":"boolean","default":true},"command":{"description":"Executable name or path used to invoke Gitleaks.","type":"string","minLength":1,"default":"gitleaks"},"timeout_seconds":{"description":"Maximum plugin runtime before Pushgate treats the scan as timed out.","type":"integer","minimum":1,"default":60},"mode":{"$ref":"#/definitions/policyMode"},"fail_fast":{"description":"Whether a blocking Gitleaks failure stops later deterministic checks.","type":"boolean","default":true},"config_path":{"description":"Optional path to a Gitleaks TOML config file.","type":"string","minLength":1},"baseline_path":{"description":"Optional path to a Gitleaks JSON baseline report.","type":"string","minLength":1},"gitleaks_ignore_path":{"description":"Optional path to a .gitleaksignore file or containing folder.","type":"string","minLength":1},"redact":{"description":"Redact detected secret values in Gitleaks output and reports.","type":"boolean","default":true},"max_decode_depth":{"description":"Optional Gitleaks decode recursion depth.","type":"integer","minimum":0},"max_archive_depth":{"description":"Optional Gitleaks archive recursion depth.","type":"integer","minimum":0},"max_target_megabytes":{"description":"Optional file-size cap forwarded to Gitleaks.","type":"integer","minimum":1},"enable_rules":{"description":"Optional rule IDs to enable exclusively.","type":"array","items":{"type":"string","minLength":1}}}},"ai":{"type":"object","additionalProperties":false,"properties":{"mode":{"type":"string","enum":["blocking","advisory","off"],"default":"blocking"},"max_changed_lines":{"description":"Maximum total added plus deleted text lines before local AI review blocks the push.","type":"integer","minimum":1,"default":500},"max_prompt_tokens":{"description":"Approximate rendered prompt token budget before local AI review is skipped.","type":"integer","minimum":1,"default":12000},"timeout_seconds":{"description":"Maximum local AI provider runtime before the provider is treated as timed out.","type":"integer","minimum":1,"default":120},"provider":{"type":"string","minLength":1},"providers":{"type":"object","default":{},"propertyNames":{"minLength":1},"additionalProperties":{"$ref":"#/definitions/providerConfig"}}}},"providerConfig":{"description":"Provider-specific settings are the v2 extension boundary.","type":"object","additionalProperties":true}}}; +const schema11 = {"$schema":"http://json-schema.org/draft-07/schema#","$id":"https://github.com/rootstrap/ai-pushgate/schemas/pushgate-config-v2.schema.json","title":"Pushgate v2 config","description":"Versioned project config for .pushgate.yml.","type":"object","additionalProperties":false,"required":["version"],"properties":{"version":{"description":"Pushgate config schema version.","const":2},"review":{"$ref":"#/definitions/review"},"tools":{"description":"Deterministic checks for the later command runner.","type":"array","default":[],"items":{"$ref":"#/definitions/tool"}},"policies":{"$ref":"#/definitions/policies"},"plugins":{"$ref":"#/definitions/plugins"},"ai":{"$ref":"#/definitions/ai"},"ignore_paths":{"description":"Gitignore-like repo-relative changed-file paths omitted by later Pushgate layers.","type":"array","default":[],"items":{"type":"string","minLength":1}}},"definitions":{"review":{"type":"object","additionalProperties":false,"properties":{"target_branch":{"type":"string","minLength":1,"default":"main"},"context_lines":{"type":"integer","minimum":0,"default":10},"max_lines_for_full_file":{"type":"integer","minimum":1,"default":300}}},"tool":{"type":"object","additionalProperties":false,"required":["name","command"],"properties":{"name":{"type":"string","minLength":1},"command":{"description":"Argv tokens for deterministic command execution.","type":"array","minItems":1,"items":{"type":"string","minLength":1}},"extensions":{"type":"array","items":{"type":"string","minLength":1}},"timeout_seconds":{"description":"Maximum runtime before the deterministic command is treated as timed out.","type":"integer","minimum":1,"default":60},"mode":{"description":"Whether command failures block the push or only warn locally.","type":"string","enum":["blocking","warning"],"default":"blocking"},"run":{"description":"Whether the command requires matching live changed files or always runs.","type":"string","enum":["changed_files","always"],"default":"changed_files"},"fail_fast":{"description":"Whether a blocking failure stops later deterministic command checks.","type":"boolean","default":true}}},"policies":{"description":"Optional built-in deterministic policy checks.","type":"object","additionalProperties":false,"default":{},"properties":{"diff_size":{"$ref":"#/definitions/diffSizePolicy"},"forbidden_paths":{"$ref":"#/definitions/forbiddenPathsPolicy"}}},"policyMode":{"description":"Whether a built-in policy violation blocks the push or only warns locally.","type":"string","enum":["blocking","warning"],"default":"blocking"},"diffSizePolicy":{"type":"object","additionalProperties":false,"required":["max_changed_lines"],"properties":{"max_changed_lines":{"description":"Maximum total added plus deleted text lines allowed in the changed diff.","type":"integer","minimum":1},"mode":{"$ref":"#/definitions/policyMode"},"fail_fast":{"description":"Whether a blocking diff-size violation stops later deterministic checks.","type":"boolean","default":true}}},"forbiddenPathsPolicy":{"type":"object","additionalProperties":false,"required":["patterns"],"properties":{"patterns":{"description":"Gitignore-like repo-relative path patterns that must not be pushed.","type":"array","minItems":1,"items":{"type":"string","minLength":1}},"mode":{"$ref":"#/definitions/policyMode"},"fail_fast":{"description":"Whether a blocking forbidden-path violation stops later deterministic checks.","type":"boolean","default":true}}},"plugins":{"description":"Optional external plugin adapters managed by Pushgate.","type":"object","additionalProperties":false,"default":{},"properties":{"gitleaks":{"$ref":"#/definitions/gitleaksPlugin"}}},"gitleaksPlugin":{"description":"Gitleaks secret-scanner plugin adapter.","type":"object","additionalProperties":false,"properties":{"enabled":{"description":"Whether the configured plugin should run.","type":"boolean","default":true},"command":{"description":"Executable name or path used to invoke Gitleaks.","type":"string","minLength":1,"default":"gitleaks"},"timeout_seconds":{"description":"Maximum plugin runtime before Pushgate treats the scan as timed out.","type":"integer","minimum":1,"default":60},"mode":{"$ref":"#/definitions/policyMode"},"fail_fast":{"description":"Whether a blocking Gitleaks failure stops later deterministic checks.","type":"boolean","default":true},"config_path":{"description":"Optional path to a Gitleaks TOML config file.","type":"string","minLength":1},"baseline_path":{"description":"Optional path to a Gitleaks JSON baseline report.","type":"string","minLength":1},"gitleaks_ignore_path":{"description":"Optional path to a .gitleaksignore file or containing folder.","type":"string","minLength":1},"redact":{"description":"Redact detected secret values in Gitleaks output and reports.","type":"boolean","default":true},"max_decode_depth":{"description":"Optional Gitleaks decode recursion depth.","type":"integer","minimum":0},"max_archive_depth":{"description":"Optional Gitleaks archive recursion depth.","type":"integer","minimum":0},"max_target_megabytes":{"description":"Optional file-size cap forwarded to Gitleaks.","type":"integer","minimum":1},"enable_rules":{"description":"Optional rule IDs to enable exclusively.","type":"array","items":{"type":"string","minLength":1}}}},"ai":{"type":"object","additionalProperties":false,"properties":{"mode":{"type":"string","enum":["blocking","advisory","off"],"default":"blocking"},"max_changed_lines":{"description":"Maximum total added plus deleted text lines before local AI review blocks the push.","type":"integer","minimum":1,"default":500},"max_prompt_tokens":{"description":"Approximate rendered prompt token budget before local AI review is skipped.","type":"integer","minimum":1,"default":12000},"timeout_seconds":{"description":"Maximum local AI provider runtime before the provider is treated as timed out.","type":"integer","minimum":1,"default":120},"provider":{"type":"string","minLength":1},"providers":{"type":"object","default":{},"propertyNames":{"minLength":1},"additionalProperties":{"$ref":"#/definitions/providerConfig"}}}},"providerConfig":{"description":"Provider-specific settings are the v2 extension boundary.","type":"object","additionalProperties":true}}}; const schema12 = {"type":"object","additionalProperties":false,"properties":{"target_branch":{"type":"string","minLength":1,"default":"main"},"context_lines":{"type":"integer","minimum":0,"default":10},"max_lines_for_full_file":{"type":"integer","minimum":1,"default":300}}}; const schema13 = {"type":"object","additionalProperties":false,"required":["name","command"],"properties":{"name":{"type":"string","minLength":1},"command":{"description":"Argv tokens for deterministic command execution.","type":"array","minItems":1,"items":{"type":"string","minLength":1}},"extensions":{"type":"array","items":{"type":"string","minLength":1}},"timeout_seconds":{"description":"Maximum runtime before the deterministic command is treated as timed out.","type":"integer","minimum":1,"default":60},"mode":{"description":"Whether command failures block the push or only warn locally.","type":"string","enum":["blocking","warning"],"default":"blocking"},"run":{"description":"Whether the command requires matching live changed files or always runs.","type":"string","enum":["changed_files","always"],"default":"changed_files"},"fail_fast":{"description":"Whether a blocking failure stops later deterministic command checks.","type":"boolean","default":true}}}; const func2 = ucs2length; const schema14 = {"description":"Optional built-in deterministic policy checks.","type":"object","additionalProperties":false,"default":{},"properties":{"diff_size":{"$ref":"#/definitions/diffSizePolicy"},"forbidden_paths":{"$ref":"#/definitions/forbiddenPathsPolicy"}}}; -const schema15 = {"type":"object","additionalProperties":false,"required":["max_changed_lines"],"properties":{"max_changed_lines":{"description":"Maximum total added plus deleted text lines allowed in the changed diff.","type":"integer","minimum":1},"mode":{"$ref":"#/definitions/policyMode"}}}; +const schema15 = {"type":"object","additionalProperties":false,"required":["max_changed_lines"],"properties":{"max_changed_lines":{"description":"Maximum total added plus deleted text lines allowed in the changed diff.","type":"integer","minimum":1},"mode":{"$ref":"#/definitions/policyMode"},"fail_fast":{"description":"Whether a blocking diff-size violation stops later deterministic checks.","type":"boolean","default":true}}}; const schema16 = {"description":"Whether a built-in policy violation blocks the push or only warns locally.","type":"string","enum":["blocking","warning"],"default":"blocking"}; function validate12(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){ @@ -63,7 +63,7 @@ vErrors.push(err0); errors++; } for(const key0 in data){ -if(!((key0 === "max_changed_lines") || (key0 === "mode"))){ +if(!(((key0 === "max_changed_lines") || (key0 === "mode")) || (key0 === "fail_fast"))){ const err1 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"}; if(vErrors === null){ vErrors = [err1]; @@ -122,9 +122,9 @@ vErrors.push(err5); errors++; } } -} -else { -const err6 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"}; +if(data.fail_fast !== undefined){ +if(typeof data.fail_fast !== "boolean"){ +const err6 = {instancePath:instancePath+"/fail_fast",schemaPath:"#/properties/fail_fast/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}; if(vErrors === null){ vErrors = [err6]; } @@ -133,11 +133,23 @@ vErrors.push(err6); } errors++; } +} +} +else { +const err7 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"}; +if(vErrors === null){ +vErrors = [err7]; +} +else { +vErrors.push(err7); +} +errors++; +} validate12.errors = vErrors; return errors === 0; } -const schema17 = {"type":"object","additionalProperties":false,"required":["patterns"],"properties":{"patterns":{"description":"Gitignore-like repo-relative path patterns that must not be pushed.","type":"array","minItems":1,"items":{"type":"string","minLength":1}},"mode":{"$ref":"#/definitions/policyMode"}}}; +const schema17 = {"type":"object","additionalProperties":false,"required":["patterns"],"properties":{"patterns":{"description":"Gitignore-like repo-relative path patterns that must not be pushed.","type":"array","minItems":1,"items":{"type":"string","minLength":1}},"mode":{"$ref":"#/definitions/policyMode"},"fail_fast":{"description":"Whether a blocking forbidden-path violation stops later deterministic checks.","type":"boolean","default":true}}}; function validate14(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){ let vErrors = null; @@ -154,7 +166,7 @@ vErrors.push(err0); errors++; } for(const key0 in data){ -if(!((key0 === "patterns") || (key0 === "mode"))){ +if(!(((key0 === "patterns") || (key0 === "mode")) || (key0 === "fail_fast"))){ const err1 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"}; if(vErrors === null){ vErrors = [err1]; @@ -239,9 +251,9 @@ vErrors.push(err7); errors++; } } -} -else { -const err8 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"}; +if(data.fail_fast !== undefined){ +if(typeof data.fail_fast !== "boolean"){ +const err8 = {instancePath:instancePath+"/fail_fast",schemaPath:"#/properties/fail_fast/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}; if(vErrors === null){ vErrors = [err8]; } @@ -250,6 +262,18 @@ vErrors.push(err8); } errors++; } +} +} +else { +const err9 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"}; +if(vErrors === null){ +vErrors = [err9]; +} +else { +vErrors.push(err9); +} +errors++; +} validate14.errors = vErrors; return errors === 0; } diff --git a/src/runner/deterministic-plan.ts b/src/runner/deterministic-plan.ts index a3207f1..affc746 100644 --- a/src/runner/deterministic-plan.ts +++ b/src/runner/deterministic-plan.ts @@ -61,6 +61,7 @@ function buildBuiltInPolicyEntries( policies: { diff_size: policies.diff_size, }, + failFast: policies.diff_size.fail_fast, resultName: "policy:diff_size", transformDetail: formatDiffSizeDisplayDetail, }), @@ -74,6 +75,7 @@ function buildBuiltInPolicyEntries( policies: { forbidden_paths: policies.forbidden_paths, }, + failFast: policies.forbidden_paths.fail_fast, resultName: "policy:forbidden_paths", }), ); @@ -85,6 +87,7 @@ function buildBuiltInPolicyEntries( function buildBuiltInPolicyEntry(options: { label: string; policies: BuiltInPoliciesConfig; + failFast: boolean; resultName: string; transformDetail?: (detail: string | undefined) => string | undefined; }): DeterministicCheckPlanEntry { @@ -92,7 +95,7 @@ function buildBuiltInPolicyEntry(options: { display: { label: options.label, }, - failFast: false, + failFast: options.failFast, async run(context) { const result = runBuiltInPolicies( options.policies, diff --git a/templates/base.yml b/templates/base.yml index 80f5094..76d946d 100644 --- a/templates/base.yml +++ b/templates/base.yml @@ -103,6 +103,7 @@ tools: [] # diff_size: # max_changed_lines: 500 # mode: warning # asks before continuing +# fail_fast: false # if blocking, continue later checks after a violation # # forbidden_paths: # patterns: @@ -111,6 +112,7 @@ tools: [] # - "secrets/**" # - "*.pem" # mode: blocking +# fail_fast: true # policies: {} diff --git a/test/config.test.ts b/test/config.test.ts index d917bef..5751c78 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -38,10 +38,12 @@ test("parses a representative v2 config with nested provider settings", async () diff_size: { max_changed_lines: 250, mode: "warning", + fail_fast: false, }, forbidden_paths: { patterns: [".env", "secrets/**"], mode: "blocking", + fail_fast: true, }, }); assert.equal(config.ai.mode, "advisory"); @@ -162,10 +164,12 @@ test("normalizes built-in policy defaults", () => { diff_size: { max_changed_lines: 200, mode: "blocking", + fail_fast: true, }, forbidden_paths: { patterns: [".env", "secrets/**"], mode: "blocking", + fail_fast: true, }, }); }); @@ -305,6 +309,18 @@ test("rejects invalid built-in policy settings", () => { ].join("\n"), /\/policies\/forbidden_paths\/mode must be equal to one of the allowed values/, ); + assertValidationError( + [ + "version: 2", + "ai:", + " mode: off", + "policies:", + " diff_size:", + " max_changed_lines: 100", + " fail_fast: eventually", + ].join("\n"), + /\/policies\/diff_size\/fail_fast must be boolean/, + ); }); test("rejects invalid Gitleaks plugin settings", () => { diff --git a/test/deterministic-runner.test.ts b/test/deterministic-runner.test.ts index 2da8f1f..972eb46 100644 --- a/test/deterministic-runner.test.ts +++ b/test/deterministic-runner.test.ts @@ -75,6 +75,7 @@ test("plans deterministic check count and changed-file needs in the runner modul diff_size: { max_changed_lines: 10, mode: "warning", + fail_fast: true, }, }, plugins: { @@ -328,6 +329,61 @@ test("fail_fast controls whether later tools run after blocking failures", async }); }); +test("fail_fast controls whether later built-in policies run after blocking failures", async () => { + const failFastOutput = captureOutput(); + const failFastSummary = await runChecks( + { + ...configWithTools([]), + policies: { + diff_size: { + max_changed_lines: 1, + mode: "blocking", + fail_fast: true, + }, + forbidden_paths: { + patterns: ["src/**"], + mode: "blocking", + fail_fast: true, + }, + }, + }, + { stdout: failFastOutput.stream }, + ); + + assert.equal(failFastSummary.exitCode, 1, failFastOutput.text()); + assert.deepEqual( + failFastSummary.results.map((result) => result.name), + ["policy:diff_size"], + ); + assert.match(failFastOutput.text(), /not run after fail_fast/); + + const aggregateOutput = captureOutput(); + const aggregateSummary = await runChecks( + { + ...configWithTools([]), + policies: { + diff_size: { + max_changed_lines: 1, + mode: "blocking", + fail_fast: false, + }, + forbidden_paths: { + patterns: ["src/**"], + mode: "blocking", + fail_fast: true, + }, + }, + }, + { stdout: aggregateOutput.stream }, + ); + + assert.equal(aggregateSummary.exitCode, 1, aggregateOutput.text()); + assert.deepEqual( + aggregateSummary.results.map((result) => result.name), + ["policy:diff_size", "policy:forbidden_paths"], + ); +}); + test("missing commands are handled according to tool mode", async () => { await withTempDir(async (repoRoot) => { const output = captureOutput(); @@ -488,10 +544,12 @@ test("runs built-in policies and makes warning versus blocking behavior explicit diff_size: { max_changed_lines: 2, mode: "warning", + fail_fast: true, }, forbidden_paths: { patterns: ["src/**"], mode: "blocking", + fail_fast: true, }, }, }, @@ -518,6 +576,7 @@ test("warning-mode built-in policy failures do not block", async () => { diff_size: { max_changed_lines: 1, mode: "warning", + fail_fast: true, }, }, }, diff --git a/test/fixtures/config/valid.yml b/test/fixtures/config/valid.yml index 754ba7d..118d951 100644 --- a/test/fixtures/config/valid.yml +++ b/test/fixtures/config/valid.yml @@ -24,6 +24,7 @@ policies: diff_size: max_changed_lines: 250 mode: warning + fail_fast: false forbidden_paths: patterns: - ".env"