diff --git a/.loopover.yml.example b/.loopover.yml.example index cd65173dc7..787b76d37a 100644 --- a/.loopover.yml.example +++ b/.loopover.yml.example @@ -202,12 +202,15 @@ gate: minScore: null # Oversized-PR gate. A PR at/above EITHER the file-count or line-count threshold - # (engine defaults, not configurable here) gets a manual-review HOLD finding — - # never a hard blocker; mode only turns this hold signal on or off. - # off | advisory | block. Default: off. Config-as-code only — no DB column or - # dashboard toggle; this can only be set here. + # gets a manual-review HOLD finding — never a hard blocker; mode only turns this + # hold signal on or off. off | advisory | block. Default: off. Config-as-code + # only — no DB column or dashboard toggle; this can only be set here. size: mode: off + # File-count threshold. Positive integer. Default: 10. + # maxFiles: 10 + # Changed (added+deleted) line-count threshold. Positive integer. Default: 1000. + # maxLines: 1000 # Lockfile-tamper-risk gate. Scans a changed package-lock.json diff for a # resolved/integrity value that changed WITHOUT the same package's version diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 366b62ea0a..125203c2b6 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -9659,6 +9659,12 @@ "appSlug" ] } + }, + "sizeGateMaxFiles": { + "type": "number" + }, + "sizeGateMaxLines": { + "type": "number" } }, "required": [ diff --git a/config/examples/loopover.full.yml b/config/examples/loopover.full.yml index 6dc4672b75..924293ff2d 100644 --- a/config/examples/loopover.full.yml +++ b/config/examples/loopover.full.yml @@ -216,12 +216,15 @@ gate: minScore: null # Oversized-PR gate. A PR at/above EITHER the file-count or line-count threshold - # (engine defaults, not configurable here) gets a manual-review HOLD finding — - # never a hard blocker; mode only turns this hold signal on or off. - # off | advisory | block. Default: off. Config-as-code only — no DB column or - # dashboard toggle; this can only be set here. + # gets a manual-review HOLD finding — never a hard blocker; mode only turns this + # hold signal on or off. off | advisory | block. Default: off. Config-as-code + # only — no DB column or dashboard toggle; this can only be set here. size: mode: off + # File-count threshold. Positive integer. Default: 10. + # maxFiles: 10 + # Changed (added+deleted) line-count threshold. Positive integer. Default: 1000. + # maxLines: 1000 # Lockfile-tamper-risk gate. Scans a changed package-lock.json diff for a # resolved/integrity value that changed WITHOUT the same package's version diff --git a/packages/loopover-engine/src/advisory/gate-advisory.ts b/packages/loopover-engine/src/advisory/gate-advisory.ts index aac5148f64..f97630379a 100644 --- a/packages/loopover-engine/src/advisory/gate-advisory.ts +++ b/packages/loopover-engine/src/advisory/gate-advisory.ts @@ -101,8 +101,13 @@ export type GateCheckPolicy = { /** PR-size HOLD (#gate-size). When set (advisory/block), a PR with >= sizeGateMaxFiles changed files OR * >= sizeGateMaxLines changed (added+deleted) lines that would OTHERWISE pass is HELD for manual review — a * neutral gate → "manual" verdict, never auto-merged and never a hard failure. Defaults off; thresholds default - * to 10 files / 1000 lines. This is a HOLD (advisory dry-run friendly), not a close. */ + * to 10 files / 1000 lines when sizeGateMaxFiles/sizeGateMaxLines are unset. This is a HOLD (advisory dry-run + * friendly), not a close. */ sizeGateMode?: GateRuleMode | undefined; + /** PR-size HOLD file-count threshold (#gate-size). `null`/undefined ⇒ the 10-file default. */ + sizeGateMaxFiles?: number | null | undefined; + /** PR-size HOLD changed-line-count threshold (#gate-size). `null`/undefined ⇒ the 1000-line default. */ + sizeGateMaxLines?: number | null | undefined; /** Lockfile-tamper-risk gate (#2563). When `block`, a `lockfile_tamper_risk` finding (produced by * review/lockfile-tamper.ts when a changed package-lock.json's resolved/integrity value changed without a * matching package.json version bump, or points off the npm registry) becomes a hard blocker. Defaults to @@ -388,16 +393,16 @@ function buildSizeHoldFinding(policy: GateCheckPolicy): AdvisoryFinding | null { if (files === undefined || files === null) files = 0; let lines = policy.changedLineCount; if (lines === undefined || lines === null) lines = 0; - if ( - files < SIZE_HOLD_DEFAULT_MAX_FILES && - lines < SIZE_HOLD_DEFAULT_MAX_LINES - ) - return null; + let maxFiles = policy.sizeGateMaxFiles; + if (maxFiles === undefined || maxFiles === null) maxFiles = SIZE_HOLD_DEFAULT_MAX_FILES; + let maxLines = policy.sizeGateMaxLines; + if (maxLines === undefined || maxLines === null) maxLines = SIZE_HOLD_DEFAULT_MAX_LINES; + if (files < maxFiles && lines < maxLines) return null; return { code: "oversized_pr", severity: "warning", title: "Large change — held for manual review", - detail: `This PR changes ${files} file(s) / ${lines} line(s) (hold threshold: ${SIZE_HOLD_DEFAULT_MAX_FILES} files or ${SIZE_HOLD_DEFAULT_MAX_LINES} lines).`, + detail: `This PR changes ${files} file(s) / ${lines} line(s) (hold threshold: ${maxFiles} files or ${maxLines} lines).`, action: "Split this into smaller, focused PRs, or a maintainer reviews and merges it manually.", }; } diff --git a/packages/loopover-engine/src/focus-manifest.ts b/packages/loopover-engine/src/focus-manifest.ts index 1eeabda074..3567cd5916 100644 --- a/packages/loopover-engine/src/focus-manifest.ts +++ b/packages/loopover-engine/src/focus-manifest.ts @@ -102,6 +102,8 @@ export type FocusManifestGateConfig = { slopMinScore: number | null; slopAiAdvisory: boolean | null; sizeMode: GateRuleMode | null; + sizeMaxFiles: number | null; + sizeMaxLines: number | null; /** `gate.lockfileIntegrity` (#2563): off|advisory|block, off by default. When not off, a changed * `package-lock.json` diff is scanned for a `resolved`/`integrity` change unaccompanied by a matching * `package.json` version bump, or a `resolved` URL outside `registry.npmjs.org` — a `lockfile_tamper_risk` @@ -981,6 +983,8 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = { slopMinScore: null, slopAiAdvisory: null, sizeMode: null, + sizeMaxFiles: null, + sizeMaxLines: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, @@ -1384,6 +1388,8 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu slopMinScore: normalizeOptionalScore(slopRecord?.minScore, "gate.slop.minScore", warnings), slopAiAdvisory: normalizeOptionalBoolean(slopRecord?.aiAdvisory, "gate.slop.aiAdvisory", warnings), sizeMode: normalizeOptionalGateMode(sizeRecord?.mode, "gate.size.mode", warnings), + sizeMaxFiles: normalizeOptionalPositiveInteger(sizeRecord?.maxFiles, "gate.size.maxFiles", warnings), + sizeMaxLines: normalizeOptionalPositiveInteger(sizeRecord?.maxLines, "gate.size.maxLines", warnings), lockfileIntegrityMode: normalizeOptionalGateMode(record.lockfileIntegrity, "gate.lockfileIntegrity", warnings), aiReviewMode: normalizeOptionalGateMode(aiReviewRecord?.mode, "gate.aiReview.mode", warnings), aiReviewByok: normalizeOptionalBoolean(aiReviewRecord?.byok, "gate.aiReview.byok", warnings), @@ -1450,6 +1456,8 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu gate.slopMinScore !== null || gate.slopAiAdvisory !== null || gate.sizeMode !== null || + gate.sizeMaxFiles !== null || + gate.sizeMaxLines !== null || gate.lockfileIntegrityMode !== null || gate.aiReviewMode !== null || gate.aiReviewByok !== null || @@ -1499,7 +1507,13 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue { if (gate.readinessMinScore !== null) readiness.minScore = gate.readinessMinScore; out.readiness = readiness; } - if (gate.sizeMode !== null) out.size = { mode: gate.sizeMode }; + if (gate.sizeMode !== null || gate.sizeMaxFiles !== null || gate.sizeMaxLines !== null) { + const size: Record = {}; + if (gate.sizeMode !== null) size.mode = gate.sizeMode; + if (gate.sizeMaxFiles !== null) size.maxFiles = gate.sizeMaxFiles; + if (gate.sizeMaxLines !== null) size.maxLines = gate.sizeMaxLines; + out.size = size; + } if (gate.lockfileIntegrityMode !== null) out.lockfileIntegrity = gate.lockfileIntegrityMode; if (gate.slopMode !== null || gate.slopMinScore !== null || gate.slopAiAdvisory !== null) { const slop: Record = {}; diff --git a/packages/loopover-engine/src/predicted-gate.ts b/packages/loopover-engine/src/predicted-gate.ts index 0523ea797c..921924d9db 100644 --- a/packages/loopover-engine/src/predicted-gate.ts +++ b/packages/loopover-engine/src/predicted-gate.ts @@ -308,6 +308,8 @@ export function buildPredictedGateVerdict(args: { // never sent to this predictor, so the size hold can only be predicted from file count (disclosed in the // note above) — never claim a line count this function has no way to know. sizeGateMode: gate.sizeMode ?? undefined, + sizeGateMaxFiles: gate.sizeMaxFiles ?? undefined, + sizeGateMaxLines: gate.sizeMaxLines ?? undefined, ...(hasChangedPaths ? { changedFileCount: changedPaths.length, diff --git a/packages/loopover-engine/src/types/manifest-deps-types.ts b/packages/loopover-engine/src/types/manifest-deps-types.ts index 57f5ee0cf4..9491e554ba 100644 --- a/packages/loopover-engine/src/types/manifest-deps-types.ts +++ b/packages/loopover-engine/src/types/manifest-deps-types.ts @@ -185,9 +185,16 @@ export type RepositorySettings = { * only, applies to every author like every blocker). Default `off` — opt-in via .loopover.yml. */ slopGateMode: GateRuleMode; /** PR-size manual-review HOLD (#gate-size). `off` (default/absent) = no size hold; `advisory`/`block` = a PR with - * >= 10 changed files OR >= 1000 changed (added+deleted) lines that would otherwise pass is HELD for manual review - * (neutral gate → "manual" verdict), never auto-merged and never a hard failure. Opt-in via `gate.size.mode`. */ + * >= sizeGateMaxFiles changed files OR >= sizeGateMaxLines changed (added+deleted) lines that would otherwise + * pass is HELD for manual review (neutral gate → "manual" verdict), never auto-merged and never a hard failure. + * Opt-in via `gate.size.mode`. */ sizeGateMode?: GateRuleMode | undefined; + /** PR-size HOLD file-count threshold (#gate-size), config-only — set via `.loopover.yml gate.size.maxFiles`. + * `undefined` ⇒ the 10-file default. */ + sizeGateMaxFiles?: number | undefined; + /** PR-size HOLD changed-line-count threshold (#gate-size), config-only — set via `.loopover.yml + * gate.size.maxLines`. `undefined` ⇒ the 1000-line default. */ + sizeGateMaxLines?: number | undefined; /** Lockfile-tamper-risk gate (#2563). `off` (default/absent) = no scan; `advisory`/`block` = a changed * `package-lock.json` whose diff changes a `resolved`/`integrity` value WITHOUT the same package's version * changing in a changed `package.json`, or whose `resolved` URL points outside `registry.npmjs.org`, produces diff --git a/packages/loopover-engine/src/types/predicted-gate-types.ts b/packages/loopover-engine/src/types/predicted-gate-types.ts index cbdd07bb53..ba2d8b2ef8 100644 --- a/packages/loopover-engine/src/types/predicted-gate-types.ts +++ b/packages/loopover-engine/src/types/predicted-gate-types.ts @@ -292,6 +292,8 @@ export type FocusManifestGateConfig = { slopMinScore: number | null; slopAiAdvisory: boolean | null; sizeMode: GateRuleMode | null; + sizeMaxFiles: number | null; + sizeMaxLines: number | null; lockfileIntegrityMode: GateRuleMode | null; aiReviewMode: GateRuleMode | null; aiReviewByok: boolean | null; diff --git a/scripts/check-docs-drift.mjs b/scripts/check-docs-drift.mjs index 2a62945439..b3fd46d913 100644 --- a/scripts/check-docs-drift.mjs +++ b/scripts/check-docs-drift.mjs @@ -140,6 +140,8 @@ export const SETTINGS_ALIAS_MANIFEST = [ { field: "aiReviewOnMerge", aliases: ["onMerge"] }, { field: "aiReviewReviewers", aliases: ["reviewers:"] }, { field: "requireFreshRebaseWindowMinutes", aliases: ["requireFreshRebaseWindow"] }, + { field: "sizeGateMaxFiles", aliases: ["maxFiles"] }, + { field: "sizeGateMaxLines", aliases: ["maxLines"] }, ]; /** camelCase -> snake_case, matching the casing convention `.loopover.yml`'s `review:` block (and everything diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 2337271c88..efea7af2fb 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -681,6 +681,8 @@ export const RepositorySettingsSchema = z qualityGateMinScore: z.number().nullable().optional(), slopGateMode: z.enum(["off", "advisory", "block"]), sizeGateMode: z.enum(["off", "advisory", "block"]).optional(), + sizeGateMaxFiles: z.number().optional(), + sizeGateMaxLines: z.number().optional(), lockfileIntegrityGateMode: z.enum(["off", "advisory", "block"]).optional(), claGateMode: z.enum(["off", "advisory", "block"]).optional(), claConsentPhrase: z.string().nullable().optional(), diff --git a/src/queue/gate-checks.ts b/src/queue/gate-checks.ts index 2106ddc783..378885eadb 100644 --- a/src/queue/gate-checks.ts +++ b/src/queue/gate-checks.ts @@ -99,10 +99,12 @@ export function gateCheckPolicy( slopGateMinScore: settings.slopGateMinScore ?? null, slopRisk: slopRisk ?? null, confirmedContributor: confirmedContributorForPack, - // PR-size + guardrail manual-review HOLD (#gate-size / #gate-guardrail): the MODE comes from config; the - // thresholds default to 10 files / 1000 lines (advisory.ts constants); the live counts + guardrail-hit come from - // the per-PR sizeContext threaded by the caller. + // PR-size + guardrail manual-review HOLD (#gate-size / #gate-guardrail): the mode AND thresholds come from + // config (`gate.size.mode`/`maxFiles`/`maxLines`), falling back to advisory.ts's 10-file/1000-line constants + // when unset; the live counts + guardrail-hit come from the per-PR sizeContext threaded by the caller. sizeGateMode: settings.sizeGateMode, + sizeGateMaxFiles: settings.sizeGateMaxFiles ?? null, + sizeGateMaxLines: settings.sizeGateMaxLines ?? null, lockfileIntegrityGateMode: settings.lockfileIntegrityGateMode, changedFileCount: sizeContext?.changedFileCount ?? null, changedLineCount: sizeContext?.changedLineCount ?? null, diff --git a/src/rules/advisory.ts b/src/rules/advisory.ts index a4ae3d15b9..bc9a76c02c 100644 --- a/src/rules/advisory.ts +++ b/src/rules/advisory.ts @@ -112,8 +112,13 @@ export type GateCheckPolicy = { /** PR-size HOLD (#gate-size). When set (advisory/block), a PR with >= sizeGateMaxFiles changed files OR * >= sizeGateMaxLines changed (added+deleted) lines that would OTHERWISE pass is HELD for manual review — a * neutral gate → "manual" verdict, never auto-merged and never a hard failure. Defaults off; thresholds default - * to 10 files / 1000 lines. This is a HOLD (advisory dry-run friendly), not a close. */ + * to 10 files / 1000 lines when sizeGateMaxFiles/sizeGateMaxLines are unset. This is a HOLD (advisory dry-run + * friendly), not a close. */ sizeGateMode?: GateRuleMode | undefined; + /** PR-size HOLD file-count threshold (#gate-size). `null`/undefined ⇒ the 10-file default. */ + sizeGateMaxFiles?: number | null | undefined; + /** PR-size HOLD changed-line-count threshold (#gate-size). `null`/undefined ⇒ the 1000-line default. */ + sizeGateMaxLines?: number | null | undefined; /** Lockfile-tamper-risk gate (#2563). When `block`, a `lockfile_tamper_risk` finding (produced by * review/lockfile-tamper.ts when a changed package-lock.json's resolved/integrity value changed without a * matching package.json version bump, or points off the npm registry) becomes a hard blocker. Defaults to @@ -517,16 +522,14 @@ function buildSizeHoldFinding(policy: GateCheckPolicy): AdvisoryFinding | null { if (!policy.sizeGateMode || policy.sizeGateMode === "off") return null; const files = policy.changedFileCount ?? 0; const lines = policy.changedLineCount ?? 0; - if ( - files < SIZE_HOLD_DEFAULT_MAX_FILES && - lines < SIZE_HOLD_DEFAULT_MAX_LINES - ) - return null; + const maxFiles = policy.sizeGateMaxFiles ?? SIZE_HOLD_DEFAULT_MAX_FILES; + const maxLines = policy.sizeGateMaxLines ?? SIZE_HOLD_DEFAULT_MAX_LINES; + if (files < maxFiles && lines < maxLines) return null; return { code: "oversized_pr", severity: "warning", title: "Large change — held for manual review", - detail: `This PR changes ${files} file(s) / ${lines} line(s) (hold threshold: ${SIZE_HOLD_DEFAULT_MAX_FILES} files or ${SIZE_HOLD_DEFAULT_MAX_LINES} lines).`, + detail: `This PR changes ${files} file(s) / ${lines} line(s) (hold threshold: ${maxFiles} files or ${maxLines} lines).`, action: "Split this into smaller, focused PRs, or a maintainer reviews and merges it manually.", }; } diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 289324d35b..76889b929d 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -470,6 +470,8 @@ function applyGateConfigOverrides(effective: RepositorySettings, gate: FocusMani if (gate.readinessMode !== null) effective.qualityGateMode = gate.readinessMode; if (gate.readinessMinScore !== null) effective.qualityGateMinScore = gate.readinessMinScore; if (gate.sizeMode !== null) effective.sizeGateMode = gate.sizeMode; + if (gate.sizeMaxFiles !== null) effective.sizeGateMaxFiles = gate.sizeMaxFiles; + if (gate.sizeMaxLines !== null) effective.sizeGateMaxLines = gate.sizeMaxLines; if (gate.lockfileIntegrityMode !== null) effective.lockfileIntegrityGateMode = gate.lockfileIntegrityMode; if (gate.slopMode !== null) effective.slopGateMode = gate.slopMode; if (gate.slopMinScore !== null) effective.slopGateMinScore = gate.slopMinScore; diff --git a/src/types.ts b/src/types.ts index be3f73bae9..ff0ef4353c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -755,9 +755,16 @@ export type RepositorySettings = { * only, applies to every author like every blocker). Default `off` — opt-in via .loopover.yml. */ slopGateMode: GateRuleMode; /** PR-size manual-review HOLD (#gate-size). `off` (default/absent) = no size hold; `advisory`/`block` = a PR with - * >= 10 changed files OR >= 1000 changed (added+deleted) lines that would otherwise pass is HELD for manual review - * (neutral gate → "manual" verdict), never auto-merged and never a hard failure. Opt-in via `gate.size.mode`. */ + * >= sizeGateMaxFiles changed files OR >= sizeGateMaxLines changed (added+deleted) lines that would otherwise + * pass is HELD for manual review (neutral gate → "manual" verdict), never auto-merged and never a hard failure. + * Opt-in via `gate.size.mode`. */ sizeGateMode?: GateRuleMode | undefined; + /** PR-size HOLD file-count threshold (#gate-size), config-only (no DB column, mirrors sizeGateMode) — set via + * `.loopover.yml gate.size.maxFiles`. `undefined` ⇒ the 10-file default (src/rules/advisory.ts). */ + sizeGateMaxFiles?: number | undefined; + /** PR-size HOLD changed-line-count threshold (#gate-size), config-only (no DB column, mirrors sizeGateMode) — + * set via `.loopover.yml gate.size.maxLines`. `undefined` ⇒ the 1000-line default (src/rules/advisory.ts). */ + sizeGateMaxLines?: number | undefined; /** Lockfile-tamper-risk gate (#2563). `off` (default/absent) = no scan; `advisory`/`block` = a changed * `package-lock.json` whose diff changes a `resolved`/`integrity` value WITHOUT the same package's version * changing in a changed `package.json`, or whose `resolved` URL points outside `registry.npmjs.org`, produces diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index fcba9bbfa0..96e1ab93d8 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -257,6 +257,8 @@ describe(".loopover.yml.example field-exhaustiveness (#1670)", () => { slopMinScore: "slop:", slopAiAdvisory: "aiAdvisory:", sizeMode: "size:", + sizeMaxFiles: "size:", + sizeMaxLines: "size:", lockfileIntegrityMode: "lockfileIntegrity:", aiReviewMode: "aiReview:", aiReviewByok: "byok:", @@ -869,7 +871,7 @@ describe("compileFocusManifestPolicy", () => { issueDiscoveryPolicy: "neutral", maintainerNotes: [], publicNotes: ["Keep PRs focused.", "Maximize your reward payout"], - gate: { present: false, enabled: null, checkMode: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewLowConfidenceDisposition: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, linkedIssueSatisfaction: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, claMode: null, claConsentPhrase: null, claCheckRunName: null, claCheckRunAppSlug: null, expectedCiContexts: null, advisoryCheckRuns: null, aiJudgmentBlockersMode: null, copycatMode: null, copycatMinScore: null }, + gate: { present: false, enabled: null, checkMode: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, sizeMaxFiles: null, sizeMaxLines: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewLowConfidenceDisposition: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, linkedIssueSatisfaction: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, claMode: null, claConsentPhrase: null, claCheckRunName: null, claCheckRunAppSlug: null, expectedCiContexts: null, advisoryCheckRuns: null, aiJudgmentBlockersMode: null, copycatMode: null, copycatMinScore: null }, settings: {}, review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: null, e2eTestDelivery: null, e2eTestAutoTrigger: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null }, features: { present: false, rag: null, reputation: null, unifiedComment: null, safety: null, grounding: null, e2eTests: null, screenshots: null, improvementSignal: null }, @@ -1181,7 +1183,7 @@ describe("parseFocusManifest gate config", () => { // the block→advisory deprecation-downgrade behavior itself is covered separately below. const m = parseFocusManifest({ gate: { linkedIssue: "block", duplicates: "advisory", readiness: { mode: "advisory", minScore: 70 } } }); expect(m.present).toBe(true); - expect(m.gate).toEqual({ present: true, enabled: null, checkMode: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "advisory", readinessMinScore: 70, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewLowConfidenceDisposition: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, linkedIssueSatisfaction: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, claMode: null, claConsentPhrase: null, claCheckRunName: null, claCheckRunAppSlug: null, expectedCiContexts: null, advisoryCheckRuns: null, aiJudgmentBlockersMode: null, copycatMode: null, copycatMinScore: null }); + expect(m.gate).toEqual({ present: true, enabled: null, checkMode: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "advisory", readinessMinScore: 70, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, sizeMaxFiles: null, sizeMaxLines: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewLowConfidenceDisposition: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, linkedIssueSatisfaction: null, manifestPolicy: null, dryRun: null, firstTimeContributorGrace: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, claMode: null, claConsentPhrase: null, claCheckRunName: null, claCheckRunAppSlug: null, expectedCiContexts: null, advisoryCheckRuns: null, aiJudgmentBlockersMode: null, copycatMode: null, copycatMinScore: null }); }); it("parses gate.mergeReadiness + gate.firstTimeContributorGrace, round-trips them, and warns on bad values (#822)", () => { diff --git a/test/unit/gate-check-policy.test.ts b/test/unit/gate-check-policy.test.ts index 5e6231549f..7482b55c6e 100644 --- a/test/unit/gate-check-policy.test.ts +++ b/test/unit/gate-check-policy.test.ts @@ -916,6 +916,29 @@ describe("size + guardrail manual-review HOLD (#gate-size / #gate-guardrail)", ( const eff = resolveEffectiveSettings(settings({}), parseFocusManifest({ gate: { size: { mode: "advisory" } } })); expect(eff.sizeGateMode).toBe("advisory"); }); + it("gateCheckPolicy threads settings.sizeGateMaxFiles/sizeGateMaxLines through to the policy object", () => { + const policy = gateCheckPolicy(settings({ sizeGateMode: "advisory", sizeGateMaxFiles: 30, sizeGateMaxLines: 2000 } as Partial), null, true); + expect(policy.sizeGateMaxFiles).toBe(30); + expect(policy.sizeGateMaxLines).toBe(2000); + }); + it("gateCheckPolicy defaults sizeGateMaxFiles/sizeGateMaxLines to null when unset on settings", () => { + const policy = gateCheckPolicy(settings({ sizeGateMode: "advisory" }), null, true); + expect(policy.sizeGateMaxFiles).toBeNull(); + expect(policy.sizeGateMaxLines).toBeNull(); + }); + it("resolveEffectiveSettings maps gate.size.maxFiles/maxLines → sizeGateMaxFiles/sizeGateMaxLines (#automation-config)", () => { + const eff = resolveEffectiveSettings(settings({}), parseFocusManifest({ gate: { size: { mode: "advisory", maxFiles: 25, maxLines: 400 } } })); + expect(eff.sizeGateMaxFiles).toBe(25); + expect(eff.sizeGateMaxLines).toBe(400); + }); + it("configured sizeGateMaxFiles/sizeGateMaxLines REPLACE the 10-file/1000-line defaults, in both directions", () => { + // A raised maxFiles threshold: a PR that would hold under the 10-file default now passes. + expect(evaluateGateCheck(clean(), { sizeGateMode: "advisory", sizeGateMaxFiles: 25, changedFileCount: 12, changedLineCount: 10 }).conclusion).toBe("success"); + // A lowered maxLines threshold: a PR that would pass under the 1000-line default now holds. + expect(evaluateGateCheck(clean(), { sizeGateMode: "advisory", sizeGateMaxLines: 400, changedFileCount: 2, changedLineCount: 500 }).conclusion).toBe("neutral"); + // null (as opposed to a configured number) falls back to the built-in default, same as unset. + expect(evaluateGateCheck(clean(), { sizeGateMode: "advisory", sizeGateMaxFiles: null, changedFileCount: 9, changedLineCount: 999 }).conclusion).toBe("success"); + }); }); describe("lockfile-tamper-risk gate blocker (#2563)", () => {