From b01bfa6743bfdcf0791458adfa187cfebc77a99a Mon Sep 17 00:00:00 2001 From: jimcody1995 Date: Tue, 7 Jul 2026 07:37:58 +0200 Subject: [PATCH 1/4] feat(engine): extract focus-manifest parse/compile core (#2280) Co-authored-by: Cursor --- .../gittensory-engine/src/focus-manifest.ts | 2918 ++++++++++++++++ packages/gittensory-engine/src/index.ts | 62 + .../src/review/enrichment-analyzer-names.ts | 65 + .../review/linked-issue-hard-rules-config.ts | 85 + .../review/linked-issue-label-propagation.ts | 93 + .../gittensory-engine/src/review/safe-url.ts | 117 + .../src/review/screenshot-table-gate.ts | 190 ++ .../review/unlinked-issue-guardrail-config.ts | 47 + .../src/settings/auto-close-exempt.ts | 57 + .../src/settings/autonomy.ts | 91 + .../src/settings/command-authorization.ts | 218 ++ .../src/settings/contributor-blacklist.ts | 91 + .../src/settings/moderation-rules.ts | 128 + .../src/settings/pr-type-label.ts | 165 + .../src/types/manifest-deps-types.ts | 484 +++ src/signals/focus-manifest.ts | 3003 +---------------- .../unit/focus-manifest-engine-barrel.test.ts | 24 + ...us-manifest-engine-branch-coverage.test.ts | 114 + 18 files changed, 5047 insertions(+), 2905 deletions(-) create mode 100644 packages/gittensory-engine/src/focus-manifest.ts create mode 100644 packages/gittensory-engine/src/review/enrichment-analyzer-names.ts create mode 100644 packages/gittensory-engine/src/review/linked-issue-hard-rules-config.ts create mode 100644 packages/gittensory-engine/src/review/linked-issue-label-propagation.ts create mode 100644 packages/gittensory-engine/src/review/safe-url.ts create mode 100644 packages/gittensory-engine/src/review/screenshot-table-gate.ts create mode 100644 packages/gittensory-engine/src/review/unlinked-issue-guardrail-config.ts create mode 100644 packages/gittensory-engine/src/settings/auto-close-exempt.ts create mode 100644 packages/gittensory-engine/src/settings/autonomy.ts create mode 100644 packages/gittensory-engine/src/settings/command-authorization.ts create mode 100644 packages/gittensory-engine/src/settings/contributor-blacklist.ts create mode 100644 packages/gittensory-engine/src/settings/moderation-rules.ts create mode 100644 packages/gittensory-engine/src/settings/pr-type-label.ts create mode 100644 packages/gittensory-engine/src/types/manifest-deps-types.ts create mode 100644 test/unit/focus-manifest-engine-barrel.test.ts create mode 100644 test/unit/focus-manifest-engine-branch-coverage.test.ts diff --git a/packages/gittensory-engine/src/focus-manifest.ts b/packages/gittensory-engine/src/focus-manifest.ts new file mode 100644 index 0000000000..915a2ad831 --- /dev/null +++ b/packages/gittensory-engine/src/focus-manifest.ts @@ -0,0 +1,2918 @@ +/** + * Focus-manifest parse/compile core (#2280). Extracted to `@jsonbored/gittensory-engine` so the maintainer + * review stack and the miner's goal-spec parser share identical, versioned manifest logic instead of drifting + * apart. This is the MINER-side parse-pattern template for {@link MinerGoalSpec} (`.gittensory-miner.yml`) — + * same tolerant-parser shape: typed config + safe defaults + warnings, never throws. + * + * App-local resolver/guidance functions (`resolveEffectiveSettings`, `buildFocusManifestGuidance`, etc.) remain + * in `src/signals/focus-manifest.ts` as a shim over this module. + */ +import { parse as parseYaml } from "yaml"; +import type { + CombineStrategy, + GatePolicyPack, + GateRuleMode, + JsonValue, + LinkedIssueHardRulesConfig, + LinkedIssueLabelPropagationConfig, + OnMerge, + PrTypeLabelSet, + RepositorySettings, + ReviewCheckMode, + ScreenshotTableGateConfig, + UnlinkedIssueGuardrailConfig, +} from "./types/manifest-deps-types.js"; +import { normalizeAutonomyPolicy, normalizeAutoMaintainPolicy } from "./settings/autonomy.js"; +import { normalizeCommandAuthorizationPolicy } from "./settings/command-authorization.js"; +import { normalizeContributorBlacklist } from "./settings/contributor-blacklist.js"; +import { normalizeAutoCloseExemptLogins } from "./settings/auto-close-exempt.js"; +import { DEFAULT_TYPE_LABELS, MAX_TYPE_LABEL_NAME_LENGTH, normalizeTypeLabelSet } from "./settings/pr-type-label.js"; +import { + DEFAULT_LINKED_ISSUE_LABEL_PROPAGATION, + normalizeLinkedIssueLabelPropagationConfig, + VALID_LINKED_ISSUE_LABEL_PROPAGATION_MODES, +} from "./review/linked-issue-label-propagation.js"; +import { + DEFAULT_LINKED_ISSUE_HARD_RULES, + isLinkedIssueHardRuleMode, + normalizeLinkedIssueHardRulesConfig, +} from "./review/linked-issue-hard-rules-config.js"; +import { + DEFAULT_UNLINKED_ISSUE_GUARDRAIL, + isUnlinkedIssueGuardrailMode, + normalizeUnlinkedIssueGuardrailConfig, +} from "./review/unlinked-issue-guardrail-config.js"; +import { + DEFAULT_SCREENSHOT_TABLE_GATE, + isScreenshotTableGateAction, + normalizeScreenshotTableGateConfig, +} from "./review/screenshot-table-gate.js"; +import { normalizeModerationLabel, normalizeModerationRules } from "./settings/moderation-rules.js"; +import { REES_ANALYZER_NAME_SET, type ReesAnalyzerName } from "./review/enrichment-analyzer-names.js"; +import { hasUnsafeWildcardCount } from "./signals/change-guardrail.js"; +import { isSafeHttpUrl } from "./review/safe-url.js"; + +/** Canonical local-filesystem-root vocabulary for public-safety filtering (from `src/signals/redaction.ts`). */ +const PUBLIC_LOCAL_PATH_INLINE = String.raw`/Users/|/home/|/root/|/var/|/opt/|/tmp/|/private/|[A-Za-z]:[\\/]Users[\\/]|[A-Za-z]:[\\/]Program Files[\\/]`; + +export type FocusManifestSource = "repo_file" | "api_record" | "none"; +export type FocusManifestLinkedIssuePolicy = "required" | "preferred" | "optional"; +export type FocusManifestIssueDiscoveryPolicy = "encouraged" | "neutral" | "discouraged"; + +/** + * Maintainer-authored gate configuration declared as code in `.gittensory.yml` under `gate:`. Each + * field is `null` when the maintainer did not set it, so the resolver can layer the manifest OVER the + * DB-backed RepositorySettings (manifest > DB > safe defaults) without clobbering unset values. All + * of these flow through the SAME confirmed-contributor-gated `evaluateGateCheck` path — the manifest + * only chooses which deterministic blockers are active, never who can be blocked. Turning the gate + * itself on/off stays a repository setting (`gateCheckMode`); `.gittensory.yml gate:` refines the + * blocker policy of an already-enabled gate. `checkMode` (#2852) is a separate, more expressive axis: + * whether/how the "Gittensory Orb Review Agent" check-RUN publishes, independent of gate evaluation + * itself (which always runs regardless of `checkMode`/`enabled`) — see {@link ReviewCheckMode}. + */ +export type FocusManifestGateConfig = { + present: boolean; + enabled: boolean | null; + /** `gate.checkMode` (#2852): explicit required|visible|disabled review-check publish mode. Takes + * precedence over the legacy `enabled` boolean below when both are set (see resolveEffectiveSettings). + * null (unset) ⇒ fall back to `enabled`, then to `settings.reviewCheckMode` (DB/dashboard), then default. */ + checkMode: ReviewCheckMode | null; + pack: GatePolicyPack | null; + linkedIssue: GateRuleMode | null; + duplicates: GateRuleMode | null; + readinessMode: GateRuleMode | null; + readinessMinScore: number | null; + slopMode: GateRuleMode | null; + slopMinScore: number | null; + slopAiAdvisory: boolean | null; + sizeMode: GateRuleMode | 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` + * finding (`block` additionally hard-blocks). Config-as-code only — no DB column or dashboard toggle. */ + lockfileIntegrityMode: GateRuleMode | null; + aiReviewMode: GateRuleMode | null; + aiReviewByok: boolean | null; + aiReviewProvider: "anthropic" | "openai" | null; + aiReviewModel: string | null; + aiReviewAllAuthors: boolean | null; + /** `gate.aiReview.closeConfidence` (#7): minimum calibrated AI-reviewer confidence (0-1) for an AI defect to BLOCK + * under `aiReview.mode: block`. null (unset) ⇒ the gate's 0.93 default. Clamped to [0,1] at parse time. */ + aiReviewCloseConfidence: number | null; + /** `gate.aiReview.combine` (#2567): per-repo override of the self-host operator's `AI_REVIEW_PLAN.combine` + * boot default (single/consensus/synthesis). null (unset) ⇒ the operator's plan (or `consensus`). A + * REFINEMENT only — see {@link aiReviewOnMerge} for the operator-floor clamp `runGittensoryAiReview` applies + * to the paired `onMerge` field; `combine` itself is not floor-clamped (the three strategies are not ordered + * by strictness, so there is no single "loosening" direction to clamp). */ + aiReviewCombine: CombineStrategy | null; + /** `gate.aiReview.onMerge` (#2567): per-repo override of the `synthesis` merge rule. `either` is the STRICTER + * rule (any one reviewer's blocker blocks/holds); `both` is more PERMISSIVE (requires every reviewer to + * agree). null (unset) ⇒ the operator's `AI_REVIEW_PLAN.onMerge`. A repo may only TIGHTEN the operator's + * floor (never loosen `either` down to `both`) — `runGittensoryAiReview` enforces the clamp at resolve time, + * since only it can see both the per-repo value and the operator's plan. */ + aiReviewOnMerge: OnMerge | null; + /** `gate.aiReview.reviewers` (#2567): per-repo override of the named reviewer pair(s) to run, in place of the + * operator's `AI_REVIEW_PLAN.reviewers` (or the free Workers-AI pair when the operator configured none). null + * (unset) ⇒ the operator's plan. No operator floor applies to WHICH reviewers run (only `onMerge` gates + * strictness), so this always wins unclamped when set. */ + aiReviewReviewers: ReadonlyArray<{ model: string; fallback?: string | null | undefined }> | null; + mergeReadiness: GateRuleMode | null; + manifestPolicy: GateRuleMode | null; + selfAuthoredLinkedIssue: GateRuleMode | null; + dryRun: boolean | null; + firstTimeContributorGrace: boolean | null; + /** `gate.premergeContentRecheck` (#2550): for a PR touching `migrations/**`, re-verify against a live, + * freshly-fetched tip of the base branch — unioned with this PR's own new migration filenames — for a + * migration-number collision immediately before an agent-driven merge, not just at CI time against the + * PR's own stale branch snapshot. On a live collision, the merge is suppressed and the PR is held with a + * rebase-needed comment instead of merging blind. null (unset) ⇒ off (byte-identical to today) — this + * costs one extra, uncached GitHub Trees-API call for any PR that touches migrations/**, so it is opt-in + * rather than a new default. */ + premergeContentRecheck: boolean | null; + /** `gate.requireFreshRebaseWindow` (#2552, anti-race): minutes. When the base branch has advanced within + * this window of the actual merge-decision moment, an agent-driven merge forces an `update_branch` + + * fresh CI recheck cycle before merging, instead of trusting a `mergeableState: clean` read that may + * already be stale relative to a sibling commit that just landed on the base. null (unset) ⇒ never force + * (byte-identical to today) — a discrete positive-minutes count, not a score, so it is neither clamped + * nor rounded; an invalid value (fractional, non-positive, non-finite) is dropped with a warning. */ + requireFreshRebaseWindowMinutes: number | null; + /** `gate.claMode` (#2564): off/advisory/block. null (unset) ⇒ off (byte-identical to today) — a repo must + * explicitly opt in before any CLA consent check runs. */ + claMode: GateRuleMode | null; + /** `gate.cla.consentPhrase` (#2564): the required PR-body consent phrase. null (unset) ⇒ phrase-match + * detection is not configured. */ + claConsentPhrase: string | null; + /** `gate.cla.checkRunName` (#2564): the CLA-bot check-run name to trust. null (unset) ⇒ check-run + * detection is not configured. */ + claCheckRunName: string | null; + /** `gate.cla.checkRunAppSlug`: the trusted GitHub App slug that must produce `checkRunName`. null (unset) ⇒ + * check-run detection remains unresolved rather than trusting a spoofable name-only match. */ + claCheckRunAppSlug: string | null; + /** `gate.expectedCiContexts` (#selfhost-ci-verification): CI check/status context names to treat as + * required when GitHub branch-protection required-status-checks are unreadable or unconfigured. null + * (unset) ⇒ no generic fallback configured — the live-CI aggregate keeps today's fold-all behavior + * when branch protection is also unreadable. See {@link RepositorySettings.expectedCiContexts}. */ + expectedCiContexts: ReadonlyArray | null; +}; + +// The converged per-PR review features a self-host operator toggles PER-REPO under `features:` in the private +// `.gittensory.yml`. Each feature ALSO has a GLOBAL env flag (GITTENSORY_REVIEW_*) that stays a master +// kill-switch (the feature never runs when its env flag is off, regardless of this block). See +// review/feature-activation.ts for the resolver (env kill-switch → per-repo override → env-allowlist default). +// NOTE: only the per-PR REVIEW features whose every activation site is migrated are listed here. grounding and +// screenshots stay on the GITTENSORY_REVIEW_REPOS allowlist for now (grounding is coupled to the merge/close +// DISPOSITION path; screenshots' capture path needs dedicated coverage) — a follow-up. contentLane got its own +// richer `contentLane:` block below (#2435) instead of a boolean here, since it resolves to a whole +// RegistryLaneSpec, not an on/off toggle — see resolveRegistryLaneSpec in review/content-lane/spec-resolver.ts. +export const CONVERGED_FEATURE_KEYS = ["rag", "reputation", "unifiedComment", "safety"] as const; +export type ConvergedFeatureKey = (typeof CONVERGED_FEATURE_KEYS)[number]; + +/** Per-repo activation overrides for the converged review features (`features:` block). `true`/`false` force the + * feature on/off for THIS repo (subject to the env kill-switch); `null` (unset) ⇒ the resolver falls back to the + * `GITTENSORY_REVIEW_REPOS` allowlist default, so an operator who sets nothing keeps today's behavior. */ +export type FocusManifestFeaturesConfig = { present: boolean } & Record; + +/** + * Per-repo registry-review lane configuration (`contentLane:` block, #2435) — lets a self-hosted maintainer + * configure their OWN registry (structural file-scope patterns + entry-count cap + dedup fields) without a + * gittensory code change. `entryFileGlob` and `collectionField` are the two REQUIRED fields to build a usable + * spec; `present` is true only when both are set (a partial config degrades to "not configured," not a broken + * half-spec — see `parseContentLaneConfig`). `validatorId` optionally references a code-registered domain + * validator (`review/content-lane/spec-resolver.ts`'s `REGISTRY_VALIDATORS`); omitted ⇒ structural gating only + * (scope/count/dedup), no domain-specific semantic check — see `RegistryLaneSpec.assessAppendedEntry`. + */ +export type FocusManifestContentLaneConfig = { + present: boolean; + entryFileGlob: string | null; + providerFileGlob: string | null; + artifactGlob: string | null; + collectionField: string | null; + maxAppendedEntries: number | null; + duplicateKeyFields: string[]; + validatorId: string | null; +}; + +/** Which generated-file types the repo-doc generation roadmap (#2993) is allowed to touch for a repo. + * "agents" covers AGENTS.md/CLAUDE.md (#3000/#3004); "skills" covers generated Claude Code/Codex skill + * files once that generator lands (#3001) -- listed here now so a maintainer can opt in ahead of time. */ +export type FocusManifestRepoDocGenerationScope = "agents" | "skills"; + +/** + * Per-repo opt-in for the repo-doc generation roadmap (#2993/#3002), declared as code under + * `repoDocGeneration:`. Purely a `.gittensory.yml` surface -- there is no DB-backed dashboard counterpart, + * so precedence is simply "the manifest value, or the default below when unset" (no DB layer to overlay). + * Defaults to fully disabled: a repo with no `repoDocGeneration:` block, or an explicit `enabled: false`, + * is never touched by the generator. `allowOverwriteExisting` is a SEPARATE opt-in specifically for a repo + * that already has a hand-maintained AGENTS.md/CLAUDE.md (no recognizable generated-content marker block, + * per generated-doc-refresh.ts's `manual-review-required` outcome) -- without it, that repo is left alone + * rather than proposed for a wholesale overwrite, even when `enabled` is true. + */ +export type FocusManifestRepoDocGenerationConfig = { + present: boolean; + enabled: boolean; + scope: FocusManifestRepoDocGenerationScope[]; + allowOverwriteExisting: boolean; + /** How many days must elapse between scheduled refresh attempts for this repo (#3003). Default 7 (weekly). + * Purely a rate-limiting knob on the SCHEDULED sweep -- it never affects correctness, since + * openRepoDocPullRequest's own no-change short-circuit already prevents a redundant PR regardless of how + * often it's invoked; this just avoids re-checking a stable repo more often than the operator wants. */ + refreshIntervalDays: number; +}; + +/** + * Per-repo opt-in for the periodic maintainer review-recap digest (#1963), declared as code under + * `reviewRecap:`. Mirrors `repoDocGeneration:` exactly: no DB-backed dashboard counterpart, so the parsed + * value (or the default below when unset) IS the effective value — there is no DB layer to overlay onto. + * Defaults to fully disabled: a repo with no `reviewRecap:` block, or an explicit `enabled: false`, never + * gets a recap posted. Discord delivery ONLY for now (reuses the SAME per-repo webhook resolution as the + * per-event notifier in notify-discord.ts, `resolveDiscordWebhook`) — Slack is a follow-up. + */ +export type FocusManifestReviewRecapConfig = { + present: boolean; + enabled: boolean; + /** How many days of review activity each recap covers, and (once the scheduler follow-up lands) how often + * it is posted. Default 7 (weekly). A purely descriptive/rate-limiting knob today — this PR ships only + * the manually-triggerable builder + delivery, so `cadenceDays` currently just sets the report WINDOW; + * the scheduled cron trigger is a scoped follow-up (see the PR description). */ + cadenceDays: number; +}; + +/** + * Generic repository-settings override declared in `.gittensory.yml` under `settings:`. A partial of + * {@link RepositorySettings} — every behaviour a maintainer can toggle in the dashboard can be set here + * as code. Unset fields are omitted so the resolver layers it OVER the DB-backed settings + * (`.gittensory.yml` > dashboard settings > safe defaults). The friendly `gate:` block is a typed alias + * for the gate-related subset and wins over `settings:` for those fields. + */ +export type FocusManifestSettings = Partial< + Pick< + RepositorySettings, + | "commentMode" + | "publicAudienceMode" + | "publicSignalLevel" + | "checkRunMode" + | "checkRunDetailLevel" + | "gateCheckMode" + | "regateSweepOrderMode" + | "reviewCheckMode" + | "autoProjectMilestoneMatch" + | "autoProjectMilestoneMatchBackend" + | "linkedIssueGateMode" + | "duplicatePrGateMode" + | "selfAuthoredLinkedIssueGateMode" + | "qualityGateMode" + | "qualityGateMinScore" + | "aiReviewMode" + | "aiReviewByok" + | "aiReviewProvider" + | "aiReviewModel" + | "aiReviewAllAuthors" + | "closeOwnerAuthors" + | "autoLabelEnabled" + | "typeLabelsEnabled" + | "badgeEnabled" + | "publicQualityMetrics" + | "gittensorLabel" + | "createMissingLabel" + | "publicSurface" + | "includeMaintainerAuthors" + | "requireLinkedIssue" + | "backfillEnabled" + | "privateTrustEnabled" + | "autonomy" + | "autoMaintain" + | "agentPaused" + | "agentDryRun" + | "commandAuthorization" + | "contributorBlacklist" + | "blacklistLabel" + | "contributorOpenPrCap" + | "contributorOpenIssueCap" + | "contributorCapLabel" + | "contributorCapCancelCi" + | "reviewNagPolicy" + | "reviewNagMaxPings" + | "reviewNagCooldownDays" + | "reviewNagLabel" + | "reviewNagMonitoredMentions" + | "autoCloseExemptLogins" + | "hardGuardrailGlobs" + | "manualReviewLabel" + | "readyToMergeLabel" + | "changesRequestedLabel" + | "migrationCollisionLabel" + | "pendingClosureLabel" + | "accountAgeThresholdDays" + | "newAccountLabel" + | "commandRateLimitPolicy" + | "commandRateLimitMaxPerWindow" + | "commandRateLimitAiMaxPerWindow" + | "commandRateLimitWindowHours" + | "moderationGateMode" + | "moderationRules" + | "moderationWarningLabel" + | "moderationBannedLabel" + | "reviewEvasionProtection" + | "reviewEvasionLabel" + | "reviewEvasionComment" + > +> & { + // `typeLabels`/`linkedIssueLabelPropagation`/`linkedIssueHardRules` are declared PARTIAL here (not via the `Pick` above, which would force a complete, defaults-filled object) so `resolveEffectiveSettings` can merge + // them field-by-field against the DB value — a `.gittensory.yml` override naming only one key (e.g. just + // `typeLabels.priority`) must inherit the OTHER keys from the DB-persisted value, not silently reset them to + // the built-in default (#priority-linked-issue-gate), and can add arbitrary categories beyond the built-in + // three (#label-modularity). `mappings` is still a complete replacement when present (arrays don't have + // per-item precedence semantics, matching the private-config layer's own documented array-replace-wholesale + // overlay behavior). + // `typeLabels: null` (distinct from an omitted key OR a sparse-but-nonempty object) is a DELIBERATE signal + // reserved for a manifest's literal `typeLabels: {}` — "zero configured categories for this repo" — the same + // load-bearing-null idiom as `blacklistLabel`/`contributorCapLabel`/etc. This is NOT the same as a sparse + // override whose named keys all failed validation (which still parses to `{}`, not `null`, and must NOT wipe + // the DB value -- see `resolveEffectiveSettings`). + typeLabels?: Partial | null | undefined; + linkedIssueLabelPropagation?: Partial | undefined; + linkedIssueHardRules?: Partial | undefined; + unlinkedIssueGuardrail?: Partial | undefined; + // Screenshot-table gate (#2006): same sparse-partial merge reasoning as linkedIssueHardRules/ + // unlinkedIssueGuardrail above -- a manifest naming only `enabled` must not silently reset `whenLabels`/ + // `whenPaths`/`action`/`message` back to their defaults. + screenshotTableGate?: Partial | undefined; +}; + +/** Field keys for the public review-panel rows a maintainer can show/hide via `review.fields`. */ +export const REVIEW_FIELD_KEYS = ["linkedIssue", "relatedWork", "reviewLoad", "validationEvidence", "openPrQueue", "contributorContext", "gateResult"] as const; +export type ReviewFieldKey = (typeof REVIEW_FIELD_KEYS)[number]; + +// `review.profile` (#review-profile): how nitpicky the AI maintainer review is. `chill` = surface only blocking +// defects (bugs/security/breakage), suppress style nits; `assertive` = also raise minor improvements & nits; +// `balanced` (default / absent) leaves the reviewer prompt byte-identical. A presentation knob only — it NEVER +// changes the gate verdict, only how much advisory detail the review write-up carries. +export const REVIEW_PROFILES = ["chill", "balanced", "assertive"] as const; +export type ReviewProfile = (typeof REVIEW_PROFILES)[number]; + +export type ReviewFindingSeverity = "critical" | "major" | "minor" | "nitpick"; + +export const REVIEW_FINDING_SEVERITY_LADDER = ["critical", "major", "minor", "nitpick"] as const; + +/** + * Maintainer overrides for the public review-panel CONTENT, declared under `review:`. Customizes the + * panel without changing what gittensory measures: a custom public-safe footer lead line, a custom intro + * note, and per-row show/hide toggles. The Gittensor attribution + register link is ALWAYS appended to + * the footer regardless (the growth surface is preserved); maintainer text that fails the public-safe + * filter is dropped, never published. + */ +export type FocusManifestReviewConfig = { + present: boolean; + footerText: string | null; + note: string | null; + fields: Partial>; + /** `review.enrichment`: per-repo REES enrichment-analyzer toggles (analyzer name → on/off). Only known analyzer + * keys are kept (unknown keys warn + drop at parse). Empty (default, absent) ⇒ the operator's default analyzer + * set runs unchanged (byte-identical). (#2050) */ + enrichmentAnalyzers: Partial>; + /** `review.profile`: chill / balanced / assertive. null (absent) = balanced = byte-identical reviewer prompt. */ + profile: ReviewProfile | null; + /** `review.tone`: a bounded public-safe voice brief complementing `review.profile` (e.g. "concise, cite line numbers"). + * Folded into the review-instructions slot at runtime. null (default, absent) ⇒ byte-identical prompt. (#2044) */ + tone: string | null; + /** `review.security_focus`: when true, the AI reviewer is told to prioritize a security-defect category + * (injection, authn/authz bypass, secret handling, unsafe deserialization, SSRF, path traversal) with + * elevated scrutiny, ON TOP OF whatever `profile` volume is set — an orthogonal "what to prioritize" axis, + * not a fourth profile level. null/false (default, absent) = byte-identical reviewer prompt. (#review-security-focus) */ + securityFocus: boolean | null; + /** `review.inline_comments`: when true, the AI reviewer ALSO leaves quiet, non-blocking inline PR comments on + * specific changed lines (in addition to the decision summary). null/false (default, absent) = no inline + * comments = byte-identical behavior. Operator-gated too (GITTENSORY_REVIEW_INLINE_COMMENTS + allowlist). + * (#inline-comments) */ + inlineComments: boolean | null; + /** `review.fixHandoff`: when true, the reviewer emits fix-handoff blocks (copy-paste remediation guidance). null/ + * false (default, absent) = no fix-handoff blocks = byte-identical. Operator-gated too (GITTENSORY_REVIEW_FIX_HANDOFF + * + the convergence cutover allowlist) — the manifest toggle is only one of the ANDed gates. (#2176, for #1962) */ + fixHandoff: boolean | null; + /** `review.auto_merge_summary`: when true, the unified comment gains a READ-ONLY collapsible showing which + * auto-merge conditions currently pass/fail (CI green, gate passing, mergeable-clean, valid linked issue), + * rendered from already-computed readiness signals. SURFACE ONLY — never changes the merge/close decision. + * null/false (default, absent) = no summary = byte-identical. (#2051, for #1959) */ + autoMergeSummary: boolean | null; + /** `review.suggestions`: when true, an inline finding whose AI-provided fix is precise enough to anchor to a + * single line is ALSO rendered as a GitHub-native ` ```suggestion ` block a contributor can commit in one + * click. Only takes effect when inline comments are already on (a suggestion has nothing to attach to + * otherwise) — this is an ADDITIONAL opt-in on top of `review.inline_comments`, not a replacement gate. + * null/false (default, absent) = no suggestion blocks = byte-identical behavior. (#1956) */ + suggestions: boolean | null; + /** `review.changed_files_summary`: when true, the unified review comment (only rendered at all when the + * `unifiedComment` convergence feature is on) gains a deterministic, no-AI "Changed files" collapsible: one + * row per file category (source/test/docs/config/generated), with file counts and +/- totals, via the + * existing `classifyChangedFile` classifier (`src/review/changed-files-classify.ts`, built for this table + * under #2143). null/false (default, absent) = no changed-files section = byte-identical behavior. (#1957) */ + changedFilesSummary: boolean | null; + /** `review.effort_score`: when true, the unified review comment (only rendered when the `unifiedComment` + * convergence feature is on) gains a compact "review effort: N/5 (~M min)" chip — a deterministic, no-AI + * complexity/time estimate from `estimateReviewEffort` (`src/review/review-effort.ts`), weighting each + * changed file's added lines by its category (source costs most; generated/vendored/lockfiles cost least) + * plus a fixed per-file overhead. Mirrors `changedFilesSummary` exactly: same table, same deterministic + * source, same display-only (never touches the AI prompt) shape. null/false (default, absent) = no chip = + * byte-identical behavior. (#1955) */ + effortScore: boolean | null; + /** `review.test_generation` (#1972, kill-switch config slice #2189): when true, a diff that touches a small, + * precise set of boundary-condition patterns (off-by-one array/index bounds, null/undefined branches, + * empty-collection checks — see `src/signals/boundary-test-generation.ts`) with NO test evidence anywhere in + * the PR gets an additional advisory finding plus a boundary-safe LOCAL-execution + * `gittensory_generate_tests` action spec (criteria/hints only, never generated test code — see + * `src/mcp/local-write-tools.ts`'s `buildTestGenSpec`). Also gated by the operator's + * `GITTENSORY_REVIEW_TEST_GENERATION` kill-switch (`src/review/test-generation.ts`'s + * `isTestGenerationEnabled`) — the caller ANDs both. Purely additive and deterministic; it never changes what + * `missingTestEvidence` already does. null/false (default, absent) ⇒ byte-identical behavior — no boundary + * scan runs and no spec is ever built. */ + testGeneration: boolean | null; + /** `review.impact_map` (#2184, config slice of #1971): when true, gates BOTH the deterministic impact-map + * computation (`computeImpactMap`, `src/review/impact-map.ts`) and its rendering as a compact section in + * the unified review comment (#2185) / additive AI-review grounding context (#2186). Deterministic/display + * + reference-context only — never touches the gate verdict. ALSO requires the global env kill-switch + * (`isImpactMapEnabled`, mirroring `isRagEnabled` in `src/review/rag-wire.ts:27`) to be on; the manifest + * flag alone cannot enable it for a self-host operator who hasn't opted in globally. null/false (default, + * absent) ⇒ no impact-map computation at all = byte-identical behavior. (#2184) */ + impactMap: boolean | null; + /** `review.culture_profile` (#2995): when true, the AI reviewer's USER prompt gains an ADDITIVE "REPO + * QUALITY-CULTURE PROFILE" reference block — typical merged-PR size + common accepted labels, derived + * deterministically from this repo's OWN `recent_merged_pull_requests` history (see + * `src/review/repo-culture-profile.ts` / `repo-culture-profile-wire.ts`). Reference-only grounding, exactly + * like RAG/CI-grounding context: it never becomes a gate/scoring input and never changes the structured + * output contract. Also requires the global `GITTENSORY_REVIEW_CULTURE_PROFILE` kill-switch to be on (this + * field only opts THIS repo in once the capability itself is enabled). null/false (default, absent) = no + * section appended = byte-identical behavior. */ + cultureProfile: boolean | null; + /** `review.memory` (#2179, config slice of #1964): when true, gates repeat-false-positive SUPPRESSION — + * before an advisory (non-blocking) AI finding is surfaced in the unified review comment, it is matched + * against this repo's stored `review_suppression` signals (a maintainer's own past false-positive + * dismissals, `src/db/repositories.ts`'s `listReviewSuppressions`, migrations/0114) and demoted/dropped on a + * match (`src/review/review-memory-match.ts`'s `matchSuppressions`). ADVISORY-ONLY BY CONSTRUCTION: it is + * never applied to gate blockers, so it can never change the merge/close disposition — only which + * non-blocking nits render. ALSO requires the global env kill-switch (`isReviewMemoryEnabled`, mirroring + * `isImpactMapEnabled` in `src/review/impact-map-wire.ts`) to be on; the manifest flag alone cannot enable + * it for a self-host operator who hasn't opted in globally. Fail-safe: a suppression-store read error or + * matcher throw leaves findings untouched. null/false (default, absent) ⇒ no suppression lookup at all = + * byte-identical behavior. */ + reviewMemory: boolean | null; + /** `review.finding_categories`: when true, an inline finding is ALSO tagged with a category (security/ + * correctness/performance/maintainability/tests/style) — the AI reviewer is asked to self-categorize, with a + * deterministic path/keyword fallback (`classifyFindingCategory`) covering whatever it omits. Only takes + * effect when inline comments are already on (a category has nothing to categorize otherwise) — this is an + * ADDITIONAL opt-in on top of `review.inline_comments`, not a replacement gate, mirroring `review.suggestions`. + * null/false (default, absent) = no category tagging = byte-identical behavior. (#1958) */ + findingCategories: boolean | null; + /** `review.inline_comments_per_category`: optional per-category sub-cap applied before the total inline-comment + * cap so one category (e.g. style) cannot crowd out security/correctness findings. null (default, absent) ⇒ + * byte-identical first-seen selection with only the hard total cap. (#2159) */ + inlineCommentsPerCategory: number | null; + /** `review.min_finding_severity`: display-only floor for AI findings with a severity tier. Findings below the + * configured level are suppressed from inline comments — never from gate blockers. null (default, absent) ⇒ every + * finding shown = byte-identical behavior. (#2048) */ + minFindingSeverity: ReviewFindingSeverity | null; + /** `review.max_findings`: optional caps on how many blocker/nit lines render in the unified review comment. + * Display-only — never removes a blocker from the gate decision. null sub-fields ⇒ no cap for that list. + * Default { blockers: null, nits: null } ⇒ byte-identical. (#2049) */ + maxFindings: MaxFindingsConfig; + /** `review.comment_verbosity`: how much of the unified review comment's collapsible detail renders. `quiet` + * drops the Nits collapsible and every extra collapsible section (blockers/gate result/signals are never + * gated by this — only decorative detail is); `detailed` renders every collapsible pre-expanded. null/normal + * (default, absent) ⇒ byte-identical to today. Net-new vs the changed-files-summary (#1957) and effort-score + * (#1955) knobs. (#2047) */ + commentVerbosity: CommentVerbosity | null; + /** `review.path_instructions`: per-path natural-language guidance handed to the AI reviewer when the PR's + * changed files match the glob. Empty (default) ⇒ byte-identical reviewer prompt. (#review-path-instructions) */ + pathInstructions: ReviewPathInstruction[]; + /** `review.instructions`: a repo-level natural-language brief handed to the AI reviewer on EVERY review (vs the + * per-path path_instructions) — the maintainer's conventions/voice for this repo. Bounded + public-safe at parse + * time (so it stays cost-cheap, unlike ingesting a whole CLAUDE.md). null (default, absent) ⇒ byte-identical + * reviewer prompt. (#review-instructions) */ + instructions: string | null; + /** `review.exclude_paths`: globs whose matching files are EXCLUDED from the AI review (diff + grounding + RAG) + * — generated/vendored/lockfiles the maintainer doesn't want reviewed. Empty (default) ⇒ every file is + * reviewed (byte-identical). Gate/slop/secret-scan are UNAFFECTED — this only narrows the AI review. + * (#review-exclude-paths) */ + excludePaths: string[]; + /** `review.path_filters`: include + `!`-negation globs that POSITIVELY scope the AI review AFTER + * `exclude_paths`. Include entries restrict to matching paths; leading `!` entries subtract matches. + * Both `*` and `**` cross slashes (see `compileManifestPathMatcher`). Empty (default) ⇒ every non-excluded + * file is reviewed (byte-identical). Gate/slop/secret-scan are UNAFFECTED. (#2043) */ + pathFilters: string[]; + /** `review.pre_merge_checks`: maintainer-declared DETERMINISTIC content assertions (title/description must + * contain a phrase, a label must be present), optionally gated to a path glob. Each FAILED check surfaces an + * advisory finding; a check with `enforce: true` becomes a hard gate blocker. Empty (default) ⇒ no finding + * (byte-identical). No AI judgment is involved. (#review-pre-merge-checks) */ + preMergeChecks: PreMergeCheck[]; + /** `review.auto_review`: deterministic eligibility filters that skip the AI review (never a gate failure). + * Empty/default ⇒ every PR is reviewed (byte-identical). (#1954 / #2038–#2041) */ + autoReview: AutoReviewConfig; + /** `review.labeling_rules`: deterministic `{label, when}` rules that SUGGEST a non-scoring label when a PR's + * changed paths / title / description match. Surfaced as advisory suggestions, and auto-applied only when the + * repo's `autoLabelEnabled` is set. Reserved `gittensor:` labels are refused at parse. Empty (default) ⇒ no + * suggestion (byte-identical). (#2045, part of #1959) */ + labelingRules: LabelingRule[]; + /** `review.ai_model`: per-repo self-host reviewer model/effort overrides (claude-code / codex). Self-host only + * — a hosted (Workers-AI) repo ignores this entirely. All-null (default, absent) ⇒ the operator's global + * CLAUDE_AI_MODEL/CLAUDE_AI_EFFORT/CODEX_AI_MODEL/CODEX_AI_EFFORT env vars apply unchanged (byte-identical). + * (#selfhost-ai-model-override) */ + aiModel: SelfHostAiModelConfig; + /** `review.visual`: per-repo before/after screenshot-capture config (#3609 preview / #3610 routes). + * All-empty (default, absent) ⇒ byte-identical to today (GitHub-native preview discovery, automatic + * file-to-route inference, built-in route cap). Only takes effect when the operator has also enabled + * GITTENSORY_REVIEW_SCREENSHOTS + the repo cutover allowlist — this config narrows/redirects that + * feature, it never turns it on by itself. */ + visual: VisualConfig; + /** `review.linkedIssueSatisfaction`: how strictly a linked issue must actually be SATISFIED by the PR — `off` + * (default; not evaluated), `advisory` (surface a finding), or `block` (can become a hard blocker). CONFIG SLICE + * ONLY (#2173, for #1961): parsed + normalized here; the merge/close decision that reads this mode is a separate + * maintainer-only slice. null (default, absent) ⇒ byte-identical to today. */ + linkedIssueSatisfaction: LinkedIssueSatisfactionMode | null; +}; + +/** `review.linkedIssueSatisfaction` modes (#2173). `off` = not evaluated (same as unset). */ +export const LINKED_ISSUE_SATISFACTION_MODES = ["off", "advisory", "block"] as const; +export type LinkedIssueSatisfactionMode = (typeof LINKED_ISSUE_SATISFACTION_MODES)[number]; + +/** `review.comment_verbosity` levels (#2047). `normal` = today's behavior (same as unset). */ +export const COMMENT_VERBOSITY_LEVELS = ["quiet", "normal", "detailed"] as const; +export type CommentVerbosity = (typeof COMMENT_VERBOSITY_LEVELS)[number]; + +/** One `review.labeling_rules[]` entry: a non-reserved `label` plus the deterministic `when` criteria that must ALL + * match for it to fire. A rule always has at least one criterion (enforced at parse). */ +export type LabelingRule = { + label: string; + whenPaths: string[]; + titleContains: string | null; + descriptionContains: string | null; +}; + +/** Per-repo AI review eligibility knobs under `review.auto_review`. Unset fields are byte-identical defaults. */ +export type AutoReviewConfig = { + /** `review.auto_review.skip_drafts`: when true, draft PRs skip AI review. null (default) ⇒ drafts reviewed as today. (#2038) */ + skipDrafts: boolean | null; + /** `review.auto_review.ignore_authors`: author-login globs whose PRs skip AI review. Empty ⇒ every author. (#2039) */ + ignoreAuthors: string[]; + /** `review.auto_review.ignore_title_keywords`: case-insensitive title substrings that skip AI review. Empty ⇒ no skip. (#2040) */ + ignoreTitleKeywords: string[]; + /** `review.auto_review.skip_labels`: case-insensitive PR label names that skip AI review. Empty ⇒ no skip. (#2062) */ + skipLabels: string[]; + /** `review.auto_review.skip_docs_only`: when true, PRs whose every changed file classifies as docs skip AI review. + * null (default) ⇒ docs PRs reviewed as today. Empty changed-file list ⇒ NOT docs-only (fail-safe eligible). (#2063) */ + skipDocsOnly: boolean | null; + /** `review.auto_review.max_added_lines`: skip AI review when total added lines exceed this cap. 0 (default) ⇒ no cap. (#2065) */ + maxAddedLines: number; + /** `review.auto_review.max_files`: skip AI review when changed-file count exceeds this cap. 0 (default) ⇒ no cap. (#2065) */ + maxFiles: number; + /** `review.auto_review.base_branches`: base-ref globs whose PRs ARE reviewed; empty/unset ⇒ every base. (#2041) */ + baseBranches: string[]; + /** `review.auto_review.auto_pause_after_reviewed_commits`: after N published AI reviews on this PR, pause further + * re-reviews. null/0 ⇒ byte-identical (re-review every sync). (#2042) */ + autoPauseAfterReviewedCommits: number | null; +}; + +export type MaxFindingsConfig = { + blockers: number | null; + nits: number | null; +}; + +export const EMPTY_MAX_FINDINGS_CONFIG: MaxFindingsConfig = { blockers: null, nits: null }; + +export const EMPTY_AUTO_REVIEW_CONFIG: AutoReviewConfig = { + skipDrafts: null, + ignoreAuthors: [], + ignoreTitleKeywords: [], + skipLabels: [], + skipDocsOnly: null, + maxAddedLines: 0, + maxFiles: 0, + baseBranches: [], + autoPauseAfterReviewedCommits: null, +}; + +/** Per-repo self-host reviewer model/effort overrides under `review.ai_model`. Each field independently overrides + * the matching global env var (CLAUDE_AI_MODEL / CLAUDE_AI_EFFORT / CODEX_AI_MODEL / CODEX_AI_EFFORT) for THIS + * repo only — it never widens what the operator's own env already permits, only narrows/redirects it, so a + * compromised repo config can change which model reviews it but not grant itself a new credential or provider. + * (#selfhost-ai-model-override) */ +export type SelfHostAiModelConfig = { + /** `review.ai_model.claude_model`: overrides CLAUDE_AI_MODEL for this repo's claude-code reviewer. null (default) ⇒ the operator's global env var, then the provider's own default. */ + claudeModel: string | null; + /** `review.ai_model.claude_effort`: overrides CLAUDE_AI_EFFORT for this repo's claude-code reviewer. null (default) ⇒ the operator's global env var, then "medium". */ + claudeEffort: string | null; + /** `review.ai_model.codex_model`: overrides CODEX_AI_MODEL for this repo's codex reviewer. null (default) ⇒ the operator's global env var, then the account default. */ + codexModel: string | null; + /** `review.ai_model.codex_effort`: overrides CODEX_AI_EFFORT for this repo's codex reviewer. null (default) ⇒ the operator's global env var, then "medium". */ + codexEffort: string | null; +}; + +export const EMPTY_SELF_HOST_AI_MODEL_CONFIG: SelfHostAiModelConfig = { + claudeModel: null, + claudeEffort: null, + codexModel: null, + codexEffort: null, +}; + +/** Per-repo before/after screenshot-capture config under `review.visual` (#3609 / #3610). Generic by design — + * every self-hoster wires their OWN repo's preview-deploy setup and route shape with config, not code. */ +export type VisualConfig = { + preview: VisualPreviewConfig; + routes: VisualRoutesConfig; + themes: VisualTheme[]; + /** `review.visual.gif`: capture a short scroll-through GIF (#3612) alongside the static before/after + * screenshots — evidence for scroll-linked behavior (parallax, reveal-on-scroll, a sticky header) that a + * single static shot can't show. Self-host only (see src/review/visual/scroll-gif.ts) and the heaviest + * capture mode this pipeline has (up to 6 extra renders per side) — false (default, every existing + * manifest) ⇒ byte-identical to today, no scroll frames captured at all. */ + gif: boolean; +}; + +/** A `prefers-color-scheme` value the capture pipeline can emulate before rendering (#3678). */ +export type VisualTheme = "light" | "dark"; + +export type VisualPreviewConfig = { + /** `review.visual.preview.url_template`: the repo's "after" preview URL, with `{number}` (PR number), + * `{head_sha}` (full commit SHA), and `{head_sha_short}` (first 7 chars) placeholders substituted at + * capture time — e.g. `https://pr-{number}.myapp.workers.dev`. ALWAYS wins over GitHub-native preview + * discovery (the Deployments API / commit checks / cloudflare-bot PR comment) when set — an explicit, + * maintainer-configured template is a stronger signal than inference, and is the only option for a + * provider (e.g. Cloudflare Workers Builds' non-production branch builds) that doesn't surface a + * GitHub-visible deployment at all. null (default) ⇒ byte-identical to today (discovery unchanged). + * Validated at parse time against the same SSRF guard the renderer itself applies (isSafeHttpUrl) with + * placeholders substituted for a dummy value, so a malformed template warns at config-read time instead + * of only failing silently at render time — this is redundant with (not a replacement for) the + * renderer's own unconditional isSafeHttpUrl check on every resolved URL, regardless of source. */ + urlTemplate: string | null; +}; + +export type VisualRoutesConfig = { + /** `review.visual.routes.paths`: an explicit, always-screenshotted route list. When non-empty, this + * REPLACES automatic file-to-route inference entirely — for repos whose routing convention isn't + * gittensory-ui's TanStack file-based one, an explicit list is simpler and more robust than trying to + * infer one. Empty (default) ⇒ automatic inference (falling back to "/" when nothing matches). */ + paths: string[]; + /** `review.visual.routes.max_routes`: overrides the built-in cap (2) on how many routes get screenshotted + * per PR. null (default) ⇒ built-in default. Applies whether routes come from `paths` above or from + * automatic inference. */ + maxRoutes: number | null; +}; + +export const EMPTY_VISUAL_CONFIG: VisualConfig = { + preview: { urlTemplate: null }, + routes: { paths: [], maxRoutes: null }, + themes: [], + gif: false, +}; + +/** One `review.path_instructions[]` entry: a manifest path glob + the public-safe instructions to apply when a + * changed file matches it. */ +export type ReviewPathInstruction = { path: string; instructions: string }; + +/** One `review.pre_merge_checks[]` entry — a DETERMINISTIC pre-merge assertion. `whenPaths` (empty ⇒ always + * applies) gates the check to PRs that touch a matching path. The check PASSES only when EVERY configured + * assertion holds: the PR title contains `titleContains`, the body contains `descriptionContains`, and the + * `requireLabel` label is present (case-insensitive substring / label match). `enforce` ⇒ a failure is a hard + * gate blocker; default (false) ⇒ advisory only. All strings are public-safe-filtered at parse time. */ +export type PreMergeCheck = { + name: string; + whenPaths: string[]; + titleContains: string | null; + descriptionContains: string | null; + requireLabel: string | null; + enforce: boolean; +}; + +// A hard cap so a hostile/huge manifest can't bloat the reviewer prompt (mirrors REVIEW_FIELD_KEYS discipline). +const MAX_PATH_INSTRUCTIONS = 50; + +/** + * Normalized maintainer focus manifest. Repo owners declare which work areas are wanted, + * preferred, and how PRs should present validation. Path-based manual review is intentionally + * not part of this manifest anymore; use `settings.hardGuardrailGlobs` for that single + * authoritative control. `maintainerNotes` are private review context and must never reach a public + * GitHub surface; `publicNotes` are explicitly opted into public output by the maintainer. + */ +export type FocusManifest = { + present: boolean; + source: FocusManifestSource; + wantedPaths: string[]; + preferredLabels: string[]; + linkedIssuePolicy: FocusManifestLinkedIssuePolicy; + testExpectations: string[]; + issueDiscoveryPolicy: FocusManifestIssueDiscoveryPolicy; + maintainerNotes: string[]; + publicNotes: string[]; + gate: FocusManifestGateConfig; + settings: FocusManifestSettings; + review: FocusManifestReviewConfig; + features: FocusManifestFeaturesConfig; + contentLane: FocusManifestContentLaneConfig; + repoDocGeneration: FocusManifestRepoDocGenerationConfig; + reviewRecap: FocusManifestReviewRecapConfig; + warnings: string[]; +}; + +export type FocusManifestFinding = { + code: + | "manifest_off_focus" + | "manifest_preferred_path" + | "manifest_missing_preferred_label" + | "manifest_linked_issue_required" + | "manifest_linked_issue_preferred" + | "manifest_missing_tests" + | "manifest_issue_discovery_discouraged" + | "manifest_malformed"; + severity: "info" | "warning" | "critical"; + title: string; + detail: string; + action?: string | undefined; +}; + +export type FocusManifestGuidance = { + present: boolean; + source: FocusManifestSource; + linkedIssuePolicy: FocusManifestLinkedIssuePolicy; + issueDiscoveryPolicy: FocusManifestIssueDiscoveryPolicy; + matchedWantedPaths: string[]; + preferredLabelHits: string[]; + findings: FocusManifestFinding[]; + publicNextSteps: string[]; + warnings: string[]; + summary: string; +}; + +const MAX_LIST_ITEMS = 200; +const MAX_ITEM_LENGTH = 300; +const MAX_GLOBSTAR_SLASH_ALTERNATIVES = 128; +// 128 KiB, not 64 KiB: gittensory.full.yml (our own reference doc, parsed by config-templates.test.ts as a +// round-trip check) organically grows every time a new review.* knob ships and had already reached 65522/65536 +// bytes on main before this comment was written -- one doc line from any PR would trip the old ceiling. A real +// per-repo .gittensory.yml never needs anywhere near this size, so the DoS-guard intent is unaffected (#2006). +export const MAX_FOCUS_MANIFEST_BYTES = 128 * 1024; + +const EMPTY_GATE_CONFIG: FocusManifestGateConfig = { + 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, + aiReviewCombine: null, + aiReviewOnMerge: null, + aiReviewReviewers: null, + mergeReadiness: null, + manifestPolicy: null, + selfAuthoredLinkedIssue: null, + dryRun: null, + firstTimeContributorGrace: null, + premergeContentRecheck: null, + requireFreshRebaseWindowMinutes: null, + claMode: null, + claConsentPhrase: null, + claCheckRunName: null, + claCheckRunAppSlug: null, + expectedCiContexts: null, +}; + +const EMPTY_FEATURES_CONFIG: FocusManifestFeaturesConfig = { + present: false, + rag: null, + reputation: null, + unifiedComment: null, + safety: null, +}; + +const EMPTY_CONTENT_LANE_CONFIG: FocusManifestContentLaneConfig = { + present: false, + entryFileGlob: null, + providerFileGlob: null, + artifactGlob: null, + collectionField: null, + maxAppendedEntries: null, + duplicateKeyFields: [], + validatorId: null, +}; + +const DEFAULT_REPO_DOC_REFRESH_INTERVAL_DAYS = 7; + +const EMPTY_REPO_DOC_GENERATION_CONFIG: FocusManifestRepoDocGenerationConfig = { + present: false, + enabled: false, + scope: ["agents"], + allowOverwriteExisting: false, + refreshIntervalDays: DEFAULT_REPO_DOC_REFRESH_INTERVAL_DAYS, +}; + +const DEFAULT_REVIEW_RECAP_CADENCE_DAYS = 7; + +const EMPTY_REVIEW_RECAP_CONFIG: FocusManifestReviewRecapConfig = { + present: false, + enabled: false, + cadenceDays: DEFAULT_REVIEW_RECAP_CADENCE_DAYS, +}; + +const EMPTY_MANIFEST: FocusManifest = { + present: false, + source: "none", + wantedPaths: [], + preferredLabels: [], + linkedIssuePolicy: "optional", + testExpectations: [], + issueDiscoveryPolicy: "neutral", + maintainerNotes: [], + publicNotes: [], + gate: { ...EMPTY_GATE_CONFIG }, + 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, testGeneration: null, impactMap: null, cultureProfile: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null }, + features: { ...EMPTY_FEATURES_CONFIG }, + contentLane: { ...EMPTY_CONTENT_LANE_CONFIG }, + repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG }, + reviewRecap: { ...EMPTY_REVIEW_RECAP_CONFIG }, + warnings: [], +}; + +// This surface's economic/identity term vocabulary is intentionally richer than the canonical +// PUBLIC_UNSAFE_TERMS (extra phrases like "public score estimate"), so it stays a local literal. The local +// filesystem paths, however, compose from the canonical PUBLIC_LOCAL_PATH_INLINE in redaction.ts (which also +// covers `/var/`, previously missed here, plus `/root/` and the forward-slash Windows form `C:/Users/`) so this +// guard cannot drift from the canonical boundary on a leaking root. +const FOCUS_MANIFEST_TERMS = /\b(reward\w*|score\w*|wallets?|hotkeys?|coldkeys?|seed[-\s]?phrases?|mnemonics?|private[-\s]?keys?|farming|payouts?|rankings?|raw[-\s]?trust(?:[-\s]?scores?)?|trust[-\s]?scores?|private[-\s]?reviewability|reviewability(?:[-\s]?internals?)?|private[-\s]?scoreability|scoreability|public[-\s]?score[-\s]?(?:estimate|prediction|claim)s?|estimated[-\s]?scores?|score[-\s]?(?:estimate|prediction|preview)s?)\b/i; +const FOCUS_MANIFEST_LOCAL_PATH_PATTERN = new RegExp(PUBLIC_LOCAL_PATH_INLINE, "i"); + +/** + * Public-safe redaction guard shared with the local-branch packet renderer. Public manifest + * text must not leak reward, wallet/key, ranking, or local filesystem path material. + */ +export function isFocusManifestPublicSafe(text: string): boolean { + return !FOCUS_MANIFEST_TERMS.test(text) && !FOCUS_MANIFEST_LOCAL_PATH_PATTERN.test(text); +} +function emptyManifest(source: FocusManifestSource, warnings: string[] = []): FocusManifest { + return { + ...EMPTY_MANIFEST, + source, + warnings, + gate: { ...EMPTY_GATE_CONFIG }, + 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, testGeneration: null, impactMap: null, cultureProfile: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null }, + features: { ...EMPTY_FEATURES_CONFIG }, + contentLane: { ...EMPTY_CONTENT_LANE_CONFIG }, + repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG }, + reviewRecap: { ...EMPTY_REVIEW_RECAP_CONFIG }, + }; +} + +function normalizeStringList(value: JsonValue | undefined, field: string, warnings: string[]): string[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) { + warnings.push(`Manifest field "${field}" must be a list; ignoring a ${typeof value} value.`); + return []; + } + const result: string[] = []; + for (const entry of value) { + if (typeof entry !== "string") { + warnings.push(`Manifest field "${field}" skipped a non-string entry.`); + continue; + } + const trimmed = entry.trim(); + if (!trimmed) continue; + // Truncate in place, then flow through the same de-dup and cap logic. Falling through (rather than + // `continue`-ing) keeps over-long entries subject to both limits, so untrusted manifests cannot + // bypass de-duplication or the MAX_LIST_ITEMS safety cap via pathological long entries. + let normalized = trimmed; + if (normalized.length > MAX_ITEM_LENGTH) { + warnings.push(`Manifest field "${field}" truncated an over-long entry.`); + normalized = normalized.slice(0, MAX_ITEM_LENGTH); + } + if (!result.includes(normalized)) result.push(normalized); + if (result.length >= MAX_LIST_ITEMS) { + warnings.push(`Manifest field "${field}" exceeded ${MAX_LIST_ITEMS} entries; extra entries ignored.`); + break; + } + } + return result; +} + +/** Like {@link normalizeStringList}, but returns `null` (not `[]`) when unset or when nothing survives + * validation — the convention every OTHER `FocusManifestGateConfig` field uses for "not configured", so + * the resolver's `!== null` overlay checks work uniformly. */ +function normalizeOptionalStringList(value: JsonValue | undefined, field: string, warnings: string[]): ReadonlyArray | null { + if (value === undefined || value === null) return null; + const list = normalizeStringList(value, field, warnings); + return list.length > 0 ? list : null; +} + +function normalizeEnum(value: JsonValue | undefined, field: string, allowed: readonly T[], fallback: T, warnings: string[]): T { + if (value === undefined || value === null) return fallback; + if (typeof value !== "string" || !allowed.includes(value as T)) { + warnings.push(`Manifest field "${field}" must be one of ${allowed.join(", ")}; falling back to "${fallback}".`); + return fallback; + } + return value as T; +} + +function normalizeSource(raw: FocusManifestSource | undefined, value: JsonValue | undefined, warnings: string[]): FocusManifestSource { + if (raw) return raw; + return normalizeEnum(value, "source", ["repo_file", "api_record", "none"], "api_record", warnings); +} + +function normalizeOptionalGateMode(value: JsonValue | undefined, field: string, warnings: string[]): GateRuleMode | null { + if (value === undefined || value === null) return null; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "off" || normalized === "advisory" || normalized === "block") return normalized; + } + warnings.push(`Manifest gate field "${field}" must be one of off, advisory, block; ignoring "${String(value)}".`); + return null; +} + +/** `gate.readiness.mode` (and its `settings.qualityGateMode` alias below) is documented and parsed as the shared + * off/advisory/block tri-state, but buildQualityGateWarning (src/rules/advisory.ts) always produces a + * warning-severity finding — never a blocker — and isConfiguredGateBlocker has no branch for it: readiness/ + * quality is intentionally informational-only and can never hard-block a PR. Without this, a maintainer who + * sets `mode: block` believes a real quality floor is enforced when the effective behavior is silently + * advisory-only (#2267). Downgrade "block" to "advisory" here, with a clear deprecation warning, so the parsed + * config always matches what the gate actually does. Exported so the settings-write API routes (the + * dashboard/API path for the SAME `qualityGateMode` field) can apply the identical downgrade before persisting. */ +export function normalizeReadinessGateMode(value: JsonValue | undefined, field: string, warnings: string[]): GateRuleMode | null { + const mode = normalizeOptionalGateMode(value, field, warnings); + if (mode !== "block") return mode; + warnings.push(`Manifest gate field "${field}" no longer accepts "block" — readiness/quality is informational-only and can never hard-block a PR; downgrading to "advisory". Use gate.manifestPolicy or another enforceable gate for a real quality floor.`); + return "advisory"; +} + +function normalizeOptionalBoolean(value: JsonValue | undefined, field: string, warnings: string[]): boolean | null { + if (value === undefined || value === null) return null; + if (typeof value === "boolean") return value; + warnings.push(`Manifest gate field "${field}" must be a boolean; ignoring a ${typeof value} value.`); + return null; +} + +function normalizeOptionalScore(value: JsonValue | undefined, field: string, warnings: string[]): number | null { + if (value === undefined || value === null) return null; + if (typeof value !== "number" || !Number.isFinite(value)) { + warnings.push(`Manifest gate field "${field}" must be a number between 0 and 100; ignoring it.`); + return null; + } + return Math.max(0, Math.min(100, Math.round(value))); +} + +function normalizeOptionalNonNegativeInt(value: JsonValue | undefined, field: string, warnings: string[]): number | null { + if (value === undefined || value === null) return null; + if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 0) { + warnings.push(`Manifest field "${field}" must be a non-negative integer; ignoring it.`); + return null; + } + return value; +} + +/** Parse auto-review size caps where 0 means disabled (byte-identical default). (#2065) */ +function normalizeAutoReviewSizeCap(value: JsonValue | undefined, field: string, warnings: string[]): number { + if (value === undefined || value === null) return 0; + if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 0) { + warnings.push(`Manifest field "${field}" must be a non-negative integer; ignoring it.`); + return 0; + } + return value; +} + +/** Normalize an optional confidence threshold in [0,1] (#7) — a fractional value (NOT a 0-100 score), so it is + * clamped into range WITHOUT rounding. Absent/null ⇒ null (the resolver leaves the gate's 0.93 default in place); + * a non-finite/non-number value is ignored with a warning. */ +function normalizeOptionalConfidence(value: JsonValue | undefined, field: string, warnings: string[]): number | null { + if (value === undefined || value === null) return null; + if (typeof value !== "number" || !Number.isFinite(value)) { + warnings.push(`Manifest gate field "${field}" must be a number between 0 and 1; ignoring it.`); + return null; + } + return Math.max(0, Math.min(1, value)); +} + +// A hard cap on `gate.aiReview.reviewers` entries — the combiner only ever addresses reviewer[0]/[1] (single runs +// one, consensus/synthesis run two), so anything beyond 2 is inert; capping at 4 leaves headroom without letting a +// hostile/huge manifest bloat the parsed config for no functional gain. +const MAX_AI_REVIEW_REVIEWERS = 4; + +/** Normalize `gate.aiReview.reviewers` (#2567) — a list of `{ model, fallback? }` entries naming self-host + * providers (e.g. `claude-code`, `codex`) to run in place of the operator's `AI_REVIEW_PLAN.reviewers`. Each + * entry needs a non-empty string `model`; `fallback` is optional and, when present, must also be a non-empty + * string. Invalid entries are dropped with a warning rather than failing the whole list, mirroring the other + * manifest list parsers. Absent/empty/all-invalid ⇒ null (so the resolver's `??` fallback to the operator's + * plan is untouched). */ +function normalizeOptionalReviewers( + value: JsonValue | undefined, + field: string, + warnings: string[], +): ReadonlyArray<{ model: string; fallback?: string | null | undefined }> | null { + if (value === undefined || value === null) return null; + if (!Array.isArray(value)) { + warnings.push(`Manifest gate field "${field}" must be a list of { model, fallback? }; ignoring it.`); + return null; + } + const out: Array<{ model: string; fallback?: string | null | undefined }> = []; + for (const [index, entry] of value.entries()) { + if (out.length >= MAX_AI_REVIEW_REVIEWERS) { + warnings.push(`Manifest gate field "${field}" is capped at ${MAX_AI_REVIEW_REVIEWERS} entries; dropping the rest.`); + break; + } + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + warnings.push(`Manifest gate field "${field}[${index}]" must be a mapping with a "model" string; ignoring it.`); + continue; + } + const e = entry as Record; + const model = typeof e.model === "string" ? e.model.trim() : ""; + if (!model) { + warnings.push(`Manifest gate field "${field}[${index}].model" must be a non-empty string; ignoring the entry.`); + continue; + } + const fallback = typeof e.fallback === "string" && e.fallback.trim() ? e.fallback.trim() : undefined; + out.push(fallback ? { model, fallback } : { model }); + } + return out.length > 0 ? out : null; +} + +/** + * Parse the optional `gate:` mapping. Every field stays `null` when unset so the resolver can layer + * this OVER DB settings without clobbering. A nested `readiness: { mode, minScore }` block is accepted. + */ +function parseGateConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestGateConfig { + if (value === undefined || value === null) return { ...EMPTY_GATE_CONFIG }; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push(`Manifest field "gate" must be a mapping; ignoring it.`); + return { ...EMPTY_GATE_CONFIG }; + } + const record = value as Record; + const readiness = record.readiness; + const readinessRecord = readiness !== null && typeof readiness === "object" && !Array.isArray(readiness) ? (readiness as Record) : undefined; + if (readiness !== undefined && readiness !== null && readinessRecord === undefined) { + warnings.push(`Manifest gate field "gate.readiness" must be a mapping; ignoring it.`); + } + const aiReview = record.aiReview; + const aiReviewRecord = aiReview !== null && typeof aiReview === "object" && !Array.isArray(aiReview) ? (aiReview as Record) : undefined; + if (aiReview !== undefined && aiReview !== null && aiReviewRecord === undefined) { + warnings.push(`Manifest gate field "gate.aiReview" must be a mapping; ignoring it.`); + } + const slop = record.slop; + const slopRecord = slop !== null && typeof slop === "object" && !Array.isArray(slop) ? (slop as Record) : undefined; + if (slop !== undefined && slop !== null && slopRecord === undefined) { + warnings.push(`Manifest gate field "gate.slop" must be a mapping; ignoring it.`); + } + const size = record.size; + const sizeRecord = size !== null && typeof size === "object" && !Array.isArray(size) ? (size as Record) : undefined; + if (size !== undefined && size !== null && sizeRecord === undefined) { + warnings.push(`Manifest gate field "gate.size" must be a mapping; ignoring it.`); + } + const cla = record.cla; + const claRecord = cla !== null && typeof cla === "object" && !Array.isArray(cla) ? (cla as Record) : undefined; + if (cla !== undefined && cla !== null && claRecord === undefined) { + warnings.push(`Manifest gate field "gate.cla" must be a mapping; ignoring it.`); + } + const gate: FocusManifestGateConfig = { + present: false, + enabled: normalizeOptionalBoolean(record.enabled, "gate.enabled", warnings), + checkMode: normalizeOptionalEnum(record.checkMode, "gate.checkMode", ["required", "visible", "disabled"] as const, warnings), + pack: normalizeOptionalEnum(record.pack, "gate.pack", ["gittensor", "oss-anti-slop"] as const, warnings), + linkedIssue: normalizeOptionalGateMode(record.linkedIssue, "gate.linkedIssue", warnings), + duplicates: normalizeOptionalGateMode(record.duplicates, "gate.duplicates", warnings), + readinessMode: normalizeReadinessGateMode(readinessRecord?.mode, "gate.readiness.mode", warnings), + readinessMinScore: normalizeOptionalScore(readinessRecord?.minScore, "gate.readiness.minScore", warnings), + slopMode: normalizeOptionalGateMode(slopRecord?.mode, "gate.slop.mode", warnings), + slopMinScore: normalizeOptionalScore(slopRecord?.minScore, "gate.slop.minScore", warnings), + slopAiAdvisory: normalizeOptionalBoolean(slopRecord?.aiAdvisory, "gate.slop.aiAdvisory", warnings), + sizeMode: normalizeOptionalGateMode(sizeRecord?.mode, "gate.size.mode", warnings), + lockfileIntegrityMode: normalizeOptionalGateMode(record.lockfileIntegrity, "gate.lockfileIntegrity", warnings), + aiReviewMode: normalizeOptionalGateMode(aiReviewRecord?.mode, "gate.aiReview.mode", warnings), + aiReviewByok: normalizeOptionalBoolean(aiReviewRecord?.byok, "gate.aiReview.byok", warnings), + aiReviewProvider: normalizeOptionalEnum(aiReviewRecord?.provider, "gate.aiReview.provider", ["anthropic", "openai"] as const, warnings), + aiReviewModel: normalizeOptionalString(aiReviewRecord?.model, "gate.aiReview.model", warnings), + aiReviewAllAuthors: normalizeOptionalBoolean(aiReviewRecord?.allAuthors, "gate.aiReview.allAuthors", warnings), + aiReviewCloseConfidence: normalizeOptionalConfidence(aiReviewRecord?.closeConfidence, "gate.aiReview.closeConfidence", warnings), + aiReviewCombine: normalizeOptionalEnum(aiReviewRecord?.combine, "gate.aiReview.combine", ["single", "consensus", "synthesis"] as const, warnings), + aiReviewOnMerge: normalizeOptionalEnum(aiReviewRecord?.onMerge, "gate.aiReview.onMerge", ["either", "both"] as const, warnings), + aiReviewReviewers: normalizeOptionalReviewers(aiReviewRecord?.reviewers, "gate.aiReview.reviewers", warnings), + mergeReadiness: normalizeOptionalGateMode(record.mergeReadiness, "gate.mergeReadiness", warnings), + manifestPolicy: normalizeOptionalGateMode(record.manifestPolicy, "gate.manifestPolicy", warnings), + selfAuthoredLinkedIssue: normalizeOptionalGateMode(record.selfAuthoredLinkedIssue, "gate.selfAuthoredLinkedIssue", warnings), + dryRun: normalizeOptionalBoolean(record.dryRun, "gate.dryRun", warnings), + firstTimeContributorGrace: normalizeOptionalBoolean(record.firstTimeContributorGrace, "gate.firstTimeContributorGrace", warnings), + premergeContentRecheck: normalizeOptionalBoolean(record.premergeContentRecheck, "gate.premergeContentRecheck", warnings), + requireFreshRebaseWindowMinutes: normalizeOptionalPositiveInteger(record.requireFreshRebaseWindow, "gate.requireFreshRebaseWindow", warnings), + claMode: normalizeOptionalGateMode(record.claMode, "gate.claMode", warnings), + claConsentPhrase: parsePublicSafeText(claRecord?.consentPhrase, "gate.cla.consentPhrase", warnings), + claCheckRunName: parsePublicSafeText(claRecord?.checkRunName, "gate.cla.checkRunName", warnings), + claCheckRunAppSlug: parsePublicSafeText(claRecord?.checkRunAppSlug, "gate.cla.checkRunAppSlug", warnings), + expectedCiContexts: normalizeOptionalStringList(record.expectedCiContexts, "gate.expectedCiContexts", warnings), + }; + // #2266: the flag is parsed, clamped, and threaded end-to-end, but the gate evaluator never reads it — a + // maintainer who sets it to true believing it softens a blocker for newcomers gets no such effect. Surface + // that inertness at parse time rather than leaving it silently no-op; `false`/unset matches the (also inert) + // default, so only an explicit `true` is worth flagging. + if (gate.firstTimeContributorGrace === true) { + warnings.push(`Manifest field "gate.firstTimeContributorGrace" is currently reserved/inert — it does not soften a blocker outcome for first-time contributors.`); + } + gate.present = + gate.enabled !== null || + gate.checkMode !== null || + gate.pack !== null || + gate.linkedIssue !== null || + gate.duplicates !== null || + gate.readinessMode !== null || + gate.readinessMinScore !== null || + gate.slopMode !== null || + gate.slopMinScore !== null || + gate.slopAiAdvisory !== null || + gate.sizeMode !== null || + gate.lockfileIntegrityMode !== null || + gate.aiReviewMode !== null || + gate.aiReviewByok !== null || + gate.aiReviewProvider !== null || + gate.aiReviewModel !== null || + gate.aiReviewAllAuthors !== null || + gate.aiReviewCloseConfidence !== null || + gate.aiReviewCombine !== null || + gate.aiReviewOnMerge !== null || + gate.aiReviewReviewers !== null || + gate.mergeReadiness !== null || + gate.manifestPolicy !== null || + gate.selfAuthoredLinkedIssue !== null || + gate.dryRun !== null || + gate.firstTimeContributorGrace !== null || + gate.premergeContentRecheck !== null || + gate.requireFreshRebaseWindowMinutes !== null || + gate.claMode !== null || + gate.claConsentPhrase !== null || + gate.claCheckRunName !== null || + gate.claCheckRunAppSlug !== null || + gate.expectedCiContexts !== null; + return gate; +} + +/** + * Serialize a gate config back into the parse-compatible `gate:` shape so a cached manifest snapshot + * round-trips through {@link parseGateConfig} unchanged. Returns null when nothing is configured. + */ +export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue { + if (!gate.present) return null; + const out: Record = {}; + if (gate.enabled !== null) out.enabled = gate.enabled; + if (gate.checkMode !== null) out.checkMode = gate.checkMode; + if (gate.pack !== null) out.pack = gate.pack; + if (gate.linkedIssue !== null) out.linkedIssue = gate.linkedIssue; + if (gate.duplicates !== null) out.duplicates = gate.duplicates; + if (gate.readinessMode !== null || gate.readinessMinScore !== null) { + const readiness: Record = {}; + if (gate.readinessMode !== null) readiness.mode = gate.readinessMode; + if (gate.readinessMinScore !== null) readiness.minScore = gate.readinessMinScore; + out.readiness = readiness; + } + if (gate.sizeMode !== null) out.size = { mode: gate.sizeMode }; + if (gate.lockfileIntegrityMode !== null) out.lockfileIntegrity = gate.lockfileIntegrityMode; + if (gate.slopMode !== null || gate.slopMinScore !== null || gate.slopAiAdvisory !== null) { + const slop: Record = {}; + if (gate.slopMode !== null) slop.mode = gate.slopMode; + if (gate.slopMinScore !== null) slop.minScore = gate.slopMinScore; + if (gate.slopAiAdvisory !== null) slop.aiAdvisory = gate.slopAiAdvisory; + out.slop = slop; + } + if ( + gate.aiReviewMode !== null || + gate.aiReviewByok !== null || + gate.aiReviewProvider !== null || + gate.aiReviewModel !== null || + gate.aiReviewAllAuthors !== null || + gate.aiReviewCloseConfidence !== null || + gate.aiReviewCombine !== null || + gate.aiReviewOnMerge !== null || + gate.aiReviewReviewers !== null + ) { + const aiReview: Record = {}; + if (gate.aiReviewMode !== null) aiReview.mode = gate.aiReviewMode; + if (gate.aiReviewByok !== null) aiReview.byok = gate.aiReviewByok; + if (gate.aiReviewProvider !== null) aiReview.provider = gate.aiReviewProvider; + if (gate.aiReviewModel !== null) aiReview.model = gate.aiReviewModel; + if (gate.aiReviewAllAuthors !== null) aiReview.allAuthors = gate.aiReviewAllAuthors; + if (gate.aiReviewCloseConfidence !== null) aiReview.closeConfidence = gate.aiReviewCloseConfidence; + if (gate.aiReviewCombine !== null) aiReview.combine = gate.aiReviewCombine; + if (gate.aiReviewOnMerge !== null) aiReview.onMerge = gate.aiReviewOnMerge; + if (gate.aiReviewReviewers !== null) { + aiReview.reviewers = gate.aiReviewReviewers.map((r) => + r.fallback ? { model: r.model, fallback: r.fallback } : { model: r.model }, + ) as JsonValue; + } + out.aiReview = aiReview; + } + if (gate.mergeReadiness !== null) out.mergeReadiness = gate.mergeReadiness; + if (gate.manifestPolicy !== null) out.manifestPolicy = gate.manifestPolicy; + if (gate.selfAuthoredLinkedIssue !== null) out.selfAuthoredLinkedIssue = gate.selfAuthoredLinkedIssue; + if (gate.dryRun !== null) out.dryRun = gate.dryRun; + if (gate.firstTimeContributorGrace !== null) out.firstTimeContributorGrace = gate.firstTimeContributorGrace; + if (gate.premergeContentRecheck !== null) out.premergeContentRecheck = gate.premergeContentRecheck; + if (gate.requireFreshRebaseWindowMinutes !== null) out.requireFreshRebaseWindow = gate.requireFreshRebaseWindowMinutes; + if (gate.claMode !== null) out.claMode = gate.claMode; + if (gate.claConsentPhrase !== null || gate.claCheckRunName !== null || gate.claCheckRunAppSlug !== null) { + const cla: Record = {}; + if (gate.claConsentPhrase !== null) cla.consentPhrase = gate.claConsentPhrase; + if (gate.claCheckRunName !== null) cla.checkRunName = gate.claCheckRunName; + if (gate.claCheckRunAppSlug !== null) cla.checkRunAppSlug = gate.claCheckRunAppSlug; + out.cla = cla; + } + if (gate.expectedCiContexts !== null) out.expectedCiContexts = gate.expectedCiContexts as JsonValue; + return out; +} + +/** + * Parse the optional `features:` mapping — per-repo activation overrides for the converged review features. + * Each recognized key becomes a tri-state (`true`/`false`/`null`); unknown keys and non-boolean values are + * dropped with a warning. `present` is true when at least one key was explicitly set, so an operator can make + * the manifest "present" with only a `features:` block. + */ +function parseFeaturesConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestFeaturesConfig { + const features: FocusManifestFeaturesConfig = { ...EMPTY_FEATURES_CONFIG }; + if (value === undefined || value === null) return features; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push('Manifest "features" must be a mapping; ignoring it.'); + return features; + } + const record = value as Record; + for (const key of CONVERGED_FEATURE_KEYS) { + features[key] = normalizeOptionalBoolean(record[key], `features.${key}`, warnings); + } + features.present = CONVERGED_FEATURE_KEYS.some((key) => features[key] !== null); + return features; +} + +/** Serialize a features config back into the parse-compatible `features:` shape so a cached snapshot round-trips + * through {@link parseFeaturesConfig} unchanged. Returns null when nothing is configured. */ +export function featuresConfigToJson(features: FocusManifestFeaturesConfig): JsonValue { + if (!features.present) return null; + const out: Record = {}; + for (const key of CONVERGED_FEATURE_KEYS) { + if (features[key] !== null) out[key] = features[key]; + } + return out; +} + +/** A positive INTEGER count (not a score/confidence) — e.g. `contentLane.maxAppendedEntries` counts discrete + * surfaces[] entries, so a fractional value (a likely typo) would render a nonsensical contributor-facing close + * message ("append between 1 and 2.5 entries"). Rejects fractional and non-positive values alike. */ +function normalizeOptionalPositiveInteger(value: JsonValue | undefined, field: string, warnings: string[]): number | null { + if (value === undefined || value === null) return null; + if (typeof value === "number" && Number.isInteger(value) && value > 0) return value; + warnings.push(`Manifest field "${field}" must be a positive whole number; ignoring it.`); + return null; +} + +const REVIEW_VISUAL_MAX_ROUTES_LIMIT = 5; + +function normalizeOptionalVisualMaxRoutes(value: JsonValue | undefined, warnings: string[]): number | null { + const maxRoutes = normalizeOptionalPositiveInteger(value, "review.visual.routes.max_routes", warnings); + if (maxRoutes === null) return null; + if (maxRoutes <= REVIEW_VISUAL_MAX_ROUTES_LIMIT) return maxRoutes; + warnings.push(`Manifest field "review.visual.routes.max_routes" must be at most ${REVIEW_VISUAL_MAX_ROUTES_LIMIT}; clamping it.`); + return REVIEW_VISUAL_MAX_ROUTES_LIMIT; +} + +/** Normalize + bound a maintainer-supplied glob string: trims/length-caps like any other string field, AND + * rejects one globToRegExp (review/content-lane/spec-resolver.ts's reuse of the guardrail-path compiler) would + * itself refuse to compile safely. Reuses `hasUnsafeWildcardCount` — globToRegExp's OWN safety predicate — + * rather than a locally-counted threshold: a caller that counts wildcards differently (e.g. raw `*` characters, + * which double-counts a `**` pair as 2 groups instead of 1) can accept a glob globToRegExp then silently + * compiles to NEVER_MATCHES, configuring a lane that is "present" but can never activate on any changed file + * (#confirmed-bug). A glob over the cap is REJECTED (warns, returns null) rather than truncated — silently + * cutting wildcards out of a maintainer's pattern would silently change its meaning, which is worse than making + * them fix an over-complex glob. */ +function normalizeOptionalGlob(value: JsonValue | undefined, field: string, warnings: string[]): string | null { + const normalized = normalizeOptionalString(value, field, warnings); + if (normalized === null) return null; + if (normalized.length > MAX_ITEM_LENGTH) { + // REJECT, not truncate: cutting characters out of a glob changes which files it matches (e.g. a + // mid-directory-name cut can turn a narrow, intended pattern into one that matches an unrelated path + // prefix, or one that never matches anything) — silently compiling a DIFFERENT pattern than the + // maintainer configured is worse than making them shorten an over-complex glob. + warnings.push(`Manifest field "${field}" is an over-long glob (${normalized.length} > ${MAX_ITEM_LENGTH} chars); ignoring it.`); + return null; + } + if (hasUnsafeWildcardCount(normalized)) { + warnings.push(`Manifest field "${field}" has too many wildcards to compile safely; ignoring it.`); + return null; + } + return normalized; +} + +/** + * Parse the optional `contentLane:` mapping — per-repo registry-review lane configuration (#2435). `entryFileGlob` + * and `collectionField` are REQUIRED to build a usable spec; a config missing either — including a glob rejected + * by `normalizeOptionalGlob`'s wildcard cap — degrades to "not configured" (a warning, falling through to the + * allowlist default) rather than a broken half-spec. Glob fields stay plain strings here — compiling them to + * RegExp is the resolver's job (`review/content-lane/spec-resolver.ts`), not the parser's, so this file stays + * free of a RegExp-from-config compile step; it's still this file's job to keep an over-complex glob from ever + * reaching that compile step at all. + */ +function parseContentLaneConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestContentLaneConfig { + if (value === undefined || value === null) return { ...EMPTY_CONTENT_LANE_CONFIG }; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push('Manifest field "contentLane" must be a mapping; ignoring it.'); + return { ...EMPTY_CONTENT_LANE_CONFIG }; + } + const record = value as Record; + const entryFileGlob = normalizeOptionalGlob(record.entryFileGlob, "contentLane.entryFileGlob", warnings); + const providerFileGlob = normalizeOptionalGlob(record.providerFileGlob, "contentLane.providerFileGlob", warnings); + const artifactGlob = normalizeOptionalGlob(record.artifactGlob, "contentLane.artifactGlob", warnings); + const collectionField = normalizeOptionalString(record.collectionField, "contentLane.collectionField", warnings); + const maxAppendedEntries = normalizeOptionalPositiveInteger(record.maxAppendedEntries, "contentLane.maxAppendedEntries", warnings); + const duplicateKeyFields = normalizeStringList(record.duplicateKeyFields, "contentLane.duplicateKeyFields", warnings); + const validatorId = normalizeOptionalString(record.validatorId, "contentLane.validatorId", warnings); + if (!entryFileGlob || !collectionField) { + warnings.push('Manifest field "contentLane" requires both entryFileGlob and collectionField; ignoring it.'); + return { ...EMPTY_CONTENT_LANE_CONFIG }; + } + return { present: true, entryFileGlob, providerFileGlob, artifactGlob, collectionField, maxAppendedEntries, duplicateKeyFields, validatorId }; +} + +/** Serialize a contentLane config back into the parse-compatible `contentLane:` shape so a cached snapshot + * round-trips through {@link parseContentLaneConfig} unchanged. Returns null when nothing is configured. */ +export function contentLaneConfigToJson(contentLane: FocusManifestContentLaneConfig): JsonValue { + if (!contentLane.present || !contentLane.entryFileGlob || !contentLane.collectionField) return null; + const out: Record = { entryFileGlob: contentLane.entryFileGlob, collectionField: contentLane.collectionField }; + if (contentLane.providerFileGlob !== null) out.providerFileGlob = contentLane.providerFileGlob; + if (contentLane.artifactGlob !== null) out.artifactGlob = contentLane.artifactGlob; + if (contentLane.maxAppendedEntries !== null) out.maxAppendedEntries = contentLane.maxAppendedEntries; + if (contentLane.duplicateKeyFields.length > 0) out.duplicateKeyFields = contentLane.duplicateKeyFields; + if (contentLane.validatorId !== null) out.validatorId = contentLane.validatorId; + return out; +} + +const REPO_DOC_GENERATION_SCOPES: readonly FocusManifestRepoDocGenerationScope[] = ["agents", "skills"]; + +/** `undefined`/`null` (key omitted) falls back to the default scope; a non-list value is a genuine type error + * and ALSO falls back to the default (rather than emptying it out, which would silently disable an otherwise + * `enabled: true` config); an actual list -- even an explicitly empty one, or one where every entry is + * invalid -- is respected as "nothing in scope", since that is a deliberate, well-typed value. */ +function parseRepoDocGenerationScope(value: JsonValue | undefined, warnings: string[]): FocusManifestRepoDocGenerationScope[] { + if (value === undefined || value === null) return [...EMPTY_REPO_DOC_GENERATION_CONFIG.scope]; + if (!Array.isArray(value)) { + warnings.push('Manifest field "repoDocGeneration.scope" must be a list; falling back to the default scope.'); + return [...EMPTY_REPO_DOC_GENERATION_CONFIG.scope]; + } + const raw = normalizeStringList(value, "repoDocGeneration.scope", warnings); + return raw.filter((entry): entry is FocusManifestRepoDocGenerationScope => { + if ((REPO_DOC_GENERATION_SCOPES as readonly string[]).includes(entry)) return true; + warnings.push(`Manifest field "repoDocGeneration.scope" has an unrecognized entry "${entry}"; ignoring it.`); + return false; + }); +} + +/** + * Parse the optional `repoDocGeneration:` mapping (#3002). Unlike `gate:`/`settings:`, every field here has a + * concrete default rather than a null "unconfigured" sentinel -- there is no DB layer to overlay onto, so the + * parsed value (or the default, when a key is omitted) IS the effective value. An explicitly empty `scope: []` + * is honored as "nothing in scope" (not coerced back to the default); only an OMITTED `scope` key falls back to + * `["agents"]`, mirroring how `undefined`/`null` mean "unset" everywhere else in this file. + */ +function parseRepoDocGenerationConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestRepoDocGenerationConfig { + if (value === undefined || value === null) return { ...EMPTY_REPO_DOC_GENERATION_CONFIG }; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push('Manifest field "repoDocGeneration" must be a mapping; ignoring it.'); + return { ...EMPTY_REPO_DOC_GENERATION_CONFIG }; + } + const record = value as Record; + const enabled = normalizeOptionalBoolean(record.enabled, "repoDocGeneration.enabled", warnings) ?? false; + const allowOverwriteExisting = normalizeOptionalBoolean(record.allowOverwriteExisting, "repoDocGeneration.allowOverwriteExisting", warnings) ?? false; + const scope = parseRepoDocGenerationScope(record.scope, warnings); + const refreshIntervalDays = normalizeOptionalPositiveInteger(record.refreshIntervalDays, "repoDocGeneration.refreshIntervalDays", warnings) ?? DEFAULT_REPO_DOC_REFRESH_INTERVAL_DAYS; + return { present: true, enabled, scope, allowOverwriteExisting, refreshIntervalDays }; +} + +/** Serialize a repoDocGeneration config back into the parse-compatible shape so a cached snapshot round-trips + * through {@link parseRepoDocGenerationConfig} unchanged. Returns null when nothing is configured. */ +export function repoDocGenerationConfigToJson(config: FocusManifestRepoDocGenerationConfig): JsonValue { + if (!config.present) return null; + return { enabled: config.enabled, scope: config.scope, allowOverwriteExisting: config.allowOverwriteExisting, refreshIntervalDays: config.refreshIntervalDays }; +} + +/** + * Parse the optional `reviewRecap:` mapping (#1963). Mirrors {@link parseRepoDocGenerationConfig}: every + * field has a concrete default (no DB layer to overlay onto), so the parsed value IS the effective value. + */ +function parseReviewRecapConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestReviewRecapConfig { + if (value === undefined || value === null) return { ...EMPTY_REVIEW_RECAP_CONFIG }; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push('Manifest field "reviewRecap" must be a mapping; ignoring it.'); + return { ...EMPTY_REVIEW_RECAP_CONFIG }; + } + const record = value as Record; + const enabled = normalizeOptionalBoolean(record.enabled, "reviewRecap.enabled", warnings) ?? false; + const cadenceDays = normalizeOptionalPositiveInteger(record.cadenceDays, "reviewRecap.cadenceDays", warnings) ?? DEFAULT_REVIEW_RECAP_CADENCE_DAYS; + return { present: true, enabled, cadenceDays }; +} + +/** Serialize a reviewRecap config back into the parse-compatible shape so a cached snapshot round-trips + * through {@link parseReviewRecapConfig} unchanged. Returns null when nothing is configured. */ +export function reviewRecapConfigToJson(config: FocusManifestReviewRecapConfig): JsonValue { + if (!config.present) return null; + return { enabled: config.enabled, cadenceDays: config.cadenceDays }; +} + +function normalizeOptionalEnum(value: JsonValue | undefined, field: string, allowed: readonly T[], warnings: string[]): T | null { + if (value === undefined || value === null) return null; + if (typeof value === "string" && (allowed as readonly string[]).includes(value)) return value as T; + warnings.push(`Manifest settings field "${field}" must be one of ${allowed.join(", ")}; ignoring "${String(value)}".`); + return null; +} + +function normalizeOptionalString(value: JsonValue | undefined, field: string, warnings: string[]): string | null { + if (value === undefined || value === null) return null; + if (typeof value === "string" && value.trim().length > 0) return value.trim(); + warnings.push(`Manifest settings field "${field}" must be a non-empty string; ignoring it.`); + return null; +} + +// Keep the review-nag lookback operationally bounded so repo-controlled config cannot overflow Date +// arithmetic. Duplicated from settings/agent-actions.ts's own MAX_REVIEW_NAG_COOLDOWN_DAYS (same value, +// same rationale) rather than imported: this module is part of the UI package's typechecked closure, and +// agent-actions.ts transitively imports github/commands.ts -> utils/crypto.ts, pulling a heavier +// GitHub-App-specific dependency chain into the UI build for one small constant. +const MAX_REVIEW_NAG_COOLDOWN_DAYS = 365; + +/** + * Parse the optional `settings:` mapping — a partial repository-settings override. Only recognized + * fields are kept; unknown/invalid values are dropped with a warning and never throw. + */ +function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]): FocusManifestSettings { + if (value === undefined || value === null) return {}; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push(`Manifest field "settings" must be a mapping; ignoring it.`); + return {}; + } + const r = value as Record; + const out: FocusManifestSettings = {}; + const commentMode = normalizeOptionalEnum(r.commentMode, "settings.commentMode", ["off", "detected_contributors_only", "all_prs"] as const, warnings); + if (commentMode !== null) out.commentMode = commentMode; + const publicAudienceMode = normalizeOptionalEnum(r.publicAudienceMode, "settings.publicAudienceMode", ["oss_maintainer", "gittensor_only"] as const, warnings); + if (publicAudienceMode !== null) out.publicAudienceMode = publicAudienceMode; + const publicSignalLevel = normalizeOptionalEnum(r.publicSignalLevel, "settings.publicSignalLevel", ["minimal", "standard"] as const, warnings); + if (publicSignalLevel !== null) out.publicSignalLevel = publicSignalLevel; + const checkRunMode = normalizeOptionalEnum(r.checkRunMode, "settings.checkRunMode", ["off", "enabled"] as const, warnings); + if (checkRunMode !== null) out.checkRunMode = checkRunMode; + const checkRunDetailLevel = normalizeOptionalEnum(r.checkRunDetailLevel, "settings.checkRunDetailLevel", ["minimal", "standard", "deep"] as const, warnings); + if (checkRunDetailLevel !== null) out.checkRunDetailLevel = checkRunDetailLevel; + const gateCheckMode = normalizeOptionalEnum(r.gateCheckMode, "settings.gateCheckMode", ["off", "enabled"] as const, warnings); + if (gateCheckMode !== null) out.gateCheckMode = gateCheckMode; + const regateSweepOrderMode = normalizeOptionalEnum(r.regateSweepOrderMode, "settings.regateSweepOrderMode", ["staleness", "oldest-first"] as const, warnings); + if (regateSweepOrderMode !== null) out.regateSweepOrderMode = regateSweepOrderMode; + // Same tri-state field as gate.checkMode above (the friendly gate alias overlays onto it in + // resolveEffectiveSettings, and wins when both are set). + const reviewCheckMode = normalizeOptionalEnum(r.reviewCheckMode, "settings.reviewCheckMode", ["required", "visible", "disabled"] as const, warnings); + if (reviewCheckMode !== null) out.reviewCheckMode = reviewCheckMode; + const autoProjectMilestoneMatch = normalizeOptionalEnum(r.autoProjectMilestoneMatch, "settings.autoProjectMilestoneMatch", ["off", "suggest", "auto"] as const, warnings); + if (autoProjectMilestoneMatch !== null) out.autoProjectMilestoneMatch = autoProjectMilestoneMatch; + const autoProjectMilestoneMatchBackend = normalizeOptionalEnum(r.autoProjectMilestoneMatchBackend, "settings.autoProjectMilestoneMatchBackend", ["github", "linear"] as const, warnings); + if (autoProjectMilestoneMatchBackend !== null) out.autoProjectMilestoneMatchBackend = autoProjectMilestoneMatchBackend; + const linkedIssueGateMode = normalizeOptionalGateMode(r.linkedIssueGateMode, "settings.linkedIssueGateMode", warnings); + if (linkedIssueGateMode !== null) out.linkedIssueGateMode = linkedIssueGateMode; + const duplicatePrGateMode = normalizeOptionalGateMode(r.duplicatePrGateMode, "settings.duplicatePrGateMode", warnings); + if (duplicatePrGateMode !== null) out.duplicatePrGateMode = duplicatePrGateMode; + const selfAuthoredLinkedIssueGateMode = normalizeOptionalGateMode(r.selfAuthoredLinkedIssueGateMode, "settings.selfAuthoredLinkedIssueGateMode", warnings); + if (selfAuthoredLinkedIssueGateMode !== null) out.selfAuthoredLinkedIssueGateMode = selfAuthoredLinkedIssueGateMode; + // Same tri-state field as gate.readiness.mode above (the friendly gate alias overlays onto it in + // resolveEffectiveSettings) — apply the identical "block" → "advisory" downgrade here too, so a maintainer + // setting `settings.qualityGateMode: block` directly hits the same deprecation warning (#2267). + const qualityGateMode = normalizeReadinessGateMode(r.qualityGateMode, "settings.qualityGateMode", warnings); + if (qualityGateMode !== null) out.qualityGateMode = qualityGateMode; + const qualityGateMinScore = normalizeOptionalScore(r.qualityGateMinScore, "settings.qualityGateMinScore", warnings); + if (qualityGateMinScore !== null) out.qualityGateMinScore = qualityGateMinScore; + const aiReviewMode = normalizeOptionalGateMode(r.aiReviewMode, "settings.aiReviewMode", warnings); + if (aiReviewMode !== null) out.aiReviewMode = aiReviewMode; + const aiReviewProvider = normalizeOptionalEnum(r.aiReviewProvider, "settings.aiReviewProvider", ["anthropic", "openai"] as const, warnings); + if (aiReviewProvider !== null) out.aiReviewProvider = aiReviewProvider; + const aiReviewModel = normalizeOptionalString(r.aiReviewModel, "settings.aiReviewModel", warnings); + if (aiReviewModel !== null) out.aiReviewModel = aiReviewModel; + const gittensorLabel = normalizeOptionalString(r.gittensorLabel, "settings.gittensorLabel", warnings); + if (gittensorLabel !== null) out.gittensorLabel = gittensorLabel; + // #label-scoping: an explicit yml `null` is load-bearing (closes WITHOUT any label), matching + // contributorOpenPrCap's own null-vs-omitted distinction — must be checked BEFORE normalizeOptionalString, + // which otherwise collapses null and undefined to the same "unset" result. + if (r.blacklistLabel === null) { + out.blacklistLabel = null; + } else { + const blacklistLabel = normalizeOptionalString(r.blacklistLabel, "settings.blacklistLabel", warnings); + if (blacklistLabel !== null) out.blacklistLabel = blacklistLabel; + } + const publicSurface = normalizeOptionalEnum(r.publicSurface, "settings.publicSurface", ["off", "comment_and_label", "comment_only", "label_only"] as const, warnings); + if (publicSurface !== null) out.publicSurface = publicSurface; + for (const key of ["aiReviewByok", "aiReviewAllAuthors", "closeOwnerAuthors", "autoLabelEnabled", "typeLabelsEnabled", "badgeEnabled", "publicQualityMetrics", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "privateTrustEnabled", "agentPaused", "agentDryRun"] as const) { + const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings); + if (flag !== null) out[key] = flag; + } + // Agent-layer autonomy dial (#773): `settings.autonomy` maps each action class to a level. Only set it + // when at least one valid class→level pair survives normalization, so a malformed block never blanks the + // DB-configured policy via the resolver's `{...dbSettings, ...manifest.settings}` overlay. + if (r.autonomy !== undefined) { + const autonomy = normalizeAutonomyPolicy(r.autonomy); + if (Object.keys(autonomy).length > 0) out.autonomy = autonomy; + } + // Auto-maintain policy (#774): `settings.autoMaintain` declares the full policy (defaults fill any unset + // field) and overlays the DB value via the resolver. Only a mapping is honoured; anything else is ignored. + if (typeof r.autoMaintain === "object" && r.autoMaintain !== null && !Array.isArray(r.autoMaintain)) { + out.autoMaintain = normalizeAutoMaintainPolicy(r.autoMaintain); + } + // Command authorization policy (#2268 config-as-code parity): `settings.commandAuthorization` declares the + // full role policy the same way `autoMaintain` does — the normalizer fills any unset/invalid FIELD from + // DEFAULT_COMMAND_AUTHORIZATION_POLICY, so a partially-valid mapping yields a complete, safe policy that + // overlays the DB value via the resolver's `{...dbSettings, ...manifest.settings}` spread. But an invalid + // TOP-LEVEL shape (not a mapping at all) is a different case: normalizeCommandAuthorizationPolicy's own + // fallback there is meant for callers with no DB value to fall back to, not for this overlay — applying it + // here would let a typo'd config silently overwrite a stricter DB-persisted policy with the built-in + // default. So only apply the normalized policy when the raw value was actually a mapping; otherwise warn + // and leave `out.commandAuthorization` unset so the resolver preserves whatever the DB already has. + if (typeof r.commandAuthorization === "object" && r.commandAuthorization !== null && !Array.isArray(r.commandAuthorization)) { + const { policy, warnings: commandAuthorizationWarnings } = normalizeCommandAuthorizationPolicy(r.commandAuthorization); + warnings.push(...commandAuthorizationWarnings); + out.commandAuthorization = policy; + } else if (r.commandAuthorization !== undefined) { + warnings.push(`Manifest "settings.commandAuthorization" must be an object; ignoring it and keeping any existing policy.`); + } + // TYPE label category overrides (#priority-linked-issue-gate, #label-modularity): unlike + // commandAuthorization/autoMaintain above, this is deliberately kept SPARSE -- only the keys actually + // present AND validly-shaped in the raw YAML are copied onto `out.typeLabels` (via + // `normalizeTypeLabelSet`, which still fills in the built-in bug/feature/priority keys to run its own + // shape checks, but those defaults-filled values are discarded here). A manifest naming only + // `typeLabels.priority` must inherit `bug`/`feature` from the DB-persisted value in + // `resolveEffectiveSettings`, not have them silently reset to the built-in gittensor:* names -- assigning + // the normalizer's complete object here would do exactly that via the resolver's wholesale + // `{...dbSettings, ...manifest.settings}` spread. The per-field shape check below (not just "is the key + // present") matters too: a malformed value (e.g. `typeLabels.priority: 123`) is present but invalid, so + // `normalizeTypeLabelSet` warns and reports its OWN built-in-default fallback for that key -- copying + // that fallback into the sparse override would silently overwrite a DB-customized value with the + // built-in default on a config typo, instead of leaving the DB value alone. The loop is generic over + // whatever keys the raw object actually has (not hardcoded to bug/feature/priority), so an arbitrary + // custom category (e.g. `security`) sparse-overrides exactly like a built-in one. The normalizer + // enforces the category-count and label-name caps before a sparse key can survive into the override. + if (typeof r.typeLabels === "object" && r.typeLabels !== null && !Array.isArray(r.typeLabels)) { + const rawTypeLabels = r.typeLabels as Record; + if (Object.keys(rawTypeLabels).length === 0) { + // A literal `typeLabels: {}` is a DELIBERATE, complete declaration -- "zero configured categories + // for this repo" -- distinct from a sparse override whose named keys all failed validation (the + // `else` branch below, which must NOT wipe the DB value). Represented as `null` so + // `resolveEffectiveSettings` can tell the two apart even though both would otherwise collapse to + // the same empty-object shape (#label-modularity). + out.typeLabels = null; + } else { + const validated = normalizeTypeLabelSet(rawTypeLabels, warnings); + const isValidLabelName = (value: unknown): boolean => typeof value === "string" && value.trim().length > 0 && value.trim().length <= MAX_TYPE_LABEL_NAME_LENGTH; + const sparseTypeLabels: Partial = {}; + for (const key of Object.keys(rawTypeLabels)) { + if (isValidLabelName(rawTypeLabels[key]) && validated[key] !== undefined) sparseTypeLabels[key] = validated[key]; + } + out.typeLabels = sparseTypeLabels; + } + } else if (r.typeLabels !== undefined) { + warnings.push(`Manifest "settings.typeLabels" must be an object; ignoring it and keeping any existing label names.`); + } + // Linked-issue label propagation (#priority-linked-issue-gate): same sparse-partial shape as typeLabels + // above, for the same reason -- this is the ONLY mechanism that can ever select a maintainer-reward + // label like gittensor:priority (never inferred from title/files/AI/PR-labels), so a manifest overriding + // just one field (e.g. `enabled`) must not silently reset `mappings` back to the built-in empty default + // and discard a DB-configured mapping list. Each field is gated on its OWN raw shape being valid (not + // just "is the key present"), for the same reason as typeLabels above -- e.g. a typo'd + // `mappings: "oops"` must never silently replace a DB-configured mapping list with the normalizer's + // empty-array fallback. A validly-shaped `mappings` array is still a complete replacement when present + // (arrays have no per-item precedence semantics here, and any individually-invalid entries inside it + // are dropped by the normalizer, not the array itself), matching the array-replace-wholesale overlay + // behavior documented for the private-config layer. + if (typeof r.linkedIssueLabelPropagation === "object" && r.linkedIssueLabelPropagation !== null && !Array.isArray(r.linkedIssueLabelPropagation)) { + const rawPropagation = r.linkedIssueLabelPropagation as Record; + const validated = normalizeLinkedIssueLabelPropagationConfig(rawPropagation, warnings); + const sparsePropagation: Partial = {}; + if (typeof rawPropagation.enabled === "boolean") sparsePropagation.enabled = validated.enabled; + if (typeof rawPropagation.mode === "string" && (VALID_LINKED_ISSUE_LABEL_PROPAGATION_MODES as readonly string[]).includes(rawPropagation.mode)) { + sparsePropagation.mode = validated.mode; + } + if (Array.isArray(rawPropagation.mappings)) sparsePropagation.mappings = validated.mappings; + out.linkedIssueLabelPropagation = sparsePropagation; + } else if (r.linkedIssueLabelPropagation !== undefined) { + warnings.push(`Manifest "settings.linkedIssueLabelPropagation" must be an object; ignoring it and keeping any existing policy.`); + } + // Linked-issue hard rules: same sparse-partial overlay contract as linkedIssueLabelPropagation. A global config + // can enable the policy and set label lists; a repo override can toggle one mode without resetting those lists. + if (typeof r.linkedIssueHardRules === "object" && r.linkedIssueHardRules !== null && !Array.isArray(r.linkedIssueHardRules)) { + const rawRules = r.linkedIssueHardRules as Record; + const validated = normalizeLinkedIssueHardRulesConfig(rawRules, warnings); + const sparseRules: Partial = {}; + if (isLinkedIssueHardRuleMode(rawRules.ownerAssignedClose)) sparseRules.ownerAssignedClose = validated.ownerAssignedClose; + if (isLinkedIssueHardRuleMode(rawRules.assignedIssueClose)) sparseRules.assignedIssueClose = validated.assignedIssueClose; + if (isLinkedIssueHardRuleMode(rawRules.missingPointLabelClose)) sparseRules.missingPointLabelClose = validated.missingPointLabelClose; + if (isLinkedIssueHardRuleMode(rawRules.maintainerOnlyLabelClose)) sparseRules.maintainerOnlyLabelClose = validated.maintainerOnlyLabelClose; + if (Array.isArray(rawRules.pointBearingLabels)) sparseRules.pointBearingLabels = validated.pointBearingLabels; + if (Array.isArray(rawRules.maintainerOnlyLabels)) sparseRules.maintainerOnlyLabels = validated.maintainerOnlyLabels; + if (typeof rawRules.defaultLabelRepo === "boolean") sparseRules.defaultLabelRepo = validated.defaultLabelRepo; + if (typeof rawRules.verifyBeforeClose === "boolean") sparseRules.verifyBeforeClose = validated.verifyBeforeClose; + if (typeof rawRules.closeDelaySeconds === "number" && Number.isFinite(rawRules.closeDelaySeconds) && rawRules.closeDelaySeconds >= 0) { + sparseRules.closeDelaySeconds = validated.closeDelaySeconds; + } + out.linkedIssueHardRules = sparseRules; + } else if (r.linkedIssueHardRules !== undefined) { + warnings.push(`Manifest "settings.linkedIssueHardRules" must be an object; ignoring it and keeping any existing policy.`); + } + // Unlinked-issue guardrail (#unlinked-issue-guardrail): same sparse-partial overlay contract as + // linkedIssueHardRules above -- a repo naming only `mode` must not silently reset `minConfidence` back to + // the built-in default. + if (typeof r.unlinkedIssueGuardrail === "object" && r.unlinkedIssueGuardrail !== null && !Array.isArray(r.unlinkedIssueGuardrail)) { + const rawGuardrail = r.unlinkedIssueGuardrail as Record; + const validated = normalizeUnlinkedIssueGuardrailConfig(rawGuardrail, warnings); + const sparseGuardrail: Partial = {}; + if (isUnlinkedIssueGuardrailMode(rawGuardrail.mode)) sparseGuardrail.mode = validated.mode; + if (typeof rawGuardrail.minConfidence === "number" && Number.isFinite(rawGuardrail.minConfidence) && rawGuardrail.minConfidence >= 0 && rawGuardrail.minConfidence <= 1) { + sparseGuardrail.minConfidence = validated.minConfidence; + } + out.unlinkedIssueGuardrail = sparseGuardrail; + } else if (r.unlinkedIssueGuardrail !== undefined) { + warnings.push(`Manifest "settings.unlinkedIssueGuardrail" must be an object; ignoring it and keeping any existing policy.`); + } + // Screenshot-table gate (#2006): same sparse-partial overlay contract as unlinkedIssueGuardrail above -- a + // repo naming only `enabled` must not silently reset `whenLabels`/`whenPaths`/`action`/`message`. + if (typeof r.screenshotTableGate === "object" && r.screenshotTableGate !== null && !Array.isArray(r.screenshotTableGate)) { + const rawGate = r.screenshotTableGate as Record; + const validated = normalizeScreenshotTableGateConfig(rawGate, warnings); + const sparseGate: Partial = {}; + if (typeof rawGate.enabled === "boolean") sparseGate.enabled = validated.enabled; + if (Array.isArray(rawGate.whenLabels)) sparseGate.whenLabels = validated.whenLabels; + if (Array.isArray(rawGate.whenPaths)) sparseGate.whenPaths = validated.whenPaths; + if (isScreenshotTableGateAction(rawGate.action)) sparseGate.action = validated.action; + if (typeof rawGate.message === "string" && rawGate.message.trim().length > 0) sparseGate.message = validated.message; + out.screenshotTableGate = sparseGate; + } else if (r.screenshotTableGate !== undefined) { + warnings.push(`Manifest "settings.screenshotTableGate" must be an object; ignoring it and keeping any existing policy.`); + } + // Contributor blacklist (#1425): `settings.contributorBlacklist` is a list of banned-login entries. Only set it + // when at least one VALID entry survives normalization, so a malformed block never blanks the DB-configured + // list via the resolver's `{...dbSettings, ...manifest.settings}` overlay. Normalization warnings are folded in. + if (r.contributorBlacklist !== undefined) { + const { entries, warnings: blacklistWarnings } = normalizeContributorBlacklist(r.contributorBlacklist); + warnings.push(...blacklistWarnings); + if (entries.length > 0) out.contributorBlacklist = entries; + } + // Per-contributor open PR/issue caps (#2270): discrete counts, not scores — reuse the same positive-integer + // normalizer as contentLane.maxAppendedEntries so a fractional/non-positive typo is dropped with a warning + // instead of configuring a nonsensical cap. UNLIKE contributorBlacklist above, an explicit yml `null` here is + // load-bearing (not the same as omitting the key): the documented `yml > DB > null` precedence means a + // maintainer must be able to force a DB-configured cap back to "no cap" via `.gittensory.yml` without deleting + // the DB row. `normalizeOptionalPositiveInteger` collapses "absent" and "null" to the same silent `null` + // return, so that distinction has to be made HERE, before calling it: a literal `null` sets the key to `null` + // (clears); omitted (`undefined`) leaves the key unset (preserves the DB value via the resolver's spread); an + // invalid non-null value (fractional/non-positive/wrong type) warns and also leaves the key unset. + if (r.contributorOpenPrCap === null) { + out.contributorOpenPrCap = null; + } else { + const contributorOpenPrCap = normalizeOptionalPositiveInteger(r.contributorOpenPrCap, "settings.contributorOpenPrCap", warnings); + if (contributorOpenPrCap !== null) out.contributorOpenPrCap = contributorOpenPrCap; + } + if (r.contributorOpenIssueCap === null) { + out.contributorOpenIssueCap = null; + } else { + const contributorOpenIssueCap = normalizeOptionalPositiveInteger(r.contributorOpenIssueCap, "settings.contributorOpenIssueCap", warnings); + if (contributorOpenIssueCap !== null) out.contributorOpenIssueCap = contributorOpenIssueCap; + } + // #label-scoping: same load-bearing-null idiom as blacklistLabel above. + if (r.contributorCapLabel === null) { + out.contributorCapLabel = null; + } else { + const contributorCapLabel = normalizeOptionalString(r.contributorCapLabel, "settings.contributorCapLabel", warnings); + if (contributorCapLabel !== null) out.contributorCapLabel = contributorCapLabel; + } + // CI-run cancellation on a contributor_cap close (#2462): an explicit yml `null` is load-bearing (clears a + // DB-configured value back to "unset", falling through to the CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT env var), + // matching contributorOpenPrCap's own null-vs-omitted distinction above. + if (r.contributorCapCancelCi === null) { + out.contributorCapCancelCi = null; + } else { + const contributorCapCancelCi = normalizeOptionalBoolean(r.contributorCapCancelCi, "settings.contributorCapCancelCi", warnings); + if (contributorCapCancelCi !== null) out.contributorCapCancelCi = contributorCapCancelCi; + } + // Review-request nagging cooldown (#2463): throttle a contributor repeatedly pinging @gittensory for review. + const reviewNagPolicy = normalizeOptionalEnum(r.reviewNagPolicy, "settings.reviewNagPolicy", ["off", "hold", "close"] as const, warnings); + if (reviewNagPolicy !== null) out.reviewNagPolicy = reviewNagPolicy; + const reviewNagMaxPings = normalizeOptionalPositiveInteger(r.reviewNagMaxPings, "settings.reviewNagMaxPings", warnings); + if (reviewNagMaxPings !== null) out.reviewNagMaxPings = reviewNagMaxPings; + const reviewNagCooldownDays = normalizeOptionalPositiveInteger(r.reviewNagCooldownDays, "settings.reviewNagCooldownDays", warnings); + if (reviewNagCooldownDays !== null && reviewNagCooldownDays <= MAX_REVIEW_NAG_COOLDOWN_DAYS) out.reviewNagCooldownDays = reviewNagCooldownDays; + if (reviewNagCooldownDays !== null && reviewNagCooldownDays > MAX_REVIEW_NAG_COOLDOWN_DAYS) { + warnings.push(`Manifest field "settings.reviewNagCooldownDays" must be at most ${MAX_REVIEW_NAG_COOLDOWN_DAYS}; ignoring it.`); + } + // #label-scoping: same load-bearing-null idiom as blacklistLabel above. + if (r.reviewNagLabel === null) { + out.reviewNagLabel = null; + } else { + const reviewNagLabel = normalizeOptionalString(r.reviewNagLabel, "settings.reviewNagLabel", warnings); + if (reviewNagLabel !== null) out.reviewNagLabel = reviewNagLabel; + } + // Maintainer-mention nag moderation (#label-scoping): GitHub logins ALSO throttled under the review-nag + // cooldown above, on top of the bot's own @gittensory handle. Only set it when at least one VALID login + // survives normalization, so a malformed block never blanks the DB-configured list via the resolver's + // `{...dbSettings, ...manifest.settings}` overlay (same reasoning as autoCloseExemptLogins below). + if (r.reviewNagMonitoredMentions !== undefined) { + const { logins: monitoredMentions, warnings: monitoredMentionWarnings } = normalizeAutoCloseExemptLogins(r.reviewNagMonitoredMentions); + warnings.push(...monitoredMentionWarnings); + if (monitoredMentions.length > 0) out.reviewNagMonitoredMentions = monitoredMentions; + } + // Shared repo-scoped exemption list (#2463): only set it when at least one VALID login survives + // normalization, so a malformed block never blanks the DB-configured list via the resolver's overlay. + if (r.autoCloseExemptLogins !== undefined) { + const { logins, warnings: exemptWarnings } = normalizeAutoCloseExemptLogins(r.autoCloseExemptLogins); + warnings.push(...exemptWarnings); + if (logins.length > 0) out.autoCloseExemptLogins = logins; + } + // Hard manual-review guardrails are config-as-code only. Arrays replace lower layers wholesale, so only an + // explicit [] or a non-empty valid list replaces a private global setting. Null/malformed values are ignored + // instead of clearing. + if (Array.isArray(r.hardGuardrailGlobs)) { + const hardGuardrailGlobs = normalizeStringList(r.hardGuardrailGlobs, "settings.hardGuardrailGlobs", warnings); + if (r.hardGuardrailGlobs.length === 0 || hardGuardrailGlobs.length > 0) { + out.hardGuardrailGlobs = hardGuardrailGlobs; + } else { + warnings.push(`Manifest "settings.hardGuardrailGlobs" did not contain any valid path globs; ignoring it and keeping any existing guardrails.`); + } + } else if (r.hardGuardrailGlobs !== undefined) { + warnings.push(`Manifest "settings.hardGuardrailGlobs" must be an array of path globs; ignoring it and keeping any existing guardrails.`); + } + // Manual-review label is deliberately separate from review_state_label so operators can use one hold label + // without enabling the old ready/changes disposition labels. Null disables only the label, not the hold. + if (r.manualReviewLabel === null) { + out.manualReviewLabel = null; + } else { + const manualReviewLabel = normalizeOptionalString(r.manualReviewLabel, "settings.manualReviewLabel", warnings); + if (manualReviewLabel !== null) out.manualReviewLabel = manualReviewLabel; + } + if (r.readyToMergeLabel === null) { + out.readyToMergeLabel = null; + } else { + const readyToMergeLabel = normalizeOptionalString(r.readyToMergeLabel, "settings.readyToMergeLabel", warnings); + if (readyToMergeLabel !== null) out.readyToMergeLabel = readyToMergeLabel; + } + if (r.changesRequestedLabel === null) { + out.changesRequestedLabel = null; + } else { + const changesRequestedLabel = normalizeOptionalString(r.changesRequestedLabel, "settings.changesRequestedLabel", warnings); + if (changesRequestedLabel !== null) out.changesRequestedLabel = changesRequestedLabel; + } + if (r.migrationCollisionLabel === null) { + out.migrationCollisionLabel = null; + } else { + const migrationCollisionLabel = normalizeOptionalString(r.migrationCollisionLabel, "settings.migrationCollisionLabel", warnings); + if (migrationCollisionLabel !== null) out.migrationCollisionLabel = migrationCollisionLabel; + } + if (r.pendingClosureLabel === null) { + out.pendingClosureLabel = null; + } else { + const pendingClosureLabel = normalizeOptionalString(r.pendingClosureLabel, "settings.pendingClosureLabel", warnings); + if (pendingClosureLabel !== null) out.pendingClosureLabel = pendingClosureLabel; + } + // Account-age throttle (#2561): an explicit yml `null` is load-bearing (clears a DB-configured threshold + // back to "off"), matching contributorOpenPrCap's own null-vs-omitted distinction above. + if (r.accountAgeThresholdDays === null) { + out.accountAgeThresholdDays = null; + } else { + const accountAgeThresholdDays = normalizeOptionalPositiveInteger(r.accountAgeThresholdDays, "settings.accountAgeThresholdDays", warnings); + if (accountAgeThresholdDays !== null) out.accountAgeThresholdDays = accountAgeThresholdDays; + } + const newAccountLabel = normalizeOptionalString(r.newAccountLabel, "settings.newAccountLabel", warnings); + if (newAccountLabel !== null) out.newAccountLabel = newAccountLabel; + // Per-command @gittensory rate limit (#2560): generalizes review-nag's cooldown pattern to every command. + const commandRateLimitPolicy = normalizeOptionalEnum(r.commandRateLimitPolicy, "settings.commandRateLimitPolicy", ["off", "hold"] as const, warnings); + if (commandRateLimitPolicy !== null) out.commandRateLimitPolicy = commandRateLimitPolicy; + const commandRateLimitMaxPerWindow = normalizeOptionalPositiveInteger(r.commandRateLimitMaxPerWindow, "settings.commandRateLimitMaxPerWindow", warnings); + if (commandRateLimitMaxPerWindow !== null) out.commandRateLimitMaxPerWindow = commandRateLimitMaxPerWindow; + const commandRateLimitAiMaxPerWindow = normalizeOptionalPositiveInteger(r.commandRateLimitAiMaxPerWindow, "settings.commandRateLimitAiMaxPerWindow", warnings); + if (commandRateLimitAiMaxPerWindow !== null) out.commandRateLimitAiMaxPerWindow = commandRateLimitAiMaxPerWindow; + const commandRateLimitWindowHours = normalizeOptionalPositiveInteger(r.commandRateLimitWindowHours, "settings.commandRateLimitWindowHours", warnings); + if (commandRateLimitWindowHours !== null) out.commandRateLimitWindowHours = commandRateLimitWindowHours; + // Moderation-rules engine (#selfhost-mod-engine): per-repo override of the global moderation config. + const moderationGateMode = normalizeOptionalEnum(r.moderationGateMode, "settings.moderationGateMode", ["inherit", "off", "enabled"] as const, warnings); + if (moderationGateMode !== null) out.moderationGateMode = moderationGateMode; + // #gate-flagged: normalizeModerationRules returns an EMPTY rules array for two semantically different + // inputs -- a genuinely empty yml list (`moderationRules: []`, an intentional "opt every rule out for this + // repo") and a MALFORMED one (a non-array, or an array where every entry fails validation) that degrades to + // empty as its safe fallback. Applying the malformed case as an override would silently disable every rule + // for this repo instead of leaving the DB-configured value intact, so the two must be told apart by the RAW + // input's own shape -- not just the normalized result -- before assigning. A PARTIAL list (some valid, some + // invalid entries) still applies the surviving valid subset, mirroring autoCloseExemptLogins' behavior. + if (r.moderationRules !== undefined) { + const { rules, warnings: moderationRuleWarnings } = normalizeModerationRules(r.moderationRules); + warnings.push(...moderationRuleWarnings); + const intentionalEmptyList = Array.isArray(r.moderationRules) && r.moderationRules.length === 0; + if (rules.length > 0 || intentionalEmptyList) out.moderationRules = rules; + } + const moderationWarningLabel = normalizeModerationLabel(r.moderationWarningLabel); + if (moderationWarningLabel !== undefined) out.moderationWarningLabel = moderationWarningLabel; + const moderationBannedLabel = normalizeModerationLabel(r.moderationBannedLabel); + if (moderationBannedLabel !== undefined) out.moderationBannedLabel = moderationBannedLabel; + // Review-evasion protection (#review-evasion-protection): a contributor closing/converting-to-draft their + // own PR while gittensory has an active review pass running is dodging the one-shot review. + const reviewEvasionProtection = normalizeOptionalEnum(r.reviewEvasionProtection, "settings.reviewEvasionProtection", ["off", "close"] as const, warnings); + if (reviewEvasionProtection !== null) out.reviewEvasionProtection = reviewEvasionProtection; + // #label-scoping: same load-bearing-null idiom as blacklistLabel above. + if (r.reviewEvasionLabel === null) { + out.reviewEvasionLabel = null; + } else { + const reviewEvasionLabel = normalizeOptionalString(r.reviewEvasionLabel, "settings.reviewEvasionLabel", warnings); + if (reviewEvasionLabel !== null) out.reviewEvasionLabel = reviewEvasionLabel; + } + const reviewEvasionComment = normalizeOptionalBoolean(r.reviewEvasionComment, "settings.reviewEvasionComment", warnings); + if (reviewEvasionComment !== null) out.reviewEvasionComment = reviewEvasionComment; + return out; +} + +/** Serialize the settings override for the cache round-trip; returns null when nothing is set. */ +export function settingsOverrideToJson(settings: FocusManifestSettings): JsonValue { + if (Object.keys(settings).length === 0) return null; + return { ...settings } as Record; +} + +/** A bounded, PUBLIC-SAFE maintainer string (footer/note). Trimmed, length-capped, and rejected with a + * warning if it contains any forbidden public term — it is then dropped, never published. */ +function parsePublicSafeText(value: JsonValue | undefined, field: string, warnings: string[]): string | null { + const text = normalizeOptionalString(value, field, warnings); + if (text === null) return null; + const bounded = text.length > MAX_ITEM_LENGTH ? text.slice(0, MAX_ITEM_LENGTH) : text; + if (!isFocusManifestPublicSafe(bounded)) { + warnings.push(`Manifest "${field}" contains content that is not public-safe; ignoring it.`); + return null; + } + return bounded; +} + +/** + * Parse the optional `review:` block — maintainer overrides for the public review-panel content. Never + * throws; invalid/unsafe values are dropped with warnings. + */ +function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestReviewConfig { + const empty: FocusManifestReviewConfig = { 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, testGeneration: null, impactMap: null, cultureProfile: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null }; + if (value === undefined || value === null) return empty; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push(`Manifest field "review" must be a mapping; ignoring it.`); + return empty; + } + const r = value as Record; + const footerRecord = r.footer !== null && typeof r.footer === "object" && !Array.isArray(r.footer) ? (r.footer as Record) : undefined; + if (r.footer !== undefined && r.footer !== null && footerRecord === undefined) warnings.push(`Manifest "review.footer" must be a mapping; ignoring it.`); + const fieldsRecord = r.fields !== null && typeof r.fields === "object" && !Array.isArray(r.fields) ? (r.fields as Record) : undefined; + if (r.fields !== undefined && r.fields !== null && fieldsRecord === undefined) warnings.push(`Manifest "review.fields" must be a mapping; ignoring it.`); + const fields: Partial> = {}; + if (fieldsRecord) { + for (const key of REVIEW_FIELD_KEYS) { + const flag = normalizeOptionalBoolean(fieldsRecord[key], `review.fields.${key}`, warnings); + if (flag !== null) fields[key] = flag; + } + } + const enrichmentRecord = r.enrichment !== null && typeof r.enrichment === "object" && !Array.isArray(r.enrichment) ? (r.enrichment as Record) : undefined; + if (r.enrichment !== undefined && r.enrichment !== null && enrichmentRecord === undefined) warnings.push(`Manifest "review.enrichment" must be a mapping; ignoring it.`); + const enrichmentAnalyzers: Partial> = {}; + if (enrichmentRecord) { + for (const key of Object.keys(enrichmentRecord)) { + if (!REES_ANALYZER_NAME_SET.has(key)) { + warnings.push(`Manifest "review.enrichment" has unknown analyzer "${key}"; ignoring it.`); + continue; + } + const flag = normalizeOptionalBoolean(enrichmentRecord[key], `review.enrichment.${key}`, warnings); + if (flag !== null) enrichmentAnalyzers[key as ReesAnalyzerName] = flag; + } + } + const footerText = footerRecord ? parsePublicSafeText(footerRecord.text, "review.footer.text", warnings) : null; + const note = parsePublicSafeText(r.note, "review.note", warnings); + const profile = parseReviewProfile(r.profile, warnings); + const tone = parsePublicSafeText(r.tone, "review.tone", warnings); + const securityFocus = normalizeOptionalBoolean(r.security_focus, "review.security_focus", warnings); + const inlineComments = normalizeOptionalBoolean(r.inline_comments, "review.inline_comments", warnings); + const fixHandoff = normalizeOptionalBoolean(r.fixHandoff, "review.fixHandoff", warnings); + const autoMergeSummary = normalizeOptionalBoolean(r.auto_merge_summary, "review.auto_merge_summary", warnings); + const suggestions = normalizeOptionalBoolean(r.suggestions, "review.suggestions", warnings); + const changedFilesSummary = normalizeOptionalBoolean(r.changed_files_summary, "review.changed_files_summary", warnings); + const effortScore = normalizeOptionalBoolean(r.effort_score, "review.effort_score", warnings); + const testGeneration = normalizeOptionalBoolean(r.test_generation, "review.test_generation", warnings); + const impactMap = normalizeOptionalBoolean(r.impact_map, "review.impact_map", warnings); + const cultureProfile = normalizeOptionalBoolean(r.culture_profile, "review.culture_profile", warnings); + const reviewMemory = normalizeOptionalBoolean(r.memory, "review.memory", warnings); + const findingCategories = normalizeOptionalBoolean(r.finding_categories, "review.finding_categories", warnings); + const inlineCommentsPerCategory = normalizeOptionalNonNegativeInt( + r.inline_comments_per_category, + "review.inline_comments_per_category", + warnings, + ); + const minFindingSeverity = normalizeOptionalEnum( + r.min_finding_severity, + "review.min_finding_severity", + REVIEW_FINDING_SEVERITY_LADDER, + warnings, + ); + const maxFindings = parseMaxFindingsConfig(r.max_findings, warnings); + const commentVerbosity = normalizeOptionalEnum(r.comment_verbosity, "review.comment_verbosity", COMMENT_VERBOSITY_LEVELS, warnings); + const pathInstructions = parseReviewPathInstructions(r.path_instructions, warnings); + const instructions = parsePublicSafeText(r.instructions, "review.instructions", warnings); + const excludePaths = parseReviewExcludePaths(r.exclude_paths, warnings); + const pathFilters = parseReviewPathFilters(r.path_filters, warnings); + const preMergeChecks = parseReviewPreMergeChecks(r.pre_merge_checks, warnings); + const autoReview = parseAutoReviewConfig(r.auto_review, warnings); + const labelingRules = parseReviewLabelingRules(r.labeling_rules, warnings); + const aiModel = parseSelfHostAiModelConfig(r.ai_model, warnings); + const visual = parseVisualConfig(r.visual, warnings); + const linkedIssueSatisfaction = normalizeOptionalEnum(r.linkedIssueSatisfaction, "review.linkedIssueSatisfaction", LINKED_ISSUE_SATISFACTION_MODES, warnings); + return { + present: + footerText !== null || + note !== null || + profile !== null || + tone !== null || + securityFocus !== null || + inlineComments !== null || + fixHandoff !== null || + autoMergeSummary !== null || + suggestions !== null || + changedFilesSummary !== null || + effortScore !== null || + testGeneration !== null || + impactMap !== null || + cultureProfile !== null || + reviewMemory !== null || + findingCategories !== null || + inlineCommentsPerCategory !== null || + minFindingSeverity !== null || + maxFindingsPresent(maxFindings) || + commentVerbosity !== null || + pathInstructions.length > 0 || + instructions !== null || + excludePaths.length > 0 || + pathFilters.length > 0 || + preMergeChecks.length > 0 || + autoReviewPresent(autoReview) || + labelingRules.length > 0 || + selfHostAiModelPresent(aiModel) || + visualConfigPresent(visual) || + linkedIssueSatisfaction !== null || + Object.keys(fields).length > 0 || + Object.keys(enrichmentAnalyzers).length > 0, + footerText, + note, + fields, + autoReview, + aiModel, + visual, + linkedIssueSatisfaction, + testGeneration, + enrichmentAnalyzers, + profile, + tone, + securityFocus, + inlineComments, + fixHandoff, + autoMergeSummary, + suggestions, + changedFilesSummary, + effortScore, + impactMap, + cultureProfile, + reviewMemory, + findingCategories, + inlineCommentsPerCategory, + minFindingSeverity, + maxFindings, + commentVerbosity, + pathInstructions, + instructions, + excludePaths, + pathFilters, + preMergeChecks, + labelingRules, + }; +} + +function maxFindingsPresent(config: MaxFindingsConfig): boolean { + return config.blockers !== null || config.nits !== null; +} + +/** Parse `review.max_findings` — optional non-negative caps for blockers/nits display in the unified comment. */ +function parseMaxFindingsConfig(value: JsonValue | undefined, warnings: string[]): MaxFindingsConfig { + if (value === undefined || value === null) return { ...EMPTY_MAX_FINDINGS_CONFIG }; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push(`Manifest "review.max_findings" must be a mapping; ignoring it.`); + return { ...EMPTY_MAX_FINDINGS_CONFIG }; + } + const record = value as Record; + return { + blockers: normalizeOptionalNonNegativeInt(record.blockers, "review.max_findings.blockers", warnings), + nits: normalizeOptionalNonNegativeInt(record.nits, "review.max_findings.nits", warnings), + }; +} + +/** The reserved label namespace Gittensor uses for scoring/type/priority (`gittensor:bug`, `gittensor:feature`, + * `gittensor:priority`, …). A maintainer's `labeling_rules` must not drive these — they're managed by the scorer + * and the type-labeler, never by ad-hoc manifest rules — so any `gittensor:`-prefixed label is refused at parse. */ +const RESERVED_LABEL_PREFIX = "gittensor:"; + +function parseReviewLabelingRules(value: JsonValue | undefined, warnings: string[]): LabelingRule[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) { + warnings.push(`Manifest "review.labeling_rules" must be a list of rules; ignoring it.`); + return []; + } + const out: LabelingRule[] = []; + for (const [index, entry] of value.entries()) { + if (out.length >= MAX_PATH_INSTRUCTIONS) { + warnings.push(`Manifest "review.labeling_rules" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`); + break; + } + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + warnings.push(`Manifest "review.labeling_rules[${index}]" must be a mapping; ignoring it.`); + continue; + } + const e = entry as Record; + const label = e.label === undefined || e.label === null ? null : parsePublicSafeText(e.label, `review.labeling_rules[${index}].label`, warnings); + if (label === null) { + if (e.label === undefined || e.label === null) warnings.push(`Manifest "review.labeling_rules[${index}].label" is required; ignoring the entry.`); + continue; // non-string / empty / not-public-safe already warned by parsePublicSafeText + } + if (label.toLowerCase().startsWith(RESERVED_LABEL_PREFIX)) { + warnings.push(`Manifest "review.labeling_rules[${index}].label" ("${label}") uses the reserved "${RESERVED_LABEL_PREFIX}" namespace; ignoring the entry.`); + continue; + } + const titleContains = e.title_contains === undefined || e.title_contains === null ? null : parsePublicSafeText(e.title_contains, `review.labeling_rules[${index}].title_contains`, warnings); + const descriptionContains = e.description_contains === undefined || e.description_contains === null ? null : parsePublicSafeText(e.description_contains, `review.labeling_rules[${index}].description_contains`, warnings); + const whenPaths = parseManifestGlobList(e.when_paths, `review.labeling_rules[${index}].when_paths`, warnings); + if (whenPaths.length === 0 && titleContains === null && descriptionContains === null) { + warnings.push(`Manifest "review.labeling_rules[${index}]" needs at least one of when_paths / title_contains / description_contains; ignoring it.`); + continue; + } + out.push({ label, whenPaths, titleContains, descriptionContains }); + } + return out; +} + +function autoReviewPresent(config: AutoReviewConfig): boolean { + return ( + config.skipDrafts !== null || + config.ignoreAuthors.length > 0 || + config.ignoreTitleKeywords.length > 0 || + config.skipLabels.length > 0 || + config.skipDocsOnly !== null || + config.maxAddedLines > 0 || + config.maxFiles > 0 || + config.baseBranches.length > 0 || + config.autoPauseAfterReviewedCommits !== null + ); +} + +/** Parse `review.auto_review` — deterministic AI review eligibility filters. (#1954 / #2038–#2041) */ +function parseAutoReviewConfig(value: JsonValue | undefined, warnings: string[]): AutoReviewConfig { + if (value === undefined || value === null) return { ...EMPTY_AUTO_REVIEW_CONFIG }; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push(`Manifest field "review.auto_review" must be a mapping; ignoring it.`); + return { ...EMPTY_AUTO_REVIEW_CONFIG }; + } + const record = value as Record; + return { + skipDrafts: normalizeOptionalBoolean(record.skip_drafts, "review.auto_review.skip_drafts", warnings), + ignoreAuthors: parseManifestGlobList(record.ignore_authors, "review.auto_review.ignore_authors", warnings), + ignoreTitleKeywords: parseAutoReviewTitleKeywords(record.ignore_title_keywords, warnings), + skipLabels: parseAutoReviewSkipLabels(record.skip_labels, warnings), + skipDocsOnly: normalizeOptionalBoolean(record.skip_docs_only, "review.auto_review.skip_docs_only", warnings), + maxAddedLines: normalizeAutoReviewSizeCap(record.max_added_lines, "review.auto_review.max_added_lines", warnings), + maxFiles: normalizeAutoReviewSizeCap(record.max_files, "review.auto_review.max_files", warnings), + baseBranches: parseManifestGlobList(record.base_branches, "review.auto_review.base_branches", warnings), + autoPauseAfterReviewedCommits: normalizeOptionalNonNegativeInt( + record.auto_pause_after_reviewed_commits, + "review.auto_review.auto_pause_after_reviewed_commits", + warnings, + ), + }; +} + +function selfHostAiModelPresent(config: SelfHostAiModelConfig): boolean { + return ( + config.claudeModel !== null || + config.claudeEffort !== null || + config.codexModel !== null || + config.codexEffort !== null + ); +} + +/** Parse `review.ai_model` — per-repo self-host reviewer model/effort overrides. Values are opaque, bounded, + * public-safe strings (like `review.tone`) — never validated against a fixed model/effort enum here, so this + * parser never drifts from the provider's own effort allowlist (`src/selfhost/ai.ts`); an invalid effort value + * degrades the SAME way an invalid env-sourced one already does (falls back to "medium" at resolve time). + * (#selfhost-ai-model-override) */ +function parseSelfHostAiModelConfig(value: JsonValue | undefined, warnings: string[]): SelfHostAiModelConfig { + if (value === undefined || value === null) return { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push(`Manifest field "review.ai_model" must be a mapping; ignoring it.`); + return { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }; + } + const record = value as Record; + return { + claudeModel: parsePublicSafeText(record.claude_model, "review.ai_model.claude_model", warnings), + claudeEffort: parsePublicSafeText(record.claude_effort, "review.ai_model.claude_effort", warnings), + codexModel: parsePublicSafeText(record.codex_model, "review.ai_model.codex_model", warnings), + codexEffort: parsePublicSafeText(record.codex_effort, "review.ai_model.codex_effort", warnings), + }; +} + +function visualConfigPresent(config: VisualConfig): boolean { + return config.preview.urlTemplate !== null || config.routes.paths.length > 0 || config.routes.maxRoutes !== null || config.themes.length > 0 || config.gif; +} + +const VISUAL_THEME_VALUES: readonly VisualTheme[] = ["light", "dark"]; + +/** Parse `review.visual.themes` — which `prefers-color-scheme` variants to capture (#3678). Empty/default ⇒ + * the capture pipeline falls back to a single light-theme render, byte-identical to today. Unlike + * `routes.paths` (an open-ended glob list), this is a closed 2-value enum, so entries are validated against + * it directly rather than reusing the generic glob-list parser. */ +function parseVisualThemes(value: JsonValue | undefined, warnings: string[]): VisualTheme[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) { + warnings.push(`Manifest "review.visual.themes" must be a list of "light"/"dark"; ignoring it.`); + return []; + } + const out: VisualTheme[] = []; + for (const [index, entry] of value.entries()) { + const theme = typeof entry === "string" ? (entry.trim().toLowerCase() as VisualTheme) : undefined; + if (!theme || !VISUAL_THEME_VALUES.includes(theme)) { + warnings.push(`Manifest "review.visual.themes[${index}]" must be "light" or "dark"; ignoring it.`); + continue; + } + if (!out.includes(theme)) out.push(theme); + } + return out; +} + +// `{number}`/`{head_sha}`/`{head_sha_short}` are GitHub-controlled facts about the PR (never attacker-supplied +// free text), so substitution itself carries no injection risk. The dummy values here exist only to make the +// TEMPLATE STRING (which a maintainer authored, and could still typo) validate as a well-formed HTTPS URL +// before it's ever used — see parseVisualUrlTemplate below. +const VISUAL_URL_TEMPLATE_DUMMY_VARS: Record = { + "{number}": "1", + "{head_sha_short}": "0000000", + "{head_sha}": "0000000000000000000000000000000000000000", +}; + +/** Parse `review.visual.preview.url_template` — validated at CONFIG-READ time against the exact same SSRF + * guard (`isSafeHttpUrl`) the renderer itself unconditionally applies to every URL it navigates to, + * regardless of source (`src/review/visual/shot.ts`). This is deliberately redundant with that runtime + * check, not a replacement for it — it exists so a maintainer sees a warning immediately for a malformed + * template (e.g. a typo'd scheme, or an accidental internal host) instead of only discovering it later as + * a silently-blank "after" cell. Placeholders are substituted with dummy values before validation since the + * raw template (e.g. `https://pr-{number}.example.com`) is not itself a parseable URL. */ +function parseVisualUrlTemplate(value: JsonValue | undefined, warnings: string[]): string | null { + const template = parsePublicSafeText(value, "review.visual.preview.url_template", warnings); + if (template === null) return null; + let probe = template; + for (const [placeholder, dummy] of Object.entries(VISUAL_URL_TEMPLATE_DUMMY_VARS)) probe = probe.split(placeholder).join(dummy); + if (!isSafeHttpUrl(probe)) { + warnings.push(`Manifest "review.visual.preview.url_template" must be a valid HTTPS URL (with {number}/{head_sha}/{head_sha_short} placeholders substituted) targeting a public host; ignoring it.`); + return null; + } + return template; +} + +/** Parse `review.visual` — per-repo before/after screenshot-capture config (#3609 preview / #3610 routes / + * #3678 themes). */ +function parseVisualConfig(value: JsonValue | undefined, warnings: string[]): VisualConfig { + if (value === undefined || value === null) return { ...EMPTY_VISUAL_CONFIG }; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push(`Manifest field "review.visual" must be a mapping; ignoring it.`); + return { ...EMPTY_VISUAL_CONFIG }; + } + const record = value as Record; + + const previewRecord = record.preview !== null && typeof record.preview === "object" && !Array.isArray(record.preview) ? (record.preview as Record) : undefined; + if (record.preview !== undefined && record.preview !== null && previewRecord === undefined) { + warnings.push(`Manifest "review.visual.preview" must be a mapping; ignoring it.`); + } + const urlTemplate = previewRecord ? parseVisualUrlTemplate(previewRecord.url_template, warnings) : null; + + const routesRecord = record.routes !== null && typeof record.routes === "object" && !Array.isArray(record.routes) ? (record.routes as Record) : undefined; + if (record.routes !== undefined && record.routes !== null && routesRecord === undefined) { + warnings.push(`Manifest "review.visual.routes" must be a mapping; ignoring it.`); + } + const paths = routesRecord ? parseManifestGlobList(routesRecord.paths, "review.visual.routes.paths", warnings) : []; + const maxRoutes = routesRecord ? normalizeOptionalVisualMaxRoutes(routesRecord.max_routes, warnings) : null; + + const themes = parseVisualThemes(record.themes, warnings); + const gif = normalizeOptionalBoolean(record.gif, "review.visual.gif", warnings) === true; + + return { preview: { urlTemplate }, routes: { paths, maxRoutes }, themes, gif }; +} + +function parseAutoReviewTitleKeywords(value: JsonValue | undefined, warnings: string[]): string[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) { + warnings.push(`Manifest "review.auto_review.ignore_title_keywords" must be a list of strings; ignoring it.`); + return []; + } + const out: string[] = []; + for (const [index, entry] of value.entries()) { + if (out.length >= MAX_PATH_INSTRUCTIONS) { + warnings.push(`Manifest "review.auto_review.ignore_title_keywords" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`); + break; + } + const raw = typeof entry === "string" ? entry.trim() : ""; + if (!raw) { + warnings.push(`Manifest "review.auto_review.ignore_title_keywords[${index}]" must be a non-empty string; ignoring it.`); + continue; + } + const safe = parsePublicSafeText(raw, `review.auto_review.ignore_title_keywords[${index}]`, warnings); + if (safe !== null) out.push(safe); + } + return out; +} + +function parseAutoReviewSkipLabels(value: JsonValue | undefined, warnings: string[]): string[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) { + warnings.push(`Manifest "review.auto_review.skip_labels" must be a list of strings; ignoring it.`); + return []; + } + const seen = new Set(); + const out: string[] = []; + for (const [index, entry] of value.entries()) { + if (out.length >= MAX_PATH_INSTRUCTIONS) { + warnings.push(`Manifest "review.auto_review.skip_labels" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`); + break; + } + const raw = typeof entry === "string" ? entry.trim() : ""; + if (!raw) { + warnings.push(`Manifest "review.auto_review.skip_labels[${index}]" must be a non-empty string; ignoring it.`); + continue; + } + const safe = parsePublicSafeText(raw, `review.auto_review.skip_labels[${index}]`, warnings); + if (safe === null) continue; + const key = safe.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(key); + } + return out; +} + +/** Parse `review.pre_merge_checks` — an array of DETERMINISTIC pre-merge assertions. Each entry needs a non-empty + * public-safe `name` and at least ONE assertion (`title_contains` / `description_contains` / `require_label`, + * each public-safe); `when_paths` (optional) gates the check to PRs touching a matching glob; `enforce` (default + * false) makes a failure a hard blocker. Invalid entries are dropped with a warning; capped at + * MAX_PATH_INSTRUCTIONS so a hostile manifest can't bloat the gate. (#review-pre-merge-checks) */ +function parseReviewPreMergeChecks(value: JsonValue | undefined, warnings: string[]): PreMergeCheck[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) { + warnings.push(`Manifest "review.pre_merge_checks" must be a list of checks; ignoring it.`); + return []; + } + const out: PreMergeCheck[] = []; + for (const [index, entry] of value.entries()) { + if (out.length >= MAX_PATH_INSTRUCTIONS) { + warnings.push(`Manifest "review.pre_merge_checks" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`); + break; + } + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + warnings.push(`Manifest "review.pre_merge_checks[${index}]" must be a mapping; ignoring it.`); + continue; + } + const e = entry as Record; + if (e.name === undefined || e.name === null) { + warnings.push(`Manifest "review.pre_merge_checks[${index}].name" is required; ignoring the entry.`); + continue; + } + const name = parsePublicSafeText(e.name, `review.pre_merge_checks[${index}].name`, warnings); + if (name === null) continue; // non-string / empty / not-public-safe → already warned + const titleContains = e.title_contains === undefined || e.title_contains === null ? null : parsePublicSafeText(e.title_contains, `review.pre_merge_checks[${index}].title_contains`, warnings); + const descriptionContains = e.description_contains === undefined || e.description_contains === null ? null : parsePublicSafeText(e.description_contains, `review.pre_merge_checks[${index}].description_contains`, warnings); + const requireLabel = e.require_label === undefined || e.require_label === null ? null : parsePublicSafeText(e.require_label, `review.pre_merge_checks[${index}].require_label`, warnings); + if (titleContains === null && descriptionContains === null && requireLabel === null) { + warnings.push(`Manifest "review.pre_merge_checks[${index}]" needs at least one of title_contains / description_contains / require_label; ignoring it.`); + continue; + } + const whenPaths = parseManifestGlobList(e.when_paths, `review.pre_merge_checks[${index}].when_paths`, warnings); + const enforce = normalizeOptionalBoolean(e.enforce, `review.pre_merge_checks[${index}].enforce`, warnings) === true; + out.push({ name, whenPaths, titleContains, descriptionContains, requireLabel, enforce }); + } + return out; +} + +/** Parse a manifest glob list (e.g. `review.exclude_paths`, a check's `when_paths`) — an array of non-empty + * string globs; blanks/non-strings are dropped with a warning. Capped at MAX_PATH_INSTRUCTIONS so a hostile + * manifest can't bloat the matcher. `fieldLabel` makes the warnings name the right field. */ +function parseManifestGlobList(value: JsonValue | undefined, fieldLabel: string, warnings: string[]): string[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) { + warnings.push(`Manifest "${fieldLabel}" must be a list of path globs; ignoring it.`); + return []; + } + const out: string[] = []; + const seen = new Set(); + for (const [index, entry] of value.entries()) { + const glob = typeof entry === "string" ? entry.trim() : ""; + if (!glob) { + warnings.push(`Manifest "${fieldLabel}[${index}]" must be a non-empty string; ignoring it.`); + continue; + } + if (glob.length > MAX_ITEM_LENGTH) { + warnings.push(`Manifest "${fieldLabel}[${index}]" exceeds ${MAX_ITEM_LENGTH} chars; ignoring it.`); + continue; + } + const key = glob.toLowerCase(); + if (seen.has(key)) continue; + if (out.length >= MAX_PATH_INSTRUCTIONS) { + warnings.push(`Manifest "${fieldLabel}" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`); + break; + } + seen.add(key); + out.push(glob); + } + return out; +} + +/** Parse `review.exclude_paths` — globs whose matching files are excluded from the AI review. (#review-exclude-paths) */ +function parseReviewExcludePaths(value: JsonValue | undefined, warnings: string[]): string[] { + return parseManifestGlobList(value, "review.exclude_paths", warnings); +} + +/** Parse `review.path_filters` — include globs plus optional leading-`!` negation entries. (#2043) */ +function parseReviewPathFilters(value: JsonValue | undefined, warnings: string[]): string[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) { + warnings.push(`Manifest "review.path_filters" must be a list of path globs; ignoring it.`); + return []; + } + const out: string[] = []; + for (const [index, entry] of value.entries()) { + if (out.length >= MAX_PATH_INSTRUCTIONS) { + warnings.push(`Manifest "review.path_filters" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`); + break; + } + const raw = typeof entry === "string" ? entry.trim() : ""; + if (!raw) { + warnings.push(`Manifest "review.path_filters[${index}]" must be a non-empty string; ignoring it.`); + continue; + } + const negated = raw.startsWith("!"); + const glob = negated ? raw.slice(1).trim() : raw; + if (!glob) { + warnings.push(`Manifest "review.path_filters[${index}]" must include a glob after a leading '!'; ignoring it.`); + continue; + } + if (glob.length > MAX_ITEM_LENGTH) { + warnings.push(`Manifest "review.path_filters[${index}]" exceeds ${MAX_ITEM_LENGTH} chars; ignoring it.`); + continue; + } + out.push(negated ? `!${glob}` : glob); + } + return out; +} + +/** Parse `review.path_instructions` — an array of `{ path, instructions }` entries. Each must have a non-empty + * string `path` (a manifest glob) and PUBLIC-SAFE string `instructions`; invalid/unsafe entries are dropped with + * a warning. Capped at MAX_PATH_INSTRUCTIONS so a huge manifest can't bloat the reviewer prompt. */ +function parseReviewPathInstructions(value: JsonValue | undefined, warnings: string[]): ReviewPathInstruction[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) { + warnings.push(`Manifest "review.path_instructions" must be a list of { path, instructions }; ignoring it.`); + return []; + } + const out: ReviewPathInstruction[] = []; + for (const [index, entry] of value.entries()) { + if (out.length >= MAX_PATH_INSTRUCTIONS) { + warnings.push(`Manifest "review.path_instructions" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`); + break; + } + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + warnings.push(`Manifest "review.path_instructions[${index}]" must be a mapping with path + instructions; ignoring it.`); + continue; + } + const e = entry as Record; + const path = typeof e.path === "string" ? e.path.trim() : ""; + if (!path) { + warnings.push(`Manifest "review.path_instructions[${index}].path" must be a non-empty string; ignoring the entry.`); + continue; + } + if (path.length > MAX_ITEM_LENGTH) { + warnings.push(`Manifest "review.path_instructions[${index}].path" exceeds ${MAX_ITEM_LENGTH} chars; ignoring the entry.`); + continue; + } + if (e.instructions === undefined || e.instructions === null) { + warnings.push(`Manifest "review.path_instructions[${index}].instructions" is required; ignoring the entry.`); + continue; + } + const instructions = parsePublicSafeText(e.instructions, `review.path_instructions[${index}].instructions`, warnings); + if (instructions === null) continue; // non-string / empty / not-public-safe → already warned + out.push({ path, instructions }); + } + return out; +} + +/** Parse `review.profile` — one of chill / balanced / assertive (case-insensitive). `balanced` normalizes to + * null (the default, so the reviewer prompt stays byte-identical). Any other value is ignored with a warning. */ +function parseReviewProfile(value: JsonValue | undefined, warnings: string[]): ReviewProfile | null { + if (value === undefined || value === null) return null; + if (typeof value !== "string") { + warnings.push(`Manifest "review.profile" must be a string (chill | balanced | assertive); ignoring it.`); + return null; + } + const normalized = value.trim().toLowerCase(); + if (normalized === "balanced") return null; // default → no prompt change + if (normalized === "chill" || normalized === "assertive") return normalized; + warnings.push(`Manifest "review.profile" must be one of chill / balanced / assertive; ignoring "${value.slice(0, 32)}".`); + return null; +} + +/** Serialize the review config for the cache round-trip; returns null when nothing is set. */ +export function reviewConfigToJson(review: FocusManifestReviewConfig): JsonValue { + if (!review.present) return null; + const out: Record = {}; + if (review.footerText !== null) out.footer = { text: review.footerText }; + if (review.note !== null) out.note = review.note; + if (review.profile !== null) out.profile = review.profile; + if (review.tone !== null) out.tone = review.tone; + if (review.securityFocus !== null) out.security_focus = review.securityFocus; + if (review.inlineComments !== null) out.inline_comments = review.inlineComments; + if (review.fixHandoff !== null) out.fixHandoff = review.fixHandoff; + if (review.autoMergeSummary !== null) out.auto_merge_summary = review.autoMergeSummary; + if (review.suggestions !== null) out.suggestions = review.suggestions; + if (review.changedFilesSummary !== null) out.changed_files_summary = review.changedFilesSummary; + if (review.effortScore !== null) out.effort_score = review.effortScore; + if (review.testGeneration !== null) out.test_generation = review.testGeneration; + if (review.impactMap !== null) out.impact_map = review.impactMap; + if (review.cultureProfile !== null) out.culture_profile = review.cultureProfile; + if (review.reviewMemory !== null) out.memory = review.reviewMemory; + if (review.findingCategories !== null) out.finding_categories = review.findingCategories; + if (review.inlineCommentsPerCategory !== null) out.inline_comments_per_category = review.inlineCommentsPerCategory; + if (review.minFindingSeverity !== null) out.min_finding_severity = review.minFindingSeverity; + if (maxFindingsPresent(review.maxFindings)) { + const maxFindings: Record = {}; + if (review.maxFindings.blockers !== null) maxFindings.blockers = review.maxFindings.blockers; + if (review.maxFindings.nits !== null) maxFindings.nits = review.maxFindings.nits; + out.max_findings = maxFindings; + } + if (review.commentVerbosity !== null) out.comment_verbosity = review.commentVerbosity; + if (review.instructions !== null) out.instructions = review.instructions; + if (review.pathInstructions.length > 0) out.path_instructions = review.pathInstructions.map((entry) => ({ path: entry.path, instructions: entry.instructions })); + if (review.excludePaths.length > 0) out.exclude_paths = [...review.excludePaths]; + if (review.pathFilters.length > 0) out.path_filters = [...review.pathFilters]; + if (autoReviewPresent(review.autoReview)) { + const autoReview: Record = {}; + if (review.autoReview.skipDrafts !== null) autoReview.skip_drafts = review.autoReview.skipDrafts; + if (review.autoReview.ignoreAuthors.length > 0) autoReview.ignore_authors = [...review.autoReview.ignoreAuthors]; + if (review.autoReview.ignoreTitleKeywords.length > 0) autoReview.ignore_title_keywords = [...review.autoReview.ignoreTitleKeywords]; + if (review.autoReview.skipLabels.length > 0) autoReview.skip_labels = [...review.autoReview.skipLabels]; + if (review.autoReview.skipDocsOnly !== null) autoReview.skip_docs_only = review.autoReview.skipDocsOnly; + if (review.autoReview.maxAddedLines > 0) autoReview.max_added_lines = review.autoReview.maxAddedLines; + if (review.autoReview.maxFiles > 0) autoReview.max_files = review.autoReview.maxFiles; + if (review.autoReview.baseBranches.length > 0) autoReview.base_branches = [...review.autoReview.baseBranches]; + if (review.autoReview.autoPauseAfterReviewedCommits !== null) { + autoReview.auto_pause_after_reviewed_commits = review.autoReview.autoPauseAfterReviewedCommits; + } + out.auto_review = autoReview; + } + if (review.preMergeChecks.length > 0) { + out.pre_merge_checks = review.preMergeChecks.map((check) => { + const entry: Record = { name: check.name }; + if (check.whenPaths.length > 0) entry.when_paths = [...check.whenPaths]; + if (check.titleContains !== null) entry.title_contains = check.titleContains; + if (check.descriptionContains !== null) entry.description_contains = check.descriptionContains; + if (check.requireLabel !== null) entry.require_label = check.requireLabel; + if (check.enforce) entry.enforce = true; + return entry; + }); + } + if (Object.keys(review.fields).length > 0) out.fields = { ...review.fields } as Record; + if (Object.keys(review.enrichmentAnalyzers).length > 0) out.enrichment = { ...review.enrichmentAnalyzers } as Record; + if (review.labelingRules.length > 0) { + out.labeling_rules = review.labelingRules.map((rule) => { + const entry: Record = { label: rule.label }; + if (rule.whenPaths.length > 0) entry.when_paths = [...rule.whenPaths]; + if (rule.titleContains !== null) entry.title_contains = rule.titleContains; + if (rule.descriptionContains !== null) entry.description_contains = rule.descriptionContains; + return entry; + }); + } + if (selfHostAiModelPresent(review.aiModel)) { + const aiModel: Record = {}; + if (review.aiModel.claudeModel !== null) aiModel.claude_model = review.aiModel.claudeModel; + if (review.aiModel.claudeEffort !== null) aiModel.claude_effort = review.aiModel.claudeEffort; + if (review.aiModel.codexModel !== null) aiModel.codex_model = review.aiModel.codexModel; + if (review.aiModel.codexEffort !== null) aiModel.codex_effort = review.aiModel.codexEffort; + out.ai_model = aiModel; + } + if (visualConfigPresent(review.visual)) { + const visual: Record = {}; + if (review.visual.preview.urlTemplate !== null) visual.preview = { url_template: review.visual.preview.urlTemplate }; + if (review.visual.routes.paths.length > 0 || review.visual.routes.maxRoutes !== null) { + const routes: Record = {}; + if (review.visual.routes.paths.length > 0) routes.paths = [...review.visual.routes.paths]; + if (review.visual.routes.maxRoutes !== null) routes.max_routes = review.visual.routes.maxRoutes; + visual.routes = routes; + } + if (review.visual.themes.length > 0) visual.themes = [...review.visual.themes]; + if (review.visual.gif) visual.gif = true; + out.visual = visual; + } + if (review.linkedIssueSatisfaction !== null) out.linkedIssueSatisfaction = review.linkedIssueSatisfaction; + return out; +} + +/** + * Resolve the `review.path_instructions` that APPLY to a PR — those whose glob matches at least one changed path + * — into a single prompt section for the AI reviewer, or "" when none match (so the prompt stays byte-identical). + * Pure; uses the same manifest path-glob semantics (`matchesManifestPath`) as the rest of the manifest. Capped to + * keep the prompt bounded. (#review-path-instructions) + */ +export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): FocusManifest { + if (raw === undefined || raw === null) return emptyManifest(source ?? "none"); + if (typeof raw !== "object" || Array.isArray(raw)) { + return emptyManifest(source ?? "api_record", ["Manifest must be a mapping of fields; ignoring malformed manifest and falling back to deterministic signals."]); + } + const record = raw as Record; + const warnings: string[] = []; + const manifest: FocusManifest = { + present: true, + source: normalizeSource(source, record.source, warnings), + wantedPaths: normalizeStringList(record.wantedPaths, "wantedPaths", warnings), + preferredLabels: normalizeStringList(record.preferredLabels, "preferredLabels", warnings), + linkedIssuePolicy: normalizeEnum(record.linkedIssuePolicy, "linkedIssuePolicy", ["required", "preferred", "optional"] as const, "optional", warnings), + testExpectations: normalizeStringList(record.testExpectations, "testExpectations", warnings), + issueDiscoveryPolicy: normalizeEnum(record.issueDiscoveryPolicy, "issueDiscoveryPolicy", ["encouraged", "neutral", "discouraged"] as const, "neutral", warnings), + maintainerNotes: normalizeStringList(record.maintainerNotes, "maintainerNotes", warnings), + publicNotes: normalizeStringList(record.publicNotes, "publicNotes", warnings).filter(isFocusManifestPublicSafe), + gate: parseGateConfig(record.gate, warnings), + settings: parseSettingsOverride(record.settings, warnings), + review: parseReviewConfig(record.review, warnings), + features: parseFeaturesConfig(record.features, warnings), + contentLane: parseContentLaneConfig(record.contentLane, warnings), + repoDocGeneration: parseRepoDocGenerationConfig(record.repoDocGeneration, warnings), + reviewRecap: parseReviewRecapConfig(record.reviewRecap, warnings), + warnings, + }; + if ( + manifest.wantedPaths.length === 0 && + manifest.preferredLabels.length === 0 && + manifest.testExpectations.length === 0 && + manifest.maintainerNotes.length === 0 && + manifest.publicNotes.length === 0 && + manifest.linkedIssuePolicy === "optional" && + manifest.issueDiscoveryPolicy === "neutral" && + !manifest.gate.present && + Object.keys(manifest.settings).length === 0 && + !manifest.review.present && + !manifest.features.present && + !manifest.contentLane.present && + !manifest.repoDocGeneration.present && + !manifest.reviewRecap.present + ) { + warnings.push("Manifest contained no recognized focus fields; falling back to deterministic signals."); + manifest.present = false; + } + return manifest; +} + +/** + * Parse raw manifest file/record content (JSON or YAML). Malformed content degrades to an empty + * manifest with a warning rather than throwing, so a broken `.gittensory` config never breaks analysis. + */ +export function parseFocusManifestContent(content: string | null | undefined, source: FocusManifestSource = "repo_file"): FocusManifest { + if (content === undefined || content === null || content.trim() === "") return emptyManifest(source); + if (content.length > MAX_FOCUS_MANIFEST_BYTES || new TextEncoder().encode(content).byteLength > MAX_FOCUS_MANIFEST_BYTES) { + return emptyManifest(source, [`Manifest content exceeded ${MAX_FOCUS_MANIFEST_BYTES} bytes; ignoring it and falling back to deterministic signals.`]); + } + const trimmed = content.trim(); + const looksLikeJson = trimmed.startsWith("{") || trimmed.startsWith("["); + let parsed: unknown; + try { + parsed = looksLikeJson ? JSON.parse(trimmed) : parseYaml(trimmed); + } catch { + return emptyManifest(source, [ + looksLikeJson + ? "Manifest content was not valid JSON; ignoring it and falling back to deterministic signals." + : "Manifest content was not valid YAML; ignoring it and falling back to deterministic signals.", + ]); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return emptyManifest(source, ["Manifest must be a mapping of fields; ignoring malformed manifest and falling back to deterministic signals."]); + } + return parseFocusManifest(parsed, source); +} + +/** + * Format a manifest's parse `warnings[]` into one grouped, deduped, order-preserving notice for the review + * surface — an acceptance criterion of #1670: an invalid/malformed `.gittensory.yml` value should fail + * clearly instead of silently falling back to a default. Empty/no warnings ⇒ `null` (byte-identical, no + * notice). Pure; reuses the warnings every parser already accumulates rather than a parallel schema. (#2056) + */ +export function formatManifestValidationNotice(warnings: string[]): string | null { + const seen = new Set(); + const deduped: string[] = []; + for (const warning of warnings) { + const trimmed = warning.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + deduped.push(trimmed); + } + if (deduped.length === 0) return null; + return deduped.map((warning) => `- ${warning}`).join("\n"); +} +function normalizePathForMatch(path: string): string { + return String(path).replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "").toLowerCase(); +} + +/** + * LINEAR-TIME wildcard matcher for a `*`-glob pattern over an already-normalized path. `*` (and a collapsed + * run of `*`) matches any run of characters INCLUDING `/` (gittensory globs cross slashes). Implemented as a + * prefix + suffix + ordered-substring (indexOf) scan rather than a `.*`-per-star regex: the old regex + * (`^.*a.*a...$`) backtracks catastrophically on a near-miss path and could hang the gate for an entire repo + * (a manifest glob with many non-adjacent `*`). This algorithm is O(path × parts) with NO backtracking. + */ +function linearGlobMatcher(pattern: string): (path: string) => boolean { + // The caller only compiles this for a pattern that contains a wildcard, so split always yields >= 2 parts. + const parts = pattern.split(/\*+/); // literal segments between (collapsed) wildcard runs + const first = parts[0]!; + const last = parts[parts.length - 1]!; + const middles = parts.slice(1, -1).filter((part) => part.length > 0); + return (path) => { + if (!path.startsWith(first) || !path.endsWith(last)) return false; + let idx = first.length; + for (const part of middles) { + const found = path.indexOf(part, idx); + if (found === -1) return false; + idx = found + part.length; + } + return path.length - last.length >= idx; // the suffix must not overlap the consumed prefix/middles + }; +} + +/** + * Compile a manifest path pattern into a predicate over an ALREADY-normalized path. Supports exact paths, + * directory prefixes (`src/` or `src`), and `*` wildcards (`*` and a double-star both match any run of chars + * across `/`). A double-star-then-separator prefix means "zero or more path segments", so the mandatory slash + * is absorbed and a double-star glob also matches a ROOT-level (zero-depth) file, not only nested ones. + * Compiling once lets a caller test many paths against one pattern without recompiling per path. + * An empty/blank pattern never matches. + */ +function expandGlobstarSlash(pattern: string): string[] { + const alternatives = [""]; + for (let idx = 0; idx < pattern.length; ) { + if (pattern.startsWith("**/", idx)) { + const count = alternatives.length; + const canKeepRootAlternatives = count * 2 <= MAX_GLOBSTAR_SLASH_ALTERNATIVES; + for (let altIdx = count - 1; altIdx >= 0; altIdx -= 1) { + const prefix = alternatives[altIdx]!; + alternatives[altIdx] = `${prefix}*/`; + if (canKeepRootAlternatives) alternatives.push(prefix); + } + idx += 3; + continue; + } + for (let altIdx = 0; altIdx < alternatives.length; altIdx += 1) alternatives[altIdx] += pattern[idx]!; + idx += 1; + } + return alternatives; +} + +function compileManifestPathMatcher(pattern: string): (normalizedPath: string) => boolean { + const normalizedPattern = normalizePathForMatch(pattern); + if (!normalizedPattern) return () => false; + if (normalizedPattern.includes("*")) { + // `**/` means zero or more whole path segments. Keep the slash in the non-root alternative so + // basename globs (e.g. `**/safe.ts`) do not degrade into suffix globs that match `unsafe.ts`. + const matchers = expandGlobstarSlash(normalizedPattern).map((globbed) => + globbed.includes("*") ? linearGlobMatcher(globbed) : (normalizedPath: string) => normalizedPath === globbed, + ); + return (normalizedPath) => matchers.some((matcher) => matcher(normalizedPath)); + } + const dirPattern = normalizedPattern.endsWith("/") ? normalizedPattern : `${normalizedPattern}/`; + return (normalizedPath) => normalizedPath === normalizedPattern || normalizedPath.startsWith(dirPattern); +} + +/** + * Match a changed path against a manifest path pattern. Supports exact paths, directory + * prefixes (`src/` or `src`), and `*` wildcards (`**` collapses to `*`). + */ +export function matchesManifestPath(path: string, pattern: string): boolean { + const normalizedPath = normalizePathForMatch(path); + if (!normalizedPath) return false; + return compileManifestPathMatcher(pattern)(normalizedPath); +} + +export type FocusManifestLanePreference = "preferred" | "neutral" | "discouraged"; + +export type FocusManifestPolicyContributionLane = { + id: string; + preference: "preferred" | "neutral" | "discouraged"; + title: string; + summary: string; + preferredPaths: string[]; + discouragedPaths: string[]; + validationExpectations: string[]; + publicNotes: string[]; +}; + +export type FocusManifestPolicyLabelPolicy = { + preferredLabels: string[]; + required: boolean; +}; + +export type FocusManifestPolicyValidation = { + expectations: string[]; + linkedIssuePolicy: FocusManifestLinkedIssuePolicy; +}; + +export type FocusManifestPolicy = { + repoFullName: string; + generatedAt: string; + source: FocusManifestSource; + present: boolean; + publicSafe: { + contributionLanes: FocusManifestPolicyContributionLane[]; + labelPolicy: FocusManifestPolicyLabelPolicy; + validation: FocusManifestPolicyValidation; + issueDiscoveryPolicy: FocusManifestIssueDiscoveryPolicy; + publicNotes: string[]; + readinessWarnings: string[]; + entryGuidance: string[]; + summary: string; + }; + authenticated: { + manifestSource: FocusManifestSource; + privateNoteCount: number; + manifestWarningCount: number; + parseWarnings: string[]; + readinessWarnings: string[]; + maintainerContext: string[]; + }; +}; + +/** + * Compile a normalized {@link FocusManifest} into a deterministic, machine-readable + * {@link FocusManifestPolicy}. Public-safe fields are segregated from authenticated + * (owner-only) fields. No reward, wallet, hotkey, raw trust, or private scoring + * language is allowed in public-safe output — unsafe strings are silently dropped. + * + * `repoFullName` is optional — when omitted it defaults to an empty string. Callers + * that persist the policy should supply the full name; single-manifest analysis + * callers may omit it. + */ +export function compileFocusManifestPolicy(manifest: FocusManifest, options?: { generatedAt?: string }): FocusManifestPolicy; +export function compileFocusManifestPolicy(repoFullName: string, manifest: FocusManifest, options?: { generatedAt?: string }): FocusManifestPolicy; +export function compileFocusManifestPolicy( + repoFullNameOrManifest: string | FocusManifest, + manifestOrOptions?: FocusManifest | { generatedAt?: string }, + options: { generatedAt?: string } = {}, +): FocusManifestPolicy { + let repoFullName: string; + let manifest: FocusManifest; + if (typeof repoFullNameOrManifest === "string") { + repoFullName = repoFullNameOrManifest; + manifest = manifestOrOptions as FocusManifest; + } else { + repoFullName = ""; + manifest = repoFullNameOrManifest; + options = (manifestOrOptions as { generatedAt?: string }) ?? {}; + } + + const generatedAt = options.generatedAt ?? new Date().toISOString(); + const safePublicNotes = manifest.publicNotes.filter(isFocusManifestPublicSafe); + const contributionLanes = buildPolicyContributionLanes(manifest); + const readinessWarnings = buildPolicyReadinessWarnings(manifest); + const entryGuidance = buildPolicyEntryGuidance(manifest); + const summary = buildPolicySummary(manifest); + + return { + repoFullName, + generatedAt, + source: manifest.source, + present: manifest.present, + publicSafe: { + contributionLanes, + labelPolicy: { + preferredLabels: manifest.preferredLabels.filter(isFocusManifestPublicSafe), + required: manifest.linkedIssuePolicy !== "optional", + }, + validation: { + expectations: manifest.testExpectations.filter(isFocusManifestPublicSafe), + linkedIssuePolicy: manifest.linkedIssuePolicy, + }, + issueDiscoveryPolicy: manifest.issueDiscoveryPolicy, + publicNotes: safePublicNotes, + readinessWarnings, + entryGuidance, + summary, + }, + authenticated: { + manifestSource: manifest.source, + privateNoteCount: manifest.maintainerNotes.length, + manifestWarningCount: manifest.warnings.length, + parseWarnings: manifest.warnings, + readinessWarnings, + maintainerContext: manifest.maintainerNotes, + }, + }; +} + +function buildPolicyEntryGuidance(manifest: FocusManifest): string[] { + const guidance: string[] = []; + // Build the sentence from the public-safe subset (as preferredLabels and publicNotes below already do, and + // as the sibling buildPolicyContributionLanes does for preferredPaths). Joining the raw wantedPaths means a + // single reserved-word path (e.g. `src/ranking/`) fails the all-or-nothing public-safety filter at the end + // and silently drops the entire focus-areas guidance line instead of surfacing the safe paths. + const safeWantedPaths = manifest.wantedPaths.filter(isFocusManifestPublicSafe); + if (safeWantedPaths.length > 0) { + guidance.push(`Focus changes on maintainer-wanted areas: ${safeWantedPaths.slice(0, 5).join(", ")}.`); + } + if (manifest.linkedIssuePolicy === "required") guidance.push("Link a tracked issue before opening a pull request."); + else if (manifest.linkedIssuePolicy === "preferred") guidance.push("Linking a tracked issue is preferred before opening a pull request."); + if (manifest.preferredLabels.length > 0) { + const safeLabels = manifest.preferredLabels.filter(isFocusManifestPublicSafe); + if (safeLabels.length > 0) guidance.push(`Apply a maintainer-preferred label: ${safeLabels.slice(0, 3).join(", ")}.`); + } + guidance.push(...manifest.publicNotes.filter(isFocusManifestPublicSafe)); + return [...new Set(guidance)].filter(isFocusManifestPublicSafe); +} + +function buildPolicySummary(manifest: FocusManifest): string { + if (!manifest.present) return "No maintainer focus manifest; contribution guidance is not constrained."; + if (manifest.issueDiscoveryPolicy === "encouraged") return "Issue-discovery is the preferred contribution mode for this repo."; + if (manifest.issueDiscoveryPolicy === "discouraged") return "Direct PRs are preferred; issue-discovery submissions are discouraged."; + if (manifest.wantedPaths.length > 0) return "Direct PRs on the maintainer-wanted areas are preferred."; + return "Contribution guidance is derived from the maintainer focus manifest."; +} + +function buildPolicyContributionLanes(manifest: FocusManifest): FocusManifestPolicyContributionLane[] { + if (!manifest.present) return []; + + const lanes: FocusManifestPolicyContributionLane[] = []; + const safeWantedPaths = manifest.wantedPaths.filter(isFocusManifestPublicSafe); + const safeTestExpectations = manifest.testExpectations.filter(isFocusManifestPublicSafe); + + // Derive the public preference only from public-safe signals: use the SAME filtered list that surfaces in + // validationExpectations below, not the raw testExpectations. Otherwise a manifest whose only test expectation is + // public-unsafe (e.g. a wallet/seed phrase) is redacted from the lane yet still flips the public preference to + // "preferred" ("…with required validation evidence"), a self-contradictory verdict with no visible basis. + const directPrPreference: "preferred" | "neutral" | "discouraged" = + manifest.issueDiscoveryPolicy === "encouraged" ? "discouraged" + : safeWantedPaths.length > 0 || safeTestExpectations.length > 0 ? "preferred" + : "neutral"; + + lanes.push({ + id: "direct-pr", + preference: directPrPreference, + title: "Direct pull request lane", + summary: + directPrPreference === "discouraged" + ? "Direct pull requests are discouraged; issue discovery is the preferred entry mode." + : directPrPreference === "preferred" + ? "Contribute changes in maintainer-wanted areas with required validation evidence." + : "Direct pull requests are accepted when they stay inside maintainer-wanted scope.", + preferredPaths: safeWantedPaths, + discouragedPaths: [], + validationExpectations: safeTestExpectations, + publicNotes: manifest.publicNotes.filter(isFocusManifestPublicSafe), + }); + + const issueDiscoveryPreference: "preferred" | "neutral" | "discouraged" = + manifest.issueDiscoveryPolicy === "encouraged" ? "preferred" + : manifest.issueDiscoveryPolicy === "discouraged" ? "discouraged" + : "neutral"; + + lanes.push({ + id: "issue-discovery", + preference: issueDiscoveryPreference, + title: "Issue discovery lane", + summary: + issueDiscoveryPreference === "preferred" + ? "File well-scoped issue reports that the maintainer has indicated are welcome." + : issueDiscoveryPreference === "discouraged" + ? "The maintainer has indicated this repo prefers direct fixes over new issue reports." + : "Issue discovery is optional; confirm maintainer scope before filing new issues.", + preferredPaths: [], + discouragedPaths: [], + validationExpectations: [], + publicNotes: [], + }); + + return lanes; +} + +function buildPolicyReadinessWarnings(manifest: FocusManifest): string[] { + if (!manifest.present) return []; + const warnings: string[] = []; + if (manifest.wantedPaths.length === 0 && manifest.preferredLabels.length === 0) { + warnings.push("Focus manifest does not define wanted paths or preferred labels; contribution scope may be unclear to contributors."); + } + if (manifest.testExpectations.length === 0) { + warnings.push("Focus manifest does not define validation expectations; contributors may not know what tests to run."); + } + return warnings.filter(isFocusManifestPublicSafe); +} diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index b22240737f..e9fb73690b 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -240,3 +240,65 @@ export { type PredictedGateInput, type PredictedGateVerdict, } from "./predicted-gate.js"; +// Focus-manifest parse/compile core (#2280): shared by the maintainer review stack and the miner's +// `.gittensory-miner.yml` goal-spec parser (see miner-goal-spec.ts for the parallel surface). +export { + compileFocusManifestPolicy, + contentLaneConfigToJson, + featuresConfigToJson, + formatManifestValidationNotice, + gateConfigToJson, + isFocusManifestPublicSafe, + matchesManifestPath, + normalizeReadinessGateMode, + parseFocusManifest, + parseFocusManifestContent, + repoDocGenerationConfigToJson, + reviewConfigToJson, + reviewRecapConfigToJson, + settingsOverrideToJson, + MAX_FOCUS_MANIFEST_BYTES, + CONVERGED_FEATURE_KEYS, + COMMENT_VERBOSITY_LEVELS, + EMPTY_AUTO_REVIEW_CONFIG, + EMPTY_MAX_FINDINGS_CONFIG, + EMPTY_SELF_HOST_AI_MODEL_CONFIG, + EMPTY_VISUAL_CONFIG, + LINKED_ISSUE_SATISFACTION_MODES, + REVIEW_FIELD_KEYS, + REVIEW_FINDING_SEVERITY_LADDER, + REVIEW_PROFILES, + type AutoReviewConfig, + type CommentVerbosity, + type ConvergedFeatureKey, + type FocusManifest, + type FocusManifestContentLaneConfig, + type FocusManifestFeaturesConfig, + type FocusManifestGateConfig, + type FocusManifestIssueDiscoveryPolicy, + type FocusManifestLanePreference, + type FocusManifestLinkedIssuePolicy, + type FocusManifestPolicy, + type FocusManifestPolicyContributionLane, + type FocusManifestPolicyLabelPolicy, + type FocusManifestPolicyValidation, + type FocusManifestRepoDocGenerationConfig, + type FocusManifestRepoDocGenerationScope, + type FocusManifestReviewConfig, + type FocusManifestReviewRecapConfig, + type FocusManifestSettings, + type FocusManifestSource, + type LabelingRule, + type LinkedIssueSatisfactionMode, + type MaxFindingsConfig, + type PreMergeCheck, + type ReviewFieldKey, + type ReviewFindingSeverity, + type ReviewPathInstruction, + type ReviewProfile, + type SelfHostAiModelConfig, + type VisualConfig, + type VisualPreviewConfig, + type VisualRoutesConfig, + type VisualTheme, +} from "./focus-manifest.js"; diff --git a/packages/gittensory-engine/src/review/enrichment-analyzer-names.ts b/packages/gittensory-engine/src/review/enrichment-analyzer-names.ts new file mode 100644 index 0000000000..3a8b7d47a1 --- /dev/null +++ b/packages/gittensory-engine/src/review/enrichment-analyzer-names.ts @@ -0,0 +1,65 @@ +// Canonical REES enrichment-analyzer name registry (#2050). The single source of truth for the analyzer keys that +// both the operator `REES_ANALYZERS` env list and the per-repo `.gittensory.yml` `review.enrichment` toggles are +// validated against. A leaf module with no imports, so the review wiring and the signals-layer manifest parser can +// share it without a heavy or circular dependency. + +export const REES_ANALYZER_NAMES = [ + "dependency", + "dependencyDiff", + "lockfileDrift", + "secret", + "license", + "installScript", + "heavyDependency", + "hardcodedUrl", + "actionPin", + "eol", + "redos", + "provenance", + "codeowners", + "secretLog", + "assetWeight", + "typosquat", + "commitSignature", + "iacMisconfig", + "nativeBuild", + "history", + "docCommentDrift", + "duplication", + "churnHotspot", + "blameLink", + "approvalIntegrity", + "ciCheckSignals", + "undocumentedExport", + "staleBranch", + "commitHygiene", + "pendingReviewRequests", + "testRatio", + "migrationSafety", + "looseRange", + "terminology", + "todoMarker", + "magicNumber", + "conflictMarker", + "debugLeftover", + "sizeSmell", + "floatingPromise", + "deepNesting", + "errorSwallow", + "unsafeAny", + "a11y", + "i18n", + "unusedExport", + "exhaustiveness", + "flakyTest", + "commitLint", + "apiBreak", + "deprecatedDep", + "revertRecurrence", + "coverageDelta", + "callerImpact", +] as const; + +export type ReesAnalyzerName = (typeof REES_ANALYZER_NAMES)[number]; + +export const REES_ANALYZER_NAME_SET: ReadonlySet = new Set(REES_ANALYZER_NAMES); diff --git a/packages/gittensory-engine/src/review/linked-issue-hard-rules-config.ts b/packages/gittensory-engine/src/review/linked-issue-hard-rules-config.ts new file mode 100644 index 0000000000..75d614c519 --- /dev/null +++ b/packages/gittensory-engine/src/review/linked-issue-hard-rules-config.ts @@ -0,0 +1,85 @@ +import type { LinkedIssueHardRulesConfig, LinkedIssueHardRulesMode } from "../types/manifest-deps-types.js"; + +const VALID_LINKED_ISSUE_HARD_RULE_MODES: readonly LinkedIssueHardRulesMode[] = ["block", "off"]; +const DEFAULT_CLOSE_DELAY_SECONDS = 30; +const MAX_CLOSE_DELAY_SECONDS = 300; + +export const DEFAULT_LINKED_ISSUE_HARD_RULES: LinkedIssueHardRulesConfig = { + ownerAssignedClose: "off", + assignedIssueClose: "off", + missingPointLabelClose: "off", + maintainerOnlyLabelClose: "off", + pointBearingLabels: [], + maintainerOnlyLabels: [], + defaultLabelRepo: false, + verifyBeforeClose: true, + closeDelaySeconds: DEFAULT_CLOSE_DELAY_SECONDS, +}; + +export function isLinkedIssueHardRuleMode(value: unknown): value is LinkedIssueHardRulesMode { + return typeof value === "string" && (VALID_LINKED_ISSUE_HARD_RULE_MODES as readonly string[]).includes(value); +} + +function normalizeStringList(value: unknown, field: string, warnings: string[]): string[] { + if (value === undefined) return []; + if (!Array.isArray(value)) { + warnings.push(`settings.linkedIssueHardRules.${field} must be an array; using no labels.`); + return []; + } + const labels: string[] = []; + for (const [index, item] of value.entries()) { + if (typeof item !== "string" || item.trim().length === 0) { + warnings.push(`settings.linkedIssueHardRules.${field}[${index}] must be a non-empty string; ignoring it.`); + continue; + } + labels.push(item.trim()); + } + return labels; +} + +function normalizeMode( + value: unknown, + field: "ownerAssignedClose" | "assignedIssueClose" | "missingPointLabelClose" | "maintainerOnlyLabelClose", + warnings: string[], +): LinkedIssueHardRulesMode { + if (value === undefined) return DEFAULT_LINKED_ISSUE_HARD_RULES[field]; + if (isLinkedIssueHardRuleMode(value)) return value; + warnings.push(`settings.linkedIssueHardRules.${field} must be one of block, off; using the default "${DEFAULT_LINKED_ISSUE_HARD_RULES[field]}".`); + return DEFAULT_LINKED_ISSUE_HARD_RULES[field]; +} + +function normalizeBoolean(value: unknown, field: "defaultLabelRepo" | "verifyBeforeClose", warnings: string[]): boolean { + if (value === undefined) return DEFAULT_LINKED_ISSUE_HARD_RULES[field]; + if (typeof value === "boolean") return value; + warnings.push(`settings.linkedIssueHardRules.${field} must be a boolean; using the default "${DEFAULT_LINKED_ISSUE_HARD_RULES[field]}".`); + return DEFAULT_LINKED_ISSUE_HARD_RULES[field]; +} + +function normalizeCloseDelaySeconds(value: unknown, warnings: string[]): number { + if (value === undefined) return DEFAULT_LINKED_ISSUE_HARD_RULES.closeDelaySeconds; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + warnings.push(`settings.linkedIssueHardRules.closeDelaySeconds must be a non-negative number; using the default "${DEFAULT_CLOSE_DELAY_SECONDS}".`); + return DEFAULT_CLOSE_DELAY_SECONDS; + } + return Math.min(MAX_CLOSE_DELAY_SECONDS, Math.floor(value)); +} + +export function normalizeLinkedIssueHardRulesConfig(input: unknown, warnings: string[]): LinkedIssueHardRulesConfig { + if (input === undefined) return { ...DEFAULT_LINKED_ISSUE_HARD_RULES, pointBearingLabels: [], maintainerOnlyLabels: [] }; + if (typeof input !== "object" || input === null || Array.isArray(input)) { + warnings.push("settings.linkedIssueHardRules must be an object; using the default all-off policy."); + return { ...DEFAULT_LINKED_ISSUE_HARD_RULES, pointBearingLabels: [], maintainerOnlyLabels: [] }; + } + const record = input as Record; + return { + ownerAssignedClose: normalizeMode(record.ownerAssignedClose, "ownerAssignedClose", warnings), + assignedIssueClose: normalizeMode(record.assignedIssueClose, "assignedIssueClose", warnings), + missingPointLabelClose: normalizeMode(record.missingPointLabelClose, "missingPointLabelClose", warnings), + maintainerOnlyLabelClose: normalizeMode(record.maintainerOnlyLabelClose, "maintainerOnlyLabelClose", warnings), + pointBearingLabels: normalizeStringList(record.pointBearingLabels, "pointBearingLabels", warnings), + maintainerOnlyLabels: normalizeStringList(record.maintainerOnlyLabels, "maintainerOnlyLabels", warnings), + defaultLabelRepo: normalizeBoolean(record.defaultLabelRepo, "defaultLabelRepo", warnings), + verifyBeforeClose: normalizeBoolean(record.verifyBeforeClose, "verifyBeforeClose", warnings), + closeDelaySeconds: normalizeCloseDelaySeconds(record.closeDelaySeconds, warnings), + }; +} diff --git a/packages/gittensory-engine/src/review/linked-issue-label-propagation.ts b/packages/gittensory-engine/src/review/linked-issue-label-propagation.ts new file mode 100644 index 0000000000..d65358b8a1 --- /dev/null +++ b/packages/gittensory-engine/src/review/linked-issue-label-propagation.ts @@ -0,0 +1,93 @@ +import type { LinkedIssueLabelPropagationConfig, LinkedIssueLabelPropagationMapping, LinkedIssueLabelPropagationMode } from "../types/manifest-deps-types.js"; + +export type { LinkedIssueLabelPropagationConfig, LinkedIssueLabelPropagationMapping, LinkedIssueLabelPropagationMode } from "../types/manifest-deps-types.js"; + +// Linked-issue label PROPAGATION (#priority-linked-issue-gate). Generic, config-driven mechanism: when a +// linked/closing issue already carries a configured label, copy a mapped label onto the PR. Built specifically +// so a maintainer-reward/bonus label (e.g. `gittensor:priority`) can NEVER be inferred from a PR's title, +// changed files, AI output, or existing PR labels — only ever from a linked issue that ALREADY carries it. +// Generic beyond that one use case: any self-hoster can map any issue label to any PR label, exclusive +// (replaces the normal bug/feature type label, like priority does) or additive (applied alongside it). +// +// PURE config types + normalizer only — no GitHub/fetch/Env-dependent imports. `focus-manifest.ts`'s YAML +// parser imports this module directly, and `focus-manifest.ts` is itself pulled into the gittensory-ui +// workspace's isolated typecheck (via `apps/gittensory-ui/src/lib/registration-workspace.ts`), which has no +// visibility into the Worker's ambient `Env` type. The actual GitHub fetch orchestrator +// (`fetchLinkedIssueLabelsForPropagation`) lives in `linked-issue-label-propagation-fetch.ts` instead, kept +// out of this file specifically so the UI workspace's typecheck never has to resolve `Env`. + +// Fail-SAFE default: propagation OFF, no mappings. A self-hoster must explicitly opt in per repo. +export const DEFAULT_LINKED_ISSUE_LABEL_PROPAGATION: LinkedIssueLabelPropagationConfig = { + enabled: false, + mode: "exclusive_type_label", + mappings: [], +}; + +// Exported so `focus-manifest.ts`'s sparse-override parser can check whether a raw `mode` value is +// actually valid before deciding to copy the normalizer's (possibly defaults-filled-on-invalid) result. +export const VALID_LINKED_ISSUE_LABEL_PROPAGATION_MODES: readonly LinkedIssueLabelPropagationMode[] = ["exclusive_type_label"]; + +function normalizeMapping(input: unknown, index: number, warnings: string[]): LinkedIssueLabelPropagationMapping | null { + if (typeof input !== "object" || input === null || Array.isArray(input)) { + warnings.push(`settings.linkedIssueLabelPropagation.mappings[${index}] must be an object; ignoring it.`); + return null; + } + const record = input as Record; + const issueLabel = typeof record.issueLabel === "string" ? record.issueLabel.trim() : ""; + const prLabel = typeof record.prLabel === "string" ? record.prLabel.trim() : ""; + if (issueLabel.length === 0 || prLabel.length === 0) { + warnings.push(`settings.linkedIssueLabelPropagation.mappings[${index}] must have non-empty "issueLabel" and "prLabel" strings; ignoring it.`); + return null; + } + // `removeOtherTypeLabels` picks exclusive (replaces the type label, the gittensor:priority case) vs. + // additive (applied alongside it) -- silently coercing a present-but-wrong-shaped value (e.g. a quoted + // `"true"` string) to `false` could flip an intended-exclusive mapping to additive without any signal, + // so a present, non-boolean value drops the whole entry with a warning instead (omitted is still a + // normal, unwarned default of `false`). + if (record.removeOtherTypeLabels !== undefined && typeof record.removeOtherTypeLabels !== "boolean") { + warnings.push(`settings.linkedIssueLabelPropagation.mappings[${index}].removeOtherTypeLabels must be a boolean; ignoring this mapping.`); + return null; + } + return { issueLabel, prLabel, removeOtherTypeLabels: record.removeOtherTypeLabels === true }; +} + +/** Defaults-fill a per-repo `linkedIssueLabelPropagation` override into an always-complete, safe config — + * mirrors `normalizeCommandAuthorizationPolicy`'s defaults-fill pattern + * (`src/settings/command-authorization.ts`). Malformed mapping entries are dropped with a warning; valid + * entries in the same array are kept (matches `commandAuthorization`'s per-entry `commands` validation). */ +export function normalizeLinkedIssueLabelPropagationConfig(input: unknown, warnings: string[]): LinkedIssueLabelPropagationConfig { + if (input === undefined) return { ...DEFAULT_LINKED_ISSUE_LABEL_PROPAGATION, mappings: [] }; + if (typeof input !== "object" || input === null || Array.isArray(input)) { + warnings.push("settings.linkedIssueLabelPropagation must be an object; propagation stays disabled."); + return { ...DEFAULT_LINKED_ISSUE_LABEL_PROPAGATION, mappings: [] }; + } + const record = input as Record; + let enabled = DEFAULT_LINKED_ISSUE_LABEL_PROPAGATION.enabled; + if (record.enabled !== undefined) { + if (typeof record.enabled === "boolean") { + enabled = record.enabled; + } else { + warnings.push(`settings.linkedIssueLabelPropagation.enabled must be a boolean; using the default "${DEFAULT_LINKED_ISSUE_LABEL_PROPAGATION.enabled}".`); + } + } + let mode: LinkedIssueLabelPropagationMode = DEFAULT_LINKED_ISSUE_LABEL_PROPAGATION.mode; + if (record.mode !== undefined) { + if (typeof record.mode === "string" && (VALID_LINKED_ISSUE_LABEL_PROPAGATION_MODES as readonly string[]).includes(record.mode)) { + mode = record.mode as LinkedIssueLabelPropagationMode; + } else { + warnings.push(`settings.linkedIssueLabelPropagation.mode must be one of ${VALID_LINKED_ISSUE_LABEL_PROPAGATION_MODES.join(", ")}; using the default "${DEFAULT_LINKED_ISSUE_LABEL_PROPAGATION.mode}".`); + } + } + let mappings: LinkedIssueLabelPropagationMapping[] = []; + if (record.mappings !== undefined) { + if (Array.isArray(record.mappings)) { + mappings = record.mappings.flatMap((entry, index) => { + const normalized = normalizeMapping(entry, index, warnings); + return normalized ? [normalized] : []; + }); + } else { + warnings.push("settings.linkedIssueLabelPropagation.mappings must be an array; using no mappings."); + } + } + return { enabled, mode, mappings }; +} diff --git a/packages/gittensory-engine/src/review/safe-url.ts b/packages/gittensory-engine/src/review/safe-url.ts new file mode 100644 index 0000000000..7576257c6b --- /dev/null +++ b/packages/gittensory-engine/src/review/safe-url.ts @@ -0,0 +1,117 @@ +// SSRF-safe URL guard (content-lane primitive). +// +// SELF-CONTAINED NATIVE PORT (reviewbot→gittensory convergence). Ported from reviewbot's +// core/source-url.ts isSafeHttpUrl + isSafeEndpointUrl (the host/IP guard, including the encoded-IP +// decoding that a dotted-quad regex misses), hardened so a trailing-dot or `*.localhost` host can't +// dodge the loopback check. PURE — no imports, no I/O. +// +// Rejects non-HTTPS (isSafeHttpUrl), localhost / `*.localhost` / .local / .internal, and private/ +// loopback/link-local IPs in any literal notation (decimal `2130706433`, hex `0x7f000001`, octal, +// short `127.1`, and the IPv6 forms). isSafeEndpointUrl additionally permits wss:/ws: for base-layer +// chain endpoints. + +function parseIpv4Component(part: string): number | null { + if (/^0x[0-9a-f]+$/i.test(part)) return parseInt(part, 16); + if (/^0[0-7]+$/.test(part)) return parseInt(part, 8); // leading-zero → octal + if (/^(?:0|[1-9]\d*)$/.test(part)) return parseInt(part, 10); + return null; +} + +function ipv4ToInt(host: string): number | null { + const parts = host.split("."); + if (parts.length < 1 || parts.length > 4) return null; + const vals: number[] = []; + for (const part of parts) { + const v = parseIpv4Component(part); + if (v === null || !Number.isFinite(v) || v < 0) return null; + vals.push(v); + } + const n = vals.length; + // Byte-faithful overflow guards from reviewbot's core/source-url.ts. Unreachable via the public + // isSafe*Url entry points: a host reaches here only after `new URL()`, and the WHATWG parser + // rejects any all-numeric dotted host whose components overflow (so the >0xff / >lastMax / >2^32 + // cases never arrive), while a host that survives parsing as a domain has a non-numeric label that + // makes parseIpv4Component bail (line 26) before these run. Retained for source parity + defense. + /* v8 ignore start -- @preserve unreachable through new URL() host normalization (see note above) */ + for (let i = 0; i < n - 1; i += 1) if ((vals[i] as number) > 0xff) return null; + const lastMax = [0xffffffff, 0xffffff, 0xffff, 0xff][n - 1] as number; + if ((vals[n - 1] as number) > lastMax) return null; + let result = vals[n - 1] as number; + for (let i = 0; i < n - 1; i += 1) result += (vals[i] as number) * 256 ** (3 - i); + return result > 0xffffffff ? null : result >>> 0; + /* v8 ignore stop */ +} + +function ipv4IsPrivateOrLocal(host: string): boolean { + const n = ipv4ToInt(host); + if (n === null) return false; + const a = (n >>> 24) & 0xff; + const b = (n >>> 16) & 0xff; + if (a === 0 || a === 10 || a === 127) return true; // 0.0.0.0/8, 10/8, loopback + if (a === 169 && b === 254) return true; // link-local (incl. cloud metadata 169.254.169.254) + if (a === 192 && b === 168) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + return false; +} + +function ipv6IsPrivateOrLocal(host: string): boolean { + const addr = host.replace(/^\[|\]$/g, ""); + // Caller (hostIsPrivateOrLocal) only invokes this when the host contains ":", and bracket + // stripping never removes an interior colon — so the no-colon guard's true side is unreachable. + /* v8 ignore next -- @preserve true side unreachable: caller guards host.includes(":") */ + if (!addr.includes(":")) return false; + if (addr === "::1" || addr === "::") return true; + // `new URL()` collapses the dotted IPv4-mapped form (::ffff:127.0.0.1) to the hex form + // (::ffff:7f00:1), so this dotted-quad regex never matches via the public entry points; the hex + // branch below carries the IPv4-mapped case. Retained for source parity. + const dotted = addr.match(/::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + /* v8 ignore next -- @preserve dotted ::ffff:N.N.N.N is normalized to hex by new URL() */ + if (dotted) return ipv4IsPrivateOrLocal(dotted[1] as string); + const hex = addr.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); + if (hex) { + const hi = parseInt(hex[1] as string, 16); + const lo = parseInt(hex[2] as string, 16); + return ipv4IsPrivateOrLocal(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`); + } + const first = addr.split(":")[0] as string; + if (first.startsWith("fc") || first.startsWith("fd")) return true; // ULA fc00::/7 + if (/^fe[89ab]/.test(first)) return true; // link-local fe80::/10 + return false; +} + +function hostIsPrivateOrLocal(host: string): boolean { + // Normalize once: lower-case, then strip the FQDN root dot(s) the parser keeps on named hosts + // (`localhost.`) but not on IP literals — else `localhost.` / `foo.internal.` would read as public. + const h = host.toLowerCase().replace(/\.+$/, ""); + // localhost + its RFC 6761 `*.localhost` namespace, plus the reserved `.local` (mDNS) / `.internal`. + if (h === "localhost" || h.endsWith(".localhost")) return true; + if (h.endsWith(".local") || h.endsWith(".internal")) return true; + if (h === "0.0.0.0" || h === "::1" || h === "[::1]") return true; + if (h.includes(":")) return ipv6IsPrivateOrLocal(h); + return ipv4IsPrivateOrLocal(h); +} + +/** https + public (non-loopback, non-private) host. */ +export function isSafeHttpUrl(raw: string): boolean { + let url: URL; + try { + url = new URL(raw); + } catch { + return false; + } + if (url.protocol !== "https:") return false; + return !hostIsPrivateOrLocal(url.hostname); +} + +/** Like isSafeHttpUrl but also permits secure WebSocket endpoints (`wss:`, `ws:`) — base-layer chain + * endpoints (subtensor RPC/WSS/archive) are probed via JSON-RPC, not HTTP. Same SSRF host/IP guard. */ +export function isSafeEndpointUrl(raw: string): boolean { + let url: URL; + try { + url = new URL(raw); + } catch { + return false; + } + if (!["https:", "wss:", "ws:"].includes(url.protocol)) return false; + return !hostIsPrivateOrLocal(url.hostname); +} diff --git a/packages/gittensory-engine/src/review/screenshot-table-gate.ts b/packages/gittensory-engine/src/review/screenshot-table-gate.ts new file mode 100644 index 0000000000..18c97c9d4e --- /dev/null +++ b/packages/gittensory-engine/src/review/screenshot-table-gate.ts @@ -0,0 +1,190 @@ +import { matchesAny } from "../signals/change-guardrail.js"; +import type { ScreenshotTableGateAction, ScreenshotTableGateConfig } from "../types/manifest-deps-types.js"; + +export type { ScreenshotTableGateAction, ScreenshotTableGateConfig } from "../types/manifest-deps-types.js"; + +// Config-driven before/after screenshot-table gate (#2006). Contributor visual/frontend PRs are unreviewable +// at a glance without before/after evidence — this is a DETERMINISTIC (no AI, zero hallucination risk) check +// that a PR's body contains a markdown table with image markup, scoped to the repo's configured labels/paths. +// Mirrors the shape of contributor-blacklist.ts / linked-issue-hard-rules-config.ts: a normalizer (DB JSON or +// `.gittensory.yml` → validated config) plus a pure evaluator the trigger calls with live PR facts. Off by +// default (`enabled: false`) — a self-hoster opts in per repo, never hard-coded for any one project. + +const MAX_LABELS = 50; +const MAX_PATHS = 50; +const MAX_LABEL_CHARS = 100; +const MAX_PATH_CHARS = 300; + +// Extensions treated as "an image file" for the committed-image-file check below. Deliberately excludes SVG: +// an SVG can embed script/foreign-object content, so it is never accepted as review evidence anywhere in this +// repo (see the PR template's own UI Evidence rule) — a committed .svg is caught by neither this check nor the +// body-table one, exactly like the template's existing screenshots-must-be-raster rule. +const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp"]; + +export const DEFAULT_SCREENSHOT_TABLE_GATE: ScreenshotTableGateConfig = { + enabled: false, + whenLabels: [], + whenPaths: [], + action: "close", +}; + +const VALID_ACTIONS: readonly ScreenshotTableGateAction[] = ["close", "request_changes", "comment"]; + +export function isScreenshotTableGateAction(value: unknown): value is ScreenshotTableGateAction { + return typeof value === "string" && (VALID_ACTIONS as readonly string[]).includes(value); +} + +function normalizeStringList(value: unknown, field: string, max: number, maxChars: number, warnings: string[]): string[] { + if (value === undefined) return []; + if (!Array.isArray(value)) { + warnings.push(`settings.requireScreenshotTable.${field} must be an array; ignoring it.`); + return []; + } + const out: string[] = []; + for (const [index, item] of value.entries()) { + if (out.length >= max) { + warnings.push(`settings.requireScreenshotTable.${field} is capped at ${max} entries; dropping the rest.`); + break; + } + if (typeof item !== "string" || item.trim().length === 0) { + warnings.push(`settings.requireScreenshotTable.${field}[${index}] must be a non-empty string; ignoring it.`); + continue; + } + out.push(item.trim().slice(0, maxChars)); + } + return out; +} + +/** Normalize a raw `requireScreenshotTable` value (DB JSON or `.gittensory.yml`) into a validated config. Never + * throws: malformed fields fall back to the default (disabled/empty), matching every other settings normalizer + * in this codebase. */ +export function normalizeScreenshotTableGateConfig(input: unknown, warnings: string[]): ScreenshotTableGateConfig { + if (input === undefined || input === null) return { ...DEFAULT_SCREENSHOT_TABLE_GATE, whenLabels: [], whenPaths: [] }; + if (typeof input !== "object" || Array.isArray(input)) { + warnings.push("settings.requireScreenshotTable must be an object; using the default (disabled)."); + return { ...DEFAULT_SCREENSHOT_TABLE_GATE, whenLabels: [], whenPaths: [] }; + } + const record = input as Record; + const enabled = typeof record.enabled === "boolean" ? record.enabled : DEFAULT_SCREENSHOT_TABLE_GATE.enabled; + if (record.enabled !== undefined && typeof record.enabled !== "boolean") { + warnings.push(`settings.requireScreenshotTable.enabled must be a boolean; using the default "${DEFAULT_SCREENSHOT_TABLE_GATE.enabled}".`); + } + const action = isScreenshotTableGateAction(record.action) + ? record.action + : (() => { + if (record.action !== undefined) warnings.push(`settings.requireScreenshotTable.action must be one of close, request_changes, comment; using the default "close".`); + return DEFAULT_SCREENSHOT_TABLE_GATE.action; + })(); + const message = typeof record.message === "string" && record.message.trim().length > 0 ? record.message.trim() : undefined; + if (record.message !== undefined && message === undefined) { + warnings.push("settings.requireScreenshotTable.message must be a non-empty string; using the default message."); + } + return { + enabled, + whenLabels: normalizeStringList(record.whenLabels, "whenLabels", MAX_LABELS, MAX_LABEL_CHARS, warnings), + whenPaths: normalizeStringList(record.whenPaths, "whenPaths", MAX_PATHS, MAX_PATH_CHARS, warnings), + action, + ...(message !== undefined ? { message } : {}), + }; +} + +/** True when `body` contains at least one markdown TABLE region (`| ... |` header + separator row) whose cells + * embed image markup — either `![alt](url)` or an `` tag — inside the table. A screenshot pasted as a + * bare inline image OUTSIDE any table does not count (the contract requires captioned thumbnails INSIDE a + * table, not a wall of raw images). Deliberately simple/regex-based (no markdown AST dependency) — false + * negatives fail toward "no table found" (in-scope PRs still need a real table), false positives fail toward + * "table found" (never blocks a PR that plausibly complied); both directions are acceptable for a + * first-pass deterministic heuristic that a maintainer can always override by hand. */ +export function hasImageBearingMarkdownTable(body: string | null | undefined): boolean { + if (!body) return false; + const lines = body.split(/\r?\n/); + const tableRowPattern = /^\s*\|.*\|\s*$/; + const separatorRowPattern = /^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/; + const imagePattern = /!\[[^\]]*\]\([^)]+\)|]*>/i; + for (let i = 0; i < lines.length - 1; i += 1) { + // `i < lines.length - 1` guarantees both indices are in bounds; the `?? ""` fallbacks only exist to + // satisfy noUncheckedIndexedAccess and are never actually reached. + /* v8 ignore next -- defensive: the loop bound above guarantees lines[i] always exists here. */ + const header = lines[i] ?? ""; + /* v8 ignore next -- defensive: the loop bound above guarantees lines[i + 1] always exists here. */ + const separator = lines[i + 1] ?? ""; + if (!tableRowPattern.test(header) || !separatorRowPattern.test(separator)) continue; + // Found a table (header + separator). Scan its body rows (until a blank line or a non-table line) for + // image markup in any cell. + let j = i + 2; + /* v8 ignore next -- defensive: the `j < lines.length` guard above guarantees lines[j] always exists here. */ + while (j < lines.length && tableRowPattern.test(lines[j] ?? "")) { + if (imagePattern.test(lines[j] ?? "")) return true; + j += 1; + } + } + return false; +} + +/** True when `body` has a large inline image OUTSIDE of any markdown table — a common way contributors dodge + * the table requirement (paste screenshots directly into the body instead of inside a captioned table row). */ +export function hasImageOutsideTable(body: string | null | undefined): boolean { + if (!body) return false; + const lines = body.split(/\r?\n/); + const tableRowPattern = /^\s*\|.*\|\s*$/; + const imagePattern = /!\[[^\]]*\]\([^)]+\)|]*>/i; + return lines.some((line) => imagePattern.test(line) && !tableRowPattern.test(line)); +} + +/** True when any changed file path is an image under a scoped path (a screenshot committed to the repo instead + * of uploaded to the PR body via GitHub's CDN, per the contract). `scopedPaths` should be the SAME glob list + * used for scope matching (`whenPaths`) so this only flags an image landing where visual work is expected — + * not an unrelated asset (e.g. a favicon) added anywhere else in the repo. Empty `scopedPaths` (no path scoping + * configured) checks every changed path. */ +export function hasCommittedImageFile(changedFiles: string[], scopedPaths: string[]): boolean { + return changedFiles.some((file) => { + const lower = file.toLowerCase(); + if (!IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext))) return false; + return scopedPaths.length === 0 || matchesAny(file, scopedPaths); + }); +} + +/** True when the PR is IN SCOPE for the gate: it carries one of `config.whenLabels` OR touches a path matching + * one of `config.whenPaths`. Both empty ⇒ every PR is in scope (an operator who enables the gate with no + * scoping at all wants it enforced everywhere). Only one non-empty list configured ⇒ that list alone decides + * scope (the other, empty list can never exclude a PR the configured one matched). */ +export function isScreenshotTableGateInScope(config: ScreenshotTableGateConfig, prLabels: string[], changedFiles: string[]): boolean { + if (config.whenLabels.length === 0 && config.whenPaths.length === 0) return true; + const wantedLabels = new Set(config.whenLabels.map((label) => label.toLowerCase())); + const labelMatch = config.whenLabels.length > 0 && prLabels.some((label) => wantedLabels.has(label.toLowerCase())); + const pathMatch = config.whenPaths.length > 0 && changedFiles.some((file) => matchesAny(file, config.whenPaths)); + return labelMatch || pathMatch; +} + +export const DEFAULT_SCREENSHOT_CONTRACT_MESSAGE = + "This pull request changes UI/visual code but its description is missing a before/after screenshot table. " + + "Every changed page/feature needs a **markdown table** with a before column and an after column, each cell a " + + "clickable thumbnail (uploaded to the PR, not committed to the repo) with a caption below — for example:\n\n" + + "| Before | After |\n| --- | --- |\n| [![before](url)](url) — caption | [![after](url)](url) — caption |\n\n" + + "Please resubmit with the table filled in."; + +export type ScreenshotTableGateResult = { + violated: boolean; + reason: string | null; +}; + +const NO_VIOLATION: ScreenshotTableGateResult = { violated: false, reason: null }; + +/** PURE evaluator. Off (`enabled: false`) or out-of-scope (no configured label/path match) ⇒ no violation. In + * scope AND (no image-bearing table in the body OR an image pasted outside a table OR a committed image file + * under a scoped path) ⇒ violated, with the configured (or default) templated message as the reason. */ +export function evaluateScreenshotTableGate(input: { + config: ScreenshotTableGateConfig; + prBody: string | null | undefined; + prLabels: string[]; + changedFiles: string[]; +}): ScreenshotTableGateResult { + const { config } = input; + if (!config.enabled) return NO_VIOLATION; + if (!isScreenshotTableGateInScope(config, input.prLabels, input.changedFiles)) return NO_VIOLATION; + const hasTable = hasImageBearingMarkdownTable(input.prBody); + const outsideTable = hasImageOutsideTable(input.prBody); + const committedImage = hasCommittedImageFile(input.changedFiles, config.whenPaths); + if (hasTable && !outsideTable && !committedImage) return NO_VIOLATION; + return { violated: true, reason: config.message ?? DEFAULT_SCREENSHOT_CONTRACT_MESSAGE }; +} diff --git a/packages/gittensory-engine/src/review/unlinked-issue-guardrail-config.ts b/packages/gittensory-engine/src/review/unlinked-issue-guardrail-config.ts new file mode 100644 index 0000000000..572698e6ab --- /dev/null +++ b/packages/gittensory-engine/src/review/unlinked-issue-guardrail-config.ts @@ -0,0 +1,47 @@ +import type { UnlinkedIssueGuardrailConfig, UnlinkedIssueGuardrailMode } from "../types/manifest-deps-types.js"; + +const VALID_UNLINKED_ISSUE_GUARDRAIL_MODES: readonly UnlinkedIssueGuardrailMode[] = ["hold", "off"]; +const DEFAULT_MIN_CONFIDENCE = 0.85; + +export const DEFAULT_UNLINKED_ISSUE_GUARDRAIL: UnlinkedIssueGuardrailConfig = { + mode: "off", + minConfidence: DEFAULT_MIN_CONFIDENCE, +}; + +export function isUnlinkedIssueGuardrailMode(value: unknown): value is UnlinkedIssueGuardrailMode { + return typeof value === "string" && (VALID_UNLINKED_ISSUE_GUARDRAIL_MODES as readonly string[]).includes(value); +} + +function normalizeMode(value: unknown, warnings: string[]): UnlinkedIssueGuardrailMode { + if (value === undefined) return DEFAULT_UNLINKED_ISSUE_GUARDRAIL.mode; + if (isUnlinkedIssueGuardrailMode(value)) return value; + warnings.push(`settings.unlinkedIssueGuardrail.mode must be one of hold, off; using the default "${DEFAULT_UNLINKED_ISSUE_GUARDRAIL.mode}".`); + return DEFAULT_UNLINKED_ISSUE_GUARDRAIL.mode; +} + +function normalizeMinConfidence(value: unknown, warnings: string[]): number { + if (value === undefined) return DEFAULT_UNLINKED_ISSUE_GUARDRAIL.minConfidence; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) { + warnings.push(`settings.unlinkedIssueGuardrail.minConfidence must be a number between 0 and 1; using the default "${DEFAULT_MIN_CONFIDENCE}".`); + return DEFAULT_MIN_CONFIDENCE; + } + return value; +} + +/** + * Normalize a raw `.gittensory.yml settings.unlinkedIssueGuardrail` value into a typed config, + * fail-safe: any malformed field falls back to its own default and pushes a warning rather than + * rejecting the whole block. Mirrors `normalizeLinkedIssueHardRulesConfig`'s per-field discipline. + */ +export function normalizeUnlinkedIssueGuardrailConfig(input: unknown, warnings: string[]): UnlinkedIssueGuardrailConfig { + if (input === undefined) return { ...DEFAULT_UNLINKED_ISSUE_GUARDRAIL }; + if (typeof input !== "object" || input === null || Array.isArray(input)) { + warnings.push("settings.unlinkedIssueGuardrail must be an object; using the default off policy."); + return { ...DEFAULT_UNLINKED_ISSUE_GUARDRAIL }; + } + const record = input as Record; + return { + mode: normalizeMode(record.mode, warnings), + minConfidence: normalizeMinConfidence(record.minConfidence, warnings), + }; +} diff --git a/packages/gittensory-engine/src/settings/auto-close-exempt.ts b/packages/gittensory-engine/src/settings/auto-close-exempt.ts new file mode 100644 index 0000000000..3ef22ecaee --- /dev/null +++ b/packages/gittensory-engine/src/settings/auto-close-exempt.ts @@ -0,0 +1,57 @@ +// Shared repo-scoped exemption list (#2463) for gittensory's deterministic anti-abuse auto-close/throttle +// mechanisms — currently the review-nag cooldown; intended to be reused by the per-contributor open-item cap +// (#2270) once that lands, rather than each feature growing its own duplicate whitelist. A maintainer-named +// GitHub login here is NEVER throttled or closed by either mechanism, on top of the standing owner/admin/ +// automation-bot exemption every such mechanism already honors. Config-driven and layered the same as other +// settings (`.gittensory.yml` > DB), never hard-coded for any repo. Mirrors contributor-blacklist.ts's shape +// (normalize → validated list + warnings), minus the reason/evidence metadata a ban carries that an exemption +// doesn't need. +// A trailing `[bot]` is a real, common GitHub App-actor login shape (e.g. `dependabot[bot]`, `sentry[bot]`) -- +// this is exactly the kind of third-party automation identity a maintainer needs to exempt (a repo-specific bot +// integration the hardcoded, install-wide well-known-bot set in agent-actions.ts has no way to know about), so +// the base GitHub-login pattern (1-39 chars, alphanumeric/single-hyphens) is extended with an optional literal +// `[bot]` suffix rather than rejecting every bot-shaped login outright. +const GITHUB_LOGIN = /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}(?:\[bot\])?$/; +const MAX_ENTRIES = 500; + +/** Normalize a raw exempt-logins value (DB JSON or `.gittensory.yml`) into a validated, de-duplicated list of + * GitHub logins. Never throws: malformed entries are dropped with a warning. De-dup is case-insensitive (the + * FIRST occurrence's casing is kept). */ +export function normalizeAutoCloseExemptLogins(input: unknown): { logins: string[]; warnings: string[] } { + const warnings: string[] = []; + if (input === undefined || input === null) return { logins: [], warnings }; + if (!Array.isArray(input)) { + warnings.push("autoCloseExemptLogins must be a list of GitHub logins; ignoring it."); + return { logins: [], warnings }; + } + const logins: string[] = []; + const seen = new Set(); + for (const [index, raw] of input.entries()) { + if (logins.length >= MAX_ENTRIES) { + warnings.push(`autoCloseExemptLogins is capped at ${MAX_ENTRIES} entries; dropping the rest.`); + break; + } + if (typeof raw !== "string") { + warnings.push(`autoCloseExemptLogins[${index}] must be a string login; ignoring it.`); + continue; + } + const login = raw.trim(); + if (!GITHUB_LOGIN.test(login)) { + warnings.push(`autoCloseExemptLogins[${index}] is not a valid GitHub login; ignoring it.`); + continue; + } + const key = login.toLowerCase(); + if (seen.has(key)) continue; // first occurrence wins + seen.add(key); + logins.push(login); + } + return { logins, warnings }; +} + +/** Case-insensitive membership check against the resolved exempt-logins list. Absent/empty list ⇒ never exempt + * (the safe default — an unconfigured repo exempts no one beyond the standing owner/admin/bot rule). */ +export function isAutoCloseExempt(login: string | null | undefined, exemptLogins: readonly string[] | undefined): boolean { + if (!login) return false; + const lower = login.toLowerCase(); + return (exemptLogins ?? []).some((entry) => entry.toLowerCase() === lower); +} diff --git a/packages/gittensory-engine/src/settings/autonomy.ts b/packages/gittensory-engine/src/settings/autonomy.ts new file mode 100644 index 0000000000..b88f97bd9c --- /dev/null +++ b/packages/gittensory-engine/src/settings/autonomy.ts @@ -0,0 +1,91 @@ +import type { AgentActionClass, AutoMaintainPolicy, AutoMergeMethod, AutonomyLevel, AutonomyPolicy } from "../types/manifest-deps-types.js"; + +// The graduated autonomy dial (#773), ordered least → most autonomous. Every later agent-layer phase reads +// this BEFORE acting. `observe` is the deny-by-default floor — gittensory watches but never takes an action. +export const AUTONOMY_LEVELS = ["observe", "suggest", "propose", "auto_with_approval", "auto"] as const; + +// The write-action classes the maintainer auto-maintain layer (#778) can take on a PR. `review_state_label` +// (#label-scoping) is a separate class from `label`: it gates the planner's own disposition-communication +// labels (ready-to-merge / changes-requested / manual-review / migration-collision / pending-closure / +// new-account), independent of the anti-abuse enforcement labels (blacklist/contributor-cap/review-nag), which +// ride on `close` instead -- see agent-actions.ts. `assign` (#3182) is its own independent class, same shape: +// best-effort assignment of the PR's opening contributor, unrelated to merge/close/approve. +export const AGENT_ACTION_CLASSES = ["review", "request_changes", "approve", "merge", "close", "label", "review_state_label", "update_branch", "assign"] as const; + +// Deny-by-default: any action class with no explicit, valid level resolves to this. +export const DEFAULT_AUTONOMY_LEVEL: AutonomyLevel = "observe"; + +const AUTONOMY_LEVEL_SET = new Set(AUTONOMY_LEVELS); + +/** + * Resolve the configured autonomy level for one action class on a repo. THE single gate the action layer + * (#778) consults before any write action. Deny-by-default: an unset (or malformed) action class is + * `observe` — gittensory observes but never acts. Pure. + */ +export function resolveAutonomy(autonomy: AutonomyPolicy | null | undefined, actionClass: AgentActionClass): AutonomyLevel { + return autonomy?.[actionClass] ?? DEFAULT_AUTONOMY_LEVEL; +} + +/** True when the level permits the agent to actually execute the action (directly or behind an approval). */ +export function isActingAutonomyLevel(level: AutonomyLevel): boolean { + return level === "auto" || level === "auto_with_approval"; +} + +/** + * True when a repo has opted into the agent layer at all — i.e. at least one action class has an acting + * autonomy level. The deny-by-default floor (every class `observe`) is NOT configured. The scheduled + * re-gate sweep (#777) uses this to skip repos that never asked the agent to act. Pure. + */ +export function isAgentConfigured(autonomy: AutonomyPolicy | null | undefined): boolean { + return AGENT_ACTION_CLASSES.some((actionClass) => isActingAutonomyLevel(resolveAutonomy(autonomy, actionClass))); +} + +/** True when the action must pass a human approval gate (#779) before it executes. */ +export function autonomyRequiresApproval(level: AutonomyLevel): boolean { + return level === "auto_with_approval"; +} + +/** + * Parse/validate an arbitrary value into an AutonomyPolicy: keep only known action classes mapped to known + * levels, drop everything else. Deny-by-default by omission. Used for the DB row, the API body, and the + * `.gittensory.yml` settings block. Pure. + */ +export function normalizeAutonomyPolicy(input: unknown): AutonomyPolicy { + if (typeof input !== "object" || input === null || Array.isArray(input)) return {}; + const record = input as Record; + const policy: AutonomyPolicy = {}; + for (const actionClass of AGENT_ACTION_CLASSES) { + const value = record[actionClass]; + if (typeof value === "string" && AUTONOMY_LEVEL_SET.has(value)) { + policy[actionClass] = value as AutonomyLevel; + } + } + return policy; +} + +// Auto-maintain policy (#774): how an action behaves once its autonomy level permits acting. +export const AUTO_MERGE_METHODS = ["merge", "squash", "rebase"] as const; +const AUTO_MERGE_METHOD_SET = new Set(AUTO_MERGE_METHODS); + +// Conservative defaults: squash (the tidiest history) + a single human approval before any auto-merge. +export const DEFAULT_AUTO_MAINTAIN_POLICY: AutoMaintainPolicy = { requireApprovals: 1, mergeMethod: "squash" }; + +// Approvals are clamped to a sane band so a malformed config can't disable the gate (negative) or stall it. +const MAX_REQUIRE_APPROVALS = 10; + +/** + * Parse/validate an arbitrary value into an AutoMaintainPolicy, filling the conservative defaults for any + * missing/invalid field. `requireApprovals` is clamped to [0, 10]. Pure. + */ +export function normalizeAutoMaintainPolicy(input: unknown): AutoMaintainPolicy { + if (typeof input !== "object" || input === null || Array.isArray(input)) return { ...DEFAULT_AUTO_MAINTAIN_POLICY }; + const record = input as Record; + const rawApprovals = record.requireApprovals; + const requireApprovals = + typeof rawApprovals === "number" && Number.isFinite(rawApprovals) + ? Math.min(MAX_REQUIRE_APPROVALS, Math.max(0, Math.trunc(rawApprovals))) + : DEFAULT_AUTO_MAINTAIN_POLICY.requireApprovals; + const rawMethod = record.mergeMethod; + const mergeMethod = typeof rawMethod === "string" && AUTO_MERGE_METHOD_SET.has(rawMethod) ? (rawMethod as AutoMergeMethod) : DEFAULT_AUTO_MAINTAIN_POLICY.mergeMethod; + return { requireApprovals, mergeMethod }; +} diff --git a/packages/gittensory-engine/src/settings/command-authorization.ts b/packages/gittensory-engine/src/settings/command-authorization.ts new file mode 100644 index 0000000000..85a8487c23 --- /dev/null +++ b/packages/gittensory-engine/src/settings/command-authorization.ts @@ -0,0 +1,218 @@ +import type { CommandAuthorizationRole, RepositoryCommandAuthorizationPolicy } from "../types/manifest-deps-types.js"; + +export const DEFAULT_COMMAND_AUTHORIZATION_POLICY: RepositoryCommandAuthorizationPolicy = { + default: ["maintainer", "collaborator", "confirmed_miner"], + commands: { + "queue-summary": ["maintainer", "collaborator"], + "confirmed-miners": ["maintainer", "collaborator"], + "review-now": ["maintainer", "collaborator"], + "needs-author": ["maintainer", "collaborator"], + "duplicate-clusters": ["maintainer", "collaborator"], + "burden-forecast": ["maintainer", "collaborator"], + "intake-health": ["maintainer", "collaborator"], + "outcome-patterns": ["maintainer", "collaborator"], + "noise-report": ["maintainer", "collaborator"], + "gate-override": ["maintainer", "collaborator"], + plan: ["maintainer", "collaborator"], + // #1960 PR control-surface verbs. "review" is deliberately widenable to confirmed_miner (same self-rerun + // precedent already applied to review-now, #824) — a confirmed miner may re-trigger review on their own PR. + // The rest (pause/resume/resolve/configuration/explain) are conservative maintainer/collaborator-only + // defaults out of the box; a maintainer who wants to widen them can do so via commandAuthorization overrides. + review: ["maintainer", "collaborator", "confirmed_miner"], + pause: ["maintainer", "collaborator"], + resume: ["maintainer", "collaborator"], + resolve: ["maintainer", "collaborator"], + configuration: ["maintainer", "collaborator"], + explain: ["maintainer", "collaborator"], + }, +}; + +const COMMAND_AUTHORIZATION_ROLES = new Set(["maintainer", "collaborator", "pr_author", "confirmed_miner"]); +// Roles that may remain configured on a maintainer-only command. The clamp drops only the spoofable +// plain `pr_author` role; `confirmed_miner` survives so a detected miner can self-trigger reruns (#824). +const MAINTAINER_COMMAND_AUTHORIZATION_ROLES = new Set(["maintainer", "collaborator", "confirmed_miner"]); +const MAINTAINER_ONLY_DEFAULT_COMMANDS = new Set(Object.keys(DEFAULT_COMMAND_AUTHORIZATION_POLICY.commands)); + +export type CommandAuthorizationDecision = { + authorized: boolean; + reason: string; + actorKind: "maintainer" | "author" | "none"; + matchedRole: CommandAuthorizationRole | null; + allowedRoles: CommandAuthorizationRole[]; +}; + +export function normalizeCommandAuthorizationPolicy(input: unknown): { policy: RepositoryCommandAuthorizationPolicy; warnings: string[] } { + const warnings: string[] = []; + if (!isRecord(input)) { + if (input !== null && input !== undefined) warnings.push("commandAuthorization must be an object; using secure defaults."); + return { policy: clonePolicy(DEFAULT_COMMAND_AUTHORIZATION_POLICY), warnings }; + } + + const defaultRoles = normalizeRoleList(input.default, DEFAULT_COMMAND_AUTHORIZATION_POLICY.default, "default", warnings); + const commands: Record = { ...DEFAULT_COMMAND_AUTHORIZATION_POLICY.commands }; + if (input.commands !== undefined) { + if (isRecord(input.commands)) { + for (const [command, roles] of Object.entries(input.commands)) { + const commandName = command.trim().toLowerCase(); + if (!/^[a-z][a-z-]{0,63}$/.test(commandName)) { + warnings.push(`Ignored malformed command authorization key: ${command.slice(0, 64)}`); + continue; + } + commands[commandName] = normalizeCommandRoleList(commandName, normalizeRoleList(roles, defaultRoles, commandName, warnings), warnings); + } + } else { + warnings.push("commandAuthorization.commands must be an object; using command defaults."); + } + } + + return { policy: { default: defaultRoles, commands }, warnings }; +} + +export function commandAuthorizationAllowedRoles(policy: RepositoryCommandAuthorizationPolicy | null | undefined, commandName: string): CommandAuthorizationRole[] { + const normalized = normalizeCommandAuthorizationPolicy(policy).policy; + // Policy command keys are stored normalized (trimmed + lowercased) by normalizeCommandAuthorizationPolicy, + // so the lookup MUST normalize the probe too. A raw mixed-case name (e.g. "Gate-Override") otherwise misses + // its restrictive override and silently falls back to the permissive default — under-stating the restriction. + const key = normalizeCommandName(commandName); + const commandRoles = Object.hasOwn(normalized.commands, key) ? normalized.commands[key] : undefined; + return dedupeRoles(commandRoles ?? normalized.default); +} + +function normalizeCommandName(commandName: string): string { + return commandName.trim().toLowerCase(); +} + +export function commandAuthorizationNeedsMinerDetection(args: { + policy?: RepositoryCommandAuthorizationPolicy | null | undefined; + commandName: string; + commenterLogin?: string | null | undefined; + commenterAssociation?: string | null | undefined; + pullRequestAuthorLogin?: string | null | undefined; +}): boolean { + const allowedRoles = commandAuthorizationAllowedRoles(args.policy, args.commandName); + if (!allowedRoles.includes("confirmed_miner")) return false; + if (!isSameLogin(args.commenterLogin, args.pullRequestAuthorLogin)) return false; + const rolesWithoutMiner = actorRoles({ ...args, minerStatus: undefined }); + return !rolesWithoutMiner.some((role) => allowedRoles.includes(role)); +} + +export function evaluateCommandAuthorization(args: { + policy?: RepositoryCommandAuthorizationPolicy | null | undefined; + commandName: string; + commenterLogin?: string | null | undefined; + commenterAssociation?: string | null | undefined; + pullRequestAuthorLogin?: string | null | undefined; + minerStatus?: "confirmed" | "not_found" | "unavailable" | undefined; +}): CommandAuthorizationDecision { + const allowedRoles = commandAuthorizationAllowedRoles(args.policy, args.commandName); + const roles = actorRoles(args); + const matchedRole = roles.find((role) => allowedRoles.includes(role)) ?? null; + if (matchedRole) { + return { + authorized: true, + reason: authorizationReason(matchedRole), + actorKind: matchedRole === "maintainer" || matchedRole === "collaborator" ? "maintainer" : "author", + matchedRole, + allowedRoles, + }; + } + const ownPrAuthor = isSameLogin(args.commenterLogin, args.pullRequestAuthorLogin); + if (ownPrAuthor && allowedRoles.includes("confirmed_miner")) { + return { + authorized: false, + reason: args.minerStatus === "unavailable" || !args.minerStatus ? "miner_detection_unavailable" : "pr_author_not_confirmed_miner", + actorKind: "author", + matchedRole: null, + allowedRoles, + }; + } + if (ownPrAuthor && MAINTAINER_ONLY_DEFAULT_COMMANDS.has(normalizeCommandName(args.commandName)) && allowedRoles.every((role) => role === "maintainer" || role === "collaborator")) { + return { authorized: false, reason: "maintainer_command_requires_maintainer", actorKind: "author", matchedRole: null, allowedRoles }; + } + return { + authorized: false, + reason: ownPrAuthor ? "command_policy_denied" : "not_maintainer_or_pr_author", + actorKind: ownPrAuthor ? "author" : "none", + matchedRole: null, + allowedRoles, + }; +} + +export function summarizeCommandAuthorizationPolicy(policy: RepositoryCommandAuthorizationPolicy | null | undefined): { + defaultAllowed: CommandAuthorizationRole[]; + commandOverrides: Array<{ command: string; allowedRoles: CommandAuthorizationRole[] }>; +} { + const normalized = normalizeCommandAuthorizationPolicy(policy).policy; + return { + defaultAllowed: normalized.default, + commandOverrides: Object.entries(normalized.commands) + .map(([command, allowedRoles]) => ({ command, allowedRoles })) + .sort((left, right) => left.command.localeCompare(right.command)), + }; +} + +function normalizeCommandRoleList(commandName: string, roles: CommandAuthorizationRole[], warnings: string[]): CommandAuthorizationRole[] { + if (!MAINTAINER_ONLY_DEFAULT_COMMANDS.has(commandName)) return roles; + + const maintainerRoles = roles.filter((role) => MAINTAINER_COMMAND_AUTHORIZATION_ROLES.has(role)); + if (maintainerRoles.length === roles.length) return roles; + + warnings.push(`Ignored author command authorization roles for maintainer-only command: ${commandName}.`); + return maintainerRoles.length > 0 ? dedupeRoles(maintainerRoles) : [...(DEFAULT_COMMAND_AUTHORIZATION_POLICY.commands[commandName] ?? ["maintainer", "collaborator"])]; +} + +function actorRoles(args: { + commenterLogin?: string | null | undefined; + commenterAssociation?: string | null | undefined; + pullRequestAuthorLogin?: string | null | undefined; + minerStatus?: "confirmed" | "not_found" | "unavailable" | undefined; +}): CommandAuthorizationRole[] { + const roles: CommandAuthorizationRole[] = []; + if (args.commenterAssociation === "OWNER" || args.commenterAssociation === "MEMBER") roles.push("maintainer"); + if (args.commenterAssociation === "COLLABORATOR") roles.push("collaborator"); + if (isSameLogin(args.commenterLogin, args.pullRequestAuthorLogin)) { + roles.push("pr_author"); + if (args.minerStatus === "confirmed") roles.push("confirmed_miner"); + } + return roles; +} + +function normalizeRoleList(input: unknown, fallback: CommandAuthorizationRole[], label: string, warnings: string[]): CommandAuthorizationRole[] { + if (!Array.isArray(input)) { + if (input !== undefined) warnings.push(`commandAuthorization.${label} must be an array of roles; using fallback roles.`); + return dedupeRoles(fallback); + } + const roles = input.filter((role): role is CommandAuthorizationRole => { + const valid = typeof role === "string" && COMMAND_AUTHORIZATION_ROLES.has(role as CommandAuthorizationRole); + if (!valid) warnings.push(`Ignored invalid command authorization role for ${label}.`); + return valid; + }); + if (roles.length === 0) { + warnings.push(`commandAuthorization.${label} had no valid roles; using fallback roles.`); + return dedupeRoles(fallback); + } + return dedupeRoles(roles); +} + +function dedupeRoles(roles: CommandAuthorizationRole[]): CommandAuthorizationRole[] { + return [...new Set(roles)]; +} + +function clonePolicy(policy: RepositoryCommandAuthorizationPolicy): RepositoryCommandAuthorizationPolicy { + return { default: [...policy.default], commands: Object.fromEntries(Object.entries(policy.commands).map(([command, roles]) => [command, [...roles]])) }; +} + +function authorizationReason(role: CommandAuthorizationRole): string { + if (role === "maintainer") return "maintainer_invocation"; + if (role === "collaborator") return "collaborator_invocation"; + if (role === "confirmed_miner") return "confirmed_miner_pr_author"; + return "allowed_pr_author"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isSameLogin(left: string | null | undefined, right: string | null | undefined): boolean { + return Boolean(left && right && left.toLowerCase() === right.toLowerCase()); +} diff --git a/packages/gittensory-engine/src/settings/contributor-blacklist.ts b/packages/gittensory-engine/src/settings/contributor-blacklist.ts new file mode 100644 index 0000000000..02b5a5d2ca --- /dev/null +++ b/packages/gittensory-engine/src/settings/contributor-blacklist.ts @@ -0,0 +1,91 @@ +// Contributor blacklist (#1425, anti-abuse). Pure resolution + matching for the banned-login list the converged +// engine acts on. Config-driven and layered the same as other settings (`.gittensory.yml` > DB) and unioned with +// the shared/global list at the point of use — NEVER hard-coded for any repo. Logins are public data; entries +// may carry private maintainer metadata, so public surfaces must not echo it. Mirrors the shape of +// command-authorization.ts (normalize → typed policy + warnings). +import type { ContributorBlacklistEntry } from "../types/manifest-deps-types.js"; + +// GitHub logins: 1–39 chars, alphanumeric or single hyphens (not leading/trailing). Anything else is dropped so a +// malformed entry can never widen the match or break the close path. +const GITHUB_LOGIN = /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/; +const MAX_ENTRIES = 1000; +const MAX_REASON_CHARS = 200; +const MAX_EVIDENCE = 10; +const MAX_EVIDENCE_CHARS = 500; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Normalize a raw blacklist value (DB JSON or `.gittensory.yml`) into validated, de-duplicated entries. Never + * throws: malformed entries are dropped with a warning. De-dup is by case-insensitive login (the FIRST wins, so + * its richer metadata is kept). */ +export function normalizeContributorBlacklist(input: unknown): { entries: ContributorBlacklistEntry[]; warnings: string[] } { + const warnings: string[] = []; + if (input === undefined || input === null) return { entries: [], warnings }; + if (!Array.isArray(input)) { + warnings.push("contributorBlacklist must be a list of entries; ignoring it."); + return { entries: [], warnings }; + } + const entries: ContributorBlacklistEntry[] = []; + const seen = new Set(); + for (const [index, raw] of input.entries()) { + if (entries.length >= MAX_ENTRIES) { + warnings.push(`contributorBlacklist is capped at ${MAX_ENTRIES} entries; dropping the rest.`); + break; + } + // Accept either a bare login string or a `{ login, ... }` object. + const record = typeof raw === "string" ? { login: raw } : raw; + if (!isRecord(record) || typeof record.login !== "string") { + warnings.push(`contributorBlacklist[${index}] needs a string login; ignoring it.`); + continue; + } + const login = record.login.trim(); + if (!GITHUB_LOGIN.test(login)) { + warnings.push(`contributorBlacklist[${index}].login is not a valid GitHub login; ignoring it.`); + continue; + } + const key = login.toLowerCase(); + if (seen.has(key)) continue; // first occurrence wins + seen.add(key); + const entry: ContributorBlacklistEntry = { login }; + if (typeof record.reason === "string" && record.reason.trim().length > 0) entry.reason = record.reason.trim().slice(0, MAX_REASON_CHARS); + if (Array.isArray(record.evidence)) { + const evidence = record.evidence.filter((ref): ref is string => typeof ref === "string" && ref.trim().length > 0).map((ref) => ref.trim().slice(0, MAX_EVIDENCE_CHARS)).slice(0, MAX_EVIDENCE); + if (evidence.length > 0) entry.evidence = evidence; + } + if (typeof record.addedAt === "string" && record.addedAt.trim().length > 0) entry.addedAt = record.addedAt.trim(); + entries.push(entry); + } + return { entries, warnings }; +} + +/** The blacklist entry matching `login` (case-insensitive), or null. Tolerates an absent list (treated as empty) + * so callers can pass the optional `settings.contributorBlacklist` directly. */ +export function findBlacklistEntry(login: string | null | undefined, entries: ContributorBlacklistEntry[] | undefined): ContributorBlacklistEntry | null { + if (!login) return null; + const key = login.toLowerCase(); + return (entries ?? []).find((entry) => entry.login.toLowerCase() === key) ?? null; +} + +/** True iff `login` is on the resolved blacklist. */ +export function isAuthorBlacklisted(login: string | null | undefined, entries: ContributorBlacklistEntry[] | undefined): boolean { + return findBlacklistEntry(login, entries) !== null; +} + +/** Union multiple blacklist sources (e.g. the shared/global list + the per-repo list) by case-insensitive login. + * A login on ANY source is blocked; the FIRST source's entry wins on a duplicate so earlier (more authoritative) + * metadata is preserved. Already-normalized inputs in, de-duplicated entries out. */ +export function mergeContributorBlacklists(...lists: ContributorBlacklistEntry[][]): ContributorBlacklistEntry[] { + const merged: ContributorBlacklistEntry[] = []; + const seen = new Set(); + for (const list of lists) { + for (const entry of list) { + const key = entry.login.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + merged.push(entry); + } + } + return merged; +} diff --git a/packages/gittensory-engine/src/settings/moderation-rules.ts b/packages/gittensory-engine/src/settings/moderation-rules.ts new file mode 100644 index 0000000000..4a6afc3ebf --- /dev/null +++ b/packages/gittensory-engine/src/settings/moderation-rules.ts @@ -0,0 +1,128 @@ +// Centralized moderation-rules engine (generic self-host feature, #selfhost-mod-engine). A single modular +// layer over the three EXISTING anti-abuse mechanisms (contributor cap, blacklist, review-nag) that already +// short-circuit a PR's disposition: every time one of them fires against a non-exempt contributor, it counts +// toward that login's install-wide violation tally (the shared `audit_events` ledger, keyed by actor). At +// >=1 lifetime violation the contributor is labeled with `warningLabel`; at >=`banThreshold` they are labeled +// `bannedLabel` and (when `autoBlacklistOnBan`) auto-added to the existing global contributor blacklist -- +// the SAME "permanent two-strikes" enforcement an already-banned login gets. +// +// Config-as-code, layered the same as every other setting: a global default (the whole layer can be off, +// which rules count, the label text, the threshold, whether a ban auto-enforces) with a PER-REPO override +// that can turn the layer off/on for just that repo and override which rules feed IT specifically. NEVER +// hard-coded for any one repo -- a self-hoster's own `.gittensory.yml`/dashboard settings choose everything. + +/** The anti-abuse mechanisms this engine can count violations from -- the three ORIGINAL mechanisms + * (contributor cap, blacklist, review-nag) plus review-evasion (#review-evasion-protection: a contributor + * closing/converting-to-draft their own PR to dodge an active review). Kept as a closed union (not an open + * string) so an unrecognized value is always a normalization error, never silently accepted. */ +export type ModerationRuleType = "contributor_cap" | "blacklist" | "review_nag" | "review_evasion"; + +const ALL_MODERATION_RULE_TYPES: readonly ModerationRuleType[] = ["contributor_cap", "blacklist", "review_nag", "review_evasion"]; + +/** The `audit_events.event_type` recorded for each rule's violation -- namespaced under `moderation.violation.*` + * so a cross-eventType, cross-repo count query (see `db/repositories.ts`) can scope to exactly this family. */ +export const MODERATION_VIOLATION_EVENT_TYPE: Record = { + contributor_cap: "moderation.violation.contributor_cap", + blacklist: "moderation.violation.blacklist", + review_nag: "moderation.violation.review_nag", + review_evasion: "moderation.violation.review_evasion", +}; + +export const DEFAULT_MODERATION_WARNING_LABEL = "mod:warning"; +export const DEFAULT_MODERATION_BANNED_LABEL = "mod:banned"; +export const DEFAULT_MODERATION_BAN_THRESHOLD = 5; +// Keep the decay lookback operationally bounded, mirroring MAX_REVIEW_NAG_COOLDOWN_DAYS -- repo-controlled +// config cannot overflow Date arithmetic. +export const MAX_MODERATION_VIOLATION_DECAY_DAYS = 3650; + +const MAX_LABEL_CHARS = 100; + +export type GlobalModerationConfig = { + enabled: boolean; + rules: ModerationRuleType[]; + warningLabel: string; + bannedLabel: string; + banThreshold: number; + // null = permanent/lifetime tally (never decays), matching the existing global-blacklist's permanent-ban + // philosophy. A positive integer = only violations within that many days count toward the threshold. + violationDecayDays: number | null; + autoBlacklistOnBan: boolean; +}; + +export const DEFAULT_GLOBAL_MODERATION_CONFIG: GlobalModerationConfig = { + enabled: false, + rules: [...ALL_MODERATION_RULE_TYPES], + warningLabel: DEFAULT_MODERATION_WARNING_LABEL, + bannedLabel: DEFAULT_MODERATION_BANNED_LABEL, + banThreshold: DEFAULT_MODERATION_BAN_THRESHOLD, + violationDecayDays: null, + autoBlacklistOnBan: true, +}; + +/** Normalize a raw moderation-rules list (DB JSON or `.gittensory.yml`) into a validated, de-duplicated list + * of known rule types. Never throws: an unknown/malformed entry is dropped with a warning, matching the + * normalize-with-warnings shape every other settings list in this codebase already uses. */ +export function normalizeModerationRules(input: unknown): { rules: ModerationRuleType[]; warnings: string[] } { + const warnings: string[] = []; + if (input === undefined || input === null) return { rules: [], warnings }; + if (!Array.isArray(input)) { + warnings.push("moderationRules must be a list of rule type strings; ignoring it."); + return { rules: [], warnings }; + } + const rules: ModerationRuleType[] = []; + const seen = new Set(); + for (const [index, raw] of input.entries()) { + if (typeof raw !== "string" || !(ALL_MODERATION_RULE_TYPES as readonly string[]).includes(raw)) { + warnings.push(`moderationRules[${index}] is not a recognized rule type (expected one of ${ALL_MODERATION_RULE_TYPES.join(", ")}); ignoring it.`); + continue; + } + const rule = raw as ModerationRuleType; + if (seen.has(rule)) continue; + seen.add(rule); + rules.push(rule); + } + return { rules, warnings }; +} + +/** Normalize a raw moderation label value: empty/whitespace-only collapses to undefined (falls back to the + * caller's default), overlong is truncated. Never throws. Mirrors blacklistLabel/contributorCapLabel's + * shape, minus the explicit-null-means-"no label" case those close-coupled labels use -- a moderation label + * is always applied when the tier is reached, never suppressible to "no label at all". */ +export function normalizeModerationLabel(input: unknown): string | undefined { + if (typeof input !== "string") return undefined; + const trimmed = input.trim(); + if (trimmed.length === 0) return undefined; + return trimmed.slice(0, MAX_LABEL_CHARS); +} + +/** Effective rule set for one repo: an explicit per-repo override REPLACES the global list entirely (not a + * union) -- a repo opting out of counting review-nag toward the shared tally, for example, must be able to + * do so without also losing the ability to opt out of the others. Absent/undefined override ⇒ inherit the + * global list unchanged. */ +export function resolveEffectiveModerationRules(globalRules: readonly ModerationRuleType[], perRepoOverride: readonly ModerationRuleType[] | null | undefined): ModerationRuleType[] { + return perRepoOverride ? [...perRepoOverride] : [...globalRules]; +} + +export type ModerationGateMode = "inherit" | "off" | "enabled"; + +/** Whether the WHOLE moderation layer runs for one repo: the global master switch is authoritative; + * `off` lets a repo opt out while the global layer is enabled, and `enabled`/`inherit` both require the + * global switch to be on. */ +export function resolveModerationGateEnabled(globalEnabled: boolean, gateMode: ModerationGateMode): boolean { + if (!globalEnabled) return false; + if (gateMode === "off") return false; + return true; +} + +export type ModerationTier = "none" | "warning" | "banned"; + +/** Pure escalation decision: given the actor's TOTAL violation count (including the one that just fired, + * already recorded by the caller) and the configured ban threshold, which tier applies. A non-positive + * threshold (malformed config) can never be reached by a real count, so it degrades to "always banned once + * any violation exists" rather than throwing -- still a safe, non-silent failure mode for a misconfigured + * threshold, not a crash. */ +export function moderationTierForViolationCount(count: number, banThreshold: number): ModerationTier { + if (count <= 0) return "none"; + if (count >= banThreshold) return "banned"; + return "warning"; +} diff --git a/packages/gittensory-engine/src/settings/pr-type-label.ts b/packages/gittensory-engine/src/settings/pr-type-label.ts new file mode 100644 index 0000000000..f834d543ac --- /dev/null +++ b/packages/gittensory-engine/src/settings/pr-type-label.ts @@ -0,0 +1,165 @@ +// Neutral per-PR TYPE label (reviewbot src/core/auto-label.ts parity). The label CATEGORIES are a +// config-driven, open `category -> label name` map (#label-modularity) — `bug`/`feature`/`priority` are +// the built-in gittensor:* categories shipped as the DEFAULT config, not hardcoded engine assumptions: +// priority — ONLY when a linked/closing issue already carries the configured priority issue label +// (#priority-linked-issue-gate, `linkedIssueLabelPropagation`). Never inferred from title, +// changed files, AI output, or existing PR labels. +// feature — genuine NEW functionality only (conventional-commit `feat`/`feature`). +// bug — EVERYTHING ELSE: fix, test, docs, chore, refactor, perf, ci, build, style, revert. +// A self-hoster can register a bounded number of ADDITIONAL categories in `typeLabels` beyond these three (e.g. +// `security: "area:security"`) — an extra category is never chosen by title-classification (only bug/ +// feature are), only ever by a configured `linkedIssueLabelPropagation` mapping's `prLabel` (which can +// target ANY string, registered in `typeLabels` or not); registering it here just makes it participate +// in the mutual-exclusivity cleanup below, i.e. eligible for automatic removal when a PR's classification +// moves away from it. Public + neutral categorization (NOT the reputation signal). Review-time + +// independent of the gate / autonomy / dry-run (matches reviewbot, where auto-label runs at review +// start). Fail-safe. +import type { LinkedIssueLabelPropagationConfig, PrTypeLabelSet } from "../types/manifest-deps-types.js"; + +export type { PrTypeLabelSet } from "../types/manifest-deps-types.js"; + +/** The gittensor: namespace Gittensor itself uses -- an EXAMPLE default config, not an engine + * assumption (#label-modularity): a self-hoster's `typeLabels` fully replaces the category set these + * keys are drawn from. The built-in categories are mutually exclusive by default (see + * `resolvePrTypeLabel`'s `removeLabels`) unless a propagation mapping is explicitly additive. */ +export const DEFAULT_TYPE_LABELS: PrTypeLabelSet = { + bug: "gittensor:bug", + feature: "gittensor:feature", + priority: "gittensor:priority", +}; + +/** Every label name in the built-in default set, generic over however many categories + * `DEFAULT_TYPE_LABELS` carries (#label-modularity) -- never hardcode `.bug`/`.feature`/`.priority` + * property access here, a configured set can carry more or fewer categories than the default. */ +export const ALL_TYPE_LABELS: readonly string[] = Object.values(DEFAULT_TYPE_LABELS); + +export const MAX_TYPE_LABEL_CATEGORIES = 32; +export const MAX_TYPE_LABEL_NAME_LENGTH = 50; + +const FEATURE_TITLE_ACTION_RE = /\b(add|adds|added|create|creates|created|enable|enables|enabled|implement|implements|implemented|integrate|integrates|integrated|introduce|introduces|introduced|launch|launches|launched|support|supports|supported|wire|wires|wired)\b/i; +const FEATURE_TITLE_DOWNGRADE_RE = /\b(avoid|block|bug|bugfix|cache|classify|classifies|classifying|cleanup|clean-up|clean up|detect|detects|detecting|docs?|fix|format|guard|lint|normalize|recognize|recognizes|recognizing|refactor|regression|rename|test|tests|testing|tighten|typo)\b/i; + +/** feature ONLY for substantial new functionality: a feat/feature prefix plus a concrete add/support/enable + * action, with small recognition/classification/cleanup-style work downgraded to bug/work. EVERYTHING else — + * fix, test, docs, chore, refactor, perf, ci, build, style, revert — is bug. */ +export function deriveKindFromTitle(title: string | undefined): "bug" | "feature" { + const normalized = (title ?? "").trim(); + const match = /^([a-zA-Z]+)/.exec(normalized); + const type = match?.[1]?.toLowerCase(); + if (type !== "feat" && type !== "feature") return "bug"; + const subject = normalized.replace(/^[a-zA-Z]+(?:\([^)]*\))?:?\s*/, ""); + if (!FEATURE_TITLE_ACTION_RE.test(subject)) return "bug"; + return FEATURE_TITLE_DOWNGRADE_RE.test(subject) ? "bug" : "feature"; +} + +/** Defaults-fill a per-repo `typeLabels` override (config-as-code), generic over an arbitrary set of + * categories (#label-modularity): every key of `DEFAULT_TYPE_LABELS` (the built-in bug/feature/ + * priority categories) is taken independently from `input` when it is a non-empty string, else falls + * back to the corresponding built-in default — so a repo can override just one built-in label name + * (e.g. only `priority`) and keep the others default. Any EXTRA key present in `input` beyond the + * built-in set (a self-hoster's own custom category, e.g. `security`) is included verbatim when + * valid, up to `MAX_TYPE_LABEL_CATEGORIES` total categories and GitHub's 50-character label-name + * limit; there is no built-in default for it to fall back to, so an invalid extra-category value is + * dropped entirely (warned, not defaulted) rather than silently defaulted. A non-object input yields + * the full default set; omitted is normal (no warning), present-but-wrong-shaped warns. An input that + * IS a valid object but has zero own keys (`{}`) also yields the full default set here — this + * function only ever defaults-fills or validates a COMPLETE settings value (the DB-persisted set, or + * a from-scratch construction); `resolveEffectiveSettings` (focus-manifest.ts) is what gives a + * manifest's *literal* `typeLabels: {}` its own distinct "deliberately zero categories" meaning, + * since collapsing that here would also flip every legacy `type_labels_json = '{}'` DB row (the SQL + * column's own default, predating any explicit customization) from full defaults to zero labels — + * the exact behavior change #priority-linked-issue-gate's migration promised existing repos would + * never see. Mirrors `normalizeCommandAuthorizationPolicy`'s defaults-fill pattern + * (`src/settings/command-authorization.ts`). */ +export function normalizeTypeLabelSet(input: unknown, warnings: string[]): PrTypeLabelSet { + if (input === undefined) return { ...DEFAULT_TYPE_LABELS }; + if (typeof input !== "object" || input === null || Array.isArray(input)) { + warnings.push("settings.typeLabels must be an object; using default label names."); + return { ...DEFAULT_TYPE_LABELS }; + } + const record = input as Record; + const keys = new Set([...Object.keys(DEFAULT_TYPE_LABELS), ...Object.keys(record)]); + const result: PrTypeLabelSet = {}; + for (const key of keys) { + const value = record[key]; + const wouldAddCategory = result[key] === undefined; + if (wouldAddCategory && Object.keys(result).length >= MAX_TYPE_LABEL_CATEGORIES) { + if (value !== undefined) warnings.push(`settings.typeLabels has more than ${MAX_TYPE_LABEL_CATEGORIES} categories; ignoring ${key}.`); + continue; + } + const builtInDefault: string | undefined = DEFAULT_TYPE_LABELS[key]; + if (typeof value === "string" && value.trim().length > 0 && value.trim().length <= MAX_TYPE_LABEL_NAME_LENGTH) { + result[key] = value.trim(); + continue; + } + if (value !== undefined) { + const reason = typeof value === "string" && value.trim().length > MAX_TYPE_LABEL_NAME_LENGTH ? `a non-empty string no longer than ${MAX_TYPE_LABEL_NAME_LENGTH} characters` : "a non-empty string"; + warnings.push( + builtInDefault !== undefined + ? `settings.typeLabels.${key} must be ${reason}; using the default "${builtInDefault}".` + : `settings.typeLabels.${key} must be ${reason}; ignoring it.`, + ); + } + // Reached for BOTH an invalid present value and an absent one -- a built-in category (bug/feature/ + // priority) always has a default to fall back to; an unknown custom category does not, so it is + // dropped entirely (warned above when it was present-but-invalid, silently absent when never named). + if (builtInDefault !== undefined) result[key] = builtInDefault; + } + return result; +} + +/** The pure decision `resolvePrTypeLabel` returns: which label(s) to apply, which configured + * type-label-set members to remove for mutual exclusivity, and why. */ +export type PrTypeLabelDecision = { + applyLabels: string[]; + removeLabels: string[]; + source: "propagation_exclusive" | "propagation_additive" | "title"; +}; + +/** + * Resolve the TYPE label decision for a PR. + * 1. Linked-issue label PROPAGATION (config-driven, #priority-linked-issue-gate): when enabled, the + * FIRST configured mapping whose `issueLabel` appears (case-insensitively) among the + * ALREADY-FETCHED `linkedIssueLabels` wins. This is the ONLY way a label like `gittensor:priority` + * can ever be chosen — this function does no I/O and never infers it from title, changed files, + * AI output, or PR labels; the caller must fetch `linkedIssueLabels` itself (see + * `fetchLinkedIssueLabelsForPropagation` in `review/linked-issue-label-propagation-fetch.ts`). + * - `removeOtherTypeLabels: true` (exclusive) — the mapped label REPLACES the type label, + * exactly like today's bug/feature/priority classification (used for `gittensor:priority`). + * - `removeOtherTypeLabels: false` (additive) — the mapped label is applied ALONGSIDE the + * normal title-based bug/feature label, which is left untouched (e.g. a generic + * `customer:vip` → `triage:vip` triage marker that has nothing to do with bug/feature/priority). + * 2. Otherwise, feature (feat/feature) / bug (everything else) by the conventional-commit title prefix + * -- ONLY when `labels` actually has a name registered for that built-in category; a configured set + * that omits `bug`/`feature` entirely (a self-hoster who only wants custom, propagation-driven + * categories, or an explicit `typeLabels: {}` resolved to zero categories) applies nothing for that + * branch rather than inventing a label name (#label-modularity). + * `removeLabels` is always "every member of the configured type-label set that isn't one of + * `applyLabels`" — generic and total over however many categories are configured, and safe even if a + * misconfigured additive mapping's `prLabel` happens to collide with a type-label-set name (it is + * excluded from removal since it is also being applied). Pure + total. + */ +export function resolvePrTypeLabel(input: { + title: string | undefined; + linkedIssueLabels?: string[] | undefined; + labels?: PrTypeLabelSet | undefined; + propagation?: LinkedIssueLabelPropagationConfig | undefined; +}): PrTypeLabelDecision { + const labels = input.labels ?? DEFAULT_TYPE_LABELS; + const isRealLabel = (label: string | undefined): label is string => typeof label === "string" && label.length > 0; + const typeLabelSet = Object.values(labels).filter(isRealLabel).filter((label) => label.length <= MAX_TYPE_LABEL_NAME_LENGTH).slice(0, MAX_TYPE_LABEL_CATEGORIES); + const titleLabel: string | undefined = labels[deriveKindFromTitle(input.title)]; + const decide = (applyLabels: ReadonlyArray, source: PrTypeLabelDecision["source"]): PrTypeLabelDecision => { + const apply = [...new Set(applyLabels.filter(isRealLabel))]; + return { applyLabels: apply, removeLabels: typeLabelSet.filter((label) => !apply.includes(label)), source }; + }; + + if (input.propagation?.enabled) { + const wanted = new Set((input.linkedIssueLabels ?? []).map((label) => label.toLowerCase())); + for (const mapping of input.propagation.mappings) { + if (!wanted.has(mapping.issueLabel.toLowerCase())) continue; + return mapping.removeOtherTypeLabels ? decide([mapping.prLabel], "propagation_exclusive") : decide([titleLabel, mapping.prLabel], "propagation_additive"); + } + } + return decide([titleLabel], "title"); +} diff --git a/packages/gittensory-engine/src/types/manifest-deps-types.ts b/packages/gittensory-engine/src/types/manifest-deps-types.ts new file mode 100644 index 0000000000..d603ff0dd6 --- /dev/null +++ b/packages/gittensory-engine/src/types/manifest-deps-types.ts @@ -0,0 +1,484 @@ +// Type mirrors from `src/types.ts` needed by focus-manifest parse/compile core and its +// engine-local settings normalizers. The engine package cannot import across into `src/` — keep in sync +// by hand. `JsonValue` is sourced from `scoring/types.ts`. + +export type { JsonValue } from "../scoring/types.js"; + +export type GateRuleMode = "off" | "advisory" | "block"; + +export type ReviewCheckMode = "required" | "visible" | "disabled"; + +export type ProjectMilestoneMatchMode = "off" | "suggest" | "auto"; + +export type ProjectMilestoneMatchBackend = "github" | "linear"; + +export type GatePolicyPack = "gittensor" | "oss-anti-slop"; + +export type CombineStrategy = "single" | "consensus" | "synthesis"; + +export type OnMerge = "either" | "both"; + +export type ScreenshotTableGateAction = "close" | "request_changes" | "comment"; + +export type ScreenshotTableGateConfig = { + enabled: boolean; + whenLabels: string[]; + whenPaths: string[]; + action: ScreenshotTableGateAction; + message?: string | undefined; +}; + +export type CommandAuthorizationRole = "maintainer" | "collaborator" | "pr_author" | "confirmed_miner"; + +export type RepositoryCommandAuthorizationPolicy = { + default: CommandAuthorizationRole[]; + commands: Record; +}; + +export type PrTypeLabelSet = Record; + +export type LinkedIssueLabelPropagationMapping = { + issueLabel: string; + prLabel: string; + removeOtherTypeLabels: boolean; +}; + +export type LinkedIssueLabelPropagationMode = "exclusive_type_label"; + +export type LinkedIssueLabelPropagationConfig = { + enabled: boolean; + mode: LinkedIssueLabelPropagationMode; + mappings: LinkedIssueLabelPropagationMapping[]; +}; + +export type LinkedIssueHardRulesMode = "block" | "off"; + +export type LinkedIssueHardRulesConfig = { + ownerAssignedClose: LinkedIssueHardRulesMode; + /** Close when an open linked issue is assigned to someone other than the PR author. */ + assignedIssueClose: LinkedIssueHardRulesMode; + missingPointLabelClose: LinkedIssueHardRulesMode; + maintainerOnlyLabelClose: LinkedIssueHardRulesMode; + pointBearingLabels: string[]; + maintainerOnlyLabels: string[]; + defaultLabelRepo: boolean; + verifyBeforeClose: boolean; + closeDelaySeconds: number; +}; + +export type UnlinkedIssueGuardrailMode = "hold" | "off"; + +export type UnlinkedIssueGuardrailConfig = { + mode: UnlinkedIssueGuardrailMode; + minConfidence: number; +}; + +export type ContributorBlacklistEntry = { + login: string; + /** Why the account is blocked. Free-text maintainer metadata; not published in automated close comments. */ + reason?: string | undefined; + /** PR/issue URLs (or other maintainer refs) evidencing the block. */ + evidence?: string[] | undefined; + /** ISO-8601 date the entry was added. */ + addedAt?: string | undefined; +}; + +export type AutonomyLevel = "observe" | "suggest" | "propose" | "auto_with_approval" | "auto"; + +export type AgentActionClass = "review" | "request_changes" | "approve" | "merge" | "close" | "label" | "review_state_label" | "update_branch" | "assign"; + +export type AutonomyPolicy = Partial>; + +export type AutoMergeMethod = "merge" | "squash" | "rebase"; + +export type AutoMaintainPolicy = { + requireApprovals: number; + mergeMethod: AutoMergeMethod; +}; + +export type RepositorySettings = { + repoFullName: string; + commentMode: "off" | "detected_contributors_only" | "all_prs"; + publicAudienceMode: "oss_maintainer" | "gittensor_only"; + publicSignalLevel: "minimal" | "standard"; + checkRunMode: "off" | "enabled"; + checkRunDetailLevel: "minimal" | "standard" | "deep"; + gateCheckMode: "off" | "enabled"; + /** Scheduled re-gate sweep candidate ordering (#3815). `staleness` (default) picks whichever open PR the + * sweep has gone longest WITHOUT re-gating (see selectRegateCandidates), which is what gives the sweep its + * documented full-coverage-in-ceil(open/max)-ticks convergence guarantee even under dry-run/pause (when + * GitHub's own `updatedAt` writes are suppressed). `oldest-first` instead always picks the oldest-created + * open PRs first, for an operator who wants deterministic creation-order draining over that guarantee. + * Selection-time only — real-time webhook-driven review is not gated by this and can process any PR at + * any time regardless of the chosen order. */ + regateSweepOrderMode: "staleness" | "oldest-first"; + /** The actual runtime authority for whether the "Gittensory Orb Review Agent" check-run publishes (#2852). + * See {@link ReviewCheckMode}. `gateCheckMode` above stays wired for API/back-compat display but no longer + * drives the publish decision on its own. */ + reviewCheckMode: ReviewCheckMode; + /** Auto-project/milestone matching (#3183). See {@link ProjectMilestoneMatchMode}. Always populated by the DB + * layer (default `"off"`); optional so existing settings fixtures/callers need not be touched. */ + autoProjectMilestoneMatch?: ProjectMilestoneMatchMode | undefined; + /** Which backend {@link ProjectMilestoneMatchMode} matches against (#3186). See {@link ProjectMilestoneMatchBackend}. + * Always populated by the DB layer (default `"github"`); optional so existing settings fixtures/callers need + * not be touched. */ + autoProjectMilestoneMatchBackend?: ProjectMilestoneMatchBackend | undefined; + /** Policy pack the gate evaluates under (#692). Default `gittensor` (registry-aware; threads confirmed + * status for scoring only). `oss-anti-slop` runs the deterministic rules against any author on any repo. */ + gatePack: GatePolicyPack; + linkedIssueGateMode: GateRuleMode; + duplicatePrGateMode: GateRuleMode; + qualityGateMode: GateRuleMode; + qualityGateMinScore?: number | null | undefined; + /** Deterministic anti-slop signal (#530/#532). `off` = no slop score; `advisory` = surface the slop + * score + warnings in context; `block` = ALSO hard-block when slopRisk >= slopGateMinScore (deterministic + * only, applies to every author like every blocker). Default `off` — opt-in via .gittensory.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`. */ + sizeGateMode?: GateRuleMode | 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 + * a `lockfile_tamper_risk` finding (`block` additionally hard-blocks). Distinct from the OSV.dev CVE analyzer + * in review-enrichment — this is a tamper/integrity-substitution check, not a known-CVE check. Config-as-code + * only — no DB column or dashboard toggle; set via `.gittensory.yml gate.lockfileIntegrity`. */ + lockfileIntegrityGateMode?: GateRuleMode | undefined; + /** CLA / license-compatibility gate (#2564). `off` (default/absent) = no CLA check at all; `advisory`/`block` = + * evaluate the configured detection method(s) (`claConsentPhrase` and/or `claCheckRunName` + `claCheckRunAppSlug`) and raise a + * `cla_consent_missing` finding when neither confirms consent — `block` also hard-blocks the gate. Config-as-code + * only (no DB column, mirrors sizeGateMode) — set via `.gittensory.yml gate.claMode`. */ + claGateMode?: GateRuleMode | undefined; + /** `gate.cla.consentPhrase`: a public-safe-filtered phrase a maintainer requires somewhere in the PR body (e.g. + * "I have read and agree to the CLA"), matched case-insensitively. `null`/absent ⇒ phrase-match detection is not + * configured. Config-as-code only, alongside {@link claGateMode}. */ + claConsentPhrase?: string | null | undefined; + /** `gate.cla.checkRunName`: the name of a separate CLA-bot check-run this repo also runs (e.g. "CLA Assistant + * Lite"). A `success`/`neutral` conclusion for a check-run with this exact name (case-insensitive), produced + * by `claCheckRunAppSlug`, also satisfies consent. `null`/absent ⇒ check-run detection is not configured. + * Config-as-code only, alongside {@link claGateMode}. */ + claCheckRunName?: string | null | undefined; + /** `gate.cla.checkRunAppSlug`: the trusted GitHub App slug that must have produced `claCheckRunName`. Required + * for check-run detection so contributor-controlled same-name runs cannot satisfy a blocking CLA gate. */ + claCheckRunAppSlug?: string | null | undefined; + /** `gate.expectedCiContexts` (#selfhost-ci-verification): maintainer-declared CI check/status context names to + * treat as required when GitHub branch protection returns no readable required-status-checks (unconfigured, + * or a 403 from a token lacking `administration:read` — common for GitHub App installations). Merged with any + * branch-protection required contexts when both exist; used ALONE when branch protection is null/empty; a + * repo with neither configured keeps the existing fold-all fail-closed behavior. A context missing from the + * commit ⇒ pending; a completed red check for a listed context ⇒ failed; every listed context settled clean + * ⇒ verified passed (no `ciCompletenessWarning`). Config-as-code only — no DB column; set via + * `.gittensory.yml gate.expectedCiContexts`. */ + expectedCiContexts?: ReadonlyArray | null | undefined; + /** Dry-run disposition (#gate-dryrun). When true, the gate renders the would-be merge/close/manual verdict (every + * advisory sub-gate promoted to block) WITHOUT enforcing — the posted check stays non-blocking. Lets advisory mode + * preview exactly what it would do before the maintainer flips to real enforcement. Default off. */ + gateDryRun?: boolean | undefined; + /** Live premerge migrations/** collision recheck (#2550). When true, an agent-driven merge of a PR that + * touches migrations/** is preceded by a fresh GitHub Trees-API read of the base branch's CURRENT migration + * filenames — unioned with this PR's own new migration filenames — checked for a live numeric collision. + * A collision suppresses the merge and holds the PR with a rebase-needed label + comment instead of merging + * blind. Config-as-code only (no DB column, mirrors gateDryRun) — set via `.gittensory.yml` + * `gate.premergeContentRecheck`. Default off/undefined — opt-in, since it costs one extra, uncached + * GitHub API call for any PR that touches migrations/**. */ + premergeContentRecheck?: boolean | undefined; + /** Merge-readiness gate (#merge-readiness). `off`/`advisory`/`block`. No min-score. Default `off`. */ + mergeReadinessGateMode: GateRuleMode; + /** Focus-manifest policy gate (#555). When `block`, the focus manifest's declared policy (required-linked + * issue and test expectations) becomes an enforceable review-agent blocker. Path-based manual-review holds + * are configured separately through `settings.hardGuardrailGlobs`. An + * INDEPENDENT dimension, deliberately not folded into the merge-readiness composite. Default `off` — opt-in. */ + manifestPolicyGateMode: GateRuleMode; + /** Self-authored linked-issue gate. When `block`, the gate closes a PR where the contributor also + * opened the linked issue (`pr.authorLogin === issue.authorLogin`). Defaults to `advisory` — the finding + * is surfaced in the review panel but never blocks unless the maintainer opts in. */ + selfAuthoredLinkedIssueGateMode: GateRuleMode; + /** First-time-contributor grace (#552). RESERVED / currently INERT (#2266): parsed, clamped, and threaded + * end-to-end, but the gate evaluator never reads it — a genuine newcomer with a real blocker is still + * one-shot closed exactly like a repeat contributor (blocker findings must remain closure outcomes). + * Setting this true has no runtime effect today; kept for potential future use. Default false. */ + firstTimeContributorGrace: boolean; + /** Slop-risk threshold (0-100) at/above which `slopGateMode: block` blocks. Default 60 (the `high` band). */ + slopGateMinScore?: number | null | undefined; + /** AI-assisted slop advisory (the `slopAiAdvisory` capability). When true AND `slopGateMode != off`, a + * free/default-reviewer pass (the configured self-host provider, or the legacy Workers-AI pair when + * none is configured) adds an ADVISORY-only `ai_slop_advisory` finding for semantic slop the + * deterministic detector cannot quantify. It NEVER feeds slopRisk or the gate (only the deterministic + * core blocks). Default false — opt-in via `.gittensory.yml gate.slop.aiAdvisory`. */ + slopAiAdvisory: boolean; + /** AI maintainer review. `off` = no AI; `advisory` = post AI review notes only; `block` = ALSO let a + * dual-model high-confidence consensus defect become a gate blocker (confirmed-contributors only, + * like every other blocker). Default `off` — AI is opt-in. */ + aiReviewMode: GateRuleMode; + /** Bring-your-own-key: when true and a provider key is configured for the repo, the advisory AI review + * is generated by the maintainer's frontier model (Anthropic/OpenAI) instead of the free/default + * reviewer. The consensus blocker always uses the free/default reviewer pair regardless (the configured + * self-host provider, or the legacy Workers-AI pair when none is configured), so BYOK never changes who + * can be blocked. Default false. */ + aiReviewByok: boolean; + /** Config-as-code BYOK provider for the advisory write-up. `null` = use the configured key's own + * provider. When set, it must match the stored key's provider or BYOK is skipped (falls back to the + * free/default reviewer). The secret key itself is never here — only via the encrypted key store. */ + aiReviewProvider?: "anthropic" | "openai" | null | undefined; + /** Config-as-code model override for the BYOK advisory write-up (e.g. "claude-3-5-sonnet-latest"). + * `null` = use the key record's model, else a conservative per-provider default. */ + aiReviewModel?: string | null | undefined; + /** Review EVERY PR's author, not only confirmed Gittensor contributors. The AI maintainer review is + * confirmed-contributor-gated by default (an AI-spend guard). When true the review runs for any author — + * intended for a self-host operator who wants real reviews on all PRs (incl. their own) and pays for the + * AI themselves. Default false — opt-in via `.gittensory.yml gate.aiReview.allAuthors`. Independent of + * `aiReviewMode`: `off` still means no AI; this only widens WHO an enabled review covers. */ + aiReviewAllAuthors: boolean; + /** Configured AI-reviewer confidence floor (0-1) for close calibration (#7). Under `aiReviewMode: block`, AI + * defect findings remain blockers even when their confidence is below this floor; the floor is retained as + * configurable context, not a manual-review downgrade. Config-as-code only — set via `.gittensory.yml + * gate.aiReview.closeConfidence` (no dashboard/DB column); unset ⇒ the gate uses the 0.93 default. Clamped to + * [0,1] at parse time. */ + aiReviewCloseConfidence?: number | null | undefined; + /** Per-repo dual-AI combine-strategy override (#2567). Config-as-code only — set via `.gittensory.yml + * gate.aiReview.combine` (no dashboard/DB column); unset ⇒ the self-host operator's `AI_REVIEW_PLAN.combine` + * boot config (or `consensus` if the operator set nothing). A REFINEMENT of the operator's plan, not a + * bypass — `runGittensoryAiReview` clamps the resolved `onMerge` to the operator's floor (see + * {@link aiReviewOnMerge}); `combine` itself carries no floor semantics (single/consensus/synthesis are not + * ordered by strictness). */ + aiReviewCombine?: CombineStrategy | null | undefined; + /** Per-repo `synthesis` merge-rule override (#2567): `either` blocks on ANY one reviewer's blocker (the + * STRICTER rule); `both` blocks only when every reviewer agrees (the more PERMISSIVE rule). Config-as-code + * only — set via `.gittensory.yml gate.aiReview.onMerge` (no dashboard/DB column). A repo override can only + * TIGHTEN the operator's `AI_REVIEW_PLAN.onMerge` floor (e.g. `either` → `either` is a no-op; `both` → an + * attempted loosening is clamped back to `either`). When the operator has not set an `onMerge` floor, any + * per-repo value is honored unclamped. See `resolveEffectiveAiReviewOnMerge` in `services/ai-review.ts`. */ + aiReviewOnMerge?: OnMerge | null | undefined; + /** Per-repo reviewer-pair override (#2567): named self-host providers (e.g. `{ model: "claude-code" }`, + * `{ model: "codex" }`) to run instead of the operator's `AI_REVIEW_PLAN.reviewers` (or the free Workers-AI + * pair when the operator configured none). Config-as-code only — set via `.gittensory.yml + * gate.aiReview.reviewers` (no dashboard/DB column). Unlike {@link aiReviewOnMerge}, WHICH reviewers run + * carries no operator floor to violate (the floor is what triggers a hold/block, not who evaluates it), so a + * repo override always wins unclamped when set. */ + aiReviewReviewers?: ReadonlyArray<{ model: string; fallback?: string | null | undefined }> | null | undefined; + /** When TRUE, the repo OWNER's (and maintainer's) own PRs are eligible for auto-CLOSE like a contributor's + * (still subject to the `close` autonomy class + the same adverse-signal conditions). Default FALSE — owner + * PRs are exempt from auto-close (merge or manual-hold only). Per-repo configurable so maintainers choose + * rather than inheriting a hardwired opinion. */ + closeOwnerAuthors: boolean; + autoLabelEnabled: boolean; + gittensorLabel: string; + createMissingLabel: boolean; + /** #label-decoupling: independently gates the per-PR TYPE/taxonomy label (bug/feature by the PR + * title, or priority via linked-issue label propagation — see `resolvePrTypeLabel` in + * `settings/pr-type-label.ts`). Distinct from {@link autoLabelEnabled} (which governs only the + * base {@link gittensorLabel} context label) and from `decidePublicSurface`'s public-surface gate + * (miner detection / `publicAudienceMode` / `includeMaintainerAuthors` / bot-author exclusion) — + * type labels are internal triage metadata applied unconditionally to every PR, not a + * contributor-facing signal, so neither of those public-surface conditions should suppress them. + * Default TRUE (matches the prior de-facto behavior before this field existed, when type labels + * were gated by `autoLabelEnabled` nested inside the public-surface check). Always populated by + * the DB layer; optional so existing settings fixtures/callers need not be touched. */ + typeLabelsEnabled?: boolean | undefined; + /** Per-repo override of the TYPE/taxonomy label NAMES, keyed by category (#priority-linked-issue-gate, + * #label-modularity). Defaults to `DEFAULT_TYPE_LABELS` (`gittensor:bug`/`gittensor:feature`/ + * `gittensor:priority`) in `settings/pr-type-label.ts` — a repo can override just one name (e.g. only + * `priority`) and keep the others default, AND/OR add arbitrary additional categories beyond the + * built-in three (e.g. `security: "area:security"`) for its own taxonomy. Always populated by the DB + * layer; optional so existing settings fixtures/callers need not be touched. */ + typeLabels?: PrTypeLabelSet | undefined; + /** Linked-issue label propagation (#priority-linked-issue-gate): the ONLY mechanism that can ever + * select the configured priority label (or any other configured mapping's PR label) — never + * inferred from a PR's title, changed files, AI output, or existing PR labels. Default disabled + * (`enabled: false`, no mappings) — a self-hoster opts in per repo. Always populated by the DB + * layer; optional so existing settings fixtures/callers need not be touched. */ + linkedIssueLabelPropagation?: LinkedIssueLabelPropagationConfig | undefined; + /** Deterministic linked-issue hard rules. Config-as-code only; set with + * `.gittensory.yml settings.linkedIssueHardRules` in private/global or per-repo config. These rules close + * contributor PRs that link ineligible issues before spending AI review budget: owner/other-assigned, + * maintainer-only, or missing point-label issues. Defaults all-off so self-hosters opt into their own policy. */ + linkedIssueHardRules?: LinkedIssueHardRulesConfig | undefined; + /** Same-account issue-avoidance guardrail (#unlinked-issue-guardrail). Config-as-code only; set with + * `.gittensory.yml settings.unlinkedIssueGuardrail` in private/global or per-repo config. Defaults + * all-off so a self-hoster opts into their own credibility-gate-farming defense. */ + unlinkedIssueGuardrail?: UnlinkedIssueGuardrailConfig | undefined; + publicSurface: "off" | "comment_and_label" | "comment_only" | "label_only"; + includeMaintainerAuthors: boolean; + requireLinkedIssue: boolean; + backfillEnabled: boolean; + privateTrustEnabled: boolean; + /** Opt-in for the public, unauthenticated README status badge (#541). Always populated by the DB layer + * (default false); optional so existing settings fixtures/callers need not be touched. */ + badgeEnabled?: boolean | undefined; + /** Opt-in for the public per-repo review-quality page (#2568). Always populated by the DB layer + * (default false); optional so existing settings fixtures/callers need not be touched. */ + publicQualityMetrics?: boolean | undefined; + commandAuthorization?: RepositoryCommandAuthorizationPolicy | undefined; + /** Per-repo contributor blacklist (#1425, anti-abuse): banned GitHub logins whose PRs/issues the engine + * deterministically closes ahead of merit review. Layered the same as other settings (`.gittensory.yml` > + * DB) and unioned with the shared/global list at the point of use. Always populated by the DB layer + * (default `[]`); optional so existing settings fixtures/callers need not be touched. */ + contributorBlacklist?: ContributorBlacklistEntry[] | undefined; + /** The label applied to a blacklisted contributor's PR (#1425). Configurable per-repo (dashboard/DB + + * `.gittensory.yml` `settings.blacklistLabel`); defaults to `"slop"` so the disposition works regardless of + * the label a repo sets. Explicit `null` closes WITHOUT applying any label (the same load-bearing-null idiom + * as {@link contributorOpenPrCap}) -- distinct from omitted/undefined, which uses the default. Always + * populated by the DB layer (default `"slop"`); optional so existing settings fixtures/callers need not be + * touched (mirrors the sibling `contributorBlacklist`). */ + blacklistLabel?: string | null | undefined; + /** Per-contributor open-PR cap (#2270, anti-abuse): the max PRs a single non-owner/admin/bot contributor may + * have open on this repo at once. `null`/absent (default) = no cap, byte-identical to today. Layered like + * every other settings field (`.gittensory.yml` `settings.contributorOpenPrCap` > DB > `null`). Enforcement + * (closing the newest PR(s) over the cap) is a separate follow-up; this field only carries the threshold. */ + contributorOpenPrCap?: number | null | undefined; + /** Per-contributor open-issue cap (#2270, anti-abuse): same shape and precedence as {@link contributorOpenPrCap}, + * applied to open issues instead of open PRs. `null`/absent (default) = no cap. */ + contributorOpenIssueCap?: number | null | undefined; + /** The label applied to a PR/issue closed for exceeding a per-contributor open-item cap (#2270). Same + * configurable-with-fallback shape as {@link blacklistLabel} (including the explicit-`null`-closes-without-a- + * label idiom); defaults to `"over-contributor-limit"` so the disposition works regardless of the label a + * repo sets. Always populated by the DB layer; optional so existing settings fixtures/callers need not be + * touched. */ + contributorCapLabel?: string | null | undefined; + /** Cancel in-flight CI runs on a contributor_cap close (#2462, anti-abuse): when true, after a PR is + * auto-closed for exceeding {@link contributorOpenPrCap}, gittensory lists and cancels that PR's + * in-progress/queued Actions runs at its head SHA. Requires the App installation to have granted + * `actions: write` -- degrades gracefully (skipped + logged, never blocks the close) when it hasn't. + * `null`/undefined (the DB-layer default) means "unset" and falls back to the + * `CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT` env var -- unlike most boolean toggles, this one is nullable so an + * explicit `false` (opt back out) is distinguishable from "not configured" for that fallback. */ + contributorCapCancelCi?: boolean | null | undefined; + /** Review-request nagging cooldown (#2463, anti-abuse): throttle a contributor repeatedly pinging + * `@gittensory` (any command) on this repo. `"off"` (default) is a no-op; `"hold"` posts a deterministic + * cooldown reply and takes no further action; `"close"` additionally closes the thread (PR threads only in + * v1 — a plain issue thread degrades to `"hold"` behavior until #2493's `closeIssue` primitive lands). + * Always populated by the DB layer (default `"off"`); optional so existing settings fixtures/callers need + * not be touched. */ + reviewNagPolicy?: "off" | "hold" | "close" | undefined; + /** Review-nag cooldown (#2463): how many `@gittensory` pings a contributor may make on this repo within + * {@link reviewNagCooldownDays} before the (N+1)th is throttled. Always populated by the DB layer (default + * `3`); optional so existing settings fixtures/callers need not be touched. Only meaningful when + * {@link reviewNagPolicy} is not `"off"`. */ + reviewNagMaxPings?: number | undefined; + /** Review-nag cooldown (#2463): the rolling window (in days) {@link reviewNagMaxPings} counts against. Always + * populated by the DB layer (default `5`); optional so existing settings fixtures/callers need not be + * touched. */ + reviewNagCooldownDays?: number | undefined; + /** The label applied to a thread closed for review-nag cooldown (#2463), mirroring {@link blacklistLabel}'s + * configurable-with-fallback shape (including the explicit-`null`-closes-without-a-label idiom). Always + * populated by the DB layer (default `"review-nag-cooldown"`); optional so existing settings + * fixtures/callers need not be touched. */ + reviewNagLabel?: string | null | undefined; + /** Maintainer-mention nag moderation: GitHub logins to ALSO throttle under the review-nag cooldown when the + * thread author repeatedly @-mentions them (on top of the bot's own `@gittensory` handle) -- e.g. a + * maintainer login instead of the bot, for a contributor who keeps tagging a specific person for review. + * Counted independently per mentioned login and independently of the `@gittensory` counter, but reuses the + * SAME {@link reviewNagPolicy}/{@link reviewNagMaxPings}/{@link reviewNagCooldownDays}/{@link reviewNagLabel} + * thresholds/action/label -- one cooldown policy, multiple watched mention targets. `[]`/undefined (default) + * = no logins watched, zero behavior change. Never fires for the repo owner, admin logins, automation bots, + * or a login on {@link autoCloseExemptLogins}. */ + reviewNagMonitoredMentions?: string[] | undefined; + /** Shared repo-scoped exemption list (#2463, anti-abuse): GitHub logins that are NEVER throttled or closed by + * gittensory's deterministic anti-abuse mechanisms (review-nag and the per-contributor open-item cap above), + * on top of the standing owner/admin/automation-bot exemption. Always populated by the DB layer (default + * `[]`); optional so existing settings fixtures/callers need not be touched. */ + autoCloseExemptLogins?: string[] | undefined; + /** Hard manual-review guardrail globs. Config-as-code only: set in private/global or per-repo + * `.gittensory.yml` under `settings.hardGuardrailGlobs`. Absent means no path guardrails. Arrays are + * replacement overlays, so a repo can clear a global default with `[]`. */ + hardGuardrailGlobs?: string[] | null | undefined; + /** Label applied when an otherwise-ready PR is held for manual review by a guardrail. Config-as-code only; + * `null` disables the label while keeping the hold. Distinct from `review_state_label`, so operators can + * apply one manual-review label without enabling ready/changes-requested disposition labels. */ + manualReviewLabel?: string | null | undefined; + /** Optional review-state label names. Config-as-code only; each `null` disables that specific label. These are + * deliberately generic defaults rather than `gittensory:*` names so self-hosters can opt into their own + * taxonomy without inheriting project-specific labels. */ + readyToMergeLabel?: string | null | undefined; + changesRequestedLabel?: string | null | undefined; + migrationCollisionLabel?: string | null | undefined; + pendingClosureLabel?: string | null | undefined; + /** Force-rebase-before-merge window in minutes (#2552, anti-race). When a base branch has advanced within + * this many minutes of the actual merge-decision moment, an agent-driven merge forces an `update_branch` + + * fresh CI recheck cycle first, rather than trusting a `mergeableState: clean` read that may already be + * stale relative to a sibling commit that just landed on the base. `null`/undefined (default) = never + * force -- a `mergeable_state: clean` read is trusted exactly as it is today. Layered like every other + * settings field (`.gittensory.yml` `gate.requireFreshRebaseWindow` > DB > `null`). */ + requireFreshRebaseWindowMinutes?: number | null | undefined; + /** Account-age throttle (#2561, anti-abuse): an account younger than this many days gets the + * {@link newAccountLabel} and a tighter effective contributor cap — friction/visibility, NEVER an + * automatic close on account age alone. `null`/undefined (default) = off. Never fires for the repo + * owner, admin logins, or automation bots. Applies on both PR and issue contributor-cap paths. */ + accountAgeThresholdDays?: number | null | undefined; + /** The label applied to a below-threshold-age account's PR (#2561), mirroring {@link blacklistLabel}'s + * configurable-with-fallback shape. Always populated by the DB layer (default `"new-account"`); optional so + * existing settings fixtures/callers need not be touched. */ + newAccountLabel?: string | undefined; + /** Per-command @gittensory rate limit (#2560, anti-abuse): generalizes the review-nag cooldown's counting + * pattern (the audit-events ledger) to EVERY `@gittensory` command, keyed by `(actor, command, targetKey)` -- + * independent of, and complementary to, review-nag's own narrower thread-author-only scope. `"off"` (default) + * is a no-op; `"hold"` posts a deterministic cooldown reply and skips the command's own dispatch. Always + * populated by the DB layer (default `"off"`); optional so existing settings fixtures/callers need not be + * touched. */ + commandRateLimitPolicy?: "off" | "hold" | undefined; + /** Per-command rate limit (#2560): how many invocations of a single command an actor may make within + * {@link commandRateLimitWindowHours} before the (N+1)th is throttled -- for a CHEAP command (cache-only, + * no AI orchestrator call). Always populated by the DB layer (default `20`); optional so existing settings + * fixtures/callers need not be touched. Only meaningful when {@link commandRateLimitPolicy} is not `"off"`. */ + commandRateLimitMaxPerWindow?: number | undefined; + /** Per-command rate limit (#2560): the same threshold as {@link commandRateLimitMaxPerWindow}, but for an + * AI-cost-bearing command (dispatches to a real orchestrator call: `ask`, `blockers`, `preflight`, + * `reviewability`, `packet`, `duplicate-check`, `next-action`, `repo-fit`). Deliberately tighter than the + * cheap-command default. Always populated by the DB layer (default `5`); optional so existing settings + * fixtures/callers need not be touched. */ + commandRateLimitAiMaxPerWindow?: number | undefined; + /** Per-command rate limit (#2560): the rolling window (in hours) both {@link commandRateLimitMaxPerWindow} + * and {@link commandRateLimitAiMaxPerWindow} count against. Always populated by the DB layer (default `24`); + * optional so existing settings fixtures/callers need not be touched. */ + commandRateLimitWindowHours?: number | undefined; + /** Agent-layer autonomy dial (#773): per-action-class level. Always populated by the DB layer (default + * `{}` = deny-by-default = "observe" for every class); optional so existing settings fixtures/callers + * need not be touched. The single source the action layer (#778) reads via `resolveAutonomy`. */ + autonomy?: AutonomyPolicy | undefined; + /** Auto-maintain policy (#774): merge method + approval count. Always populated by the DB layer with + * defaults (squash / 1 approval); optional so existing settings fixtures/callers need not be touched. */ + autoMaintain?: AutoMaintainPolicy | undefined; + /** Per-repo agent kill-switch (#776): when true, the action layer takes NO action on this repo (the + * global env switch overrides this too). Default false. */ + agentPaused?: boolean | undefined; + /** Per-repo dry-run/shadow mode (#776): when true, the action layer records what it WOULD do without + * performing any GitHub mutation. Default false. */ + agentDryRun?: boolean | undefined; + /** Moderation-rules engine (#selfhost-mod-engine): whether the whole layer runs on THIS repo. `"inherit"` + * (the DB default) defers to `global_moderation_config.enabled`; `"off"`/`"enabled"` force this repo + * regardless of the global default. Always populated by the DB layer; optional so existing settings + * fixtures/callers need not be touched. */ + moderationGateMode?: "inherit" | "off" | "enabled" | undefined; + /** Moderation-rules engine: a per-repo override of WHICH of the anti-abuse mechanisms (contributor cap, + * blacklist, review-nag, review-evasion) feed a contributor's shared, cross-repo violation tally. + * `undefined`/absent ⇒ inherit the global rule set (`resolveEffectiveModerationRules`'s default shape). */ + moderationRules?: ("contributor_cap" | "blacklist" | "review_nag" | "review_evasion")[] | undefined; + /** Moderation-rules engine: per-repo override of the label applied at >=1 lifetime violation. `undefined` ⇒ + * the global config's `warningLabel` (itself defaulting to `"mod:warning"`). */ + moderationWarningLabel?: string | undefined; + /** Moderation-rules engine: per-repo override of the label applied at >= the ban threshold. `undefined` ⇒ + * the global config's `bannedLabel` (itself defaulting to `"mod:banned"`). */ + moderationBannedLabel?: string | undefined; + /** Review-evasion protection (#review-evasion-protection): a contributor closing or converting their OWN + * PR to draft while gittensory has an ACTIVE review pass running against it is dodging the one-shot + * review process. `"off"` (the default) disables detection entirely; `"close"` reopens (if needed) and + * re-closes as the App -- a close the contributor cannot themselves reopen (#one-shot-reopen) -- applies + * the configured label/comment, and records a `review_evasion` moderation strike. */ + reviewEvasionProtection?: "off" | "close" | undefined; + /** Review-evasion protection: label applied alongside the enforcement close, gated on `close` autonomy + * like every other anti-abuse label (#label-scoping), mirroring {@link blacklistLabel}'s shape. `undefined` + * ⇒ the `"review-evasion"` default; explicit `null` ⇒ close without any label. */ + reviewEvasionLabel?: string | null | undefined; + /** Review-evasion protection: whether to post the public explanation comment before the enforcement close. + * Default true. */ + reviewEvasionComment?: boolean | undefined; + /** Config-driven before/after screenshot-table gate (#2006): a DETERMINISTIC check (no AI, zero hallucination + * risk) that a contributor visual/frontend PR's body contains a markdown table with before/after image + * markup, scoped to the repo's configured labels/paths (`whenLabels`/`whenPaths`, OR-matched). Off by + * default (`enabled: false`) -- opt in per repo, mirroring every other anti-abuse mechanism's shape. See + * `review/screenshot-table-gate.ts` for the normalizer and the pure evaluator. */ + screenshotTableGate?: ScreenshotTableGateConfig | undefined; + createdAt?: string | null | undefined; + updatedAt?: string | null | undefined; +}; diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 1bf7992c6a..06ad93ddd5 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -1,2498 +1,103 @@ -import { parse as parseYaml } from "yaml"; -import type { GatePolicyPack, GateRuleMode, JsonValue, LinkedIssueHardRulesConfig, LinkedIssueLabelPropagationConfig, PrTypeLabelSet, RepositorySettings, ReviewCheckMode, ScreenshotTableGateConfig, UnlinkedIssueGuardrailConfig } from "../types"; -import { normalizeAutonomyPolicy, normalizeAutoMaintainPolicy } from "../settings/autonomy"; -import { normalizeCommandAuthorizationPolicy } from "../settings/command-authorization"; -import { mergeContributorBlacklists, normalizeContributorBlacklist } from "../settings/contributor-blacklist"; -import { normalizeAutoCloseExemptLogins } from "../settings/auto-close-exempt"; -import { DEFAULT_TYPE_LABELS, MAX_TYPE_LABEL_NAME_LENGTH, normalizeTypeLabelSet } from "../settings/pr-type-label"; -import { DEFAULT_LINKED_ISSUE_LABEL_PROPAGATION, normalizeLinkedIssueLabelPropagationConfig, VALID_LINKED_ISSUE_LABEL_PROPAGATION_MODES } from "../review/linked-issue-label-propagation"; -import { DEFAULT_LINKED_ISSUE_HARD_RULES, isLinkedIssueHardRuleMode, normalizeLinkedIssueHardRulesConfig } from "../review/linked-issue-hard-rules-config"; -import { DEFAULT_UNLINKED_ISSUE_GUARDRAIL, isUnlinkedIssueGuardrailMode, normalizeUnlinkedIssueGuardrailConfig } from "../review/unlinked-issue-guardrail-config"; -import { DEFAULT_SCREENSHOT_TABLE_GATE, isScreenshotTableGateAction, normalizeScreenshotTableGateConfig } from "../review/screenshot-table-gate"; -import { normalizeModerationLabel, normalizeModerationRules } from "../settings/moderation-rules"; -import { REES_ANALYZER_NAME_SET, type ReesAnalyzerName } from "../review/enrichment-analyzer-names"; -import { hasUnsafeWildcardCount } from "./change-guardrail"; -import { PUBLIC_LOCAL_PATH_INLINE } from "./redaction"; -import { classifyChangedFile } from "./path-matchers"; -import { isSafeHttpUrl } from "../review/content-lane/safe-url"; - -export type FocusManifestSource = "repo_file" | "api_record" | "none"; -export type FocusManifestLinkedIssuePolicy = "required" | "preferred" | "optional"; -export type FocusManifestIssueDiscoveryPolicy = "encouraged" | "neutral" | "discouraged"; - -/** - * Maintainer-authored gate configuration declared as code in `.gittensory.yml` under `gate:`. Each - * field is `null` when the maintainer did not set it, so the resolver can layer the manifest OVER the - * DB-backed RepositorySettings (manifest > DB > safe defaults) without clobbering unset values. All - * of these flow through the SAME confirmed-contributor-gated `evaluateGateCheck` path — the manifest - * only chooses which deterministic blockers are active, never who can be blocked. Turning the gate - * itself on/off stays a repository setting (`gateCheckMode`); `.gittensory.yml gate:` refines the - * blocker policy of an already-enabled gate. `checkMode` (#2852) is a separate, more expressive axis: - * whether/how the "Gittensory Orb Review Agent" check-RUN publishes, independent of gate evaluation - * itself (which always runs regardless of `checkMode`/`enabled`) — see {@link ReviewCheckMode}. - */ -export type FocusManifestGateConfig = { - present: boolean; - enabled: boolean | null; - /** `gate.checkMode` (#2852): explicit required|visible|disabled review-check publish mode. Takes - * precedence over the legacy `enabled` boolean below when both are set (see resolveEffectiveSettings). - * null (unset) ⇒ fall back to `enabled`, then to `settings.reviewCheckMode` (DB/dashboard), then default. */ - checkMode: ReviewCheckMode | null; - pack: GatePolicyPack | null; - linkedIssue: GateRuleMode | null; - duplicates: GateRuleMode | null; - readinessMode: GateRuleMode | null; - readinessMinScore: number | null; - slopMode: GateRuleMode | null; - slopMinScore: number | null; - slopAiAdvisory: boolean | null; - sizeMode: GateRuleMode | 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` - * finding (`block` additionally hard-blocks). Config-as-code only — no DB column or dashboard toggle. */ - lockfileIntegrityMode: GateRuleMode | null; - aiReviewMode: GateRuleMode | null; - aiReviewByok: boolean | null; - aiReviewProvider: "anthropic" | "openai" | null; - aiReviewModel: string | null; - aiReviewAllAuthors: boolean | null; - /** `gate.aiReview.closeConfidence` (#7): minimum calibrated AI-reviewer confidence (0-1) for an AI defect to BLOCK - * under `aiReview.mode: block`. null (unset) ⇒ the gate's 0.93 default. Clamped to [0,1] at parse time. */ - aiReviewCloseConfidence: number | null; - /** `gate.aiReview.combine` (#2567): per-repo override of the self-host operator's `AI_REVIEW_PLAN.combine` - * boot default (single/consensus/synthesis). null (unset) ⇒ the operator's plan (or `consensus`). A - * REFINEMENT only — see {@link aiReviewOnMerge} for the operator-floor clamp `runGittensoryAiReview` applies - * to the paired `onMerge` field; `combine` itself is not floor-clamped (the three strategies are not ordered - * by strictness, so there is no single "loosening" direction to clamp). */ - aiReviewCombine: import("../types").CombineStrategy | null; - /** `gate.aiReview.onMerge` (#2567): per-repo override of the `synthesis` merge rule. `either` is the STRICTER - * rule (any one reviewer's blocker blocks/holds); `both` is more PERMISSIVE (requires every reviewer to - * agree). null (unset) ⇒ the operator's `AI_REVIEW_PLAN.onMerge`. A repo may only TIGHTEN the operator's - * floor (never loosen `either` down to `both`) — `runGittensoryAiReview` enforces the clamp at resolve time, - * since only it can see both the per-repo value and the operator's plan. */ - aiReviewOnMerge: import("../types").OnMerge | null; - /** `gate.aiReview.reviewers` (#2567): per-repo override of the named reviewer pair(s) to run, in place of the - * operator's `AI_REVIEW_PLAN.reviewers` (or the free Workers-AI pair when the operator configured none). null - * (unset) ⇒ the operator's plan. No operator floor applies to WHICH reviewers run (only `onMerge` gates - * strictness), so this always wins unclamped when set. */ - aiReviewReviewers: ReadonlyArray<{ model: string; fallback?: string | null | undefined }> | null; - mergeReadiness: GateRuleMode | null; - manifestPolicy: GateRuleMode | null; - selfAuthoredLinkedIssue: GateRuleMode | null; - dryRun: boolean | null; - firstTimeContributorGrace: boolean | null; - /** `gate.premergeContentRecheck` (#2550): for a PR touching `migrations/**`, re-verify against a live, - * freshly-fetched tip of the base branch — unioned with this PR's own new migration filenames — for a - * migration-number collision immediately before an agent-driven merge, not just at CI time against the - * PR's own stale branch snapshot. On a live collision, the merge is suppressed and the PR is held with a - * rebase-needed comment instead of merging blind. null (unset) ⇒ off (byte-identical to today) — this - * costs one extra, uncached GitHub Trees-API call for any PR that touches migrations/**, so it is opt-in - * rather than a new default. */ - premergeContentRecheck: boolean | null; - /** `gate.requireFreshRebaseWindow` (#2552, anti-race): minutes. When the base branch has advanced within - * this window of the actual merge-decision moment, an agent-driven merge forces an `update_branch` + - * fresh CI recheck cycle before merging, instead of trusting a `mergeableState: clean` read that may - * already be stale relative to a sibling commit that just landed on the base. null (unset) ⇒ never force - * (byte-identical to today) — a discrete positive-minutes count, not a score, so it is neither clamped - * nor rounded; an invalid value (fractional, non-positive, non-finite) is dropped with a warning. */ - requireFreshRebaseWindowMinutes: number | null; - /** `gate.claMode` (#2564): off/advisory/block. null (unset) ⇒ off (byte-identical to today) — a repo must - * explicitly opt in before any CLA consent check runs. */ - claMode: GateRuleMode | null; - /** `gate.cla.consentPhrase` (#2564): the required PR-body consent phrase. null (unset) ⇒ phrase-match - * detection is not configured. */ - claConsentPhrase: string | null; - /** `gate.cla.checkRunName` (#2564): the CLA-bot check-run name to trust. null (unset) ⇒ check-run - * detection is not configured. */ - claCheckRunName: string | null; - /** `gate.cla.checkRunAppSlug`: the trusted GitHub App slug that must produce `checkRunName`. null (unset) ⇒ - * check-run detection remains unresolved rather than trusting a spoofable name-only match. */ - claCheckRunAppSlug: string | null; - /** `gate.expectedCiContexts` (#selfhost-ci-verification): CI check/status context names to treat as - * required when GitHub branch-protection required-status-checks are unreadable or unconfigured. null - * (unset) ⇒ no generic fallback configured — the live-CI aggregate keeps today's fold-all behavior - * when branch protection is also unreadable. See {@link RepositorySettings.expectedCiContexts}. */ - expectedCiContexts: ReadonlyArray | null; -}; - -// The converged per-PR review features a self-host operator toggles PER-REPO under `features:` in the private -// `.gittensory.yml`. Each feature ALSO has a GLOBAL env flag (GITTENSORY_REVIEW_*) that stays a master -// kill-switch (the feature never runs when its env flag is off, regardless of this block). See -// review/feature-activation.ts for the resolver (env kill-switch → per-repo override → env-allowlist default). -// NOTE: only the per-PR REVIEW features whose every activation site is migrated are listed here. grounding and -// screenshots stay on the GITTENSORY_REVIEW_REPOS allowlist for now (grounding is coupled to the merge/close -// DISPOSITION path; screenshots' capture path needs dedicated coverage) — a follow-up. contentLane got its own -// richer `contentLane:` block below (#2435) instead of a boolean here, since it resolves to a whole -// RegistryLaneSpec, not an on/off toggle — see resolveRegistryLaneSpec in review/content-lane/spec-resolver.ts. -export const CONVERGED_FEATURE_KEYS = ["rag", "reputation", "unifiedComment", "safety"] as const; -export type ConvergedFeatureKey = (typeof CONVERGED_FEATURE_KEYS)[number]; - -/** Per-repo activation overrides for the converged review features (`features:` block). `true`/`false` force the - * feature on/off for THIS repo (subject to the env kill-switch); `null` (unset) ⇒ the resolver falls back to the - * `GITTENSORY_REVIEW_REPOS` allowlist default, so an operator who sets nothing keeps today's behavior. */ -export type FocusManifestFeaturesConfig = { present: boolean } & Record; - -/** - * Per-repo registry-review lane configuration (`contentLane:` block, #2435) — lets a self-hosted maintainer - * configure their OWN registry (structural file-scope patterns + entry-count cap + dedup fields) without a - * gittensory code change. `entryFileGlob` and `collectionField` are the two REQUIRED fields to build a usable - * spec; `present` is true only when both are set (a partial config degrades to "not configured," not a broken - * half-spec — see `parseContentLaneConfig`). `validatorId` optionally references a code-registered domain - * validator (`review/content-lane/spec-resolver.ts`'s `REGISTRY_VALIDATORS`); omitted ⇒ structural gating only - * (scope/count/dedup), no domain-specific semantic check — see `RegistryLaneSpec.assessAppendedEntry`. - */ -export type FocusManifestContentLaneConfig = { - present: boolean; - entryFileGlob: string | null; - providerFileGlob: string | null; - artifactGlob: string | null; - collectionField: string | null; - maxAppendedEntries: number | null; - duplicateKeyFields: string[]; - validatorId: string | null; -}; - -/** Which generated-file types the repo-doc generation roadmap (#2993) is allowed to touch for a repo. - * "agents" covers AGENTS.md/CLAUDE.md (#3000/#3004); "skills" covers generated Claude Code/Codex skill - * files once that generator lands (#3001) -- listed here now so a maintainer can opt in ahead of time. */ -export type FocusManifestRepoDocGenerationScope = "agents" | "skills"; - -/** - * Per-repo opt-in for the repo-doc generation roadmap (#2993/#3002), declared as code under - * `repoDocGeneration:`. Purely a `.gittensory.yml` surface -- there is no DB-backed dashboard counterpart, - * so precedence is simply "the manifest value, or the default below when unset" (no DB layer to overlay). - * Defaults to fully disabled: a repo with no `repoDocGeneration:` block, or an explicit `enabled: false`, - * is never touched by the generator. `allowOverwriteExisting` is a SEPARATE opt-in specifically for a repo - * that already has a hand-maintained AGENTS.md/CLAUDE.md (no recognizable generated-content marker block, - * per generated-doc-refresh.ts's `manual-review-required` outcome) -- without it, that repo is left alone - * rather than proposed for a wholesale overwrite, even when `enabled` is true. - */ -export type FocusManifestRepoDocGenerationConfig = { - present: boolean; - enabled: boolean; - scope: FocusManifestRepoDocGenerationScope[]; - allowOverwriteExisting: boolean; - /** How many days must elapse between scheduled refresh attempts for this repo (#3003). Default 7 (weekly). - * Purely a rate-limiting knob on the SCHEDULED sweep -- it never affects correctness, since - * openRepoDocPullRequest's own no-change short-circuit already prevents a redundant PR regardless of how - * often it's invoked; this just avoids re-checking a stable repo more often than the operator wants. */ - refreshIntervalDays: number; -}; - -/** - * Per-repo opt-in for the periodic maintainer review-recap digest (#1963), declared as code under - * `reviewRecap:`. Mirrors `repoDocGeneration:` exactly: no DB-backed dashboard counterpart, so the parsed - * value (or the default below when unset) IS the effective value — there is no DB layer to overlay onto. - * Defaults to fully disabled: a repo with no `reviewRecap:` block, or an explicit `enabled: false`, never - * gets a recap posted. Discord delivery ONLY for now (reuses the SAME per-repo webhook resolution as the - * per-event notifier in notify-discord.ts, `resolveDiscordWebhook`) — Slack is a follow-up. - */ -export type FocusManifestReviewRecapConfig = { - present: boolean; - enabled: boolean; - /** How many days of review activity each recap covers, and (once the scheduler follow-up lands) how often - * it is posted. Default 7 (weekly). A purely descriptive/rate-limiting knob today — this PR ships only - * the manually-triggerable builder + delivery, so `cadenceDays` currently just sets the report WINDOW; - * the scheduled cron trigger is a scoped follow-up (see the PR description). */ - cadenceDays: number; -}; - -/** - * Generic repository-settings override declared in `.gittensory.yml` under `settings:`. A partial of - * {@link RepositorySettings} — every behaviour a maintainer can toggle in the dashboard can be set here - * as code. Unset fields are omitted so the resolver layers it OVER the DB-backed settings - * (`.gittensory.yml` > dashboard settings > safe defaults). The friendly `gate:` block is a typed alias - * for the gate-related subset and wins over `settings:` for those fields. - */ -export type FocusManifestSettings = Partial< - Pick< - RepositorySettings, - | "commentMode" - | "publicAudienceMode" - | "publicSignalLevel" - | "checkRunMode" - | "checkRunDetailLevel" - | "gateCheckMode" - | "regateSweepOrderMode" - | "reviewCheckMode" - | "autoProjectMilestoneMatch" - | "autoProjectMilestoneMatchBackend" - | "linkedIssueGateMode" - | "duplicatePrGateMode" - | "selfAuthoredLinkedIssueGateMode" - | "qualityGateMode" - | "qualityGateMinScore" - | "aiReviewMode" - | "aiReviewByok" - | "aiReviewProvider" - | "aiReviewModel" - | "aiReviewAllAuthors" - | "closeOwnerAuthors" - | "autoLabelEnabled" - | "typeLabelsEnabled" - | "badgeEnabled" - | "publicQualityMetrics" - | "gittensorLabel" - | "createMissingLabel" - | "publicSurface" - | "includeMaintainerAuthors" - | "requireLinkedIssue" - | "backfillEnabled" - | "privateTrustEnabled" - | "autonomy" - | "autoMaintain" - | "agentPaused" - | "agentDryRun" - | "commandAuthorization" - | "contributorBlacklist" - | "blacklistLabel" - | "contributorOpenPrCap" - | "contributorOpenIssueCap" - | "contributorCapLabel" - | "contributorCapCancelCi" - | "reviewNagPolicy" - | "reviewNagMaxPings" - | "reviewNagCooldownDays" - | "reviewNagLabel" - | "reviewNagMonitoredMentions" - | "autoCloseExemptLogins" - | "hardGuardrailGlobs" - | "manualReviewLabel" - | "readyToMergeLabel" - | "changesRequestedLabel" - | "migrationCollisionLabel" - | "pendingClosureLabel" - | "accountAgeThresholdDays" - | "newAccountLabel" - | "commandRateLimitPolicy" - | "commandRateLimitMaxPerWindow" - | "commandRateLimitAiMaxPerWindow" - | "commandRateLimitWindowHours" - | "moderationGateMode" - | "moderationRules" - | "moderationWarningLabel" - | "moderationBannedLabel" - | "reviewEvasionProtection" - | "reviewEvasionLabel" - | "reviewEvasionComment" - > -> & { - // `typeLabels`/`linkedIssueLabelPropagation`/`linkedIssueHardRules` are declared PARTIAL here (not via the `Pick` above, which would force a complete, defaults-filled object) so `resolveEffectiveSettings` can merge - // them field-by-field against the DB value — a `.gittensory.yml` override naming only one key (e.g. just - // `typeLabels.priority`) must inherit the OTHER keys from the DB-persisted value, not silently reset them to - // the built-in default (#priority-linked-issue-gate), and can add arbitrary categories beyond the built-in - // three (#label-modularity). `mappings` is still a complete replacement when present (arrays don't have - // per-item precedence semantics, matching the private-config layer's own documented array-replace-wholesale - // overlay behavior). - // `typeLabels: null` (distinct from an omitted key OR a sparse-but-nonempty object) is a DELIBERATE signal - // reserved for a manifest's literal `typeLabels: {}` — "zero configured categories for this repo" — the same - // load-bearing-null idiom as `blacklistLabel`/`contributorCapLabel`/etc. This is NOT the same as a sparse - // override whose named keys all failed validation (which still parses to `{}`, not `null`, and must NOT wipe - // the DB value -- see `resolveEffectiveSettings`). - typeLabels?: Partial | null | undefined; - linkedIssueLabelPropagation?: Partial | undefined; - linkedIssueHardRules?: Partial | undefined; - unlinkedIssueGuardrail?: Partial | undefined; - // Screenshot-table gate (#2006): same sparse-partial merge reasoning as linkedIssueHardRules/ - // unlinkedIssueGuardrail above -- a manifest naming only `enabled` must not silently reset `whenLabels`/ - // `whenPaths`/`action`/`message` back to their defaults. - screenshotTableGate?: Partial | undefined; -}; - -/** Field keys for the public review-panel rows a maintainer can show/hide via `review.fields`. */ -export const REVIEW_FIELD_KEYS = ["linkedIssue", "relatedWork", "reviewLoad", "validationEvidence", "openPrQueue", "contributorContext", "gateResult"] as const; -export type ReviewFieldKey = (typeof REVIEW_FIELD_KEYS)[number]; - -// `review.profile` (#review-profile): how nitpicky the AI maintainer review is. `chill` = surface only blocking -// defects (bugs/security/breakage), suppress style nits; `assertive` = also raise minor improvements & nits; -// `balanced` (default / absent) leaves the reviewer prompt byte-identical. A presentation knob only — it NEVER -// changes the gate verdict, only how much advisory detail the review write-up carries. -export const REVIEW_PROFILES = ["chill", "balanced", "assertive"] as const; -export type ReviewProfile = (typeof REVIEW_PROFILES)[number]; - -export type ReviewFindingSeverity = "critical" | "major" | "minor" | "nitpick"; - -export const REVIEW_FINDING_SEVERITY_LADDER = ["critical", "major", "minor", "nitpick"] as const; - /** - * Maintainer overrides for the public review-panel CONTENT, declared under `review:`. Customizes the - * panel without changing what gittensory measures: a custom public-safe footer lead line, a custom intro - * note, and per-row show/hide toggles. The Gittensor attribution + register link is ALWAYS appended to - * the footer regardless (the growth surface is preserved); maintainer text that fails the public-safe - * filter is dropped, never published. + * Focus-manifest shim (#2280). Parse/compile core lives in `packages/gittensory-engine/src/focus-manifest.ts`; + * this file re-exports the engine surface and keeps app-local resolver/guidance functions that depend on + * `src/` modules (`classifyChangedFile`, `mergeContributorBlacklists`, etc.). */ -export type FocusManifestReviewConfig = { - present: boolean; - footerText: string | null; - note: string | null; - fields: Partial>; - /** `review.enrichment`: per-repo REES enrichment-analyzer toggles (analyzer name → on/off). Only known analyzer - * keys are kept (unknown keys warn + drop at parse). Empty (default, absent) ⇒ the operator's default analyzer - * set runs unchanged (byte-identical). (#2050) */ - enrichmentAnalyzers: Partial>; - /** `review.profile`: chill / balanced / assertive. null (absent) = balanced = byte-identical reviewer prompt. */ - profile: ReviewProfile | null; - /** `review.tone`: a bounded public-safe voice brief complementing `review.profile` (e.g. "concise, cite line numbers"). - * Folded into the review-instructions slot at runtime. null (default, absent) ⇒ byte-identical prompt. (#2044) */ - tone: string | null; - /** `review.security_focus`: when true, the AI reviewer is told to prioritize a security-defect category - * (injection, authn/authz bypass, secret handling, unsafe deserialization, SSRF, path traversal) with - * elevated scrutiny, ON TOP OF whatever `profile` volume is set — an orthogonal "what to prioritize" axis, - * not a fourth profile level. null/false (default, absent) = byte-identical reviewer prompt. (#review-security-focus) */ - securityFocus: boolean | null; - /** `review.inline_comments`: when true, the AI reviewer ALSO leaves quiet, non-blocking inline PR comments on - * specific changed lines (in addition to the decision summary). null/false (default, absent) = no inline - * comments = byte-identical behavior. Operator-gated too (GITTENSORY_REVIEW_INLINE_COMMENTS + allowlist). - * (#inline-comments) */ - inlineComments: boolean | null; - /** `review.fixHandoff`: when true, the reviewer emits fix-handoff blocks (copy-paste remediation guidance). null/ - * false (default, absent) = no fix-handoff blocks = byte-identical. Operator-gated too (GITTENSORY_REVIEW_FIX_HANDOFF - * + the convergence cutover allowlist) — the manifest toggle is only one of the ANDed gates. (#2176, for #1962) */ - fixHandoff: boolean | null; - /** `review.auto_merge_summary`: when true, the unified comment gains a READ-ONLY collapsible showing which - * auto-merge conditions currently pass/fail (CI green, gate passing, mergeable-clean, valid linked issue), - * rendered from already-computed readiness signals. SURFACE ONLY — never changes the merge/close decision. - * null/false (default, absent) = no summary = byte-identical. (#2051, for #1959) */ - autoMergeSummary: boolean | null; - /** `review.suggestions`: when true, an inline finding whose AI-provided fix is precise enough to anchor to a - * single line is ALSO rendered as a GitHub-native ` ```suggestion ` block a contributor can commit in one - * click. Only takes effect when inline comments are already on (a suggestion has nothing to attach to - * otherwise) — this is an ADDITIONAL opt-in on top of `review.inline_comments`, not a replacement gate. - * null/false (default, absent) = no suggestion blocks = byte-identical behavior. (#1956) */ - suggestions: boolean | null; - /** `review.changed_files_summary`: when true, the unified review comment (only rendered at all when the - * `unifiedComment` convergence feature is on) gains a deterministic, no-AI "Changed files" collapsible: one - * row per file category (source/test/docs/config/generated), with file counts and +/- totals, via the - * existing `classifyChangedFile` classifier (`src/review/changed-files-classify.ts`, built for this table - * under #2143). null/false (default, absent) = no changed-files section = byte-identical behavior. (#1957) */ - changedFilesSummary: boolean | null; - /** `review.effort_score`: when true, the unified review comment (only rendered when the `unifiedComment` - * convergence feature is on) gains a compact "review effort: N/5 (~M min)" chip — a deterministic, no-AI - * complexity/time estimate from `estimateReviewEffort` (`src/review/review-effort.ts`), weighting each - * changed file's added lines by its category (source costs most; generated/vendored/lockfiles cost least) - * plus a fixed per-file overhead. Mirrors `changedFilesSummary` exactly: same table, same deterministic - * source, same display-only (never touches the AI prompt) shape. null/false (default, absent) = no chip = - * byte-identical behavior. (#1955) */ - effortScore: boolean | null; - /** `review.test_generation` (#1972, kill-switch config slice #2189): when true, a diff that touches a small, - * precise set of boundary-condition patterns (off-by-one array/index bounds, null/undefined branches, - * empty-collection checks — see `src/signals/boundary-test-generation.ts`) with NO test evidence anywhere in - * the PR gets an additional advisory finding plus a boundary-safe LOCAL-execution - * `gittensory_generate_tests` action spec (criteria/hints only, never generated test code — see - * `src/mcp/local-write-tools.ts`'s `buildTestGenSpec`). Also gated by the operator's - * `GITTENSORY_REVIEW_TEST_GENERATION` kill-switch (`src/review/test-generation.ts`'s - * `isTestGenerationEnabled`) — the caller ANDs both. Purely additive and deterministic; it never changes what - * `missingTestEvidence` already does. null/false (default, absent) ⇒ byte-identical behavior — no boundary - * scan runs and no spec is ever built. */ - testGeneration: boolean | null; - /** `review.impact_map` (#2184, config slice of #1971): when true, gates BOTH the deterministic impact-map - * computation (`computeImpactMap`, `src/review/impact-map.ts`) and its rendering as a compact section in - * the unified review comment (#2185) / additive AI-review grounding context (#2186). Deterministic/display - * + reference-context only — never touches the gate verdict. ALSO requires the global env kill-switch - * (`isImpactMapEnabled`, mirroring `isRagEnabled` in `src/review/rag-wire.ts:27`) to be on; the manifest - * flag alone cannot enable it for a self-host operator who hasn't opted in globally. null/false (default, - * absent) ⇒ no impact-map computation at all = byte-identical behavior. (#2184) */ - impactMap: boolean | null; - /** `review.culture_profile` (#2995): when true, the AI reviewer's USER prompt gains an ADDITIVE "REPO - * QUALITY-CULTURE PROFILE" reference block — typical merged-PR size + common accepted labels, derived - * deterministically from this repo's OWN `recent_merged_pull_requests` history (see - * `src/review/repo-culture-profile.ts` / `repo-culture-profile-wire.ts`). Reference-only grounding, exactly - * like RAG/CI-grounding context: it never becomes a gate/scoring input and never changes the structured - * output contract. Also requires the global `GITTENSORY_REVIEW_CULTURE_PROFILE` kill-switch to be on (this - * field only opts THIS repo in once the capability itself is enabled). null/false (default, absent) = no - * section appended = byte-identical behavior. */ - cultureProfile: boolean | null; - /** `review.memory` (#2179, config slice of #1964): when true, gates repeat-false-positive SUPPRESSION — - * before an advisory (non-blocking) AI finding is surfaced in the unified review comment, it is matched - * against this repo's stored `review_suppression` signals (a maintainer's own past false-positive - * dismissals, `src/db/repositories.ts`'s `listReviewSuppressions`, migrations/0114) and demoted/dropped on a - * match (`src/review/review-memory-match.ts`'s `matchSuppressions`). ADVISORY-ONLY BY CONSTRUCTION: it is - * never applied to gate blockers, so it can never change the merge/close disposition — only which - * non-blocking nits render. ALSO requires the global env kill-switch (`isReviewMemoryEnabled`, mirroring - * `isImpactMapEnabled` in `src/review/impact-map-wire.ts`) to be on; the manifest flag alone cannot enable - * it for a self-host operator who hasn't opted in globally. Fail-safe: a suppression-store read error or - * matcher throw leaves findings untouched. null/false (default, absent) ⇒ no suppression lookup at all = - * byte-identical behavior. */ - reviewMemory: boolean | null; - /** `review.finding_categories`: when true, an inline finding is ALSO tagged with a category (security/ - * correctness/performance/maintainability/tests/style) — the AI reviewer is asked to self-categorize, with a - * deterministic path/keyword fallback (`classifyFindingCategory`) covering whatever it omits. Only takes - * effect when inline comments are already on (a category has nothing to categorize otherwise) — this is an - * ADDITIONAL opt-in on top of `review.inline_comments`, not a replacement gate, mirroring `review.suggestions`. - * null/false (default, absent) = no category tagging = byte-identical behavior. (#1958) */ - findingCategories: boolean | null; - /** `review.inline_comments_per_category`: optional per-category sub-cap applied before the total inline-comment - * cap so one category (e.g. style) cannot crowd out security/correctness findings. null (default, absent) ⇒ - * byte-identical first-seen selection with only the hard total cap. (#2159) */ - inlineCommentsPerCategory: number | null; - /** `review.min_finding_severity`: display-only floor for AI findings with a severity tier. Findings below the - * configured level are suppressed from inline comments — never from gate blockers. null (default, absent) ⇒ every - * finding shown = byte-identical behavior. (#2048) */ - minFindingSeverity: ReviewFindingSeverity | null; - /** `review.max_findings`: optional caps on how many blocker/nit lines render in the unified review comment. - * Display-only — never removes a blocker from the gate decision. null sub-fields ⇒ no cap for that list. - * Default { blockers: null, nits: null } ⇒ byte-identical. (#2049) */ - maxFindings: MaxFindingsConfig; - /** `review.comment_verbosity`: how much of the unified review comment's collapsible detail renders. `quiet` - * drops the Nits collapsible and every extra collapsible section (blockers/gate result/signals are never - * gated by this — only decorative detail is); `detailed` renders every collapsible pre-expanded. null/normal - * (default, absent) ⇒ byte-identical to today. Net-new vs the changed-files-summary (#1957) and effort-score - * (#1955) knobs. (#2047) */ - commentVerbosity: CommentVerbosity | null; - /** `review.path_instructions`: per-path natural-language guidance handed to the AI reviewer when the PR's - * changed files match the glob. Empty (default) ⇒ byte-identical reviewer prompt. (#review-path-instructions) */ - pathInstructions: ReviewPathInstruction[]; - /** `review.instructions`: a repo-level natural-language brief handed to the AI reviewer on EVERY review (vs the - * per-path path_instructions) — the maintainer's conventions/voice for this repo. Bounded + public-safe at parse - * time (so it stays cost-cheap, unlike ingesting a whole CLAUDE.md). null (default, absent) ⇒ byte-identical - * reviewer prompt. (#review-instructions) */ - instructions: string | null; - /** `review.exclude_paths`: globs whose matching files are EXCLUDED from the AI review (diff + grounding + RAG) - * — generated/vendored/lockfiles the maintainer doesn't want reviewed. Empty (default) ⇒ every file is - * reviewed (byte-identical). Gate/slop/secret-scan are UNAFFECTED — this only narrows the AI review. - * (#review-exclude-paths) */ - excludePaths: string[]; - /** `review.path_filters`: include + `!`-negation globs that POSITIVELY scope the AI review AFTER - * `exclude_paths`. Include entries restrict to matching paths; leading `!` entries subtract matches. - * Both `*` and `**` cross slashes (see `compileManifestPathMatcher`). Empty (default) ⇒ every non-excluded - * file is reviewed (byte-identical). Gate/slop/secret-scan are UNAFFECTED. (#2043) */ - pathFilters: string[]; - /** `review.pre_merge_checks`: maintainer-declared DETERMINISTIC content assertions (title/description must - * contain a phrase, a label must be present), optionally gated to a path glob. Each FAILED check surfaces an - * advisory finding; a check with `enforce: true` becomes a hard gate blocker. Empty (default) ⇒ no finding - * (byte-identical). No AI judgment is involved. (#review-pre-merge-checks) */ - preMergeChecks: PreMergeCheck[]; - /** `review.auto_review`: deterministic eligibility filters that skip the AI review (never a gate failure). - * Empty/default ⇒ every PR is reviewed (byte-identical). (#1954 / #2038–#2041) */ - autoReview: AutoReviewConfig; - /** `review.labeling_rules`: deterministic `{label, when}` rules that SUGGEST a non-scoring label when a PR's - * changed paths / title / description match. Surfaced as advisory suggestions, and auto-applied only when the - * repo's `autoLabelEnabled` is set. Reserved `gittensor:` labels are refused at parse. Empty (default) ⇒ no - * suggestion (byte-identical). (#2045, part of #1959) */ - labelingRules: LabelingRule[]; - /** `review.ai_model`: per-repo self-host reviewer model/effort overrides (claude-code / codex). Self-host only - * — a hosted (Workers-AI) repo ignores this entirely. All-null (default, absent) ⇒ the operator's global - * CLAUDE_AI_MODEL/CLAUDE_AI_EFFORT/CODEX_AI_MODEL/CODEX_AI_EFFORT env vars apply unchanged (byte-identical). - * (#selfhost-ai-model-override) */ - aiModel: SelfHostAiModelConfig; - /** `review.visual`: per-repo before/after screenshot-capture config (#3609 preview / #3610 routes). - * All-empty (default, absent) ⇒ byte-identical to today (GitHub-native preview discovery, automatic - * file-to-route inference, built-in route cap). Only takes effect when the operator has also enabled - * GITTENSORY_REVIEW_SCREENSHOTS + the repo cutover allowlist — this config narrows/redirects that - * feature, it never turns it on by itself. */ - visual: VisualConfig; - /** `review.linkedIssueSatisfaction`: how strictly a linked issue must actually be SATISFIED by the PR — `off` - * (default; not evaluated), `advisory` (surface a finding), or `block` (can become a hard blocker). CONFIG SLICE - * ONLY (#2173, for #1961): parsed + normalized here; the merge/close decision that reads this mode is a separate - * maintainer-only slice. null (default, absent) ⇒ byte-identical to today. */ - linkedIssueSatisfaction: LinkedIssueSatisfactionMode | null; -}; - -/** `review.linkedIssueSatisfaction` modes (#2173). `off` = not evaluated (same as unset). */ -export const LINKED_ISSUE_SATISFACTION_MODES = ["off", "advisory", "block"] as const; -export type LinkedIssueSatisfactionMode = (typeof LINKED_ISSUE_SATISFACTION_MODES)[number]; - -/** `review.comment_verbosity` levels (#2047). `normal` = today's behavior (same as unset). */ -export const COMMENT_VERBOSITY_LEVELS = ["quiet", "normal", "detailed"] as const; -export type CommentVerbosity = (typeof COMMENT_VERBOSITY_LEVELS)[number]; - -/** One `review.labeling_rules[]` entry: a non-reserved `label` plus the deterministic `when` criteria that must ALL - * match for it to fire. A rule always has at least one criterion (enforced at parse). */ -export type LabelingRule = { - label: string; - whenPaths: string[]; - titleContains: string | null; - descriptionContains: string | null; -}; - -/** Per-repo AI review eligibility knobs under `review.auto_review`. Unset fields are byte-identical defaults. */ -export type AutoReviewConfig = { - /** `review.auto_review.skip_drafts`: when true, draft PRs skip AI review. null (default) ⇒ drafts reviewed as today. (#2038) */ - skipDrafts: boolean | null; - /** `review.auto_review.ignore_authors`: author-login globs whose PRs skip AI review. Empty ⇒ every author. (#2039) */ - ignoreAuthors: string[]; - /** `review.auto_review.ignore_title_keywords`: case-insensitive title substrings that skip AI review. Empty ⇒ no skip. (#2040) */ - ignoreTitleKeywords: string[]; - /** `review.auto_review.skip_labels`: case-insensitive PR label names that skip AI review. Empty ⇒ no skip. (#2062) */ - skipLabels: string[]; - /** `review.auto_review.skip_docs_only`: when true, PRs whose every changed file classifies as docs skip AI review. - * null (default) ⇒ docs PRs reviewed as today. Empty changed-file list ⇒ NOT docs-only (fail-safe eligible). (#2063) */ - skipDocsOnly: boolean | null; - /** `review.auto_review.max_added_lines`: skip AI review when total added lines exceed this cap. 0 (default) ⇒ no cap. (#2065) */ - maxAddedLines: number; - /** `review.auto_review.max_files`: skip AI review when changed-file count exceeds this cap. 0 (default) ⇒ no cap. (#2065) */ - maxFiles: number; - /** `review.auto_review.base_branches`: base-ref globs whose PRs ARE reviewed; empty/unset ⇒ every base. (#2041) */ - baseBranches: string[]; - /** `review.auto_review.auto_pause_after_reviewed_commits`: after N published AI reviews on this PR, pause further - * re-reviews. null/0 ⇒ byte-identical (re-review every sync). (#2042) */ - autoPauseAfterReviewedCommits: number | null; -}; - -export type MaxFindingsConfig = { - blockers: number | null; - nits: number | null; -}; - -export const EMPTY_MAX_FINDINGS_CONFIG: MaxFindingsConfig = { blockers: null, nits: null }; - -export const EMPTY_AUTO_REVIEW_CONFIG: AutoReviewConfig = { - skipDrafts: null, - ignoreAuthors: [], - ignoreTitleKeywords: [], - skipLabels: [], - skipDocsOnly: null, - maxAddedLines: 0, - maxFiles: 0, - baseBranches: [], - autoPauseAfterReviewedCommits: null, -}; - -/** Per-repo self-host reviewer model/effort overrides under `review.ai_model`. Each field independently overrides - * the matching global env var (CLAUDE_AI_MODEL / CLAUDE_AI_EFFORT / CODEX_AI_MODEL / CODEX_AI_EFFORT) for THIS - * repo only — it never widens what the operator's own env already permits, only narrows/redirects it, so a - * compromised repo config can change which model reviews it but not grant itself a new credential or provider. - * (#selfhost-ai-model-override) */ -export type SelfHostAiModelConfig = { - /** `review.ai_model.claude_model`: overrides CLAUDE_AI_MODEL for this repo's claude-code reviewer. null (default) ⇒ the operator's global env var, then the provider's own default. */ - claudeModel: string | null; - /** `review.ai_model.claude_effort`: overrides CLAUDE_AI_EFFORT for this repo's claude-code reviewer. null (default) ⇒ the operator's global env var, then "medium". */ - claudeEffort: string | null; - /** `review.ai_model.codex_model`: overrides CODEX_AI_MODEL for this repo's codex reviewer. null (default) ⇒ the operator's global env var, then the account default. */ - codexModel: string | null; - /** `review.ai_model.codex_effort`: overrides CODEX_AI_EFFORT for this repo's codex reviewer. null (default) ⇒ the operator's global env var, then "medium". */ - codexEffort: string | null; -}; - -export const EMPTY_SELF_HOST_AI_MODEL_CONFIG: SelfHostAiModelConfig = { - claudeModel: null, - claudeEffort: null, - codexModel: null, - codexEffort: null, -}; - -/** Per-repo before/after screenshot-capture config under `review.visual` (#3609 / #3610). Generic by design — - * every self-hoster wires their OWN repo's preview-deploy setup and route shape with config, not code. */ -export type VisualConfig = { - preview: VisualPreviewConfig; - routes: VisualRoutesConfig; - themes: VisualTheme[]; - /** `review.visual.gif`: capture a short scroll-through GIF (#3612) alongside the static before/after - * screenshots — evidence for scroll-linked behavior (parallax, reveal-on-scroll, a sticky header) that a - * single static shot can't show. Self-host only (see src/review/visual/scroll-gif.ts) and the heaviest - * capture mode this pipeline has (up to 6 extra renders per side) — false (default, every existing - * manifest) ⇒ byte-identical to today, no scroll frames captured at all. */ - gif: boolean; -}; - -/** A `prefers-color-scheme` value the capture pipeline can emulate before rendering (#3678). */ -export type VisualTheme = "light" | "dark"; - -export type VisualPreviewConfig = { - /** `review.visual.preview.url_template`: the repo's "after" preview URL, with `{number}` (PR number), - * `{head_sha}` (full commit SHA), and `{head_sha_short}` (first 7 chars) placeholders substituted at - * capture time — e.g. `https://pr-{number}.myapp.workers.dev`. ALWAYS wins over GitHub-native preview - * discovery (the Deployments API / commit checks / cloudflare-bot PR comment) when set — an explicit, - * maintainer-configured template is a stronger signal than inference, and is the only option for a - * provider (e.g. Cloudflare Workers Builds' non-production branch builds) that doesn't surface a - * GitHub-visible deployment at all. null (default) ⇒ byte-identical to today (discovery unchanged). - * Validated at parse time against the same SSRF guard the renderer itself applies (isSafeHttpUrl) with - * placeholders substituted for a dummy value, so a malformed template warns at config-read time instead - * of only failing silently at render time — this is redundant with (not a replacement for) the - * renderer's own unconditional isSafeHttpUrl check on every resolved URL, regardless of source. */ - urlTemplate: string | null; -}; - -export type VisualRoutesConfig = { - /** `review.visual.routes.paths`: an explicit, always-screenshotted route list. When non-empty, this - * REPLACES automatic file-to-route inference entirely — for repos whose routing convention isn't - * gittensory-ui's TanStack file-based one, an explicit list is simpler and more robust than trying to - * infer one. Empty (default) ⇒ automatic inference (falling back to "/" when nothing matches). */ - paths: string[]; - /** `review.visual.routes.max_routes`: overrides the built-in cap (2) on how many routes get screenshotted - * per PR. null (default) ⇒ built-in default. Applies whether routes come from `paths` above or from - * automatic inference. */ - maxRoutes: number | null; -}; - -export const EMPTY_VISUAL_CONFIG: VisualConfig = { - preview: { urlTemplate: null }, - routes: { paths: [], maxRoutes: null }, - themes: [], - gif: false, -}; - -/** One `review.path_instructions[]` entry: a manifest path glob + the public-safe instructions to apply when a - * changed file matches it. */ -export type ReviewPathInstruction = { path: string; instructions: string }; - -/** One `review.pre_merge_checks[]` entry — a DETERMINISTIC pre-merge assertion. `whenPaths` (empty ⇒ always - * applies) gates the check to PRs that touch a matching path. The check PASSES only when EVERY configured - * assertion holds: the PR title contains `titleContains`, the body contains `descriptionContains`, and the - * `requireLabel` label is present (case-insensitive substring / label match). `enforce` ⇒ a failure is a hard - * gate blocker; default (false) ⇒ advisory only. All strings are public-safe-filtered at parse time. */ -export type PreMergeCheck = { - name: string; - whenPaths: string[]; - titleContains: string | null; - descriptionContains: string | null; - requireLabel: string | null; - enforce: boolean; -}; - -// A hard cap so a hostile/huge manifest can't bloat the reviewer prompt (mirrors REVIEW_FIELD_KEYS discipline). -const MAX_PATH_INSTRUCTIONS = 50; - -/** - * Normalized maintainer focus manifest. Repo owners declare which work areas are wanted, - * preferred, and how PRs should present validation. Path-based manual review is intentionally - * not part of this manifest anymore; use `settings.hardGuardrailGlobs` for that single - * authoritative control. `maintainerNotes` are private review context and must never reach a public - * GitHub surface; `publicNotes` are explicitly opted into public output by the maintainer. - */ -export type FocusManifest = { - present: boolean; - source: FocusManifestSource; - wantedPaths: string[]; - preferredLabels: string[]; - linkedIssuePolicy: FocusManifestLinkedIssuePolicy; - testExpectations: string[]; - issueDiscoveryPolicy: FocusManifestIssueDiscoveryPolicy; - maintainerNotes: string[]; - publicNotes: string[]; - gate: FocusManifestGateConfig; - settings: FocusManifestSettings; - review: FocusManifestReviewConfig; - features: FocusManifestFeaturesConfig; - contentLane: FocusManifestContentLaneConfig; - repoDocGeneration: FocusManifestRepoDocGenerationConfig; - reviewRecap: FocusManifestReviewRecapConfig; - warnings: string[]; -}; - -export type FocusManifestFinding = { - code: - | "manifest_off_focus" - | "manifest_preferred_path" - | "manifest_missing_preferred_label" - | "manifest_linked_issue_required" - | "manifest_linked_issue_preferred" - | "manifest_missing_tests" - | "manifest_issue_discovery_discouraged" - | "manifest_malformed"; - severity: "info" | "warning" | "critical"; - title: string; - detail: string; - action?: string | undefined; -}; - -export type FocusManifestGuidance = { - present: boolean; - source: FocusManifestSource; - linkedIssuePolicy: FocusManifestLinkedIssuePolicy; - issueDiscoveryPolicy: FocusManifestIssueDiscoveryPolicy; - matchedWantedPaths: string[]; - preferredLabelHits: string[]; - findings: FocusManifestFinding[]; - publicNextSteps: string[]; - warnings: string[]; - summary: string; -}; - -const MAX_LIST_ITEMS = 200; -const MAX_ITEM_LENGTH = 300; -const MAX_GLOBSTAR_SLASH_ALTERNATIVES = 128; -// 128 KiB, not 64 KiB: gittensory.full.yml (our own reference doc, parsed by config-templates.test.ts as a -// round-trip check) organically grows every time a new review.* knob ships and had already reached 65522/65536 -// bytes on main before this comment was written -- one doc line from any PR would trip the old ceiling. A real -// per-repo .gittensory.yml never needs anywhere near this size, so the DoS-guard intent is unaffected (#2006). -export const MAX_FOCUS_MANIFEST_BYTES = 128 * 1024; - -const EMPTY_GATE_CONFIG: FocusManifestGateConfig = { - 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, - aiReviewCombine: null, - aiReviewOnMerge: null, - aiReviewReviewers: null, - mergeReadiness: null, - manifestPolicy: null, - selfAuthoredLinkedIssue: null, - dryRun: null, - firstTimeContributorGrace: null, - premergeContentRecheck: null, - requireFreshRebaseWindowMinutes: null, - claMode: null, - claConsentPhrase: null, - claCheckRunName: null, - claCheckRunAppSlug: null, - expectedCiContexts: null, -}; - -const EMPTY_FEATURES_CONFIG: FocusManifestFeaturesConfig = { - present: false, - rag: null, - reputation: null, - unifiedComment: null, - safety: null, -}; - -const EMPTY_CONTENT_LANE_CONFIG: FocusManifestContentLaneConfig = { - present: false, - entryFileGlob: null, - providerFileGlob: null, - artifactGlob: null, - collectionField: null, - maxAppendedEntries: null, - duplicateKeyFields: [], - validatorId: null, -}; - -const DEFAULT_REPO_DOC_REFRESH_INTERVAL_DAYS = 7; - -const EMPTY_REPO_DOC_GENERATION_CONFIG: FocusManifestRepoDocGenerationConfig = { - present: false, - enabled: false, - scope: ["agents"], - allowOverwriteExisting: false, - refreshIntervalDays: DEFAULT_REPO_DOC_REFRESH_INTERVAL_DAYS, -}; - -const DEFAULT_REVIEW_RECAP_CADENCE_DAYS = 7; - -const EMPTY_REVIEW_RECAP_CONFIG: FocusManifestReviewRecapConfig = { - present: false, - enabled: false, - cadenceDays: DEFAULT_REVIEW_RECAP_CADENCE_DAYS, -}; - -const EMPTY_MANIFEST: FocusManifest = { - present: false, - source: "none", - wantedPaths: [], - preferredLabels: [], - linkedIssuePolicy: "optional", - testExpectations: [], - issueDiscoveryPolicy: "neutral", - maintainerNotes: [], - publicNotes: [], - gate: { ...EMPTY_GATE_CONFIG }, - 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, testGeneration: null, impactMap: null, cultureProfile: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null }, - features: { ...EMPTY_FEATURES_CONFIG }, - contentLane: { ...EMPTY_CONTENT_LANE_CONFIG }, - repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG }, - reviewRecap: { ...EMPTY_REVIEW_RECAP_CONFIG }, - warnings: [], -}; - -// This surface's economic/identity term vocabulary is intentionally richer than the canonical -// PUBLIC_UNSAFE_TERMS (extra phrases like "public score estimate"), so it stays a local literal. The local -// filesystem paths, however, compose from the canonical PUBLIC_LOCAL_PATH_INLINE in redaction.ts (which also -// covers `/var/`, previously missed here, plus `/root/` and the forward-slash Windows form `C:/Users/`) so this -// guard cannot drift from the canonical boundary on a leaking root. -const FOCUS_MANIFEST_TERMS = /\b(reward\w*|score\w*|wallets?|hotkeys?|coldkeys?|seed[-\s]?phrases?|mnemonics?|private[-\s]?keys?|farming|payouts?|rankings?|raw[-\s]?trust(?:[-\s]?scores?)?|trust[-\s]?scores?|private[-\s]?reviewability|reviewability(?:[-\s]?internals?)?|private[-\s]?scoreability|scoreability|public[-\s]?score[-\s]?(?:estimate|prediction|claim)s?|estimated[-\s]?scores?|score[-\s]?(?:estimate|prediction|preview)s?)\b/i; -const FOCUS_MANIFEST_LOCAL_PATH_PATTERN = new RegExp(PUBLIC_LOCAL_PATH_INLINE, "i"); - -/** - * Public-safe redaction guard shared with the local-branch packet renderer. Public manifest - * text must not leak reward, wallet/key, ranking, or local filesystem path material. - */ -export function isFocusManifestPublicSafe(text: string): boolean { - return !FOCUS_MANIFEST_TERMS.test(text) && !FOCUS_MANIFEST_LOCAL_PATH_PATTERN.test(text); -} - -function emptyManifest(source: FocusManifestSource, warnings: string[] = []): FocusManifest { - return { - ...EMPTY_MANIFEST, - source, - warnings, - gate: { ...EMPTY_GATE_CONFIG }, - 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, testGeneration: null, impactMap: null, cultureProfile: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null }, - features: { ...EMPTY_FEATURES_CONFIG }, - contentLane: { ...EMPTY_CONTENT_LANE_CONFIG }, - repoDocGeneration: { ...EMPTY_REPO_DOC_GENERATION_CONFIG }, - reviewRecap: { ...EMPTY_REVIEW_RECAP_CONFIG }, - }; -} - -function normalizeStringList(value: JsonValue | undefined, field: string, warnings: string[]): string[] { - if (value === undefined || value === null) return []; - if (!Array.isArray(value)) { - warnings.push(`Manifest field "${field}" must be a list; ignoring a ${typeof value} value.`); - return []; - } - const result: string[] = []; - for (const entry of value) { - if (typeof entry !== "string") { - warnings.push(`Manifest field "${field}" skipped a non-string entry.`); - continue; - } - const trimmed = entry.trim(); - if (!trimmed) continue; - // Truncate in place, then flow through the same de-dup and cap logic. Falling through (rather than - // `continue`-ing) keeps over-long entries subject to both limits, so untrusted manifests cannot - // bypass de-duplication or the MAX_LIST_ITEMS safety cap via pathological long entries. - let normalized = trimmed; - if (normalized.length > MAX_ITEM_LENGTH) { - warnings.push(`Manifest field "${field}" truncated an over-long entry.`); - normalized = normalized.slice(0, MAX_ITEM_LENGTH); - } - if (!result.includes(normalized)) result.push(normalized); - if (result.length >= MAX_LIST_ITEMS) { - warnings.push(`Manifest field "${field}" exceeded ${MAX_LIST_ITEMS} entries; extra entries ignored.`); - break; - } - } - return result; -} - -/** Like {@link normalizeStringList}, but returns `null` (not `[]`) when unset or when nothing survives - * validation — the convention every OTHER `FocusManifestGateConfig` field uses for "not configured", so - * the resolver's `!== null` overlay checks work uniformly. */ -function normalizeOptionalStringList(value: JsonValue | undefined, field: string, warnings: string[]): ReadonlyArray | null { - if (value === undefined || value === null) return null; - const list = normalizeStringList(value, field, warnings); - return list.length > 0 ? list : null; -} - -function normalizeEnum(value: JsonValue | undefined, field: string, allowed: readonly T[], fallback: T, warnings: string[]): T { - if (value === undefined || value === null) return fallback; - if (typeof value !== "string" || !allowed.includes(value as T)) { - warnings.push(`Manifest field "${field}" must be one of ${allowed.join(", ")}; falling back to "${fallback}".`); - return fallback; - } - return value as T; -} - -function normalizeSource(raw: FocusManifestSource | undefined, value: JsonValue | undefined, warnings: string[]): FocusManifestSource { - if (raw) return raw; - return normalizeEnum(value, "source", ["repo_file", "api_record", "none"], "api_record", warnings); -} - -function normalizeOptionalGateMode(value: JsonValue | undefined, field: string, warnings: string[]): GateRuleMode | null { - if (value === undefined || value === null) return null; - if (typeof value === "string") { - const normalized = value.trim().toLowerCase(); - if (normalized === "off" || normalized === "advisory" || normalized === "block") return normalized; - } - warnings.push(`Manifest gate field "${field}" must be one of off, advisory, block; ignoring "${String(value)}".`); - return null; -} - -/** `gate.readiness.mode` (and its `settings.qualityGateMode` alias below) is documented and parsed as the shared - * off/advisory/block tri-state, but buildQualityGateWarning (src/rules/advisory.ts) always produces a - * warning-severity finding — never a blocker — and isConfiguredGateBlocker has no branch for it: readiness/ - * quality is intentionally informational-only and can never hard-block a PR. Without this, a maintainer who - * sets `mode: block` believes a real quality floor is enforced when the effective behavior is silently - * advisory-only (#2267). Downgrade "block" to "advisory" here, with a clear deprecation warning, so the parsed - * config always matches what the gate actually does. Exported so the settings-write API routes (the - * dashboard/API path for the SAME `qualityGateMode` field) can apply the identical downgrade before persisting. */ -export function normalizeReadinessGateMode(value: JsonValue | undefined, field: string, warnings: string[]): GateRuleMode | null { - const mode = normalizeOptionalGateMode(value, field, warnings); - if (mode !== "block") return mode; - warnings.push(`Manifest gate field "${field}" no longer accepts "block" — readiness/quality is informational-only and can never hard-block a PR; downgrading to "advisory". Use gate.manifestPolicy or another enforceable gate for a real quality floor.`); - return "advisory"; -} - -function normalizeOptionalBoolean(value: JsonValue | undefined, field: string, warnings: string[]): boolean | null { - if (value === undefined || value === null) return null; - if (typeof value === "boolean") return value; - warnings.push(`Manifest gate field "${field}" must be a boolean; ignoring a ${typeof value} value.`); - return null; -} - -function normalizeOptionalScore(value: JsonValue | undefined, field: string, warnings: string[]): number | null { - if (value === undefined || value === null) return null; - if (typeof value !== "number" || !Number.isFinite(value)) { - warnings.push(`Manifest gate field "${field}" must be a number between 0 and 100; ignoring it.`); - return null; - } - return Math.max(0, Math.min(100, Math.round(value))); -} - -function normalizeOptionalNonNegativeInt(value: JsonValue | undefined, field: string, warnings: string[]): number | null { - if (value === undefined || value === null) return null; - if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 0) { - warnings.push(`Manifest field "${field}" must be a non-negative integer; ignoring it.`); - return null; - } - return value; -} - -/** Parse auto-review size caps where 0 means disabled (byte-identical default). (#2065) */ -function normalizeAutoReviewSizeCap(value: JsonValue | undefined, field: string, warnings: string[]): number { - if (value === undefined || value === null) return 0; - if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 0) { - warnings.push(`Manifest field "${field}" must be a non-negative integer; ignoring it.`); - return 0; - } - return value; -} - -/** Normalize an optional confidence threshold in [0,1] (#7) — a fractional value (NOT a 0-100 score), so it is - * clamped into range WITHOUT rounding. Absent/null ⇒ null (the resolver leaves the gate's 0.93 default in place); - * a non-finite/non-number value is ignored with a warning. */ -function normalizeOptionalConfidence(value: JsonValue | undefined, field: string, warnings: string[]): number | null { - if (value === undefined || value === null) return null; - if (typeof value !== "number" || !Number.isFinite(value)) { - warnings.push(`Manifest gate field "${field}" must be a number between 0 and 1; ignoring it.`); - return null; - } - return Math.max(0, Math.min(1, value)); -} - -// A hard cap on `gate.aiReview.reviewers` entries — the combiner only ever addresses reviewer[0]/[1] (single runs -// one, consensus/synthesis run two), so anything beyond 2 is inert; capping at 4 leaves headroom without letting a -// hostile/huge manifest bloat the parsed config for no functional gain. -const MAX_AI_REVIEW_REVIEWERS = 4; - -/** Normalize `gate.aiReview.reviewers` (#2567) — a list of `{ model, fallback? }` entries naming self-host - * providers (e.g. `claude-code`, `codex`) to run in place of the operator's `AI_REVIEW_PLAN.reviewers`. Each - * entry needs a non-empty string `model`; `fallback` is optional and, when present, must also be a non-empty - * string. Invalid entries are dropped with a warning rather than failing the whole list, mirroring the other - * manifest list parsers. Absent/empty/all-invalid ⇒ null (so the resolver's `??` fallback to the operator's - * plan is untouched). */ -function normalizeOptionalReviewers( - value: JsonValue | undefined, - field: string, - warnings: string[], -): ReadonlyArray<{ model: string; fallback?: string | null | undefined }> | null { - if (value === undefined || value === null) return null; - if (!Array.isArray(value)) { - warnings.push(`Manifest gate field "${field}" must be a list of { model, fallback? }; ignoring it.`); - return null; - } - const out: Array<{ model: string; fallback?: string | null | undefined }> = []; - for (const [index, entry] of value.entries()) { - if (out.length >= MAX_AI_REVIEW_REVIEWERS) { - warnings.push(`Manifest gate field "${field}" is capped at ${MAX_AI_REVIEW_REVIEWERS} entries; dropping the rest.`); - break; - } - if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { - warnings.push(`Manifest gate field "${field}[${index}]" must be a mapping with a "model" string; ignoring it.`); - continue; - } - const e = entry as Record; - const model = typeof e.model === "string" ? e.model.trim() : ""; - if (!model) { - warnings.push(`Manifest gate field "${field}[${index}].model" must be a non-empty string; ignoring the entry.`); - continue; - } - const fallback = typeof e.fallback === "string" && e.fallback.trim() ? e.fallback.trim() : undefined; - out.push(fallback ? { model, fallback } : { model }); - } - return out.length > 0 ? out : null; -} - -/** - * Parse the optional `gate:` mapping. Every field stays `null` when unset so the resolver can layer - * this OVER DB settings without clobbering. A nested `readiness: { mode, minScore }` block is accepted. - */ -function parseGateConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestGateConfig { - if (value === undefined || value === null) return { ...EMPTY_GATE_CONFIG }; - if (typeof value !== "object" || Array.isArray(value)) { - warnings.push(`Manifest field "gate" must be a mapping; ignoring it.`); - return { ...EMPTY_GATE_CONFIG }; - } - const record = value as Record; - const readiness = record.readiness; - const readinessRecord = readiness !== null && typeof readiness === "object" && !Array.isArray(readiness) ? (readiness as Record) : undefined; - if (readiness !== undefined && readiness !== null && readinessRecord === undefined) { - warnings.push(`Manifest gate field "gate.readiness" must be a mapping; ignoring it.`); - } - const aiReview = record.aiReview; - const aiReviewRecord = aiReview !== null && typeof aiReview === "object" && !Array.isArray(aiReview) ? (aiReview as Record) : undefined; - if (aiReview !== undefined && aiReview !== null && aiReviewRecord === undefined) { - warnings.push(`Manifest gate field "gate.aiReview" must be a mapping; ignoring it.`); - } - const slop = record.slop; - const slopRecord = slop !== null && typeof slop === "object" && !Array.isArray(slop) ? (slop as Record) : undefined; - if (slop !== undefined && slop !== null && slopRecord === undefined) { - warnings.push(`Manifest gate field "gate.slop" must be a mapping; ignoring it.`); - } - const size = record.size; - const sizeRecord = size !== null && typeof size === "object" && !Array.isArray(size) ? (size as Record) : undefined; - if (size !== undefined && size !== null && sizeRecord === undefined) { - warnings.push(`Manifest gate field "gate.size" must be a mapping; ignoring it.`); - } - const cla = record.cla; - const claRecord = cla !== null && typeof cla === "object" && !Array.isArray(cla) ? (cla as Record) : undefined; - if (cla !== undefined && cla !== null && claRecord === undefined) { - warnings.push(`Manifest gate field "gate.cla" must be a mapping; ignoring it.`); - } - const gate: FocusManifestGateConfig = { - present: false, - enabled: normalizeOptionalBoolean(record.enabled, "gate.enabled", warnings), - checkMode: normalizeOptionalEnum(record.checkMode, "gate.checkMode", ["required", "visible", "disabled"] as const, warnings), - pack: normalizeOptionalEnum(record.pack, "gate.pack", ["gittensor", "oss-anti-slop"] as const, warnings), - linkedIssue: normalizeOptionalGateMode(record.linkedIssue, "gate.linkedIssue", warnings), - duplicates: normalizeOptionalGateMode(record.duplicates, "gate.duplicates", warnings), - readinessMode: normalizeReadinessGateMode(readinessRecord?.mode, "gate.readiness.mode", warnings), - readinessMinScore: normalizeOptionalScore(readinessRecord?.minScore, "gate.readiness.minScore", warnings), - slopMode: normalizeOptionalGateMode(slopRecord?.mode, "gate.slop.mode", warnings), - slopMinScore: normalizeOptionalScore(slopRecord?.minScore, "gate.slop.minScore", warnings), - slopAiAdvisory: normalizeOptionalBoolean(slopRecord?.aiAdvisory, "gate.slop.aiAdvisory", warnings), - sizeMode: normalizeOptionalGateMode(sizeRecord?.mode, "gate.size.mode", warnings), - lockfileIntegrityMode: normalizeOptionalGateMode(record.lockfileIntegrity, "gate.lockfileIntegrity", warnings), - aiReviewMode: normalizeOptionalGateMode(aiReviewRecord?.mode, "gate.aiReview.mode", warnings), - aiReviewByok: normalizeOptionalBoolean(aiReviewRecord?.byok, "gate.aiReview.byok", warnings), - aiReviewProvider: normalizeOptionalEnum(aiReviewRecord?.provider, "gate.aiReview.provider", ["anthropic", "openai"] as const, warnings), - aiReviewModel: normalizeOptionalString(aiReviewRecord?.model, "gate.aiReview.model", warnings), - aiReviewAllAuthors: normalizeOptionalBoolean(aiReviewRecord?.allAuthors, "gate.aiReview.allAuthors", warnings), - aiReviewCloseConfidence: normalizeOptionalConfidence(aiReviewRecord?.closeConfidence, "gate.aiReview.closeConfidence", warnings), - aiReviewCombine: normalizeOptionalEnum(aiReviewRecord?.combine, "gate.aiReview.combine", ["single", "consensus", "synthesis"] as const, warnings), - aiReviewOnMerge: normalizeOptionalEnum(aiReviewRecord?.onMerge, "gate.aiReview.onMerge", ["either", "both"] as const, warnings), - aiReviewReviewers: normalizeOptionalReviewers(aiReviewRecord?.reviewers, "gate.aiReview.reviewers", warnings), - mergeReadiness: normalizeOptionalGateMode(record.mergeReadiness, "gate.mergeReadiness", warnings), - manifestPolicy: normalizeOptionalGateMode(record.manifestPolicy, "gate.manifestPolicy", warnings), - selfAuthoredLinkedIssue: normalizeOptionalGateMode(record.selfAuthoredLinkedIssue, "gate.selfAuthoredLinkedIssue", warnings), - dryRun: normalizeOptionalBoolean(record.dryRun, "gate.dryRun", warnings), - firstTimeContributorGrace: normalizeOptionalBoolean(record.firstTimeContributorGrace, "gate.firstTimeContributorGrace", warnings), - premergeContentRecheck: normalizeOptionalBoolean(record.premergeContentRecheck, "gate.premergeContentRecheck", warnings), - requireFreshRebaseWindowMinutes: normalizeOptionalPositiveInteger(record.requireFreshRebaseWindow, "gate.requireFreshRebaseWindow", warnings), - claMode: normalizeOptionalGateMode(record.claMode, "gate.claMode", warnings), - claConsentPhrase: parsePublicSafeText(claRecord?.consentPhrase, "gate.cla.consentPhrase", warnings), - claCheckRunName: parsePublicSafeText(claRecord?.checkRunName, "gate.cla.checkRunName", warnings), - claCheckRunAppSlug: parsePublicSafeText(claRecord?.checkRunAppSlug, "gate.cla.checkRunAppSlug", warnings), - expectedCiContexts: normalizeOptionalStringList(record.expectedCiContexts, "gate.expectedCiContexts", warnings), - }; - // #2266: the flag is parsed, clamped, and threaded end-to-end, but the gate evaluator never reads it — a - // maintainer who sets it to true believing it softens a blocker for newcomers gets no such effect. Surface - // that inertness at parse time rather than leaving it silently no-op; `false`/unset matches the (also inert) - // default, so only an explicit `true` is worth flagging. - if (gate.firstTimeContributorGrace === true) { - warnings.push(`Manifest field "gate.firstTimeContributorGrace" is currently reserved/inert — it does not soften a blocker outcome for first-time contributors.`); - } - gate.present = - gate.enabled !== null || - gate.checkMode !== null || - gate.pack !== null || - gate.linkedIssue !== null || - gate.duplicates !== null || - gate.readinessMode !== null || - gate.readinessMinScore !== null || - gate.slopMode !== null || - gate.slopMinScore !== null || - gate.slopAiAdvisory !== null || - gate.sizeMode !== null || - gate.lockfileIntegrityMode !== null || - gate.aiReviewMode !== null || - gate.aiReviewByok !== null || - gate.aiReviewProvider !== null || - gate.aiReviewModel !== null || - gate.aiReviewAllAuthors !== null || - gate.aiReviewCloseConfidence !== null || - gate.aiReviewCombine !== null || - gate.aiReviewOnMerge !== null || - gate.aiReviewReviewers !== null || - gate.mergeReadiness !== null || - gate.manifestPolicy !== null || - gate.selfAuthoredLinkedIssue !== null || - gate.dryRun !== null || - gate.firstTimeContributorGrace !== null || - gate.premergeContentRecheck !== null || - gate.requireFreshRebaseWindowMinutes !== null || - gate.claMode !== null || - gate.claConsentPhrase !== null || - gate.claCheckRunName !== null || - gate.claCheckRunAppSlug !== null || - gate.expectedCiContexts !== null; - return gate; -} - -/** - * Serialize a gate config back into the parse-compatible `gate:` shape so a cached manifest snapshot - * round-trips through {@link parseGateConfig} unchanged. Returns null when nothing is configured. - */ -export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue { - if (!gate.present) return null; - const out: Record = {}; - if (gate.enabled !== null) out.enabled = gate.enabled; - if (gate.checkMode !== null) out.checkMode = gate.checkMode; - if (gate.pack !== null) out.pack = gate.pack; - if (gate.linkedIssue !== null) out.linkedIssue = gate.linkedIssue; - if (gate.duplicates !== null) out.duplicates = gate.duplicates; - if (gate.readinessMode !== null || gate.readinessMinScore !== null) { - const readiness: Record = {}; - if (gate.readinessMode !== null) readiness.mode = gate.readinessMode; - if (gate.readinessMinScore !== null) readiness.minScore = gate.readinessMinScore; - out.readiness = readiness; - } - if (gate.sizeMode !== null) out.size = { mode: gate.sizeMode }; - if (gate.lockfileIntegrityMode !== null) out.lockfileIntegrity = gate.lockfileIntegrityMode; - if (gate.slopMode !== null || gate.slopMinScore !== null || gate.slopAiAdvisory !== null) { - const slop: Record = {}; - if (gate.slopMode !== null) slop.mode = gate.slopMode; - if (gate.slopMinScore !== null) slop.minScore = gate.slopMinScore; - if (gate.slopAiAdvisory !== null) slop.aiAdvisory = gate.slopAiAdvisory; - out.slop = slop; - } - if ( - gate.aiReviewMode !== null || - gate.aiReviewByok !== null || - gate.aiReviewProvider !== null || - gate.aiReviewModel !== null || - gate.aiReviewAllAuthors !== null || - gate.aiReviewCloseConfidence !== null || - gate.aiReviewCombine !== null || - gate.aiReviewOnMerge !== null || - gate.aiReviewReviewers !== null - ) { - const aiReview: Record = {}; - if (gate.aiReviewMode !== null) aiReview.mode = gate.aiReviewMode; - if (gate.aiReviewByok !== null) aiReview.byok = gate.aiReviewByok; - if (gate.aiReviewProvider !== null) aiReview.provider = gate.aiReviewProvider; - if (gate.aiReviewModel !== null) aiReview.model = gate.aiReviewModel; - if (gate.aiReviewAllAuthors !== null) aiReview.allAuthors = gate.aiReviewAllAuthors; - if (gate.aiReviewCloseConfidence !== null) aiReview.closeConfidence = gate.aiReviewCloseConfidence; - if (gate.aiReviewCombine !== null) aiReview.combine = gate.aiReviewCombine; - if (gate.aiReviewOnMerge !== null) aiReview.onMerge = gate.aiReviewOnMerge; - if (gate.aiReviewReviewers !== null) { - aiReview.reviewers = gate.aiReviewReviewers.map((r) => - r.fallback ? { model: r.model, fallback: r.fallback } : { model: r.model }, - ) as JsonValue; - } - out.aiReview = aiReview; - } - if (gate.mergeReadiness !== null) out.mergeReadiness = gate.mergeReadiness; - if (gate.manifestPolicy !== null) out.manifestPolicy = gate.manifestPolicy; - if (gate.selfAuthoredLinkedIssue !== null) out.selfAuthoredLinkedIssue = gate.selfAuthoredLinkedIssue; - if (gate.dryRun !== null) out.dryRun = gate.dryRun; - if (gate.firstTimeContributorGrace !== null) out.firstTimeContributorGrace = gate.firstTimeContributorGrace; - if (gate.premergeContentRecheck !== null) out.premergeContentRecheck = gate.premergeContentRecheck; - if (gate.requireFreshRebaseWindowMinutes !== null) out.requireFreshRebaseWindow = gate.requireFreshRebaseWindowMinutes; - if (gate.claMode !== null) out.claMode = gate.claMode; - if (gate.claConsentPhrase !== null || gate.claCheckRunName !== null || gate.claCheckRunAppSlug !== null) { - const cla: Record = {}; - if (gate.claConsentPhrase !== null) cla.consentPhrase = gate.claConsentPhrase; - if (gate.claCheckRunName !== null) cla.checkRunName = gate.claCheckRunName; - if (gate.claCheckRunAppSlug !== null) cla.checkRunAppSlug = gate.claCheckRunAppSlug; - out.cla = cla; - } - if (gate.expectedCiContexts !== null) out.expectedCiContexts = gate.expectedCiContexts as JsonValue; - return out; -} - -/** - * Parse the optional `features:` mapping — per-repo activation overrides for the converged review features. - * Each recognized key becomes a tri-state (`true`/`false`/`null`); unknown keys and non-boolean values are - * dropped with a warning. `present` is true when at least one key was explicitly set, so an operator can make - * the manifest "present" with only a `features:` block. - */ -function parseFeaturesConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestFeaturesConfig { - const features: FocusManifestFeaturesConfig = { ...EMPTY_FEATURES_CONFIG }; - if (value === undefined || value === null) return features; - if (typeof value !== "object" || Array.isArray(value)) { - warnings.push('Manifest "features" must be a mapping; ignoring it.'); - return features; - } - const record = value as Record; - for (const key of CONVERGED_FEATURE_KEYS) { - features[key] = normalizeOptionalBoolean(record[key], `features.${key}`, warnings); - } - features.present = CONVERGED_FEATURE_KEYS.some((key) => features[key] !== null); - return features; -} - -/** Serialize a features config back into the parse-compatible `features:` shape so a cached snapshot round-trips - * through {@link parseFeaturesConfig} unchanged. Returns null when nothing is configured. */ -export function featuresConfigToJson(features: FocusManifestFeaturesConfig): JsonValue { - if (!features.present) return null; - const out: Record = {}; - for (const key of CONVERGED_FEATURE_KEYS) { - if (features[key] !== null) out[key] = features[key]; - } - return out; -} - -/** A positive INTEGER count (not a score/confidence) — e.g. `contentLane.maxAppendedEntries` counts discrete - * surfaces[] entries, so a fractional value (a likely typo) would render a nonsensical contributor-facing close - * message ("append between 1 and 2.5 entries"). Rejects fractional and non-positive values alike. */ -function normalizeOptionalPositiveInteger(value: JsonValue | undefined, field: string, warnings: string[]): number | null { - if (value === undefined || value === null) return null; - if (typeof value === "number" && Number.isInteger(value) && value > 0) return value; - warnings.push(`Manifest field "${field}" must be a positive whole number; ignoring it.`); - return null; -} - -const REVIEW_VISUAL_MAX_ROUTES_LIMIT = 5; - -function normalizeOptionalVisualMaxRoutes(value: JsonValue | undefined, warnings: string[]): number | null { - const maxRoutes = normalizeOptionalPositiveInteger(value, "review.visual.routes.max_routes", warnings); - if (maxRoutes === null) return null; - if (maxRoutes <= REVIEW_VISUAL_MAX_ROUTES_LIMIT) return maxRoutes; - warnings.push(`Manifest field "review.visual.routes.max_routes" must be at most ${REVIEW_VISUAL_MAX_ROUTES_LIMIT}; clamping it.`); - return REVIEW_VISUAL_MAX_ROUTES_LIMIT; -} - -/** Normalize + bound a maintainer-supplied glob string: trims/length-caps like any other string field, AND - * rejects one globToRegExp (review/content-lane/spec-resolver.ts's reuse of the guardrail-path compiler) would - * itself refuse to compile safely. Reuses `hasUnsafeWildcardCount` — globToRegExp's OWN safety predicate — - * rather than a locally-counted threshold: a caller that counts wildcards differently (e.g. raw `*` characters, - * which double-counts a `**` pair as 2 groups instead of 1) can accept a glob globToRegExp then silently - * compiles to NEVER_MATCHES, configuring a lane that is "present" but can never activate on any changed file - * (#confirmed-bug). A glob over the cap is REJECTED (warns, returns null) rather than truncated — silently - * cutting wildcards out of a maintainer's pattern would silently change its meaning, which is worse than making - * them fix an over-complex glob. */ -function normalizeOptionalGlob(value: JsonValue | undefined, field: string, warnings: string[]): string | null { - const normalized = normalizeOptionalString(value, field, warnings); - if (normalized === null) return null; - if (normalized.length > MAX_ITEM_LENGTH) { - // REJECT, not truncate: cutting characters out of a glob changes which files it matches (e.g. a - // mid-directory-name cut can turn a narrow, intended pattern into one that matches an unrelated path - // prefix, or one that never matches anything) — silently compiling a DIFFERENT pattern than the - // maintainer configured is worse than making them shorten an over-complex glob. - warnings.push(`Manifest field "${field}" is an over-long glob (${normalized.length} > ${MAX_ITEM_LENGTH} chars); ignoring it.`); - return null; - } - if (hasUnsafeWildcardCount(normalized)) { - warnings.push(`Manifest field "${field}" has too many wildcards to compile safely; ignoring it.`); - return null; - } - return normalized; -} - -/** - * Parse the optional `contentLane:` mapping — per-repo registry-review lane configuration (#2435). `entryFileGlob` - * and `collectionField` are REQUIRED to build a usable spec; a config missing either — including a glob rejected - * by `normalizeOptionalGlob`'s wildcard cap — degrades to "not configured" (a warning, falling through to the - * allowlist default) rather than a broken half-spec. Glob fields stay plain strings here — compiling them to - * RegExp is the resolver's job (`review/content-lane/spec-resolver.ts`), not the parser's, so this file stays - * free of a RegExp-from-config compile step; it's still this file's job to keep an over-complex glob from ever - * reaching that compile step at all. - */ -function parseContentLaneConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestContentLaneConfig { - if (value === undefined || value === null) return { ...EMPTY_CONTENT_LANE_CONFIG }; - if (typeof value !== "object" || Array.isArray(value)) { - warnings.push('Manifest field "contentLane" must be a mapping; ignoring it.'); - return { ...EMPTY_CONTENT_LANE_CONFIG }; - } - const record = value as Record; - const entryFileGlob = normalizeOptionalGlob(record.entryFileGlob, "contentLane.entryFileGlob", warnings); - const providerFileGlob = normalizeOptionalGlob(record.providerFileGlob, "contentLane.providerFileGlob", warnings); - const artifactGlob = normalizeOptionalGlob(record.artifactGlob, "contentLane.artifactGlob", warnings); - const collectionField = normalizeOptionalString(record.collectionField, "contentLane.collectionField", warnings); - const maxAppendedEntries = normalizeOptionalPositiveInteger(record.maxAppendedEntries, "contentLane.maxAppendedEntries", warnings); - const duplicateKeyFields = normalizeStringList(record.duplicateKeyFields, "contentLane.duplicateKeyFields", warnings); - const validatorId = normalizeOptionalString(record.validatorId, "contentLane.validatorId", warnings); - if (!entryFileGlob || !collectionField) { - warnings.push('Manifest field "contentLane" requires both entryFileGlob and collectionField; ignoring it.'); - return { ...EMPTY_CONTENT_LANE_CONFIG }; - } - return { present: true, entryFileGlob, providerFileGlob, artifactGlob, collectionField, maxAppendedEntries, duplicateKeyFields, validatorId }; -} - -/** Serialize a contentLane config back into the parse-compatible `contentLane:` shape so a cached snapshot - * round-trips through {@link parseContentLaneConfig} unchanged. Returns null when nothing is configured. */ -export function contentLaneConfigToJson(contentLane: FocusManifestContentLaneConfig): JsonValue { - if (!contentLane.present || !contentLane.entryFileGlob || !contentLane.collectionField) return null; - const out: Record = { entryFileGlob: contentLane.entryFileGlob, collectionField: contentLane.collectionField }; - if (contentLane.providerFileGlob !== null) out.providerFileGlob = contentLane.providerFileGlob; - if (contentLane.artifactGlob !== null) out.artifactGlob = contentLane.artifactGlob; - if (contentLane.maxAppendedEntries !== null) out.maxAppendedEntries = contentLane.maxAppendedEntries; - if (contentLane.duplicateKeyFields.length > 0) out.duplicateKeyFields = contentLane.duplicateKeyFields; - if (contentLane.validatorId !== null) out.validatorId = contentLane.validatorId; - return out; -} - -const REPO_DOC_GENERATION_SCOPES: readonly FocusManifestRepoDocGenerationScope[] = ["agents", "skills"]; - -/** `undefined`/`null` (key omitted) falls back to the default scope; a non-list value is a genuine type error - * and ALSO falls back to the default (rather than emptying it out, which would silently disable an otherwise - * `enabled: true` config); an actual list -- even an explicitly empty one, or one where every entry is - * invalid -- is respected as "nothing in scope", since that is a deliberate, well-typed value. */ -function parseRepoDocGenerationScope(value: JsonValue | undefined, warnings: string[]): FocusManifestRepoDocGenerationScope[] { - if (value === undefined || value === null) return [...EMPTY_REPO_DOC_GENERATION_CONFIG.scope]; - if (!Array.isArray(value)) { - warnings.push('Manifest field "repoDocGeneration.scope" must be a list; falling back to the default scope.'); - return [...EMPTY_REPO_DOC_GENERATION_CONFIG.scope]; - } - const raw = normalizeStringList(value, "repoDocGeneration.scope", warnings); - return raw.filter((entry): entry is FocusManifestRepoDocGenerationScope => { - if ((REPO_DOC_GENERATION_SCOPES as readonly string[]).includes(entry)) return true; - warnings.push(`Manifest field "repoDocGeneration.scope" has an unrecognized entry "${entry}"; ignoring it.`); - return false; - }); -} - -/** - * Parse the optional `repoDocGeneration:` mapping (#3002). Unlike `gate:`/`settings:`, every field here has a - * concrete default rather than a null "unconfigured" sentinel -- there is no DB layer to overlay onto, so the - * parsed value (or the default, when a key is omitted) IS the effective value. An explicitly empty `scope: []` - * is honored as "nothing in scope" (not coerced back to the default); only an OMITTED `scope` key falls back to - * `["agents"]`, mirroring how `undefined`/`null` mean "unset" everywhere else in this file. - */ -function parseRepoDocGenerationConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestRepoDocGenerationConfig { - if (value === undefined || value === null) return { ...EMPTY_REPO_DOC_GENERATION_CONFIG }; - if (typeof value !== "object" || Array.isArray(value)) { - warnings.push('Manifest field "repoDocGeneration" must be a mapping; ignoring it.'); - return { ...EMPTY_REPO_DOC_GENERATION_CONFIG }; - } - const record = value as Record; - const enabled = normalizeOptionalBoolean(record.enabled, "repoDocGeneration.enabled", warnings) ?? false; - const allowOverwriteExisting = normalizeOptionalBoolean(record.allowOverwriteExisting, "repoDocGeneration.allowOverwriteExisting", warnings) ?? false; - const scope = parseRepoDocGenerationScope(record.scope, warnings); - const refreshIntervalDays = normalizeOptionalPositiveInteger(record.refreshIntervalDays, "repoDocGeneration.refreshIntervalDays", warnings) ?? DEFAULT_REPO_DOC_REFRESH_INTERVAL_DAYS; - return { present: true, enabled, scope, allowOverwriteExisting, refreshIntervalDays }; -} - -/** Serialize a repoDocGeneration config back into the parse-compatible shape so a cached snapshot round-trips - * through {@link parseRepoDocGenerationConfig} unchanged. Returns null when nothing is configured. */ -export function repoDocGenerationConfigToJson(config: FocusManifestRepoDocGenerationConfig): JsonValue { - if (!config.present) return null; - return { enabled: config.enabled, scope: config.scope, allowOverwriteExisting: config.allowOverwriteExisting, refreshIntervalDays: config.refreshIntervalDays }; -} - -/** - * Parse the optional `reviewRecap:` mapping (#1963). Mirrors {@link parseRepoDocGenerationConfig}: every - * field has a concrete default (no DB layer to overlay onto), so the parsed value IS the effective value. - */ -function parseReviewRecapConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestReviewRecapConfig { - if (value === undefined || value === null) return { ...EMPTY_REVIEW_RECAP_CONFIG }; - if (typeof value !== "object" || Array.isArray(value)) { - warnings.push('Manifest field "reviewRecap" must be a mapping; ignoring it.'); - return { ...EMPTY_REVIEW_RECAP_CONFIG }; - } - const record = value as Record; - const enabled = normalizeOptionalBoolean(record.enabled, "reviewRecap.enabled", warnings) ?? false; - const cadenceDays = normalizeOptionalPositiveInteger(record.cadenceDays, "reviewRecap.cadenceDays", warnings) ?? DEFAULT_REVIEW_RECAP_CADENCE_DAYS; - return { present: true, enabled, cadenceDays }; -} - -/** Serialize a reviewRecap config back into the parse-compatible shape so a cached snapshot round-trips - * through {@link parseReviewRecapConfig} unchanged. Returns null when nothing is configured. */ -export function reviewRecapConfigToJson(config: FocusManifestReviewRecapConfig): JsonValue { - if (!config.present) return null; - return { enabled: config.enabled, cadenceDays: config.cadenceDays }; -} - -function normalizeOptionalEnum(value: JsonValue | undefined, field: string, allowed: readonly T[], warnings: string[]): T | null { - if (value === undefined || value === null) return null; - if (typeof value === "string" && (allowed as readonly string[]).includes(value)) return value as T; - warnings.push(`Manifest settings field "${field}" must be one of ${allowed.join(", ")}; ignoring "${String(value)}".`); - return null; -} - -function normalizeOptionalString(value: JsonValue | undefined, field: string, warnings: string[]): string | null { - if (value === undefined || value === null) return null; - if (typeof value === "string" && value.trim().length > 0) return value.trim(); - warnings.push(`Manifest settings field "${field}" must be a non-empty string; ignoring it.`); - return null; -} - -// Keep the review-nag lookback operationally bounded so repo-controlled config cannot overflow Date -// arithmetic. Duplicated from settings/agent-actions.ts's own MAX_REVIEW_NAG_COOLDOWN_DAYS (same value, -// same rationale) rather than imported: this module is part of the UI package's typechecked closure, and -// agent-actions.ts transitively imports github/commands.ts -> utils/crypto.ts, pulling a heavier -// GitHub-App-specific dependency chain into the UI build for one small constant. -const MAX_REVIEW_NAG_COOLDOWN_DAYS = 365; - -/** - * Parse the optional `settings:` mapping — a partial repository-settings override. Only recognized - * fields are kept; unknown/invalid values are dropped with a warning and never throw. - */ -function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]): FocusManifestSettings { - if (value === undefined || value === null) return {}; - if (typeof value !== "object" || Array.isArray(value)) { - warnings.push(`Manifest field "settings" must be a mapping; ignoring it.`); - return {}; - } - const r = value as Record; - const out: FocusManifestSettings = {}; - const commentMode = normalizeOptionalEnum(r.commentMode, "settings.commentMode", ["off", "detected_contributors_only", "all_prs"] as const, warnings); - if (commentMode !== null) out.commentMode = commentMode; - const publicAudienceMode = normalizeOptionalEnum(r.publicAudienceMode, "settings.publicAudienceMode", ["oss_maintainer", "gittensor_only"] as const, warnings); - if (publicAudienceMode !== null) out.publicAudienceMode = publicAudienceMode; - const publicSignalLevel = normalizeOptionalEnum(r.publicSignalLevel, "settings.publicSignalLevel", ["minimal", "standard"] as const, warnings); - if (publicSignalLevel !== null) out.publicSignalLevel = publicSignalLevel; - const checkRunMode = normalizeOptionalEnum(r.checkRunMode, "settings.checkRunMode", ["off", "enabled"] as const, warnings); - if (checkRunMode !== null) out.checkRunMode = checkRunMode; - const checkRunDetailLevel = normalizeOptionalEnum(r.checkRunDetailLevel, "settings.checkRunDetailLevel", ["minimal", "standard", "deep"] as const, warnings); - if (checkRunDetailLevel !== null) out.checkRunDetailLevel = checkRunDetailLevel; - const gateCheckMode = normalizeOptionalEnum(r.gateCheckMode, "settings.gateCheckMode", ["off", "enabled"] as const, warnings); - if (gateCheckMode !== null) out.gateCheckMode = gateCheckMode; - const regateSweepOrderMode = normalizeOptionalEnum(r.regateSweepOrderMode, "settings.regateSweepOrderMode", ["staleness", "oldest-first"] as const, warnings); - if (regateSweepOrderMode !== null) out.regateSweepOrderMode = regateSweepOrderMode; - // Same tri-state field as gate.checkMode above (the friendly gate alias overlays onto it in - // resolveEffectiveSettings, and wins when both are set). - const reviewCheckMode = normalizeOptionalEnum(r.reviewCheckMode, "settings.reviewCheckMode", ["required", "visible", "disabled"] as const, warnings); - if (reviewCheckMode !== null) out.reviewCheckMode = reviewCheckMode; - const autoProjectMilestoneMatch = normalizeOptionalEnum(r.autoProjectMilestoneMatch, "settings.autoProjectMilestoneMatch", ["off", "suggest", "auto"] as const, warnings); - if (autoProjectMilestoneMatch !== null) out.autoProjectMilestoneMatch = autoProjectMilestoneMatch; - const autoProjectMilestoneMatchBackend = normalizeOptionalEnum(r.autoProjectMilestoneMatchBackend, "settings.autoProjectMilestoneMatchBackend", ["github", "linear"] as const, warnings); - if (autoProjectMilestoneMatchBackend !== null) out.autoProjectMilestoneMatchBackend = autoProjectMilestoneMatchBackend; - const linkedIssueGateMode = normalizeOptionalGateMode(r.linkedIssueGateMode, "settings.linkedIssueGateMode", warnings); - if (linkedIssueGateMode !== null) out.linkedIssueGateMode = linkedIssueGateMode; - const duplicatePrGateMode = normalizeOptionalGateMode(r.duplicatePrGateMode, "settings.duplicatePrGateMode", warnings); - if (duplicatePrGateMode !== null) out.duplicatePrGateMode = duplicatePrGateMode; - const selfAuthoredLinkedIssueGateMode = normalizeOptionalGateMode(r.selfAuthoredLinkedIssueGateMode, "settings.selfAuthoredLinkedIssueGateMode", warnings); - if (selfAuthoredLinkedIssueGateMode !== null) out.selfAuthoredLinkedIssueGateMode = selfAuthoredLinkedIssueGateMode; - // Same tri-state field as gate.readiness.mode above (the friendly gate alias overlays onto it in - // resolveEffectiveSettings) — apply the identical "block" → "advisory" downgrade here too, so a maintainer - // setting `settings.qualityGateMode: block` directly hits the same deprecation warning (#2267). - const qualityGateMode = normalizeReadinessGateMode(r.qualityGateMode, "settings.qualityGateMode", warnings); - if (qualityGateMode !== null) out.qualityGateMode = qualityGateMode; - const qualityGateMinScore = normalizeOptionalScore(r.qualityGateMinScore, "settings.qualityGateMinScore", warnings); - if (qualityGateMinScore !== null) out.qualityGateMinScore = qualityGateMinScore; - const aiReviewMode = normalizeOptionalGateMode(r.aiReviewMode, "settings.aiReviewMode", warnings); - if (aiReviewMode !== null) out.aiReviewMode = aiReviewMode; - const aiReviewProvider = normalizeOptionalEnum(r.aiReviewProvider, "settings.aiReviewProvider", ["anthropic", "openai"] as const, warnings); - if (aiReviewProvider !== null) out.aiReviewProvider = aiReviewProvider; - const aiReviewModel = normalizeOptionalString(r.aiReviewModel, "settings.aiReviewModel", warnings); - if (aiReviewModel !== null) out.aiReviewModel = aiReviewModel; - const gittensorLabel = normalizeOptionalString(r.gittensorLabel, "settings.gittensorLabel", warnings); - if (gittensorLabel !== null) out.gittensorLabel = gittensorLabel; - // #label-scoping: an explicit yml `null` is load-bearing (closes WITHOUT any label), matching - // contributorOpenPrCap's own null-vs-omitted distinction — must be checked BEFORE normalizeOptionalString, - // which otherwise collapses null and undefined to the same "unset" result. - if (r.blacklistLabel === null) { - out.blacklistLabel = null; - } else { - const blacklistLabel = normalizeOptionalString(r.blacklistLabel, "settings.blacklistLabel", warnings); - if (blacklistLabel !== null) out.blacklistLabel = blacklistLabel; - } - const publicSurface = normalizeOptionalEnum(r.publicSurface, "settings.publicSurface", ["off", "comment_and_label", "comment_only", "label_only"] as const, warnings); - if (publicSurface !== null) out.publicSurface = publicSurface; - for (const key of ["aiReviewByok", "aiReviewAllAuthors", "closeOwnerAuthors", "autoLabelEnabled", "typeLabelsEnabled", "badgeEnabled", "publicQualityMetrics", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "privateTrustEnabled", "agentPaused", "agentDryRun"] as const) { - const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings); - if (flag !== null) out[key] = flag; - } - // Agent-layer autonomy dial (#773): `settings.autonomy` maps each action class to a level. Only set it - // when at least one valid class→level pair survives normalization, so a malformed block never blanks the - // DB-configured policy via the resolver's `{...dbSettings, ...manifest.settings}` overlay. - if (r.autonomy !== undefined) { - const autonomy = normalizeAutonomyPolicy(r.autonomy); - if (Object.keys(autonomy).length > 0) out.autonomy = autonomy; - } - // Auto-maintain policy (#774): `settings.autoMaintain` declares the full policy (defaults fill any unset - // field) and overlays the DB value via the resolver. Only a mapping is honoured; anything else is ignored. - if (typeof r.autoMaintain === "object" && r.autoMaintain !== null && !Array.isArray(r.autoMaintain)) { - out.autoMaintain = normalizeAutoMaintainPolicy(r.autoMaintain); - } - // Command authorization policy (#2268 config-as-code parity): `settings.commandAuthorization` declares the - // full role policy the same way `autoMaintain` does — the normalizer fills any unset/invalid FIELD from - // DEFAULT_COMMAND_AUTHORIZATION_POLICY, so a partially-valid mapping yields a complete, safe policy that - // overlays the DB value via the resolver's `{...dbSettings, ...manifest.settings}` spread. But an invalid - // TOP-LEVEL shape (not a mapping at all) is a different case: normalizeCommandAuthorizationPolicy's own - // fallback there is meant for callers with no DB value to fall back to, not for this overlay — applying it - // here would let a typo'd config silently overwrite a stricter DB-persisted policy with the built-in - // default. So only apply the normalized policy when the raw value was actually a mapping; otherwise warn - // and leave `out.commandAuthorization` unset so the resolver preserves whatever the DB already has. - if (typeof r.commandAuthorization === "object" && r.commandAuthorization !== null && !Array.isArray(r.commandAuthorization)) { - const { policy, warnings: commandAuthorizationWarnings } = normalizeCommandAuthorizationPolicy(r.commandAuthorization); - warnings.push(...commandAuthorizationWarnings); - out.commandAuthorization = policy; - } else if (r.commandAuthorization !== undefined) { - warnings.push(`Manifest "settings.commandAuthorization" must be an object; ignoring it and keeping any existing policy.`); - } - // TYPE label category overrides (#priority-linked-issue-gate, #label-modularity): unlike - // commandAuthorization/autoMaintain above, this is deliberately kept SPARSE -- only the keys actually - // present AND validly-shaped in the raw YAML are copied onto `out.typeLabels` (via - // `normalizeTypeLabelSet`, which still fills in the built-in bug/feature/priority keys to run its own - // shape checks, but those defaults-filled values are discarded here). A manifest naming only - // `typeLabels.priority` must inherit `bug`/`feature` from the DB-persisted value in - // `resolveEffectiveSettings`, not have them silently reset to the built-in gittensor:* names -- assigning - // the normalizer's complete object here would do exactly that via the resolver's wholesale - // `{...dbSettings, ...manifest.settings}` spread. The per-field shape check below (not just "is the key - // present") matters too: a malformed value (e.g. `typeLabels.priority: 123`) is present but invalid, so - // `normalizeTypeLabelSet` warns and reports its OWN built-in-default fallback for that key -- copying - // that fallback into the sparse override would silently overwrite a DB-customized value with the - // built-in default on a config typo, instead of leaving the DB value alone. The loop is generic over - // whatever keys the raw object actually has (not hardcoded to bug/feature/priority), so an arbitrary - // custom category (e.g. `security`) sparse-overrides exactly like a built-in one. The normalizer - // enforces the category-count and label-name caps before a sparse key can survive into the override. - if (typeof r.typeLabels === "object" && r.typeLabels !== null && !Array.isArray(r.typeLabels)) { - const rawTypeLabels = r.typeLabels as Record; - if (Object.keys(rawTypeLabels).length === 0) { - // A literal `typeLabels: {}` is a DELIBERATE, complete declaration -- "zero configured categories - // for this repo" -- distinct from a sparse override whose named keys all failed validation (the - // `else` branch below, which must NOT wipe the DB value). Represented as `null` so - // `resolveEffectiveSettings` can tell the two apart even though both would otherwise collapse to - // the same empty-object shape (#label-modularity). - out.typeLabels = null; - } else { - const validated = normalizeTypeLabelSet(rawTypeLabels, warnings); - const isValidLabelName = (value: unknown): boolean => typeof value === "string" && value.trim().length > 0 && value.trim().length <= MAX_TYPE_LABEL_NAME_LENGTH; - const sparseTypeLabels: Partial = {}; - for (const key of Object.keys(rawTypeLabels)) { - if (isValidLabelName(rawTypeLabels[key]) && validated[key] !== undefined) sparseTypeLabels[key] = validated[key]; - } - out.typeLabels = sparseTypeLabels; - } - } else if (r.typeLabels !== undefined) { - warnings.push(`Manifest "settings.typeLabels" must be an object; ignoring it and keeping any existing label names.`); - } - // Linked-issue label propagation (#priority-linked-issue-gate): same sparse-partial shape as typeLabels - // above, for the same reason -- this is the ONLY mechanism that can ever select a maintainer-reward - // label like gittensor:priority (never inferred from title/files/AI/PR-labels), so a manifest overriding - // just one field (e.g. `enabled`) must not silently reset `mappings` back to the built-in empty default - // and discard a DB-configured mapping list. Each field is gated on its OWN raw shape being valid (not - // just "is the key present"), for the same reason as typeLabels above -- e.g. a typo'd - // `mappings: "oops"` must never silently replace a DB-configured mapping list with the normalizer's - // empty-array fallback. A validly-shaped `mappings` array is still a complete replacement when present - // (arrays have no per-item precedence semantics here, and any individually-invalid entries inside it - // are dropped by the normalizer, not the array itself), matching the array-replace-wholesale overlay - // behavior documented for the private-config layer. - if (typeof r.linkedIssueLabelPropagation === "object" && r.linkedIssueLabelPropagation !== null && !Array.isArray(r.linkedIssueLabelPropagation)) { - const rawPropagation = r.linkedIssueLabelPropagation as Record; - const validated = normalizeLinkedIssueLabelPropagationConfig(rawPropagation, warnings); - const sparsePropagation: Partial = {}; - if (typeof rawPropagation.enabled === "boolean") sparsePropagation.enabled = validated.enabled; - if (typeof rawPropagation.mode === "string" && (VALID_LINKED_ISSUE_LABEL_PROPAGATION_MODES as readonly string[]).includes(rawPropagation.mode)) { - sparsePropagation.mode = validated.mode; - } - if (Array.isArray(rawPropagation.mappings)) sparsePropagation.mappings = validated.mappings; - out.linkedIssueLabelPropagation = sparsePropagation; - } else if (r.linkedIssueLabelPropagation !== undefined) { - warnings.push(`Manifest "settings.linkedIssueLabelPropagation" must be an object; ignoring it and keeping any existing policy.`); - } - // Linked-issue hard rules: same sparse-partial overlay contract as linkedIssueLabelPropagation. A global config - // can enable the policy and set label lists; a repo override can toggle one mode without resetting those lists. - if (typeof r.linkedIssueHardRules === "object" && r.linkedIssueHardRules !== null && !Array.isArray(r.linkedIssueHardRules)) { - const rawRules = r.linkedIssueHardRules as Record; - const validated = normalizeLinkedIssueHardRulesConfig(rawRules, warnings); - const sparseRules: Partial = {}; - if (isLinkedIssueHardRuleMode(rawRules.ownerAssignedClose)) sparseRules.ownerAssignedClose = validated.ownerAssignedClose; - if (isLinkedIssueHardRuleMode(rawRules.assignedIssueClose)) sparseRules.assignedIssueClose = validated.assignedIssueClose; - if (isLinkedIssueHardRuleMode(rawRules.missingPointLabelClose)) sparseRules.missingPointLabelClose = validated.missingPointLabelClose; - if (isLinkedIssueHardRuleMode(rawRules.maintainerOnlyLabelClose)) sparseRules.maintainerOnlyLabelClose = validated.maintainerOnlyLabelClose; - if (Array.isArray(rawRules.pointBearingLabels)) sparseRules.pointBearingLabels = validated.pointBearingLabels; - if (Array.isArray(rawRules.maintainerOnlyLabels)) sparseRules.maintainerOnlyLabels = validated.maintainerOnlyLabels; - if (typeof rawRules.defaultLabelRepo === "boolean") sparseRules.defaultLabelRepo = validated.defaultLabelRepo; - if (typeof rawRules.verifyBeforeClose === "boolean") sparseRules.verifyBeforeClose = validated.verifyBeforeClose; - if (typeof rawRules.closeDelaySeconds === "number" && Number.isFinite(rawRules.closeDelaySeconds) && rawRules.closeDelaySeconds >= 0) { - sparseRules.closeDelaySeconds = validated.closeDelaySeconds; - } - out.linkedIssueHardRules = sparseRules; - } else if (r.linkedIssueHardRules !== undefined) { - warnings.push(`Manifest "settings.linkedIssueHardRules" must be an object; ignoring it and keeping any existing policy.`); - } - // Unlinked-issue guardrail (#unlinked-issue-guardrail): same sparse-partial overlay contract as - // linkedIssueHardRules above -- a repo naming only `mode` must not silently reset `minConfidence` back to - // the built-in default. - if (typeof r.unlinkedIssueGuardrail === "object" && r.unlinkedIssueGuardrail !== null && !Array.isArray(r.unlinkedIssueGuardrail)) { - const rawGuardrail = r.unlinkedIssueGuardrail as Record; - const validated = normalizeUnlinkedIssueGuardrailConfig(rawGuardrail, warnings); - const sparseGuardrail: Partial = {}; - if (isUnlinkedIssueGuardrailMode(rawGuardrail.mode)) sparseGuardrail.mode = validated.mode; - if (typeof rawGuardrail.minConfidence === "number" && Number.isFinite(rawGuardrail.minConfidence) && rawGuardrail.minConfidence >= 0 && rawGuardrail.minConfidence <= 1) { - sparseGuardrail.minConfidence = validated.minConfidence; - } - out.unlinkedIssueGuardrail = sparseGuardrail; - } else if (r.unlinkedIssueGuardrail !== undefined) { - warnings.push(`Manifest "settings.unlinkedIssueGuardrail" must be an object; ignoring it and keeping any existing policy.`); - } - // Screenshot-table gate (#2006): same sparse-partial overlay contract as unlinkedIssueGuardrail above -- a - // repo naming only `enabled` must not silently reset `whenLabels`/`whenPaths`/`action`/`message`. - if (typeof r.screenshotTableGate === "object" && r.screenshotTableGate !== null && !Array.isArray(r.screenshotTableGate)) { - const rawGate = r.screenshotTableGate as Record; - const validated = normalizeScreenshotTableGateConfig(rawGate, warnings); - const sparseGate: Partial = {}; - if (typeof rawGate.enabled === "boolean") sparseGate.enabled = validated.enabled; - if (Array.isArray(rawGate.whenLabels)) sparseGate.whenLabels = validated.whenLabels; - if (Array.isArray(rawGate.whenPaths)) sparseGate.whenPaths = validated.whenPaths; - if (isScreenshotTableGateAction(rawGate.action)) sparseGate.action = validated.action; - if (typeof rawGate.message === "string" && rawGate.message.trim().length > 0) sparseGate.message = validated.message; - out.screenshotTableGate = sparseGate; - } else if (r.screenshotTableGate !== undefined) { - warnings.push(`Manifest "settings.screenshotTableGate" must be an object; ignoring it and keeping any existing policy.`); - } - // Contributor blacklist (#1425): `settings.contributorBlacklist` is a list of banned-login entries. Only set it - // when at least one VALID entry survives normalization, so a malformed block never blanks the DB-configured - // list via the resolver's `{...dbSettings, ...manifest.settings}` overlay. Normalization warnings are folded in. - if (r.contributorBlacklist !== undefined) { - const { entries, warnings: blacklistWarnings } = normalizeContributorBlacklist(r.contributorBlacklist); - warnings.push(...blacklistWarnings); - if (entries.length > 0) out.contributorBlacklist = entries; - } - // Per-contributor open PR/issue caps (#2270): discrete counts, not scores — reuse the same positive-integer - // normalizer as contentLane.maxAppendedEntries so a fractional/non-positive typo is dropped with a warning - // instead of configuring a nonsensical cap. UNLIKE contributorBlacklist above, an explicit yml `null` here is - // load-bearing (not the same as omitting the key): the documented `yml > DB > null` precedence means a - // maintainer must be able to force a DB-configured cap back to "no cap" via `.gittensory.yml` without deleting - // the DB row. `normalizeOptionalPositiveInteger` collapses "absent" and "null" to the same silent `null` - // return, so that distinction has to be made HERE, before calling it: a literal `null` sets the key to `null` - // (clears); omitted (`undefined`) leaves the key unset (preserves the DB value via the resolver's spread); an - // invalid non-null value (fractional/non-positive/wrong type) warns and also leaves the key unset. - if (r.contributorOpenPrCap === null) { - out.contributorOpenPrCap = null; - } else { - const contributorOpenPrCap = normalizeOptionalPositiveInteger(r.contributorOpenPrCap, "settings.contributorOpenPrCap", warnings); - if (contributorOpenPrCap !== null) out.contributorOpenPrCap = contributorOpenPrCap; - } - if (r.contributorOpenIssueCap === null) { - out.contributorOpenIssueCap = null; - } else { - const contributorOpenIssueCap = normalizeOptionalPositiveInteger(r.contributorOpenIssueCap, "settings.contributorOpenIssueCap", warnings); - if (contributorOpenIssueCap !== null) out.contributorOpenIssueCap = contributorOpenIssueCap; - } - // #label-scoping: same load-bearing-null idiom as blacklistLabel above. - if (r.contributorCapLabel === null) { - out.contributorCapLabel = null; - } else { - const contributorCapLabel = normalizeOptionalString(r.contributorCapLabel, "settings.contributorCapLabel", warnings); - if (contributorCapLabel !== null) out.contributorCapLabel = contributorCapLabel; - } - // CI-run cancellation on a contributor_cap close (#2462): an explicit yml `null` is load-bearing (clears a - // DB-configured value back to "unset", falling through to the CONTRIBUTOR_CAP_CANCEL_CI_DEFAULT env var), - // matching contributorOpenPrCap's own null-vs-omitted distinction above. - if (r.contributorCapCancelCi === null) { - out.contributorCapCancelCi = null; - } else { - const contributorCapCancelCi = normalizeOptionalBoolean(r.contributorCapCancelCi, "settings.contributorCapCancelCi", warnings); - if (contributorCapCancelCi !== null) out.contributorCapCancelCi = contributorCapCancelCi; - } - // Review-request nagging cooldown (#2463): throttle a contributor repeatedly pinging @gittensory for review. - const reviewNagPolicy = normalizeOptionalEnum(r.reviewNagPolicy, "settings.reviewNagPolicy", ["off", "hold", "close"] as const, warnings); - if (reviewNagPolicy !== null) out.reviewNagPolicy = reviewNagPolicy; - const reviewNagMaxPings = normalizeOptionalPositiveInteger(r.reviewNagMaxPings, "settings.reviewNagMaxPings", warnings); - if (reviewNagMaxPings !== null) out.reviewNagMaxPings = reviewNagMaxPings; - const reviewNagCooldownDays = normalizeOptionalPositiveInteger(r.reviewNagCooldownDays, "settings.reviewNagCooldownDays", warnings); - if (reviewNagCooldownDays !== null && reviewNagCooldownDays <= MAX_REVIEW_NAG_COOLDOWN_DAYS) out.reviewNagCooldownDays = reviewNagCooldownDays; - if (reviewNagCooldownDays !== null && reviewNagCooldownDays > MAX_REVIEW_NAG_COOLDOWN_DAYS) { - warnings.push(`Manifest field "settings.reviewNagCooldownDays" must be at most ${MAX_REVIEW_NAG_COOLDOWN_DAYS}; ignoring it.`); - } - // #label-scoping: same load-bearing-null idiom as blacklistLabel above. - if (r.reviewNagLabel === null) { - out.reviewNagLabel = null; - } else { - const reviewNagLabel = normalizeOptionalString(r.reviewNagLabel, "settings.reviewNagLabel", warnings); - if (reviewNagLabel !== null) out.reviewNagLabel = reviewNagLabel; - } - // Maintainer-mention nag moderation (#label-scoping): GitHub logins ALSO throttled under the review-nag - // cooldown above, on top of the bot's own @gittensory handle. Only set it when at least one VALID login - // survives normalization, so a malformed block never blanks the DB-configured list via the resolver's - // `{...dbSettings, ...manifest.settings}` overlay (same reasoning as autoCloseExemptLogins below). - if (r.reviewNagMonitoredMentions !== undefined) { - const { logins: monitoredMentions, warnings: monitoredMentionWarnings } = normalizeAutoCloseExemptLogins(r.reviewNagMonitoredMentions); - warnings.push(...monitoredMentionWarnings); - if (monitoredMentions.length > 0) out.reviewNagMonitoredMentions = monitoredMentions; - } - // Shared repo-scoped exemption list (#2463): only set it when at least one VALID login survives - // normalization, so a malformed block never blanks the DB-configured list via the resolver's overlay. - if (r.autoCloseExemptLogins !== undefined) { - const { logins, warnings: exemptWarnings } = normalizeAutoCloseExemptLogins(r.autoCloseExemptLogins); - warnings.push(...exemptWarnings); - if (logins.length > 0) out.autoCloseExemptLogins = logins; - } - // Hard manual-review guardrails are config-as-code only. Arrays replace lower layers wholesale, so only an - // explicit [] or a non-empty valid list replaces a private global setting. Null/malformed values are ignored - // instead of clearing. - if (Array.isArray(r.hardGuardrailGlobs)) { - const hardGuardrailGlobs = normalizeStringList(r.hardGuardrailGlobs, "settings.hardGuardrailGlobs", warnings); - if (r.hardGuardrailGlobs.length === 0 || hardGuardrailGlobs.length > 0) { - out.hardGuardrailGlobs = hardGuardrailGlobs; - } else { - warnings.push(`Manifest "settings.hardGuardrailGlobs" did not contain any valid path globs; ignoring it and keeping any existing guardrails.`); - } - } else if (r.hardGuardrailGlobs !== undefined) { - warnings.push(`Manifest "settings.hardGuardrailGlobs" must be an array of path globs; ignoring it and keeping any existing guardrails.`); - } - // Manual-review label is deliberately separate from review_state_label so operators can use one hold label - // without enabling the old ready/changes disposition labels. Null disables only the label, not the hold. - if (r.manualReviewLabel === null) { - out.manualReviewLabel = null; - } else { - const manualReviewLabel = normalizeOptionalString(r.manualReviewLabel, "settings.manualReviewLabel", warnings); - if (manualReviewLabel !== null) out.manualReviewLabel = manualReviewLabel; - } - if (r.readyToMergeLabel === null) { - out.readyToMergeLabel = null; - } else { - const readyToMergeLabel = normalizeOptionalString(r.readyToMergeLabel, "settings.readyToMergeLabel", warnings); - if (readyToMergeLabel !== null) out.readyToMergeLabel = readyToMergeLabel; - } - if (r.changesRequestedLabel === null) { - out.changesRequestedLabel = null; - } else { - const changesRequestedLabel = normalizeOptionalString(r.changesRequestedLabel, "settings.changesRequestedLabel", warnings); - if (changesRequestedLabel !== null) out.changesRequestedLabel = changesRequestedLabel; - } - if (r.migrationCollisionLabel === null) { - out.migrationCollisionLabel = null; - } else { - const migrationCollisionLabel = normalizeOptionalString(r.migrationCollisionLabel, "settings.migrationCollisionLabel", warnings); - if (migrationCollisionLabel !== null) out.migrationCollisionLabel = migrationCollisionLabel; - } - if (r.pendingClosureLabel === null) { - out.pendingClosureLabel = null; - } else { - const pendingClosureLabel = normalizeOptionalString(r.pendingClosureLabel, "settings.pendingClosureLabel", warnings); - if (pendingClosureLabel !== null) out.pendingClosureLabel = pendingClosureLabel; - } - // Account-age throttle (#2561): an explicit yml `null` is load-bearing (clears a DB-configured threshold - // back to "off"), matching contributorOpenPrCap's own null-vs-omitted distinction above. - if (r.accountAgeThresholdDays === null) { - out.accountAgeThresholdDays = null; - } else { - const accountAgeThresholdDays = normalizeOptionalPositiveInteger(r.accountAgeThresholdDays, "settings.accountAgeThresholdDays", warnings); - if (accountAgeThresholdDays !== null) out.accountAgeThresholdDays = accountAgeThresholdDays; - } - const newAccountLabel = normalizeOptionalString(r.newAccountLabel, "settings.newAccountLabel", warnings); - if (newAccountLabel !== null) out.newAccountLabel = newAccountLabel; - // Per-command @gittensory rate limit (#2560): generalizes review-nag's cooldown pattern to every command. - const commandRateLimitPolicy = normalizeOptionalEnum(r.commandRateLimitPolicy, "settings.commandRateLimitPolicy", ["off", "hold"] as const, warnings); - if (commandRateLimitPolicy !== null) out.commandRateLimitPolicy = commandRateLimitPolicy; - const commandRateLimitMaxPerWindow = normalizeOptionalPositiveInteger(r.commandRateLimitMaxPerWindow, "settings.commandRateLimitMaxPerWindow", warnings); - if (commandRateLimitMaxPerWindow !== null) out.commandRateLimitMaxPerWindow = commandRateLimitMaxPerWindow; - const commandRateLimitAiMaxPerWindow = normalizeOptionalPositiveInteger(r.commandRateLimitAiMaxPerWindow, "settings.commandRateLimitAiMaxPerWindow", warnings); - if (commandRateLimitAiMaxPerWindow !== null) out.commandRateLimitAiMaxPerWindow = commandRateLimitAiMaxPerWindow; - const commandRateLimitWindowHours = normalizeOptionalPositiveInteger(r.commandRateLimitWindowHours, "settings.commandRateLimitWindowHours", warnings); - if (commandRateLimitWindowHours !== null) out.commandRateLimitWindowHours = commandRateLimitWindowHours; - // Moderation-rules engine (#selfhost-mod-engine): per-repo override of the global moderation config. - const moderationGateMode = normalizeOptionalEnum(r.moderationGateMode, "settings.moderationGateMode", ["inherit", "off", "enabled"] as const, warnings); - if (moderationGateMode !== null) out.moderationGateMode = moderationGateMode; - // #gate-flagged: normalizeModerationRules returns an EMPTY rules array for two semantically different - // inputs -- a genuinely empty yml list (`moderationRules: []`, an intentional "opt every rule out for this - // repo") and a MALFORMED one (a non-array, or an array where every entry fails validation) that degrades to - // empty as its safe fallback. Applying the malformed case as an override would silently disable every rule - // for this repo instead of leaving the DB-configured value intact, so the two must be told apart by the RAW - // input's own shape -- not just the normalized result -- before assigning. A PARTIAL list (some valid, some - // invalid entries) still applies the surviving valid subset, mirroring autoCloseExemptLogins' behavior. - if (r.moderationRules !== undefined) { - const { rules, warnings: moderationRuleWarnings } = normalizeModerationRules(r.moderationRules); - warnings.push(...moderationRuleWarnings); - const intentionalEmptyList = Array.isArray(r.moderationRules) && r.moderationRules.length === 0; - if (rules.length > 0 || intentionalEmptyList) out.moderationRules = rules; - } - const moderationWarningLabel = normalizeModerationLabel(r.moderationWarningLabel); - if (moderationWarningLabel !== undefined) out.moderationWarningLabel = moderationWarningLabel; - const moderationBannedLabel = normalizeModerationLabel(r.moderationBannedLabel); - if (moderationBannedLabel !== undefined) out.moderationBannedLabel = moderationBannedLabel; - // Review-evasion protection (#review-evasion-protection): a contributor closing/converting-to-draft their - // own PR while gittensory has an active review pass running is dodging the one-shot review. - const reviewEvasionProtection = normalizeOptionalEnum(r.reviewEvasionProtection, "settings.reviewEvasionProtection", ["off", "close"] as const, warnings); - if (reviewEvasionProtection !== null) out.reviewEvasionProtection = reviewEvasionProtection; - // #label-scoping: same load-bearing-null idiom as blacklistLabel above. - if (r.reviewEvasionLabel === null) { - out.reviewEvasionLabel = null; - } else { - const reviewEvasionLabel = normalizeOptionalString(r.reviewEvasionLabel, "settings.reviewEvasionLabel", warnings); - if (reviewEvasionLabel !== null) out.reviewEvasionLabel = reviewEvasionLabel; - } - const reviewEvasionComment = normalizeOptionalBoolean(r.reviewEvasionComment, "settings.reviewEvasionComment", warnings); - if (reviewEvasionComment !== null) out.reviewEvasionComment = reviewEvasionComment; - return out; -} - -/** Serialize the settings override for the cache round-trip; returns null when nothing is set. */ -export function settingsOverrideToJson(settings: FocusManifestSettings): JsonValue { - if (Object.keys(settings).length === 0) return null; - return { ...settings } as Record; -} - -/** A bounded, PUBLIC-SAFE maintainer string (footer/note). Trimmed, length-capped, and rejected with a - * warning if it contains any forbidden public term — it is then dropped, never published. */ -function parsePublicSafeText(value: JsonValue | undefined, field: string, warnings: string[]): string | null { - const text = normalizeOptionalString(value, field, warnings); - if (text === null) return null; - const bounded = text.length > MAX_ITEM_LENGTH ? text.slice(0, MAX_ITEM_LENGTH) : text; - if (!isFocusManifestPublicSafe(bounded)) { - warnings.push(`Manifest "${field}" contains content that is not public-safe; ignoring it.`); - return null; - } - return bounded; -} - -/** - * Parse the optional `review:` block — maintainer overrides for the public review-panel content. Never - * throws; invalid/unsafe values are dropped with warnings. - */ -function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestReviewConfig { - const empty: FocusManifestReviewConfig = { 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, testGeneration: null, impactMap: null, cultureProfile: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { ...EMPTY_MAX_FINDINGS_CONFIG }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, labelingRules: [], aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null }; - if (value === undefined || value === null) return empty; - if (typeof value !== "object" || Array.isArray(value)) { - warnings.push(`Manifest field "review" must be a mapping; ignoring it.`); - return empty; - } - const r = value as Record; - const footerRecord = r.footer !== null && typeof r.footer === "object" && !Array.isArray(r.footer) ? (r.footer as Record) : undefined; - if (r.footer !== undefined && r.footer !== null && footerRecord === undefined) warnings.push(`Manifest "review.footer" must be a mapping; ignoring it.`); - const fieldsRecord = r.fields !== null && typeof r.fields === "object" && !Array.isArray(r.fields) ? (r.fields as Record) : undefined; - if (r.fields !== undefined && r.fields !== null && fieldsRecord === undefined) warnings.push(`Manifest "review.fields" must be a mapping; ignoring it.`); - const fields: Partial> = {}; - if (fieldsRecord) { - for (const key of REVIEW_FIELD_KEYS) { - const flag = normalizeOptionalBoolean(fieldsRecord[key], `review.fields.${key}`, warnings); - if (flag !== null) fields[key] = flag; - } - } - const enrichmentRecord = r.enrichment !== null && typeof r.enrichment === "object" && !Array.isArray(r.enrichment) ? (r.enrichment as Record) : undefined; - if (r.enrichment !== undefined && r.enrichment !== null && enrichmentRecord === undefined) warnings.push(`Manifest "review.enrichment" must be a mapping; ignoring it.`); - const enrichmentAnalyzers: Partial> = {}; - if (enrichmentRecord) { - for (const key of Object.keys(enrichmentRecord)) { - if (!REES_ANALYZER_NAME_SET.has(key)) { - warnings.push(`Manifest "review.enrichment" has unknown analyzer "${key}"; ignoring it.`); - continue; - } - const flag = normalizeOptionalBoolean(enrichmentRecord[key], `review.enrichment.${key}`, warnings); - if (flag !== null) enrichmentAnalyzers[key as ReesAnalyzerName] = flag; - } - } - const footerText = footerRecord ? parsePublicSafeText(footerRecord.text, "review.footer.text", warnings) : null; - const note = parsePublicSafeText(r.note, "review.note", warnings); - const profile = parseReviewProfile(r.profile, warnings); - const tone = parsePublicSafeText(r.tone, "review.tone", warnings); - const securityFocus = normalizeOptionalBoolean(r.security_focus, "review.security_focus", warnings); - const inlineComments = normalizeOptionalBoolean(r.inline_comments, "review.inline_comments", warnings); - const fixHandoff = normalizeOptionalBoolean(r.fixHandoff, "review.fixHandoff", warnings); - const autoMergeSummary = normalizeOptionalBoolean(r.auto_merge_summary, "review.auto_merge_summary", warnings); - const suggestions = normalizeOptionalBoolean(r.suggestions, "review.suggestions", warnings); - const changedFilesSummary = normalizeOptionalBoolean(r.changed_files_summary, "review.changed_files_summary", warnings); - const effortScore = normalizeOptionalBoolean(r.effort_score, "review.effort_score", warnings); - const testGeneration = normalizeOptionalBoolean(r.test_generation, "review.test_generation", warnings); - const impactMap = normalizeOptionalBoolean(r.impact_map, "review.impact_map", warnings); - const cultureProfile = normalizeOptionalBoolean(r.culture_profile, "review.culture_profile", warnings); - const reviewMemory = normalizeOptionalBoolean(r.memory, "review.memory", warnings); - const findingCategories = normalizeOptionalBoolean(r.finding_categories, "review.finding_categories", warnings); - const inlineCommentsPerCategory = normalizeOptionalNonNegativeInt( - r.inline_comments_per_category, - "review.inline_comments_per_category", - warnings, - ); - const minFindingSeverity = normalizeOptionalEnum( - r.min_finding_severity, - "review.min_finding_severity", - REVIEW_FINDING_SEVERITY_LADDER, - warnings, - ); - const maxFindings = parseMaxFindingsConfig(r.max_findings, warnings); - const commentVerbosity = normalizeOptionalEnum(r.comment_verbosity, "review.comment_verbosity", COMMENT_VERBOSITY_LEVELS, warnings); - const pathInstructions = parseReviewPathInstructions(r.path_instructions, warnings); - const instructions = parsePublicSafeText(r.instructions, "review.instructions", warnings); - const excludePaths = parseReviewExcludePaths(r.exclude_paths, warnings); - const pathFilters = parseReviewPathFilters(r.path_filters, warnings); - const preMergeChecks = parseReviewPreMergeChecks(r.pre_merge_checks, warnings); - const autoReview = parseAutoReviewConfig(r.auto_review, warnings); - const labelingRules = parseReviewLabelingRules(r.labeling_rules, warnings); - const aiModel = parseSelfHostAiModelConfig(r.ai_model, warnings); - const visual = parseVisualConfig(r.visual, warnings); - const linkedIssueSatisfaction = normalizeOptionalEnum(r.linkedIssueSatisfaction, "review.linkedIssueSatisfaction", LINKED_ISSUE_SATISFACTION_MODES, warnings); - return { - present: - footerText !== null || - note !== null || - profile !== null || - tone !== null || - securityFocus !== null || - inlineComments !== null || - fixHandoff !== null || - autoMergeSummary !== null || - suggestions !== null || - changedFilesSummary !== null || - effortScore !== null || - testGeneration !== null || - impactMap !== null || - cultureProfile !== null || - reviewMemory !== null || - findingCategories !== null || - inlineCommentsPerCategory !== null || - minFindingSeverity !== null || - maxFindingsPresent(maxFindings) || - commentVerbosity !== null || - pathInstructions.length > 0 || - instructions !== null || - excludePaths.length > 0 || - pathFilters.length > 0 || - preMergeChecks.length > 0 || - autoReviewPresent(autoReview) || - labelingRules.length > 0 || - selfHostAiModelPresent(aiModel) || - visualConfigPresent(visual) || - linkedIssueSatisfaction !== null || - Object.keys(fields).length > 0 || - Object.keys(enrichmentAnalyzers).length > 0, - footerText, - note, - fields, - autoReview, - aiModel, - visual, - linkedIssueSatisfaction, - testGeneration, - enrichmentAnalyzers, - profile, - tone, - securityFocus, - inlineComments, - fixHandoff, - autoMergeSummary, - suggestions, - changedFilesSummary, - effortScore, - impactMap, - cultureProfile, - reviewMemory, - findingCategories, - inlineCommentsPerCategory, - minFindingSeverity, - maxFindings, - commentVerbosity, - pathInstructions, - instructions, - excludePaths, - pathFilters, - preMergeChecks, - labelingRules, - }; -} - -function maxFindingsPresent(config: MaxFindingsConfig): boolean { - return config.blockers !== null || config.nits !== null; -} - -/** Parse `review.max_findings` — optional non-negative caps for blockers/nits display in the unified comment. */ -function parseMaxFindingsConfig(value: JsonValue | undefined, warnings: string[]): MaxFindingsConfig { - if (value === undefined || value === null) return { ...EMPTY_MAX_FINDINGS_CONFIG }; - if (typeof value !== "object" || Array.isArray(value)) { - warnings.push(`Manifest "review.max_findings" must be a mapping; ignoring it.`); - return { ...EMPTY_MAX_FINDINGS_CONFIG }; - } - const record = value as Record; - return { - blockers: normalizeOptionalNonNegativeInt(record.blockers, "review.max_findings.blockers", warnings), - nits: normalizeOptionalNonNegativeInt(record.nits, "review.max_findings.nits", warnings), - }; -} - -/** The reserved label namespace Gittensor uses for scoring/type/priority (`gittensor:bug`, `gittensor:feature`, - * `gittensor:priority`, …). A maintainer's `labeling_rules` must not drive these — they're managed by the scorer - * and the type-labeler, never by ad-hoc manifest rules — so any `gittensor:`-prefixed label is refused at parse. */ -const RESERVED_LABEL_PREFIX = "gittensor:"; - -function parseReviewLabelingRules(value: JsonValue | undefined, warnings: string[]): LabelingRule[] { - if (value === undefined || value === null) return []; - if (!Array.isArray(value)) { - warnings.push(`Manifest "review.labeling_rules" must be a list of rules; ignoring it.`); - return []; - } - const out: LabelingRule[] = []; - for (const [index, entry] of value.entries()) { - if (out.length >= MAX_PATH_INSTRUCTIONS) { - warnings.push(`Manifest "review.labeling_rules" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`); - break; - } - if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { - warnings.push(`Manifest "review.labeling_rules[${index}]" must be a mapping; ignoring it.`); - continue; - } - const e = entry as Record; - const label = e.label === undefined || e.label === null ? null : parsePublicSafeText(e.label, `review.labeling_rules[${index}].label`, warnings); - if (label === null) { - if (e.label === undefined || e.label === null) warnings.push(`Manifest "review.labeling_rules[${index}].label" is required; ignoring the entry.`); - continue; // non-string / empty / not-public-safe already warned by parsePublicSafeText - } - if (label.toLowerCase().startsWith(RESERVED_LABEL_PREFIX)) { - warnings.push(`Manifest "review.labeling_rules[${index}].label" ("${label}") uses the reserved "${RESERVED_LABEL_PREFIX}" namespace; ignoring the entry.`); - continue; - } - const titleContains = e.title_contains === undefined || e.title_contains === null ? null : parsePublicSafeText(e.title_contains, `review.labeling_rules[${index}].title_contains`, warnings); - const descriptionContains = e.description_contains === undefined || e.description_contains === null ? null : parsePublicSafeText(e.description_contains, `review.labeling_rules[${index}].description_contains`, warnings); - const whenPaths = parseManifestGlobList(e.when_paths, `review.labeling_rules[${index}].when_paths`, warnings); - if (whenPaths.length === 0 && titleContains === null && descriptionContains === null) { - warnings.push(`Manifest "review.labeling_rules[${index}]" needs at least one of when_paths / title_contains / description_contains; ignoring it.`); - continue; - } - out.push({ label, whenPaths, titleContains, descriptionContains }); - } - return out; -} - -function autoReviewPresent(config: AutoReviewConfig): boolean { - return ( - config.skipDrafts !== null || - config.ignoreAuthors.length > 0 || - config.ignoreTitleKeywords.length > 0 || - config.skipLabels.length > 0 || - config.skipDocsOnly !== null || - config.maxAddedLines > 0 || - config.maxFiles > 0 || - config.baseBranches.length > 0 || - config.autoPauseAfterReviewedCommits !== null - ); -} - -/** Parse `review.auto_review` — deterministic AI review eligibility filters. (#1954 / #2038–#2041) */ -function parseAutoReviewConfig(value: JsonValue | undefined, warnings: string[]): AutoReviewConfig { - if (value === undefined || value === null) return { ...EMPTY_AUTO_REVIEW_CONFIG }; - if (typeof value !== "object" || Array.isArray(value)) { - warnings.push(`Manifest field "review.auto_review" must be a mapping; ignoring it.`); - return { ...EMPTY_AUTO_REVIEW_CONFIG }; - } - const record = value as Record; - return { - skipDrafts: normalizeOptionalBoolean(record.skip_drafts, "review.auto_review.skip_drafts", warnings), - ignoreAuthors: parseManifestGlobList(record.ignore_authors, "review.auto_review.ignore_authors", warnings), - ignoreTitleKeywords: parseAutoReviewTitleKeywords(record.ignore_title_keywords, warnings), - skipLabels: parseAutoReviewSkipLabels(record.skip_labels, warnings), - skipDocsOnly: normalizeOptionalBoolean(record.skip_docs_only, "review.auto_review.skip_docs_only", warnings), - maxAddedLines: normalizeAutoReviewSizeCap(record.max_added_lines, "review.auto_review.max_added_lines", warnings), - maxFiles: normalizeAutoReviewSizeCap(record.max_files, "review.auto_review.max_files", warnings), - baseBranches: parseManifestGlobList(record.base_branches, "review.auto_review.base_branches", warnings), - autoPauseAfterReviewedCommits: normalizeOptionalNonNegativeInt( - record.auto_pause_after_reviewed_commits, - "review.auto_review.auto_pause_after_reviewed_commits", - warnings, - ), - }; -} - -function selfHostAiModelPresent(config: SelfHostAiModelConfig): boolean { - return ( - config.claudeModel !== null || - config.claudeEffort !== null || - config.codexModel !== null || - config.codexEffort !== null - ); -} - -/** Parse `review.ai_model` — per-repo self-host reviewer model/effort overrides. Values are opaque, bounded, - * public-safe strings (like `review.tone`) — never validated against a fixed model/effort enum here, so this - * parser never drifts from the provider's own effort allowlist (`src/selfhost/ai.ts`); an invalid effort value - * degrades the SAME way an invalid env-sourced one already does (falls back to "medium" at resolve time). - * (#selfhost-ai-model-override) */ -function parseSelfHostAiModelConfig(value: JsonValue | undefined, warnings: string[]): SelfHostAiModelConfig { - if (value === undefined || value === null) return { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }; - if (typeof value !== "object" || Array.isArray(value)) { - warnings.push(`Manifest field "review.ai_model" must be a mapping; ignoring it.`); - return { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }; - } - const record = value as Record; - return { - claudeModel: parsePublicSafeText(record.claude_model, "review.ai_model.claude_model", warnings), - claudeEffort: parsePublicSafeText(record.claude_effort, "review.ai_model.claude_effort", warnings), - codexModel: parsePublicSafeText(record.codex_model, "review.ai_model.codex_model", warnings), - codexEffort: parsePublicSafeText(record.codex_effort, "review.ai_model.codex_effort", warnings), - }; -} - -function visualConfigPresent(config: VisualConfig): boolean { - return config.preview.urlTemplate !== null || config.routes.paths.length > 0 || config.routes.maxRoutes !== null || config.themes.length > 0 || config.gif; -} - -const VISUAL_THEME_VALUES: readonly VisualTheme[] = ["light", "dark"]; - -/** Parse `review.visual.themes` — which `prefers-color-scheme` variants to capture (#3678). Empty/default ⇒ - * the capture pipeline falls back to a single light-theme render, byte-identical to today. Unlike - * `routes.paths` (an open-ended glob list), this is a closed 2-value enum, so entries are validated against - * it directly rather than reusing the generic glob-list parser. */ -function parseVisualThemes(value: JsonValue | undefined, warnings: string[]): VisualTheme[] { - if (value === undefined || value === null) return []; - if (!Array.isArray(value)) { - warnings.push(`Manifest "review.visual.themes" must be a list of "light"/"dark"; ignoring it.`); - return []; - } - const out: VisualTheme[] = []; - for (const [index, entry] of value.entries()) { - const theme = typeof entry === "string" ? (entry.trim().toLowerCase() as VisualTheme) : undefined; - if (!theme || !VISUAL_THEME_VALUES.includes(theme)) { - warnings.push(`Manifest "review.visual.themes[${index}]" must be "light" or "dark"; ignoring it.`); - continue; - } - if (!out.includes(theme)) out.push(theme); - } - return out; -} - -// `{number}`/`{head_sha}`/`{head_sha_short}` are GitHub-controlled facts about the PR (never attacker-supplied -// free text), so substitution itself carries no injection risk. The dummy values here exist only to make the -// TEMPLATE STRING (which a maintainer authored, and could still typo) validate as a well-formed HTTPS URL -// before it's ever used — see parseVisualUrlTemplate below. -const VISUAL_URL_TEMPLATE_DUMMY_VARS: Record = { - "{number}": "1", - "{head_sha_short}": "0000000", - "{head_sha}": "0000000000000000000000000000000000000000", -}; - -/** Parse `review.visual.preview.url_template` — validated at CONFIG-READ time against the exact same SSRF - * guard (`isSafeHttpUrl`) the renderer itself unconditionally applies to every URL it navigates to, - * regardless of source (`src/review/visual/shot.ts`). This is deliberately redundant with that runtime - * check, not a replacement for it — it exists so a maintainer sees a warning immediately for a malformed - * template (e.g. a typo'd scheme, or an accidental internal host) instead of only discovering it later as - * a silently-blank "after" cell. Placeholders are substituted with dummy values before validation since the - * raw template (e.g. `https://pr-{number}.example.com`) is not itself a parseable URL. */ -function parseVisualUrlTemplate(value: JsonValue | undefined, warnings: string[]): string | null { - const template = parsePublicSafeText(value, "review.visual.preview.url_template", warnings); - if (template === null) return null; - let probe = template; - for (const [placeholder, dummy] of Object.entries(VISUAL_URL_TEMPLATE_DUMMY_VARS)) probe = probe.split(placeholder).join(dummy); - if (!isSafeHttpUrl(probe)) { - warnings.push(`Manifest "review.visual.preview.url_template" must be a valid HTTPS URL (with {number}/{head_sha}/{head_sha_short} placeholders substituted) targeting a public host; ignoring it.`); - return null; - } - return template; -} - -/** Parse `review.visual` — per-repo before/after screenshot-capture config (#3609 preview / #3610 routes / - * #3678 themes). */ -function parseVisualConfig(value: JsonValue | undefined, warnings: string[]): VisualConfig { - if (value === undefined || value === null) return { ...EMPTY_VISUAL_CONFIG }; - if (typeof value !== "object" || Array.isArray(value)) { - warnings.push(`Manifest field "review.visual" must be a mapping; ignoring it.`); - return { ...EMPTY_VISUAL_CONFIG }; - } - const record = value as Record; - - const previewRecord = record.preview !== null && typeof record.preview === "object" && !Array.isArray(record.preview) ? (record.preview as Record) : undefined; - if (record.preview !== undefined && record.preview !== null && previewRecord === undefined) { - warnings.push(`Manifest "review.visual.preview" must be a mapping; ignoring it.`); - } - const urlTemplate = previewRecord ? parseVisualUrlTemplate(previewRecord.url_template, warnings) : null; - - const routesRecord = record.routes !== null && typeof record.routes === "object" && !Array.isArray(record.routes) ? (record.routes as Record) : undefined; - if (record.routes !== undefined && record.routes !== null && routesRecord === undefined) { - warnings.push(`Manifest "review.visual.routes" must be a mapping; ignoring it.`); - } - const paths = routesRecord ? parseManifestGlobList(routesRecord.paths, "review.visual.routes.paths", warnings) : []; - const maxRoutes = routesRecord ? normalizeOptionalVisualMaxRoutes(routesRecord.max_routes, warnings) : null; - - const themes = parseVisualThemes(record.themes, warnings); - const gif = normalizeOptionalBoolean(record.gif, "review.visual.gif", warnings) === true; - - return { preview: { urlTemplate }, routes: { paths, maxRoutes }, themes, gif }; -} - -function parseAutoReviewTitleKeywords(value: JsonValue | undefined, warnings: string[]): string[] { - if (value === undefined || value === null) return []; - if (!Array.isArray(value)) { - warnings.push(`Manifest "review.auto_review.ignore_title_keywords" must be a list of strings; ignoring it.`); - return []; - } - const out: string[] = []; - for (const [index, entry] of value.entries()) { - if (out.length >= MAX_PATH_INSTRUCTIONS) { - warnings.push(`Manifest "review.auto_review.ignore_title_keywords" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`); - break; - } - const raw = typeof entry === "string" ? entry.trim() : ""; - if (!raw) { - warnings.push(`Manifest "review.auto_review.ignore_title_keywords[${index}]" must be a non-empty string; ignoring it.`); - continue; - } - const safe = parsePublicSafeText(raw, `review.auto_review.ignore_title_keywords[${index}]`, warnings); - if (safe !== null) out.push(safe); - } - return out; -} - -function parseAutoReviewSkipLabels(value: JsonValue | undefined, warnings: string[]): string[] { - if (value === undefined || value === null) return []; - if (!Array.isArray(value)) { - warnings.push(`Manifest "review.auto_review.skip_labels" must be a list of strings; ignoring it.`); - return []; - } - const seen = new Set(); - const out: string[] = []; - for (const [index, entry] of value.entries()) { - if (out.length >= MAX_PATH_INSTRUCTIONS) { - warnings.push(`Manifest "review.auto_review.skip_labels" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`); - break; - } - const raw = typeof entry === "string" ? entry.trim() : ""; - if (!raw) { - warnings.push(`Manifest "review.auto_review.skip_labels[${index}]" must be a non-empty string; ignoring it.`); - continue; - } - const safe = parsePublicSafeText(raw, `review.auto_review.skip_labels[${index}]`, warnings); - if (safe === null) continue; - const key = safe.toLowerCase(); - if (seen.has(key)) continue; - seen.add(key); - out.push(key); - } - return out; -} - -/** Parse `review.pre_merge_checks` — an array of DETERMINISTIC pre-merge assertions. Each entry needs a non-empty - * public-safe `name` and at least ONE assertion (`title_contains` / `description_contains` / `require_label`, - * each public-safe); `when_paths` (optional) gates the check to PRs touching a matching glob; `enforce` (default - * false) makes a failure a hard blocker. Invalid entries are dropped with a warning; capped at - * MAX_PATH_INSTRUCTIONS so a hostile manifest can't bloat the gate. (#review-pre-merge-checks) */ -function parseReviewPreMergeChecks(value: JsonValue | undefined, warnings: string[]): PreMergeCheck[] { - if (value === undefined || value === null) return []; - if (!Array.isArray(value)) { - warnings.push(`Manifest "review.pre_merge_checks" must be a list of checks; ignoring it.`); - return []; - } - const out: PreMergeCheck[] = []; - for (const [index, entry] of value.entries()) { - if (out.length >= MAX_PATH_INSTRUCTIONS) { - warnings.push(`Manifest "review.pre_merge_checks" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`); - break; - } - if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { - warnings.push(`Manifest "review.pre_merge_checks[${index}]" must be a mapping; ignoring it.`); - continue; - } - const e = entry as Record; - if (e.name === undefined || e.name === null) { - warnings.push(`Manifest "review.pre_merge_checks[${index}].name" is required; ignoring the entry.`); - continue; - } - const name = parsePublicSafeText(e.name, `review.pre_merge_checks[${index}].name`, warnings); - if (name === null) continue; // non-string / empty / not-public-safe → already warned - const titleContains = e.title_contains === undefined || e.title_contains === null ? null : parsePublicSafeText(e.title_contains, `review.pre_merge_checks[${index}].title_contains`, warnings); - const descriptionContains = e.description_contains === undefined || e.description_contains === null ? null : parsePublicSafeText(e.description_contains, `review.pre_merge_checks[${index}].description_contains`, warnings); - const requireLabel = e.require_label === undefined || e.require_label === null ? null : parsePublicSafeText(e.require_label, `review.pre_merge_checks[${index}].require_label`, warnings); - if (titleContains === null && descriptionContains === null && requireLabel === null) { - warnings.push(`Manifest "review.pre_merge_checks[${index}]" needs at least one of title_contains / description_contains / require_label; ignoring it.`); - continue; - } - const whenPaths = parseManifestGlobList(e.when_paths, `review.pre_merge_checks[${index}].when_paths`, warnings); - const enforce = normalizeOptionalBoolean(e.enforce, `review.pre_merge_checks[${index}].enforce`, warnings) === true; - out.push({ name, whenPaths, titleContains, descriptionContains, requireLabel, enforce }); - } - return out; -} - -/** Parse a manifest glob list (e.g. `review.exclude_paths`, a check's `when_paths`) — an array of non-empty - * string globs; blanks/non-strings are dropped with a warning. Capped at MAX_PATH_INSTRUCTIONS so a hostile - * manifest can't bloat the matcher. `fieldLabel` makes the warnings name the right field. */ -function parseManifestGlobList(value: JsonValue | undefined, fieldLabel: string, warnings: string[]): string[] { - if (value === undefined || value === null) return []; - if (!Array.isArray(value)) { - warnings.push(`Manifest "${fieldLabel}" must be a list of path globs; ignoring it.`); - return []; - } - const out: string[] = []; - const seen = new Set(); - for (const [index, entry] of value.entries()) { - const glob = typeof entry === "string" ? entry.trim() : ""; - if (!glob) { - warnings.push(`Manifest "${fieldLabel}[${index}]" must be a non-empty string; ignoring it.`); - continue; - } - if (glob.length > MAX_ITEM_LENGTH) { - warnings.push(`Manifest "${fieldLabel}[${index}]" exceeds ${MAX_ITEM_LENGTH} chars; ignoring it.`); - continue; - } - const key = glob.toLowerCase(); - if (seen.has(key)) continue; - if (out.length >= MAX_PATH_INSTRUCTIONS) { - warnings.push(`Manifest "${fieldLabel}" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`); - break; - } - seen.add(key); - out.push(glob); - } - return out; -} - -/** Parse `review.exclude_paths` — globs whose matching files are excluded from the AI review. (#review-exclude-paths) */ -function parseReviewExcludePaths(value: JsonValue | undefined, warnings: string[]): string[] { - return parseManifestGlobList(value, "review.exclude_paths", warnings); -} - -/** Parse `review.path_filters` — include globs plus optional leading-`!` negation entries. (#2043) */ -function parseReviewPathFilters(value: JsonValue | undefined, warnings: string[]): string[] { - if (value === undefined || value === null) return []; - if (!Array.isArray(value)) { - warnings.push(`Manifest "review.path_filters" must be a list of path globs; ignoring it.`); - return []; - } - const out: string[] = []; - for (const [index, entry] of value.entries()) { - if (out.length >= MAX_PATH_INSTRUCTIONS) { - warnings.push(`Manifest "review.path_filters" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`); - break; - } - const raw = typeof entry === "string" ? entry.trim() : ""; - if (!raw) { - warnings.push(`Manifest "review.path_filters[${index}]" must be a non-empty string; ignoring it.`); - continue; - } - const negated = raw.startsWith("!"); - const glob = negated ? raw.slice(1).trim() : raw; - if (!glob) { - warnings.push(`Manifest "review.path_filters[${index}]" must include a glob after a leading '!'; ignoring it.`); - continue; - } - if (glob.length > MAX_ITEM_LENGTH) { - warnings.push(`Manifest "review.path_filters[${index}]" exceeds ${MAX_ITEM_LENGTH} chars; ignoring it.`); - continue; - } - out.push(negated ? `!${glob}` : glob); - } - return out; -} - -/** Parse `review.path_instructions` — an array of `{ path, instructions }` entries. Each must have a non-empty - * string `path` (a manifest glob) and PUBLIC-SAFE string `instructions`; invalid/unsafe entries are dropped with - * a warning. Capped at MAX_PATH_INSTRUCTIONS so a huge manifest can't bloat the reviewer prompt. */ -function parseReviewPathInstructions(value: JsonValue | undefined, warnings: string[]): ReviewPathInstruction[] { - if (value === undefined || value === null) return []; - if (!Array.isArray(value)) { - warnings.push(`Manifest "review.path_instructions" must be a list of { path, instructions }; ignoring it.`); - return []; - } - const out: ReviewPathInstruction[] = []; - for (const [index, entry] of value.entries()) { - if (out.length >= MAX_PATH_INSTRUCTIONS) { - warnings.push(`Manifest "review.path_instructions" is capped at ${MAX_PATH_INSTRUCTIONS} entries; dropping the rest.`); - break; - } - if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { - warnings.push(`Manifest "review.path_instructions[${index}]" must be a mapping with path + instructions; ignoring it.`); - continue; - } - const e = entry as Record; - const path = typeof e.path === "string" ? e.path.trim() : ""; - if (!path) { - warnings.push(`Manifest "review.path_instructions[${index}].path" must be a non-empty string; ignoring the entry.`); - continue; - } - if (path.length > MAX_ITEM_LENGTH) { - warnings.push(`Manifest "review.path_instructions[${index}].path" exceeds ${MAX_ITEM_LENGTH} chars; ignoring the entry.`); - continue; - } - if (e.instructions === undefined || e.instructions === null) { - warnings.push(`Manifest "review.path_instructions[${index}].instructions" is required; ignoring the entry.`); - continue; - } - const instructions = parsePublicSafeText(e.instructions, `review.path_instructions[${index}].instructions`, warnings); - if (instructions === null) continue; // non-string / empty / not-public-safe → already warned - out.push({ path, instructions }); - } - return out; -} - -/** Parse `review.profile` — one of chill / balanced / assertive (case-insensitive). `balanced` normalizes to - * null (the default, so the reviewer prompt stays byte-identical). Any other value is ignored with a warning. */ -function parseReviewProfile(value: JsonValue | undefined, warnings: string[]): ReviewProfile | null { - if (value === undefined || value === null) return null; - if (typeof value !== "string") { - warnings.push(`Manifest "review.profile" must be a string (chill | balanced | assertive); ignoring it.`); - return null; - } - const normalized = value.trim().toLowerCase(); - if (normalized === "balanced") return null; // default → no prompt change - if (normalized === "chill" || normalized === "assertive") return normalized; - warnings.push(`Manifest "review.profile" must be one of chill / balanced / assertive; ignoring "${value.slice(0, 32)}".`); - return null; -} - -/** Serialize the review config for the cache round-trip; returns null when nothing is set. */ -export function reviewConfigToJson(review: FocusManifestReviewConfig): JsonValue { - if (!review.present) return null; - const out: Record = {}; - if (review.footerText !== null) out.footer = { text: review.footerText }; - if (review.note !== null) out.note = review.note; - if (review.profile !== null) out.profile = review.profile; - if (review.tone !== null) out.tone = review.tone; - if (review.securityFocus !== null) out.security_focus = review.securityFocus; - if (review.inlineComments !== null) out.inline_comments = review.inlineComments; - if (review.fixHandoff !== null) out.fixHandoff = review.fixHandoff; - if (review.autoMergeSummary !== null) out.auto_merge_summary = review.autoMergeSummary; - if (review.suggestions !== null) out.suggestions = review.suggestions; - if (review.changedFilesSummary !== null) out.changed_files_summary = review.changedFilesSummary; - if (review.effortScore !== null) out.effort_score = review.effortScore; - if (review.testGeneration !== null) out.test_generation = review.testGeneration; - if (review.impactMap !== null) out.impact_map = review.impactMap; - if (review.cultureProfile !== null) out.culture_profile = review.cultureProfile; - if (review.reviewMemory !== null) out.memory = review.reviewMemory; - if (review.findingCategories !== null) out.finding_categories = review.findingCategories; - if (review.inlineCommentsPerCategory !== null) out.inline_comments_per_category = review.inlineCommentsPerCategory; - if (review.minFindingSeverity !== null) out.min_finding_severity = review.minFindingSeverity; - if (maxFindingsPresent(review.maxFindings)) { - const maxFindings: Record = {}; - if (review.maxFindings.blockers !== null) maxFindings.blockers = review.maxFindings.blockers; - if (review.maxFindings.nits !== null) maxFindings.nits = review.maxFindings.nits; - out.max_findings = maxFindings; - } - if (review.commentVerbosity !== null) out.comment_verbosity = review.commentVerbosity; - if (review.instructions !== null) out.instructions = review.instructions; - if (review.pathInstructions.length > 0) out.path_instructions = review.pathInstructions.map((entry) => ({ path: entry.path, instructions: entry.instructions })); - if (review.excludePaths.length > 0) out.exclude_paths = [...review.excludePaths]; - if (review.pathFilters.length > 0) out.path_filters = [...review.pathFilters]; - if (autoReviewPresent(review.autoReview)) { - const autoReview: Record = {}; - if (review.autoReview.skipDrafts !== null) autoReview.skip_drafts = review.autoReview.skipDrafts; - if (review.autoReview.ignoreAuthors.length > 0) autoReview.ignore_authors = [...review.autoReview.ignoreAuthors]; - if (review.autoReview.ignoreTitleKeywords.length > 0) autoReview.ignore_title_keywords = [...review.autoReview.ignoreTitleKeywords]; - if (review.autoReview.skipLabels.length > 0) autoReview.skip_labels = [...review.autoReview.skipLabels]; - if (review.autoReview.skipDocsOnly !== null) autoReview.skip_docs_only = review.autoReview.skipDocsOnly; - if (review.autoReview.maxAddedLines > 0) autoReview.max_added_lines = review.autoReview.maxAddedLines; - if (review.autoReview.maxFiles > 0) autoReview.max_files = review.autoReview.maxFiles; - if (review.autoReview.baseBranches.length > 0) autoReview.base_branches = [...review.autoReview.baseBranches]; - if (review.autoReview.autoPauseAfterReviewedCommits !== null) { - autoReview.auto_pause_after_reviewed_commits = review.autoReview.autoPauseAfterReviewedCommits; - } - out.auto_review = autoReview; - } - if (review.preMergeChecks.length > 0) { - out.pre_merge_checks = review.preMergeChecks.map((check) => { - const entry: Record = { name: check.name }; - if (check.whenPaths.length > 0) entry.when_paths = [...check.whenPaths]; - if (check.titleContains !== null) entry.title_contains = check.titleContains; - if (check.descriptionContains !== null) entry.description_contains = check.descriptionContains; - if (check.requireLabel !== null) entry.require_label = check.requireLabel; - if (check.enforce) entry.enforce = true; - return entry; - }); - } - if (Object.keys(review.fields).length > 0) out.fields = { ...review.fields } as Record; - if (Object.keys(review.enrichmentAnalyzers).length > 0) out.enrichment = { ...review.enrichmentAnalyzers } as Record; - if (review.labelingRules.length > 0) { - out.labeling_rules = review.labelingRules.map((rule) => { - const entry: Record = { label: rule.label }; - if (rule.whenPaths.length > 0) entry.when_paths = [...rule.whenPaths]; - if (rule.titleContains !== null) entry.title_contains = rule.titleContains; - if (rule.descriptionContains !== null) entry.description_contains = rule.descriptionContains; - return entry; - }); - } - if (selfHostAiModelPresent(review.aiModel)) { - const aiModel: Record = {}; - if (review.aiModel.claudeModel !== null) aiModel.claude_model = review.aiModel.claudeModel; - if (review.aiModel.claudeEffort !== null) aiModel.claude_effort = review.aiModel.claudeEffort; - if (review.aiModel.codexModel !== null) aiModel.codex_model = review.aiModel.codexModel; - if (review.aiModel.codexEffort !== null) aiModel.codex_effort = review.aiModel.codexEffort; - out.ai_model = aiModel; - } - if (visualConfigPresent(review.visual)) { - const visual: Record = {}; - if (review.visual.preview.urlTemplate !== null) visual.preview = { url_template: review.visual.preview.urlTemplate }; - if (review.visual.routes.paths.length > 0 || review.visual.routes.maxRoutes !== null) { - const routes: Record = {}; - if (review.visual.routes.paths.length > 0) routes.paths = [...review.visual.routes.paths]; - if (review.visual.routes.maxRoutes !== null) routes.max_routes = review.visual.routes.maxRoutes; - visual.routes = routes; - } - if (review.visual.themes.length > 0) visual.themes = [...review.visual.themes]; - if (review.visual.gif) visual.gif = true; - out.visual = visual; - } - if (review.linkedIssueSatisfaction !== null) out.linkedIssueSatisfaction = review.linkedIssueSatisfaction; - return out; -} +export { + COMMENT_VERBOSITY_LEVELS, + CONVERGED_FEATURE_KEYS, + EMPTY_AUTO_REVIEW_CONFIG, + EMPTY_MAX_FINDINGS_CONFIG, + EMPTY_SELF_HOST_AI_MODEL_CONFIG, + EMPTY_VISUAL_CONFIG, + LINKED_ISSUE_SATISFACTION_MODES, + MAX_FOCUS_MANIFEST_BYTES, + REVIEW_FIELD_KEYS, + REVIEW_FINDING_SEVERITY_LADDER, + REVIEW_PROFILES, + compileFocusManifestPolicy, + contentLaneConfigToJson, + featuresConfigToJson, + formatManifestValidationNotice, + gateConfigToJson, + isFocusManifestPublicSafe, + matchesManifestPath, + normalizeReadinessGateMode, + parseFocusManifest, + parseFocusManifestContent, + repoDocGenerationConfigToJson, + reviewConfigToJson, + reviewRecapConfigToJson, + settingsOverrideToJson, + type AutoReviewConfig, + type CommentVerbosity, + type ConvergedFeatureKey, + type FocusManifest, + type FocusManifestContentLaneConfig, + type FocusManifestFeaturesConfig, + type FocusManifestFinding, + type FocusManifestGateConfig, + type FocusManifestGuidance, + type FocusManifestIssueDiscoveryPolicy, + type FocusManifestLanePreference, + type FocusManifestLinkedIssuePolicy, + type FocusManifestPolicy, + type FocusManifestPolicyContributionLane, + type FocusManifestPolicyLabelPolicy, + type FocusManifestPolicyValidation, + type FocusManifestRepoDocGenerationConfig, + type FocusManifestRepoDocGenerationScope, + type FocusManifestReviewConfig, + type FocusManifestReviewRecapConfig, + type FocusManifestSettings, + type FocusManifestSource, + type LabelingRule, + type LinkedIssueSatisfactionMode, + type MaxFindingsConfig, + type PreMergeCheck, + type ReviewFieldKey, + type ReviewFindingSeverity, + type ReviewPathInstruction, + type ReviewProfile, + type SelfHostAiModelConfig, + type VisualConfig, + type VisualPreviewConfig, + type VisualRoutesConfig, + type VisualTheme, +} from "../../packages/gittensory-engine/src/focus-manifest.js"; + +import type { PrTypeLabelSet, RepositorySettings } from "../types"; +import { mergeContributorBlacklists } from "../settings/contributor-blacklist"; +import { DEFAULT_TYPE_LABELS } from "../settings/pr-type-label"; +import { DEFAULT_LINKED_ISSUE_LABEL_PROPAGATION } from "../review/linked-issue-label-propagation"; +import { DEFAULT_LINKED_ISSUE_HARD_RULES } from "../review/linked-issue-hard-rules-config"; +import { DEFAULT_UNLINKED_ISSUE_GUARDRAIL } from "../review/unlinked-issue-guardrail-config"; +import { DEFAULT_SCREENSHOT_TABLE_GATE } from "../review/screenshot-table-gate"; +import { classifyChangedFile } from "./path-matchers"; +import { + EMPTY_AUTO_REVIEW_CONFIG, + EMPTY_MAX_FINDINGS_CONFIG, + EMPTY_SELF_HOST_AI_MODEL_CONFIG, + EMPTY_VISUAL_CONFIG, + isFocusManifestPublicSafe, + matchesManifestPath, + type AutoReviewConfig, + type CommentVerbosity, + type FocusManifest, + type FocusManifestFinding, + type FocusManifestGateConfig, + type FocusManifestGuidance, + type FocusManifestSource, + type MaxFindingsConfig, + type PreMergeCheck, + type ReviewFindingSeverity, + type ReviewPathInstruction, + type ReviewProfile, + type SelfHostAiModelConfig, + type VisualConfig, +} from "../../packages/gittensory-engine/src/focus-manifest.js"; +import type { ReesAnalyzerName } from "../review/enrichment-analyzer-names"; -/** - * Resolve the `review.path_instructions` that APPLY to a PR — those whose glob matches at least one changed path - * — into a single prompt section for the AI reviewer, or "" when none match (so the prompt stays byte-identical). - * Pure; uses the same manifest path-glob semantics (`matchesManifestPath`) as the rest of the manifest. Capped to - * keep the prompt bounded. (#review-path-instructions) - */ export function resolveReviewPathInstructions(pathInstructions: ReviewPathInstruction[], changedPaths: string[]): string { if (pathInstructions.length === 0 || changedPaths.length === 0) return ""; const applicable = pathInstructions.filter((entry) => changedPaths.some((path) => matchesManifestPath(path, entry.path))); @@ -2969,202 +574,10 @@ export function resolveEffectiveSettings( return effective; } -/** - * Tolerantly normalize an already-parsed manifest object into a {@link FocusManifest}. - * Never throws: malformed shapes degrade to safe defaults and accumulate warnings so callers - * can surface them instead of crashing. - */ -export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): FocusManifest { - if (raw === undefined || raw === null) return emptyManifest(source ?? "none"); - if (typeof raw !== "object" || Array.isArray(raw)) { - return emptyManifest(source ?? "api_record", ["Manifest must be a mapping of fields; ignoring malformed manifest and falling back to deterministic signals."]); - } - const record = raw as Record; - const warnings: string[] = []; - const manifest: FocusManifest = { - present: true, - source: normalizeSource(source, record.source, warnings), - wantedPaths: normalizeStringList(record.wantedPaths, "wantedPaths", warnings), - preferredLabels: normalizeStringList(record.preferredLabels, "preferredLabels", warnings), - linkedIssuePolicy: normalizeEnum(record.linkedIssuePolicy, "linkedIssuePolicy", ["required", "preferred", "optional"] as const, "optional", warnings), - testExpectations: normalizeStringList(record.testExpectations, "testExpectations", warnings), - issueDiscoveryPolicy: normalizeEnum(record.issueDiscoveryPolicy, "issueDiscoveryPolicy", ["encouraged", "neutral", "discouraged"] as const, "neutral", warnings), - maintainerNotes: normalizeStringList(record.maintainerNotes, "maintainerNotes", warnings), - publicNotes: normalizeStringList(record.publicNotes, "publicNotes", warnings).filter(isFocusManifestPublicSafe), - gate: parseGateConfig(record.gate, warnings), - settings: parseSettingsOverride(record.settings, warnings), - review: parseReviewConfig(record.review, warnings), - features: parseFeaturesConfig(record.features, warnings), - contentLane: parseContentLaneConfig(record.contentLane, warnings), - repoDocGeneration: parseRepoDocGenerationConfig(record.repoDocGeneration, warnings), - reviewRecap: parseReviewRecapConfig(record.reviewRecap, warnings), - warnings, - }; - if ( - manifest.wantedPaths.length === 0 && - manifest.preferredLabels.length === 0 && - manifest.testExpectations.length === 0 && - manifest.maintainerNotes.length === 0 && - manifest.publicNotes.length === 0 && - manifest.linkedIssuePolicy === "optional" && - manifest.issueDiscoveryPolicy === "neutral" && - !manifest.gate.present && - Object.keys(manifest.settings).length === 0 && - !manifest.review.present && - !manifest.features.present && - !manifest.contentLane.present && - !manifest.repoDocGeneration.present && - !manifest.reviewRecap.present - ) { - warnings.push("Manifest contained no recognized focus fields; falling back to deterministic signals."); - manifest.present = false; - } - return manifest; -} - -/** - * Parse raw manifest file/record content (JSON or YAML). Malformed content degrades to an empty - * manifest with a warning rather than throwing, so a broken `.gittensory` config never breaks analysis. - */ -export function parseFocusManifestContent(content: string | null | undefined, source: FocusManifestSource = "repo_file"): FocusManifest { - if (content === undefined || content === null || content.trim() === "") return emptyManifest(source); - if (content.length > MAX_FOCUS_MANIFEST_BYTES || new TextEncoder().encode(content).byteLength > MAX_FOCUS_MANIFEST_BYTES) { - return emptyManifest(source, [`Manifest content exceeded ${MAX_FOCUS_MANIFEST_BYTES} bytes; ignoring it and falling back to deterministic signals.`]); - } - const trimmed = content.trim(); - const looksLikeJson = trimmed.startsWith("{") || trimmed.startsWith("["); - let parsed: unknown; - try { - parsed = looksLikeJson ? JSON.parse(trimmed) : parseYaml(trimmed); - } catch { - return emptyManifest(source, [ - looksLikeJson - ? "Manifest content was not valid JSON; ignoring it and falling back to deterministic signals." - : "Manifest content was not valid YAML; ignoring it and falling back to deterministic signals.", - ]); - } - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - return emptyManifest(source, ["Manifest must be a mapping of fields; ignoring malformed manifest and falling back to deterministic signals."]); - } - return parseFocusManifest(parsed, source); -} - -/** - * Format a manifest's parse `warnings[]` into one grouped, deduped, order-preserving notice for the review - * surface — an acceptance criterion of #1670: an invalid/malformed `.gittensory.yml` value should fail - * clearly instead of silently falling back to a default. Empty/no warnings ⇒ `null` (byte-identical, no - * notice). Pure; reuses the warnings every parser already accumulates rather than a parallel schema. (#2056) - */ -export function formatManifestValidationNotice(warnings: string[]): string | null { - const seen = new Set(); - const deduped: string[] = []; - for (const warning of warnings) { - const trimmed = warning.trim(); - if (!trimmed || seen.has(trimmed)) continue; - seen.add(trimmed); - deduped.push(trimmed); - } - if (deduped.length === 0) return null; - return deduped.map((warning) => `- ${warning}`).join("\n"); -} - -function normalizePathForMatch(path: string): string { - return String(path).replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "").toLowerCase(); -} - -/** - * LINEAR-TIME wildcard matcher for a `*`-glob pattern over an already-normalized path. `*` (and a collapsed - * run of `*`) matches any run of characters INCLUDING `/` (gittensory globs cross slashes). Implemented as a - * prefix + suffix + ordered-substring (indexOf) scan rather than a `.*`-per-star regex: the old regex - * (`^.*a.*a...$`) backtracks catastrophically on a near-miss path and could hang the gate for an entire repo - * (a manifest glob with many non-adjacent `*`). This algorithm is O(path × parts) with NO backtracking. - */ -function linearGlobMatcher(pattern: string): (path: string) => boolean { - // The caller only compiles this for a pattern that contains a wildcard, so split always yields >= 2 parts. - const parts = pattern.split(/\*+/); // literal segments between (collapsed) wildcard runs - const first = parts[0]!; - const last = parts[parts.length - 1]!; - const middles = parts.slice(1, -1).filter((part) => part.length > 0); - return (path) => { - if (!path.startsWith(first) || !path.endsWith(last)) return false; - let idx = first.length; - for (const part of middles) { - const found = path.indexOf(part, idx); - if (found === -1) return false; - idx = found + part.length; - } - return path.length - last.length >= idx; // the suffix must not overlap the consumed prefix/middles - }; -} - -/** - * Compile a manifest path pattern into a predicate over an ALREADY-normalized path. Supports exact paths, - * directory prefixes (`src/` or `src`), and `*` wildcards (`*` and a double-star both match any run of chars - * across `/`). A double-star-then-separator prefix means "zero or more path segments", so the mandatory slash - * is absorbed and a double-star glob also matches a ROOT-level (zero-depth) file, not only nested ones. - * Compiling once lets a caller test many paths against one pattern without recompiling per path — see - * {@link matchedPatterns}. An empty/blank pattern never matches. - */ -function expandGlobstarSlash(pattern: string): string[] { - const alternatives = [""]; - for (let idx = 0; idx < pattern.length; ) { - if (pattern.startsWith("**/", idx)) { - const count = alternatives.length; - const canKeepRootAlternatives = count * 2 <= MAX_GLOBSTAR_SLASH_ALTERNATIVES; - for (let altIdx = count - 1; altIdx >= 0; altIdx -= 1) { - const prefix = alternatives[altIdx]!; - alternatives[altIdx] = `${prefix}*/`; - if (canKeepRootAlternatives) alternatives.push(prefix); - } - idx += 3; - continue; - } - for (let altIdx = 0; altIdx < alternatives.length; altIdx += 1) alternatives[altIdx] += pattern[idx]!; - idx += 1; - } - return alternatives; -} - -function compileManifestPathMatcher(pattern: string): (normalizedPath: string) => boolean { - const normalizedPattern = normalizePathForMatch(pattern); - if (!normalizedPattern) return () => false; - if (normalizedPattern.includes("*")) { - // `**/` means zero or more whole path segments. Keep the slash in the non-root alternative so - // basename globs (e.g. `**/safe.ts`) do not degrade into suffix globs that match `unsafe.ts`. - const matchers = expandGlobstarSlash(normalizedPattern).map((globbed) => - globbed.includes("*") ? linearGlobMatcher(globbed) : (normalizedPath: string) => normalizedPath === globbed, - ); - return (normalizedPath) => matchers.some((matcher) => matcher(normalizedPath)); - } - const dirPattern = normalizedPattern.endsWith("/") ? normalizedPattern : `${normalizedPattern}/`; - return (normalizedPath) => normalizedPath === normalizedPattern || normalizedPath.startsWith(dirPattern); -} - -/** - * Match a changed path against a manifest path pattern. Supports exact paths, directory - * prefixes (`src/` or `src`), and `*` wildcards (`**` collapses to `*`). - */ -export function matchesManifestPath(path: string, pattern: string): boolean { - const normalizedPath = normalizePathForMatch(path); - if (!normalizedPath) return false; - return compileManifestPathMatcher(pattern)(normalizedPath); -} - function matchedPatterns(paths: string[], patterns: string[]): string[] { - // Normalize each path once and compile each pattern once, instead of redoing both for every (path, - // pattern) pair — the wildcard regex was previously recompiled per path. - const normalizedPaths = paths.map(normalizePathForMatch).filter(Boolean); - return patterns.filter((pattern) => { - const matches = compileManifestPathMatcher(pattern); - return normalizedPaths.some((normalizedPath) => matches(normalizedPath)); - }); + return patterns.filter((pattern) => paths.some((path) => matchesManifestPath(path, pattern))); } -/** - * Build deterministic, public-safe guidance from a focus manifest for a concrete change set. - * Explains why changed paths are preferred or discouraged and surfaces manifest-driven blockers - * without leaking maintainer-private notes into public next steps. - */ export function buildFocusManifestGuidance(args: { manifest: FocusManifest; changedPaths: string[]; @@ -3303,224 +716,6 @@ function summarize(manifest: FocusManifest, wanted: string[]): string { return "Maintainer focus manifest applied with no path-specific verdict."; } -// ─── Focus Manifest Policy Schema ──────────────────────────────────────────── - -/** Preference signal for a contribution lane derived from the focus manifest. */ -export type FocusManifestLanePreference = "preferred" | "neutral" | "discouraged"; - -export type FocusManifestPolicyContributionLane = { - id: string; - preference: "preferred" | "neutral" | "discouraged"; - title: string; - summary: string; - preferredPaths: string[]; - discouragedPaths: string[]; - validationExpectations: string[]; - publicNotes: string[]; -}; - -export type FocusManifestPolicyLabelPolicy = { - preferredLabels: string[]; - required: boolean; -}; - -export type FocusManifestPolicyValidation = { - expectations: string[]; - linkedIssuePolicy: FocusManifestLinkedIssuePolicy; -}; - -export type FocusManifestPolicy = { - repoFullName: string; - generatedAt: string; - source: FocusManifestSource; - present: boolean; - publicSafe: { - contributionLanes: FocusManifestPolicyContributionLane[]; - labelPolicy: FocusManifestPolicyLabelPolicy; - validation: FocusManifestPolicyValidation; - issueDiscoveryPolicy: FocusManifestIssueDiscoveryPolicy; - publicNotes: string[]; - readinessWarnings: string[]; - entryGuidance: string[]; - summary: string; - }; - authenticated: { - manifestSource: FocusManifestSource; - privateNoteCount: number; - manifestWarningCount: number; - parseWarnings: string[]; - readinessWarnings: string[]; - maintainerContext: string[]; - }; -}; - -/** - * Compile a normalized {@link FocusManifest} into a deterministic, machine-readable - * {@link FocusManifestPolicy}. Public-safe fields are segregated from authenticated - * (owner-only) fields. No reward, wallet, hotkey, raw trust, or private scoring - * language is allowed in public-safe output — unsafe strings are silently dropped. - * - * `repoFullName` is optional — when omitted it defaults to an empty string. Callers - * that persist the policy should supply the full name; single-manifest analysis - * callers may omit it. - */ -export function compileFocusManifestPolicy(manifest: FocusManifest, options?: { generatedAt?: string }): FocusManifestPolicy; -export function compileFocusManifestPolicy(repoFullName: string, manifest: FocusManifest, options?: { generatedAt?: string }): FocusManifestPolicy; -export function compileFocusManifestPolicy( - repoFullNameOrManifest: string | FocusManifest, - manifestOrOptions?: FocusManifest | { generatedAt?: string }, - options: { generatedAt?: string } = {}, -): FocusManifestPolicy { - let repoFullName: string; - let manifest: FocusManifest; - if (typeof repoFullNameOrManifest === "string") { - repoFullName = repoFullNameOrManifest; - manifest = manifestOrOptions as FocusManifest; - } else { - repoFullName = ""; - manifest = repoFullNameOrManifest; - options = (manifestOrOptions as { generatedAt?: string }) ?? {}; - } - - const generatedAt = options.generatedAt ?? new Date().toISOString(); - const safePublicNotes = manifest.publicNotes.filter(isFocusManifestPublicSafe); - const contributionLanes = buildPolicyContributionLanes(manifest); - const readinessWarnings = buildPolicyReadinessWarnings(manifest); - const entryGuidance = buildPolicyEntryGuidance(manifest); - const summary = buildPolicySummary(manifest); - - return { - repoFullName, - generatedAt, - source: manifest.source, - present: manifest.present, - publicSafe: { - contributionLanes, - labelPolicy: { - preferredLabels: manifest.preferredLabels.filter(isFocusManifestPublicSafe), - required: manifest.linkedIssuePolicy !== "optional", - }, - validation: { - expectations: manifest.testExpectations.filter(isFocusManifestPublicSafe), - linkedIssuePolicy: manifest.linkedIssuePolicy, - }, - issueDiscoveryPolicy: manifest.issueDiscoveryPolicy, - publicNotes: safePublicNotes, - readinessWarnings, - entryGuidance, - summary, - }, - authenticated: { - manifestSource: manifest.source, - privateNoteCount: manifest.maintainerNotes.length, - manifestWarningCount: manifest.warnings.length, - parseWarnings: manifest.warnings, - readinessWarnings, - maintainerContext: manifest.maintainerNotes, - }, - }; -} - -function buildPolicyEntryGuidance(manifest: FocusManifest): string[] { - const guidance: string[] = []; - // Build the sentence from the public-safe subset (as preferredLabels and publicNotes below already do, and - // as the sibling buildPolicyContributionLanes does for preferredPaths). Joining the raw wantedPaths means a - // single reserved-word path (e.g. `src/ranking/`) fails the all-or-nothing public-safety filter at the end - // and silently drops the entire focus-areas guidance line instead of surfacing the safe paths. - const safeWantedPaths = manifest.wantedPaths.filter(isFocusManifestPublicSafe); - if (safeWantedPaths.length > 0) { - guidance.push(`Focus changes on maintainer-wanted areas: ${safeWantedPaths.slice(0, 5).join(", ")}.`); - } - if (manifest.linkedIssuePolicy === "required") guidance.push("Link a tracked issue before opening a pull request."); - else if (manifest.linkedIssuePolicy === "preferred") guidance.push("Linking a tracked issue is preferred before opening a pull request."); - if (manifest.preferredLabels.length > 0) { - const safeLabels = manifest.preferredLabels.filter(isFocusManifestPublicSafe); - if (safeLabels.length > 0) guidance.push(`Apply a maintainer-preferred label: ${safeLabels.slice(0, 3).join(", ")}.`); - } - guidance.push(...manifest.publicNotes.filter(isFocusManifestPublicSafe)); - return [...new Set(guidance)].filter(isFocusManifestPublicSafe); -} - -function buildPolicySummary(manifest: FocusManifest): string { - if (!manifest.present) return "No maintainer focus manifest; contribution guidance is not constrained."; - if (manifest.issueDiscoveryPolicy === "encouraged") return "Issue-discovery is the preferred contribution mode for this repo."; - if (manifest.issueDiscoveryPolicy === "discouraged") return "Direct PRs are preferred; issue-discovery submissions are discouraged."; - if (manifest.wantedPaths.length > 0) return "Direct PRs on the maintainer-wanted areas are preferred."; - return "Contribution guidance is derived from the maintainer focus manifest."; -} - -function buildPolicyContributionLanes(manifest: FocusManifest): FocusManifestPolicyContributionLane[] { - if (!manifest.present) return []; - - const lanes: FocusManifestPolicyContributionLane[] = []; - const safeWantedPaths = manifest.wantedPaths.filter(isFocusManifestPublicSafe); - const safeTestExpectations = manifest.testExpectations.filter(isFocusManifestPublicSafe); - - // Derive the public preference only from public-safe signals: use the SAME filtered list that surfaces in - // validationExpectations below, not the raw testExpectations. Otherwise a manifest whose only test expectation is - // public-unsafe (e.g. a wallet/seed phrase) is redacted from the lane yet still flips the public preference to - // "preferred" ("…with required validation evidence"), a self-contradictory verdict with no visible basis. - const directPrPreference: "preferred" | "neutral" | "discouraged" = - manifest.issueDiscoveryPolicy === "encouraged" ? "discouraged" - : safeWantedPaths.length > 0 || safeTestExpectations.length > 0 ? "preferred" - : "neutral"; - - lanes.push({ - id: "direct-pr", - preference: directPrPreference, - title: "Direct pull request lane", - summary: - directPrPreference === "discouraged" - ? "Direct pull requests are discouraged; issue discovery is the preferred entry mode." - : directPrPreference === "preferred" - ? "Contribute changes in maintainer-wanted areas with required validation evidence." - : "Direct pull requests are accepted when they stay inside maintainer-wanted scope.", - preferredPaths: safeWantedPaths, - discouragedPaths: [], - validationExpectations: safeTestExpectations, - publicNotes: manifest.publicNotes.filter(isFocusManifestPublicSafe), - }); - - const issueDiscoveryPreference: "preferred" | "neutral" | "discouraged" = - manifest.issueDiscoveryPolicy === "encouraged" ? "preferred" - : manifest.issueDiscoveryPolicy === "discouraged" ? "discouraged" - : "neutral"; - - lanes.push({ - id: "issue-discovery", - preference: issueDiscoveryPreference, - title: "Issue discovery lane", - summary: - issueDiscoveryPreference === "preferred" - ? "File well-scoped issue reports that the maintainer has indicated are welcome." - : issueDiscoveryPreference === "discouraged" - ? "The maintainer has indicated this repo prefers direct fixes over new issue reports." - : "Issue discovery is optional; confirm maintainer scope before filing new issues.", - preferredPaths: [], - discouragedPaths: [], - validationExpectations: [], - publicNotes: [], - }); - - return lanes; -} - -function buildPolicyReadinessWarnings(manifest: FocusManifest): string[] { - if (!manifest.present) return []; - const warnings: string[] = []; - if (manifest.wantedPaths.length === 0 && manifest.preferredLabels.length === 0) { - warnings.push("Focus manifest does not define wanted paths or preferred labels; contribution scope may be unclear to contributors."); - } - if (manifest.testExpectations.length === 0) { - warnings.push("Focus manifest does not define validation expectations; contributors may not know what tests to run."); - } - return warnings.filter(isFocusManifestPublicSafe); -} - -// --------------------------------------------------------------------------- -// Contribution lane derivation -// --------------------------------------------------------------------------- - export type ContributionLanePreference = "preferred" | "neutral" | "discouraged"; export type ContributionLanes = { @@ -3653,5 +848,3 @@ function buildLanesSummary(manifest: FocusManifest, directPrLane: ContributionLa if (issueDiscoveryLane === "discouraged") return "Direct PRs are preferred; issue-discovery submissions are discouraged."; return "Contribution lanes are guided by the maintainer focus manifest."; } - -// ─── Focus Manifest Policy Schema ──────────────────────────────────────────── diff --git a/test/unit/focus-manifest-engine-barrel.test.ts b/test/unit/focus-manifest-engine-barrel.test.ts new file mode 100644 index 0000000000..250e02288c --- /dev/null +++ b/test/unit/focus-manifest-engine-barrel.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { + compileFocusManifestPolicy, + matchesManifestPath, + parseFocusManifest, + parseFocusManifestContent, +} from "../../packages/gittensory-engine/src/focus-manifest"; + +describe("gittensory-engine focus-manifest barrel exports (#2280)", () => { + it("re-exports the focus-manifest parse/compile API from the package barrel", async () => { + const barrel = await import("../../packages/gittensory-engine/src/index"); + expect(typeof barrel.parseFocusManifest).toBe("function"); + expect(typeof barrel.parseFocusManifestContent).toBe("function"); + expect(typeof barrel.compileFocusManifestPolicy).toBe("function"); + expect(typeof barrel.matchesManifestPath).toBe("function"); + expect(typeof barrel.isFocusManifestPublicSafe).toBe("function"); + expect(barrel.MAX_FOCUS_MANIFEST_BYTES).toBeGreaterThan(0); + expect(typeof barrel.parseFocusManifest).toBe(typeof parseFocusManifest); + expect(typeof barrel.parseFocusManifestContent).toBe(typeof parseFocusManifestContent); + expect(typeof barrel.compileFocusManifestPolicy).toBe(typeof compileFocusManifestPolicy); + expect(typeof barrel.matchesManifestPath).toBe(typeof matchesManifestPath); + }); +}); diff --git a/test/unit/focus-manifest-engine-branch-coverage.test.ts b/test/unit/focus-manifest-engine-branch-coverage.test.ts new file mode 100644 index 0000000000..ecd2eef7a0 --- /dev/null +++ b/test/unit/focus-manifest-engine-branch-coverage.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; + +import { + MAX_FOCUS_MANIFEST_BYTES, + parseFocusManifest, + parseFocusManifestContent, + reviewConfigToJson, +} from "../../packages/gittensory-engine/src/focus-manifest"; + +describe("focus-manifest engine branch coverage (#2280)", () => { + it("warns when settings.linkedIssueHardRules is not an object", () => { + const parsed = parseFocusManifest({ settings: { linkedIssueHardRules: "not-an-object" } }); + expect(parsed.settings.linkedIssueHardRules).toBeUndefined(); + expect(parsed.warnings.some((w) => w.includes('settings.linkedIssueHardRules" must be an object'))).toBe(true); + }); + + it("warns on malformed review.enrichment and unknown analyzer keys", () => { + const parsed = parseFocusManifest({ + review: { + enrichment: "not-a-mapping", + }, + }); + expect(parsed.review.enrichmentAnalyzers).toEqual({}); + expect(parsed.warnings.some((w) => w.includes('review.enrichment" must be a mapping'))).toBe(true); + + const withUnknown = parseFocusManifest({ + review: { + enrichment: { dependency: true, notARealAnalyzer: false }, + }, + }); + expect(withUnknown.review.enrichmentAnalyzers).toEqual({ dependency: true }); + expect(withUnknown.warnings.some((w) => w.includes('unknown analyzer "notARealAnalyzer"'))).toBe(true); + }); + + it("validates review.labeling_rules entries and reserved gittensor: labels", () => { + const parsed = parseFocusManifest({ + review: { + labeling_rules: "not-a-list", + }, + }); + expect(parsed.review.labelingRules).toEqual([]); + expect(parsed.warnings.some((w) => w.includes("labeling_rules") && w.includes("list"))).toBe(true); + + const capped = parseFocusManifest({ + review: { + labeling_rules: Array.from({ length: 51 }, (_, index) => ({ + label: `area:${index}`, + when_paths: ["src/**"], + })), + }, + }); + expect(capped.review.labelingRules).toHaveLength(50); + expect(capped.warnings.some((w) => w.includes("capped at 50"))).toBe(true); + + const withMissingLabel = parseFocusManifest({ + review: { + labeling_rules: [{ when_paths: ["src/**"] }], + }, + }); + expect(withMissingLabel.review.labelingRules).toEqual([]); + expect(withMissingLabel.warnings.some((w) => w.includes(".label\" is required"))).toBe(true); + + const withRules = parseFocusManifest({ + review: { + labeling_rules: [ + "not-a-mapping", + { label: "gittensor:priority", when_paths: ["src/**"] }, + { label: "area:ui", when_paths: ["src/**"] }, + { label: "area:docs", title_contains: "docs" }, + { label: "area:empty" }, + ], + }, + }); + expect(withRules.review.labelingRules).toEqual([ + { label: "area:ui", whenPaths: ["src/**"], titleContains: null, descriptionContains: null }, + { label: "area:docs", whenPaths: [], titleContains: "docs", descriptionContains: null }, + ]); + expect(withRules.warnings.some((w) => w.includes("labeling_rules[0]") && w.includes("mapping"))).toBe(true); + expect(withRules.warnings.some((w) => w.includes('reserved "gittensor:"'))).toBe(true); + expect(withRules.warnings.some((w) => w.includes("needs at least one of when_paths"))).toBe(true); + }); + + it("serializes labeling_rules optional fields through reviewConfigToJson", () => { + const manifest = parseFocusManifest({ + review: { + labeling_rules: [ + { + label: "area:ui", + when_paths: ["src/**"], + title_contains: "feat", + description_contains: "screenshot", + }, + ], + }, + }); + expect(reviewConfigToJson(manifest.review)).toEqual({ + labeling_rules: [ + { + label: "area:ui", + when_paths: ["src/**"], + title_contains: "feat", + description_contains: "screenshot", + }, + ], + }); + }); + + it("rejects manifest content whose UTF-8 byte length exceeds MAX_FOCUS_MANIFEST_BYTES", () => { + const oversized = `wantedPaths:\n - ${"x".repeat(MAX_FOCUS_MANIFEST_BYTES)}`; + const parsed = parseFocusManifestContent(oversized); + expect(parsed.present).toBe(false); + expect(parsed.warnings.some((w) => w.includes(`${MAX_FOCUS_MANIFEST_BYTES} bytes`))).toBe(true); + }); +}); From a0d973150ee537f80eec80e7c7cd7133306098fb Mon Sep 17 00:00:00 2001 From: jimcody1995 Date: Tue, 7 Jul 2026 07:45:42 +0200 Subject: [PATCH 2/4] test(engine): reach 100% branch coverage for focus-manifest extraction (#2280) Add targeted branch-coverage tests for gate serialization, settings overlays, review enrichment/visual edges, and labeling-rule parser branches. Co-authored-by: Cursor --- ...us-manifest-engine-branch-coverage.test.ts | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/test/unit/focus-manifest-engine-branch-coverage.test.ts b/test/unit/focus-manifest-engine-branch-coverage.test.ts index ecd2eef7a0..e2d43d874b 100644 --- a/test/unit/focus-manifest-engine-branch-coverage.test.ts +++ b/test/unit/focus-manifest-engine-branch-coverage.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + gateConfigToJson, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifest, parseFocusManifestContent, @@ -111,4 +112,138 @@ describe("focus-manifest engine branch coverage (#2280)", () => { expect(parsed.present).toBe(false); expect(parsed.warnings.some((w) => w.includes(`${MAX_FOCUS_MANIFEST_BYTES} bytes`))).toBe(true); }); + + it("serializes gate pack, slop-only mode, and partial cla blocks through gateConfigToJson", () => { + const gate = parseFocusManifest({ + gate: { + pack: "oss-anti-slop", + slop: { mode: "block" }, + cla: { checkRunName: "CLA" }, + }, + }).gate; + expect(gateConfigToJson(gate)).toMatchObject({ + pack: "oss-anti-slop", + slop: { mode: "block" }, + cla: { checkRunName: "CLA" }, + }); + }); + + it("parses sparse linkedIssueHardRules overlays and settings ai review fields", () => { + const parsed = parseFocusManifest({ + settings: { + aiReviewProvider: "openai", + aiReviewModel: "gpt-4.1", + manualReviewLabel: "needs-human", + linkedIssueHardRules: { + ownerAssignedClose: "block", + assignedIssueClose: "off", + missingPointLabelClose: "block", + maintainerOnlyLabelClose: "off", + pointBearingLabels: ["gittensor:priority"], + maintainerOnlyLabels: ["maintainer-only"], + defaultLabelRepo: true, + verifyBeforeClose: false, + closeDelaySeconds: 45, + }, + }, + }); + expect(parsed.settings.aiReviewProvider).toBe("openai"); + expect(parsed.settings.aiReviewModel).toBe("gpt-4.1"); + expect(parsed.settings.manualReviewLabel).toBe("needs-human"); + expect(parsed.settings.linkedIssueHardRules).toMatchObject({ + ownerAssignedClose: "block", + pointBearingLabels: ["gittensor:priority"], + closeDelaySeconds: 45, + }); + }); + + it("accepts valid review enrichment toggles and rejects unsafe visual url templates", () => { + const enriched = parseFocusManifest({ + review: { + enrichment: { dependency: true, secret: false }, + visual: { + preview: { url_template: "http://127.0.0.1/pr-{number}" }, + }, + }, + }); + expect(enriched.review.enrichmentAnalyzers).toEqual({ dependency: true, secret: false }); + expect(enriched.review.visual.preview.urlTemplate).toBeNull(); + expect(enriched.warnings.some((w) => w.includes("url_template"))).toBe(true); + }); + + it("serializes review optional fields through reviewConfigToJson", () => { + const manifest = parseFocusManifest({ + review: { + fixHandoff: true, + auto_merge_summary: false, + enrichment: { dependency: true }, + labeling_rules: [{ label: "area:ui", title_contains: "ui" }], + linkedIssueSatisfaction: "advisory", + visual: { routes: { max_routes: 3 } }, + }, + }); + expect(reviewConfigToJson(manifest.review)).toMatchObject({ + fixHandoff: true, + auto_merge_summary: false, + enrichment: { dependency: true }, + labeling_rules: [{ label: "area:ui", title_contains: "ui" }], + linkedIssueSatisfaction: "advisory", + visual: { routes: { max_routes: 3 } }, + }); + }); + + it("warns when a labeling rule entry omits label entirely", () => { + const parsed = parseFocusManifest({ + review: { + labeling_rules: [{ when_paths: ["src/**"] }, { label: null, when_paths: ["docs/**"] }], + }, + }); + expect(parsed.review.labelingRules).toEqual([]); + expect(parsed.warnings.filter((w) => w.includes(".label")).length).toBeGreaterThanOrEqual(2); + }); + + it("covers remaining serializer and parser branch edges", () => { + const slopScoreOnly = parseFocusManifest({ gate: { slop: { minScore: 55 } } }); + expect(gateConfigToJson(slopScoreOnly.gate)).toEqual({ slop: { minScore: 55 } }); + + const invalidEnrichmentFlag = parseFocusManifest({ + review: { enrichment: { dependency: "not-a-boolean" } }, + }); + expect(invalidEnrichmentFlag.review.enrichmentAnalyzers).toEqual({}); + expect(invalidEnrichmentFlag.warnings.some((w) => w.includes("review.enrichment.dependency"))).toBe(true); + + const missingLabelKey = parseFocusManifest({ + review: { labeling_rules: [{ when_paths: ["src/**"] }] }, + }); + expect(missingLabelKey.warnings.some((w) => w.includes('.label" is required'))).toBe(true); + + const explicitNullLabel = parseFocusManifest({ + review: { labeling_rules: [{ label: null, when_paths: ["src/**"] }] }, + }); + expect(explicitNullLabel.warnings.some((w) => w.includes('.label" is required'))).toBe(true); + + const notPublicSafeLabel = parseFocusManifest({ + review: { labeling_rules: [{ label: "reward farming", when_paths: ["src/**"] }] }, + }); + expect(notPublicSafeLabel.review.labelingRules).toEqual([]); + expect(notPublicSafeLabel.warnings.some((w) => w.includes("review.labeling_rules[0].label"))).toBe(true); + expect(notPublicSafeLabel.warnings.some((w) => w.includes('.label" is required'))).toBe(false); + + const emptyTemplate = parseFocusManifest({ + review: { visual: { preview: { url_template: "" } } }, + }); + expect(emptyTemplate.review.visual.preview.urlTemplate).toBeNull(); + + const withInstructions = parseFocusManifest({ + review: { instructions: "Prefer small diffs." }, + }); + expect(reviewConfigToJson(withInstructions.review)).toEqual({ instructions: "Prefer small diffs." }); + + const pathsOnlyRule = parseFocusManifest({ + review: { labeling_rules: [{ label: "area:ui", when_paths: ["src/**"] }] }, + }); + expect(reviewConfigToJson(pathsOnlyRule.review)).toEqual({ + labeling_rules: [{ label: "area:ui", when_paths: ["src/**"] }], + }); + }); }); From 043d8afc2742fb2018cb72bfe0b36d084a1e3453 Mon Sep 17 00:00:00 2001 From: jimcody1995 Date: Tue, 7 Jul 2026 07:54:48 +0200 Subject: [PATCH 3/4] fix(review): replace ReDoS-prone markdown separator regex (#2280) Split table separator validation into per-cell checks so PR bodies with long whitespace runs cannot catastrophically backtrack the review worker. Mirrors the fix in both engine and app copies of screenshot-table-gate. Co-authored-by: Cursor --- .../src/review/screenshot-table-gate.ts | 17 +++++++++++++++-- src/review/screenshot-table-gate.ts | 17 +++++++++++++++-- test/unit/screenshot-table-gate.test.ts | 8 ++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/gittensory-engine/src/review/screenshot-table-gate.ts b/packages/gittensory-engine/src/review/screenshot-table-gate.ts index 18c97c9d4e..2529821c44 100644 --- a/packages/gittensory-engine/src/review/screenshot-table-gate.ts +++ b/packages/gittensory-engine/src/review/screenshot-table-gate.ts @@ -88,6 +88,20 @@ export function normalizeScreenshotTableGateConfig(input: unknown, warnings: str }; } +/** Linear-time markdown table separator check. The previous single-regex form nested unbounded `\\s*` inside a + * repeated group and could catastrophically backtrack on attacker-controlled PR bodies; this splits on `|` and + * validates each cell independently instead. */ +const TABLE_SEPARATOR_CELL = /^\s*:?-{3,}:?\s*$/; + +function isMarkdownTableSeparatorRow(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed || !/-{3,}/.test(trimmed)) return false; + const withoutEdgePipes = trimmed.replace(/^\|/, "").replace(/\|$/, "").trim(); + if (!withoutEdgePipes) return false; + const cells = withoutEdgePipes.split("|"); + return cells.length > 0 && cells.every((cell) => TABLE_SEPARATOR_CELL.test(cell)); +} + /** True when `body` contains at least one markdown TABLE region (`| ... |` header + separator row) whose cells * embed image markup — either `![alt](url)` or an `` tag — inside the table. A screenshot pasted as a * bare inline image OUTSIDE any table does not count (the contract requires captioned thumbnails INSIDE a @@ -99,7 +113,6 @@ export function hasImageBearingMarkdownTable(body: string | null | undefined): b if (!body) return false; const lines = body.split(/\r?\n/); const tableRowPattern = /^\s*\|.*\|\s*$/; - const separatorRowPattern = /^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/; const imagePattern = /!\[[^\]]*\]\([^)]+\)|]*>/i; for (let i = 0; i < lines.length - 1; i += 1) { // `i < lines.length - 1` guarantees both indices are in bounds; the `?? ""` fallbacks only exist to @@ -108,7 +121,7 @@ export function hasImageBearingMarkdownTable(body: string | null | undefined): b const header = lines[i] ?? ""; /* v8 ignore next -- defensive: the loop bound above guarantees lines[i + 1] always exists here. */ const separator = lines[i + 1] ?? ""; - if (!tableRowPattern.test(header) || !separatorRowPattern.test(separator)) continue; + if (!tableRowPattern.test(header) || !isMarkdownTableSeparatorRow(separator)) continue; // Found a table (header + separator). Scan its body rows (until a blank line or a non-table line) for // image markup in any cell. let j = i + 2; diff --git a/src/review/screenshot-table-gate.ts b/src/review/screenshot-table-gate.ts index dbb363b9cc..da7b3e4959 100644 --- a/src/review/screenshot-table-gate.ts +++ b/src/review/screenshot-table-gate.ts @@ -88,6 +88,20 @@ export function normalizeScreenshotTableGateConfig(input: unknown, warnings: str }; } +/** Linear-time markdown table separator check. The previous single-regex form nested unbounded `\\s*` inside a + * repeated group and could catastrophically backtrack on attacker-controlled PR bodies; this splits on `|` and + * validates each cell independently instead. */ +const TABLE_SEPARATOR_CELL = /^\s*:?-{3,}:?\s*$/; + +function isMarkdownTableSeparatorRow(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed || !/-{3,}/.test(trimmed)) return false; + const withoutEdgePipes = trimmed.replace(/^\|/, "").replace(/\|$/, "").trim(); + if (!withoutEdgePipes) return false; + const cells = withoutEdgePipes.split("|"); + return cells.length > 0 && cells.every((cell) => TABLE_SEPARATOR_CELL.test(cell)); +} + /** True when `body` contains at least one markdown TABLE region (`| ... |` header + separator row) whose cells * embed image markup — either `![alt](url)` or an `` tag — inside the table. A screenshot pasted as a * bare inline image OUTSIDE any table does not count (the contract requires captioned thumbnails INSIDE a @@ -99,7 +113,6 @@ export function hasImageBearingMarkdownTable(body: string | null | undefined): b if (!body) return false; const lines = body.split(/\r?\n/); const tableRowPattern = /^\s*\|.*\|\s*$/; - const separatorRowPattern = /^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/; const imagePattern = /!\[[^\]]*\]\([^)]+\)|]*>/i; for (let i = 0; i < lines.length - 1; i += 1) { // `i < lines.length - 1` guarantees both indices are in bounds; the `?? ""` fallbacks only exist to @@ -108,7 +121,7 @@ export function hasImageBearingMarkdownTable(body: string | null | undefined): b const header = lines[i] ?? ""; /* v8 ignore next -- defensive: the loop bound above guarantees lines[i + 1] always exists here. */ const separator = lines[i + 1] ?? ""; - if (!tableRowPattern.test(header) || !separatorRowPattern.test(separator)) continue; + if (!tableRowPattern.test(header) || !isMarkdownTableSeparatorRow(separator)) continue; // Found a table (header + separator). Scan its body rows (until a blank line or a non-table line) for // image markup in any cell. let j = i + 2; diff --git a/test/unit/screenshot-table-gate.test.ts b/test/unit/screenshot-table-gate.test.ts index 102d6065db..96be6fdc97 100644 --- a/test/unit/screenshot-table-gate.test.ts +++ b/test/unit/screenshot-table-gate.test.ts @@ -66,6 +66,14 @@ describe("hasImageBearingMarkdownTable", () => { const body = ["| Before | After |", "|:---:|:---:|", "| ![a](x.png) | ![b](y.png) |"].join("\n"); expect(hasImageBearingMarkdownTable(body)).toBe(true); }); + + it("rejects long whitespace-only separator candidates without hanging", () => { + const whitespace = " ".repeat(8_000); + const body = ["| Before | After |", whitespace, "| ![a](x.png) | ![b](y.png) |"].join("\n"); + const started = performance.now(); + expect(hasImageBearingMarkdownTable(body)).toBe(false); + expect(performance.now() - started).toBeLessThan(50); + }); }); describe("hasImageOutsideTable", () => { From 43dd5c8d8c38f1b67564d416251cf597b12043cc Mon Sep 17 00:00:00 2001 From: jimcody1995 Date: Tue, 7 Jul 2026 08:39:16 +0200 Subject: [PATCH 4/4] test(engine): cover extracted focus-manifest dependency modules (#2280) The focus-manifest extraction added engine copies of its settings/review dependency modules (command-authorization, screenshot-table-gate, safe-url, contributor-blacklist, pr-type-label, autonomy, moderation-rules, the linked-issue/unlinked-issue configs, etc.) that no test exercised directly, dropping codecov patch coverage to ~48%. Add engine-owned unit suites (mirroring the app suites against the engine paths) plus targeted branch cases, taking every extracted module to 100% statements/branches/functions/lines. Also drop a dead separator-row guard in both screenshot-table-gate copies and isolate an unreachable, type-required command-authorization fallback behind a v8 ignore so the patch is fully covered. Co-authored-by: Cursor --- .../src/review/screenshot-table-gate.ts | 3 +- .../src/settings/command-authorization.ts | 5 +- src/review/screenshot-table-gate.ts | 3 +- src/settings/command-authorization.ts | 5 +- test/unit/auto-close-exempt-engine.test.ts | 62 ++++ test/unit/autonomy-engine.test.ts | 136 ++++++++ .../unit/command-authorization-engine.test.ts | 228 +++++++++++++ .../unit/contributor-blacklist-engine.test.ts | 96 ++++++ ...ked-issue-hard-rules-config-engine.test.ts | 111 ++++++ ...ked-issue-label-propagation-engine.test.ts | 130 +++++++ test/unit/moderation-rules-engine.test.ts | 147 ++++++++ test/unit/pr-type-label-engine.test.ts | 320 ++++++++++++++++++ test/unit/safe-url-engine.test.ts | 142 ++++++++ .../unit/screenshot-table-gate-engine.test.ts | 313 +++++++++++++++++ test/unit/screenshot-table-gate.test.ts | 5 + ...nked-issue-guardrail-config-engine.test.ts | 85 +++++ 16 files changed, 1785 insertions(+), 6 deletions(-) create mode 100644 test/unit/auto-close-exempt-engine.test.ts create mode 100644 test/unit/autonomy-engine.test.ts create mode 100644 test/unit/command-authorization-engine.test.ts create mode 100644 test/unit/contributor-blacklist-engine.test.ts create mode 100644 test/unit/linked-issue-hard-rules-config-engine.test.ts create mode 100644 test/unit/linked-issue-label-propagation-engine.test.ts create mode 100644 test/unit/moderation-rules-engine.test.ts create mode 100644 test/unit/pr-type-label-engine.test.ts create mode 100644 test/unit/safe-url-engine.test.ts create mode 100644 test/unit/screenshot-table-gate-engine.test.ts create mode 100644 test/unit/unlinked-issue-guardrail-config-engine.test.ts diff --git a/packages/gittensory-engine/src/review/screenshot-table-gate.ts b/packages/gittensory-engine/src/review/screenshot-table-gate.ts index 2529821c44..12273b2bd7 100644 --- a/packages/gittensory-engine/src/review/screenshot-table-gate.ts +++ b/packages/gittensory-engine/src/review/screenshot-table-gate.ts @@ -97,9 +97,8 @@ function isMarkdownTableSeparatorRow(line: string): boolean { const trimmed = line.trim(); if (!trimmed || !/-{3,}/.test(trimmed)) return false; const withoutEdgePipes = trimmed.replace(/^\|/, "").replace(/\|$/, "").trim(); - if (!withoutEdgePipes) return false; const cells = withoutEdgePipes.split("|"); - return cells.length > 0 && cells.every((cell) => TABLE_SEPARATOR_CELL.test(cell)); + return cells.every((cell) => TABLE_SEPARATOR_CELL.test(cell)); } /** True when `body` contains at least one markdown TABLE region (`| ... |` header + separator row) whose cells diff --git a/packages/gittensory-engine/src/settings/command-authorization.ts b/packages/gittensory-engine/src/settings/command-authorization.ts index 85a8487c23..b3b4c861a7 100644 --- a/packages/gittensory-engine/src/settings/command-authorization.ts +++ b/packages/gittensory-engine/src/settings/command-authorization.ts @@ -158,7 +158,10 @@ function normalizeCommandRoleList(commandName: string, roles: CommandAuthorizati if (maintainerRoles.length === roles.length) return roles; warnings.push(`Ignored author command authorization roles for maintainer-only command: ${commandName}.`); - return maintainerRoles.length > 0 ? dedupeRoles(maintainerRoles) : [...(DEFAULT_COMMAND_AUTHORIZATION_POLICY.commands[commandName] ?? ["maintainer", "collaborator"])]; + if (maintainerRoles.length > 0) return dedupeRoles(maintainerRoles); + const defaultRoles = DEFAULT_COMMAND_AUTHORIZATION_POLICY.commands[commandName]; + /* v8 ignore next -- defensive: MAINTAINER_ONLY_DEFAULT_COMMANDS is derived from these keys, so a maintainer-only command always resolves a default list. */ + return [...(defaultRoles ?? ["maintainer", "collaborator"])]; } function actorRoles(args: { diff --git a/src/review/screenshot-table-gate.ts b/src/review/screenshot-table-gate.ts index da7b3e4959..2ed45b154a 100644 --- a/src/review/screenshot-table-gate.ts +++ b/src/review/screenshot-table-gate.ts @@ -97,9 +97,8 @@ function isMarkdownTableSeparatorRow(line: string): boolean { const trimmed = line.trim(); if (!trimmed || !/-{3,}/.test(trimmed)) return false; const withoutEdgePipes = trimmed.replace(/^\|/, "").replace(/\|$/, "").trim(); - if (!withoutEdgePipes) return false; const cells = withoutEdgePipes.split("|"); - return cells.length > 0 && cells.every((cell) => TABLE_SEPARATOR_CELL.test(cell)); + return cells.every((cell) => TABLE_SEPARATOR_CELL.test(cell)); } /** True when `body` contains at least one markdown TABLE region (`| ... |` header + separator row) whose cells diff --git a/src/settings/command-authorization.ts b/src/settings/command-authorization.ts index e854e9b052..e3b8931386 100644 --- a/src/settings/command-authorization.ts +++ b/src/settings/command-authorization.ts @@ -158,7 +158,10 @@ function normalizeCommandRoleList(commandName: string, roles: CommandAuthorizati if (maintainerRoles.length === roles.length) return roles; warnings.push(`Ignored author command authorization roles for maintainer-only command: ${commandName}.`); - return maintainerRoles.length > 0 ? dedupeRoles(maintainerRoles) : [...(DEFAULT_COMMAND_AUTHORIZATION_POLICY.commands[commandName] ?? ["maintainer", "collaborator"])]; + if (maintainerRoles.length > 0) return dedupeRoles(maintainerRoles); + const defaultRoles = DEFAULT_COMMAND_AUTHORIZATION_POLICY.commands[commandName]; + /* v8 ignore next -- defensive: MAINTAINER_ONLY_DEFAULT_COMMANDS is derived from these keys, so a maintainer-only command always resolves a default list. */ + return [...(defaultRoles ?? ["maintainer", "collaborator"])]; } function actorRoles(args: { diff --git a/test/unit/auto-close-exempt-engine.test.ts b/test/unit/auto-close-exempt-engine.test.ts new file mode 100644 index 0000000000..f86a747b39 --- /dev/null +++ b/test/unit/auto-close-exempt-engine.test.ts @@ -0,0 +1,62 @@ +// Mirror of the app suite pointed at the gittensory-engine copy so the extracted module owns its branch coverage (#2280). +import { describe, expect, it } from "vitest"; +import { isAutoCloseExempt, normalizeAutoCloseExemptLogins } from "../../packages/gittensory-engine/src/settings/auto-close-exempt"; + +describe("normalizeAutoCloseExemptLogins (#2463)", () => { + it("returns [] for null/undefined and a non-array (with a warning)", () => { + expect(normalizeAutoCloseExemptLogins(undefined).logins).toEqual([]); + expect(normalizeAutoCloseExemptLogins(null).logins).toEqual([]); + const notArray = normalizeAutoCloseExemptLogins({ login: "x" }); + expect(notArray.logins).toEqual([]); + expect(notArray.warnings[0]).toMatch(/must be a list/); + }); + + it("accepts valid GitHub logins (alnum, single internal hyphen, ≤39 chars)", () => { + const { logins } = normalizeAutoCloseExemptLogins(["a-b", "user123", "a".repeat(39)]); + expect(logins).toEqual(["a-b", "user123", "a".repeat(39)]); + }); + + it("accepts a `[bot]`-suffixed App-actor login (e.g. a third-party automation integration like sentry[bot]) — a maintainer must be able to exempt a repo-specific bot the hardcoded well-known-bot set doesn't know about", () => { + const { logins, warnings } = normalizeAutoCloseExemptLogins(["sentry[bot]", "dependabot[bot]", "github-actions[bot]"]); + expect(logins).toEqual(["sentry[bot]", "dependabot[bot]", "github-actions[bot]"]); + expect(warnings).toEqual([]); + }); + + it("drops non-string and invalid-login entries with a warning", () => { + const { logins, warnings } = normalizeAutoCloseExemptLogins([42, "-bad", "bad-", "a--b", "has space", "a".repeat(40), "sentry[Bot]", "[bot]", "weird[bot][bot]"]); + expect(logins).toEqual([]); + expect(warnings.length).toBeGreaterThanOrEqual(8); + }); + + it("trims whitespace around a login", () => { + const { logins } = normalizeAutoCloseExemptLogins([" spaced-login "]); + expect(logins).toEqual(["spaced-login"]); + }); + + it("de-duplicates by case-insensitive login, keeping the FIRST occurrence's casing", () => { + const { logins } = normalizeAutoCloseExemptLogins(["Mona", "mona"]); + expect(logins).toEqual(["Mona"]); + }); + + it("caps the list and warns when over the limit", () => { + const many = Array.from({ length: 505 }, (_, i) => `user${i}`); + const { logins, warnings } = normalizeAutoCloseExemptLogins(many); + expect(logins).toHaveLength(500); + expect(warnings.some((w) => w.includes("capped"))).toBe(true); + }); +}); + +describe("isAutoCloseExempt (#2463)", () => { + it("matches case-insensitively", () => { + expect(isAutoCloseExempt("mona", ["Mona", "octocat"])).toBe(true); + expect(isAutoCloseExempt("OCTOCAT", ["Mona", "octocat"])).toBe(true); + }); + + it("returns false for a non-match, a missing login, or an absent/empty list", () => { + expect(isAutoCloseExempt("stranger", ["Mona"])).toBe(false); + expect(isAutoCloseExempt(null, ["Mona"])).toBe(false); + expect(isAutoCloseExempt(undefined, ["Mona"])).toBe(false); + expect(isAutoCloseExempt("anyone", undefined)).toBe(false); + expect(isAutoCloseExempt("anyone", [])).toBe(false); + }); +}); diff --git a/test/unit/autonomy-engine.test.ts b/test/unit/autonomy-engine.test.ts new file mode 100644 index 0000000000..25729a4119 --- /dev/null +++ b/test/unit/autonomy-engine.test.ts @@ -0,0 +1,136 @@ +// Mirror of the app suite pointed at the gittensory-engine copy so the extracted module owns its branch coverage (#2280). +import { describe, expect, it } from "vitest"; +import { + AGENT_ACTION_CLASSES, + AUTONOMY_LEVELS, + AUTO_MERGE_METHODS, + DEFAULT_AUTONOMY_LEVEL, + DEFAULT_AUTO_MAINTAIN_POLICY, + autonomyRequiresApproval, + isActingAutonomyLevel, + isAgentConfigured, + normalizeAutoMaintainPolicy, + normalizeAutonomyPolicy, + resolveAutonomy, +} from "../../packages/gittensory-engine/src/settings/autonomy"; +import type { AutonomyPolicy } from "../../packages/gittensory-engine/src/types/manifest-deps-types"; + +describe("resolveAutonomy (#773 deny-by-default gate)", () => { + it("returns the configured level for an action class", () => { + const autonomy: AutonomyPolicy = { merge: "auto_with_approval", label: "auto" }; + expect(resolveAutonomy(autonomy, "merge")).toBe("auto_with_approval"); + expect(resolveAutonomy(autonomy, "label")).toBe("auto"); + }); + + it("denies by default — an unset action class resolves to observe", () => { + expect(resolveAutonomy({ merge: "auto" }, "close")).toBe("observe"); + expect(resolveAutonomy({}, "merge")).toBe(DEFAULT_AUTONOMY_LEVEL); + expect(DEFAULT_AUTONOMY_LEVEL).toBe("observe"); + }); + + it("denies by default for a null/undefined policy (no config at all)", () => { + expect(resolveAutonomy(null, "merge")).toBe("observe"); + expect(resolveAutonomy(undefined, "review")).toBe("observe"); + }); + + it("every action class resolves to observe under an empty policy", () => { + for (const actionClass of AGENT_ACTION_CLASSES) { + expect(resolveAutonomy({}, actionClass)).toBe("observe"); + } + }); + + it("review_state_label (#label-scoping) is independent of label — setting one does not act for the other", () => { + expect(AGENT_ACTION_CLASSES).toContain("review_state_label"); + const autonomy: AutonomyPolicy = { label: "auto" }; + expect(resolveAutonomy(autonomy, "label")).toBe("auto"); + expect(resolveAutonomy(autonomy, "review_state_label")).toBe("observe"); + const inverted: AutonomyPolicy = { review_state_label: "auto" }; + expect(resolveAutonomy(inverted, "review_state_label")).toBe("auto"); + expect(resolveAutonomy(inverted, "label")).toBe("observe"); + }); +}); + +describe("autonomy level predicates", () => { + it("isActingAutonomyLevel is true only for auto / auto_with_approval", () => { + expect(isActingAutonomyLevel("auto")).toBe(true); + expect(isActingAutonomyLevel("auto_with_approval")).toBe(true); + expect(isActingAutonomyLevel("propose")).toBe(false); + expect(isActingAutonomyLevel("suggest")).toBe(false); + expect(isActingAutonomyLevel("observe")).toBe(false); + }); + + it("autonomyRequiresApproval is true only for auto_with_approval", () => { + expect(autonomyRequiresApproval("auto_with_approval")).toBe(true); + expect(autonomyRequiresApproval("auto")).toBe(false); + expect(autonomyRequiresApproval("observe")).toBe(false); + }); + + it("the level ladder is ordered observe → … → auto with observe at the floor", () => { + expect(AUTONOMY_LEVELS[0]).toBe("observe"); + expect(AUTONOMY_LEVELS[AUTONOMY_LEVELS.length - 1]).toBe("auto"); + expect(AUTONOMY_LEVELS).toEqual(["observe", "suggest", "propose", "auto_with_approval", "auto"]); + }); +}); + +describe("normalizeAutonomyPolicy", () => { + it("keeps only known action classes mapped to known levels", () => { + expect(normalizeAutonomyPolicy({ merge: "auto", review: "suggest" })).toEqual({ merge: "auto", review: "suggest" }); + }); + + it("drops unknown action classes and unknown levels (deny-by-omission)", () => { + expect( + normalizeAutonomyPolicy({ merge: "auto", deploy: "auto", close: "rampage", label: 7 }), + ).toEqual({ merge: "auto" }); + }); + + it("returns an empty policy for non-object / array / null input", () => { + expect(normalizeAutonomyPolicy(null)).toEqual({}); + expect(normalizeAutonomyPolicy("auto")).toEqual({}); + expect(normalizeAutonomyPolicy(["merge"])).toEqual({}); + expect(normalizeAutonomyPolicy(undefined)).toEqual({}); + }); + + it("round-trips a valid policy through normalization", () => { + const policy: AutonomyPolicy = { review: "propose", request_changes: "auto_with_approval", merge: "observe" }; + expect(normalizeAutonomyPolicy(policy)).toEqual(policy); + }); +}); + +describe("normalizeAutoMaintainPolicy (#774)", () => { + it("fills conservative defaults (squash / 1 approval) for missing or non-object input", () => { + expect(normalizeAutoMaintainPolicy({})).toEqual({ requireApprovals: 1, mergeMethod: "squash" }); + expect(normalizeAutoMaintainPolicy(null)).toEqual(DEFAULT_AUTO_MAINTAIN_POLICY); + expect(normalizeAutoMaintainPolicy("nope")).toEqual(DEFAULT_AUTO_MAINTAIN_POLICY); + expect(normalizeAutoMaintainPolicy([1])).toEqual(DEFAULT_AUTO_MAINTAIN_POLICY); + }); + + it("keeps valid fields and round-trips a full policy", () => { + expect(normalizeAutoMaintainPolicy({ requireApprovals: 2, mergeMethod: "rebase" })).toEqual({ requireApprovals: 2, mergeMethod: "rebase" }); + }); + + it("clamps requireApprovals to [0,10] and truncates, and rejects an invalid merge method", () => { + expect(normalizeAutoMaintainPolicy({ requireApprovals: -3, mergeMethod: "foo" })).toEqual({ requireApprovals: 0, mergeMethod: "squash" }); + expect(normalizeAutoMaintainPolicy({ requireApprovals: 99 }).requireApprovals).toBe(10); + expect(normalizeAutoMaintainPolicy({ requireApprovals: 2.9 }).requireApprovals).toBe(2); + expect(normalizeAutoMaintainPolicy({ requireApprovals: "two" }).requireApprovals).toBe(1); // non-number → default + }); + + it("AUTO_MERGE_METHODS is the closed set merge/squash/rebase", () => { + expect(AUTO_MERGE_METHODS).toEqual(["merge", "squash", "rebase"]); + }); +}); + +describe("isAgentConfigured (#777 opt-in detection)", () => { + it("is true when any action class has an acting level", () => { + expect(isAgentConfigured({ merge: "auto" })).toBe(true); + expect(isAgentConfigured({ label: "auto_with_approval" })).toBe(true); + expect(isAgentConfigured({ review: "suggest", close: "auto" })).toBe(true); + }); + + it("is false for the deny-by-default floor (all observe / non-acting / empty / null)", () => { + expect(isAgentConfigured({ merge: "observe", review: "suggest", approve: "propose" })).toBe(false); + expect(isAgentConfigured({})).toBe(false); + expect(isAgentConfigured(null)).toBe(false); + expect(isAgentConfigured(undefined)).toBe(false); + }); +}); diff --git a/test/unit/command-authorization-engine.test.ts b/test/unit/command-authorization-engine.test.ts new file mode 100644 index 0000000000..e3a0606353 --- /dev/null +++ b/test/unit/command-authorization-engine.test.ts @@ -0,0 +1,228 @@ +// Mirror of the app suite pointed at the gittensory-engine copy so the extracted module owns its branch coverage (#2280). +import { describe, expect, it } from "vitest"; +import { + commandAuthorizationAllowedRoles, + commandAuthorizationNeedsMinerDetection, + evaluateCommandAuthorization, + normalizeCommandAuthorizationPolicy, + summarizeCommandAuthorizationPolicy, +} from "../../packages/gittensory-engine/src/settings/command-authorization"; + +describe("repo command authorization policy", () => { + it("preserves secure defaults for maintainers, collaborators, and confirmed-miner PR authors", () => { + expect(evaluateCommandAuthorization({ commandName: "preflight", commenterAssociation: "OWNER" })).toMatchObject({ + authorized: true, + reason: "maintainer_invocation", + actorKind: "maintainer", + }); + expect(evaluateCommandAuthorization({ commandName: "preflight", commenterAssociation: "COLLABORATOR" })).toMatchObject({ + authorized: true, + reason: "collaborator_invocation", + actorKind: "maintainer", + }); + expect( + evaluateCommandAuthorization({ + commandName: "next-action", + commenterLogin: "miner", + pullRequestAuthorLogin: "miner", + minerStatus: "confirmed", + }), + ).toMatchObject({ authorized: true, reason: "confirmed_miner_pr_author", actorKind: "author" }); + expect(evaluateCommandAuthorization({ commandName: "queue-summary", commenterLogin: "miner", pullRequestAuthorLogin: "miner", minerStatus: "confirmed" })).toMatchObject({ + authorized: false, + reason: "maintainer_command_requires_maintainer", + }); + }); + + it("gate-override is maintainer/collaborator only and ignores spoofable author_association", () => { + // The gateOverridePolicy ships maintainer+collaborator only (no pr_author / confirmed_miner). + expect(commandAuthorizationAllowedRoles(undefined, "gate-override")).toEqual(["maintainer", "collaborator"]); + // Real admin/maintain → MEMBER and real write → COLLABORATOR are the only associations that pass. + expect(evaluateCommandAuthorization({ commandName: "gate-override", commenterAssociation: "MEMBER" })).toMatchObject({ authorized: true, reason: "maintainer_invocation", actorKind: "maintainer" }); + expect(evaluateCommandAuthorization({ commandName: "gate-override", commenterAssociation: "COLLABORATOR" })).toMatchObject({ authorized: true, reason: "collaborator_invocation", actorKind: "maintainer" }); + // An org member WITHOUT real repo write resolves (in the handler) to a null association → denied here, + // even if the PR author tries it themselves. + expect(evaluateCommandAuthorization({ commandName: "gate-override", commenterAssociation: null })).toMatchObject({ authorized: false }); + expect(evaluateCommandAuthorization({ commandName: "gate-override", commenterLogin: "author", pullRequestAuthorLogin: "author", commenterAssociation: null })).toMatchObject({ authorized: false }); + }); + + it("matches command keys case-insensitively so a mixed-case name cannot dodge the maintainer-only restriction", () => { + // Policy keys are stored lowercased; a raw mixed-case/whitespace probe must normalize to the same key, + // otherwise it falls through to the permissive default and skips the maintainer-only guard. + expect(commandAuthorizationAllowedRoles(undefined, "Gate-Override")).toEqual(["maintainer", "collaborator"]); + expect(commandAuthorizationAllowedRoles(undefined, " QUEUE-SUMMARY ")).toEqual(["maintainer", "collaborator"]); + // A PR author invoking the maintainer-only command under a different casing is still denied (not granted + // the permissive default), and the miner lookup is still required where confirmed_miner is allowed. + expect( + evaluateCommandAuthorization({ commandName: "Gate-Override", commenterLogin: "author", pullRequestAuthorLogin: "author", commenterAssociation: null }), + ).toMatchObject({ authorized: false, reason: "maintainer_command_requires_maintainer", actorKind: "author" }); + expect( + commandAuthorizationNeedsMinerDetection({ commandName: "REVIEW-NOW", commenterLogin: "miner", pullRequestAuthorLogin: "miner" }), + ).toBe(false); + }); + + it("clamps the spoofable pr_author role off maintainer-only commands but keeps confirmed_miner (#824)", () => { + const { policy, warnings } = normalizeCommandAuthorizationPolicy({ + commands: { + "review-now": ["confirmed_miner"], + "queue-summary": ["collaborator", "pr_author"], + "needs-author": ["pr_author"], + }, + }); + + // confirmed_miner is exempt from the maintainer-only clamp, so it survives without a warning. + expect(warnings).not.toContain("Ignored author command authorization roles for maintainer-only command: review-now."); + expect(warnings).toContain("Ignored author command authorization roles for maintainer-only command: queue-summary."); + expect(warnings).toContain("Ignored author command authorization roles for maintainer-only command: needs-author."); + expect(policy.commands["review-now"]).toEqual(["confirmed_miner"]); + expect(policy.commands["queue-summary"]).toEqual(["collaborator"]); + // Dropping the only role (plain pr_author) falls back to the secure maintainer/collaborator default. + expect(policy.commands["needs-author"]).toEqual(["maintainer", "collaborator"]); + expect(commandAuthorizationAllowedRoles(policy, "review-now")).toEqual(["confirmed_miner"]); + // A confirmed-miner PR author can self-trigger a maintainer-only command when the policy allows it. + expect( + evaluateCommandAuthorization({ + policy: { commands: { "review-now": ["confirmed_miner"] }, default: ["confirmed_miner"] }, + commandName: "review-now", + commenterLogin: "miner", + pullRequestAuthorLogin: "miner", + minerStatus: "confirmed", + }), + ).toMatchObject({ + authorized: true, + reason: "confirmed_miner_pr_author", + actorKind: "author", + allowedRoles: ["confirmed_miner"], + }); + // A plain PR author (not a confirmed miner) is still denied on the same maintainer-only command. + expect( + evaluateCommandAuthorization({ + policy: { commands: { "review-now": ["confirmed_miner"] }, default: ["confirmed_miner"] }, + commandName: "review-now", + commenterLogin: "author", + pullRequestAuthorLogin: "author", + minerStatus: "not_found", + }), + ).toMatchObject({ + authorized: false, + reason: "pr_author_not_confirmed_miner", + allowedRoles: ["confirmed_miner"], + }); + }); + + it("requires miner detection when a PR author self-invokes a confirmed_miner command with no other qualifying role", () => { + // "review" allows confirmed_miner; a self-invoking author with no maintainer/collaborator role + // forces a miner lookup to decide authorization. + expect( + commandAuthorizationNeedsMinerDetection({ commandName: "review", commenterLogin: "author", pullRequestAuthorLogin: "author" }), + ).toBe(true); + // A different commenter than the PR author never needs miner detection (not a self-invocation). + expect( + commandAuthorizationNeedsMinerDetection({ commandName: "review", commenterLogin: "someone-else", pullRequestAuthorLogin: "author" }), + ).toBe(false); + }); + + it("reports miner_detection_unavailable when a self-invoking author lacks a resolvable miner status", () => { + expect( + evaluateCommandAuthorization({ commandName: "review", commenterLogin: "author", pullRequestAuthorLogin: "author" }), + ).toMatchObject({ authorized: false, reason: "miner_detection_unavailable", actorKind: "author" }); + expect( + evaluateCommandAuthorization({ commandName: "review", commenterLogin: "author", pullRequestAuthorLogin: "author", minerStatus: "unavailable" }), + ).toMatchObject({ authorized: false, reason: "miner_detection_unavailable" }); + }); + + it("falls back to the built-in default roles when a maintainer-only default command is given only author roles", () => { + // queue-summary is a maintainer-only default command; stripping its lone pr_author role restores + // the built-in default (not the generic maintainer/collaborator literal fallback). + const { policy } = normalizeCommandAuthorizationPolicy({ commands: { "queue-summary": ["pr_author"] } }); + expect(policy.commands["queue-summary"]).toEqual(["maintainer", "collaborator"]); + }); + + it("honors command overrides and avoids miner lookup when plain PR author is allowed", () => { + const policy = normalizeCommandAuthorizationPolicy({ default: ["maintainer"], commands: { "next-action": ["pr_author"] } }).policy; + expect( + commandAuthorizationNeedsMinerDetection({ + policy, + commandName: "next-action", + commenterLogin: "author", + pullRequestAuthorLogin: "author", + }), + ).toBe(false); + expect(evaluateCommandAuthorization({ policy, commandName: "next-action", commenterLogin: "author", pullRequestAuthorLogin: "author" })).toMatchObject({ + authorized: true, + reason: "allowed_pr_author", + actorKind: "author", + matchedRole: "pr_author", + }); + expect(evaluateCommandAuthorization({ policy, commandName: "packet", commenterLogin: "author", pullRequestAuthorLogin: "author" })).toMatchObject({ + authorized: false, + reason: "command_policy_denied", + }); + }); + + it("defaults the #1960 PR control-surface verbs to maintainer/collaborator-only, except review (widenable to confirmed_miner)", () => { + expect(commandAuthorizationAllowedRoles(undefined, "review")).toEqual(["maintainer", "collaborator", "confirmed_miner"]); + for (const command of ["pause", "resume", "resolve", "configuration", "explain"]) { + expect(commandAuthorizationAllowedRoles(undefined, command)).toEqual(["maintainer", "collaborator"]); + } + // A confirmed-miner PR author can self-trigger "review" (the #824 self-rerun precedent), but not "pause". + expect( + evaluateCommandAuthorization({ commandName: "review", commenterLogin: "miner", pullRequestAuthorLogin: "miner", minerStatus: "confirmed" }), + ).toMatchObject({ authorized: true, reason: "confirmed_miner_pr_author", actorKind: "author" }); + expect( + evaluateCommandAuthorization({ commandName: "pause", commenterLogin: "miner", pullRequestAuthorLogin: "miner", minerStatus: "confirmed" }), + ).toMatchObject({ authorized: false, reason: "maintainer_command_requires_maintainer" }); + // Maintainers and collaborators are authorized on every new verb. + for (const command of ["review", "pause", "resume", "resolve", "configuration", "explain"]) { + expect(evaluateCommandAuthorization({ commandName: command, commenterAssociation: "OWNER" })).toMatchObject({ authorized: true, reason: "maintainer_invocation" }); + expect(evaluateCommandAuthorization({ commandName: command, commenterAssociation: "COLLABORATOR" })).toMatchObject({ authorized: true, reason: "collaborator_invocation" }); + } + // A spoofable pr_author role added to one of the maintainer-only new verbs is clamped off with a warning; + // the confirmed_miner role on "review" is not spoofable via author_association and survives untouched. + const clamped = normalizeCommandAuthorizationPolicy({ commands: { resolve: ["collaborator", "pr_author"], review: ["confirmed_miner"] } }); + expect(clamped.warnings).toContain("Ignored author command authorization roles for maintainer-only command: resolve."); + expect(clamped.warnings).not.toContain("Ignored author command authorization roles for maintainer-only command: review."); + expect(clamped.policy.commands.resolve).toEqual(["collaborator"]); + expect(clamped.policy.commands.review).toEqual(["confirmed_miner"]); + }); + + it("falls back to default roles for inherited object property command names", () => { + for (const commandName of ["constructor", "toString", "__proto__", "hasOwnProperty"]) { + expect(commandAuthorizationAllowedRoles(undefined, commandName)).toEqual(["maintainer", "collaborator", "confirmed_miner"]); + expect(evaluateCommandAuthorization({ commandName, commenterAssociation: "OWNER" })).toMatchObject({ + authorized: true, + reason: "maintainer_invocation", + allowedRoles: ["maintainer", "collaborator", "confirmed_miner"], + }); + } + }); + + it("warns on malformed policy and falls back to default command roles", () => { + const nonObject = normalizeCommandAuthorizationPolicy("not-a-policy"); + expect(nonObject.warnings).toEqual(["commandAuthorization must be an object; using secure defaults."]); + expect(nonObject.policy.default).toEqual(["maintainer", "collaborator", "confirmed_miner"]); + + const defaultOnly = normalizeCommandAuthorizationPolicy({ default: ["pr_author"] }); + expect(defaultOnly.warnings).toEqual([]); + expect(defaultOnly.policy.default).toEqual(["pr_author"]); + expect(defaultOnly.policy.commands["queue-summary"]).toEqual(["maintainer", "collaborator"]); + + const { policy, warnings } = normalizeCommandAuthorizationPolicy({ + default: ["unknown", "confirmed_miner"], + commands: { + "bad command": ["maintainer"], + preflight: ["bogus"], + blockers: "maintainer", + }, + }); + expect(warnings.length).toBeGreaterThanOrEqual(3); + expect(policy.default).toEqual(["confirmed_miner"]); + expect(policy.commands.preflight).toEqual(["confirmed_miner"]); + expect(policy.commands.blockers).toEqual(["confirmed_miner"]); + expect(summarizeCommandAuthorizationPolicy(policy).commandOverrides.map((entry) => entry.command)).toContain("queue-summary"); + + const malformedCommands = normalizeCommandAuthorizationPolicy({ commands: ["preflight"] }); + expect(malformedCommands.warnings).toContain("commandAuthorization.commands must be an object; using command defaults."); + expect(malformedCommands.policy.commands["queue-summary"]).toEqual(["maintainer", "collaborator"]); + }); +}); diff --git a/test/unit/contributor-blacklist-engine.test.ts b/test/unit/contributor-blacklist-engine.test.ts new file mode 100644 index 0000000000..209f467c93 --- /dev/null +++ b/test/unit/contributor-blacklist-engine.test.ts @@ -0,0 +1,96 @@ +// AUTO-GENERATED-STYLE mirror of the app suite to cover the gittensory-engine copy (#2280). +// DB round-trip cases stay in the app suite; the engine package only owns the pure normalizers. +import { describe, expect, it } from "vitest"; +import { findBlacklistEntry, isAuthorBlacklisted, mergeContributorBlacklists, normalizeContributorBlacklist } from "../../packages/gittensory-engine/src/settings/contributor-blacklist"; +import type { ContributorBlacklistEntry } from "../../packages/gittensory-engine/src/types/manifest-deps-types"; + +describe("normalizeContributorBlacklist (#1425) [engine]", () => { + it("returns [] for null/undefined and a non-array (with a warning)", () => { + expect(normalizeContributorBlacklist(undefined).entries).toEqual([]); + expect(normalizeContributorBlacklist(null).entries).toEqual([]); + const notArray = normalizeContributorBlacklist({ login: "x" }); + expect(notArray.entries).toEqual([]); + expect(notArray.warnings[0]).toMatch(/must be a list/); + }); + + it("accepts a bare login string and a full entry object", () => { + const { entries } = normalizeContributorBlacklist(["octocat", { login: "mona", reason: "farming", evidence: ["https://github.com/o/r/pull/1"], addedAt: "2026-06-26T00:00:00Z" }]); + expect(entries).toEqual([ + { login: "octocat" }, + { login: "mona", reason: "farming", evidence: ["https://github.com/o/r/pull/1"], addedAt: "2026-06-26T00:00:00Z" }, + ]); + }); + + it("drops entries with no/invalid login", () => { + const { entries, warnings } = normalizeContributorBlacklist([{ reason: "no login" }, 42, { login: "-bad" }, { login: "bad-" }, { login: "a--b" }, { login: "has space" }, { login: "a".repeat(40) }]); + expect(entries).toEqual([]); + expect(warnings.length).toBeGreaterThanOrEqual(5); + }); + + it("accepts valid GitHub logins (alnum, single internal hyphen, ≤39 chars)", () => { + const { entries } = normalizeContributorBlacklist(["a-b", "user123", "a".repeat(39)]); + expect(entries.map((e) => e.login)).toEqual(["a-b", "user123", "a".repeat(39)]); + }); + + it("de-duplicates by case-insensitive login, keeping the FIRST (richer) occurrence", () => { + const { entries } = normalizeContributorBlacklist([{ login: "Mona", reason: "first" }, { login: "mona", reason: "second" }]); + expect(entries).toEqual([{ login: "Mona", reason: "first" }]); + }); + + it("caps the list and warns when over the limit", () => { + const many = Array.from({ length: 1005 }, (_, i) => `user${i}`); + const { entries, warnings } = normalizeContributorBlacklist(many); + expect(entries).toHaveLength(1000); + expect(warnings.some((w) => w.includes("capped"))).toBe(true); + }); + + it("normalizes metadata: trims + caps reason, filters/caps evidence, omits empties", () => { + const { entries } = normalizeContributorBlacklist([ + { login: "a", reason: " spaced ", evidence: [" url ", "", 5, "u2"], addedAt: " 2026-01-01 " }, + { login: "b", reason: " ", evidence: [""] }, + { login: "c", reason: "x".repeat(300), evidence: Array.from({ length: 20 }, (_, i) => `e${i}`) }, + ]); + expect(entries[0]).toEqual({ login: "a", reason: "spaced", evidence: ["url", "u2"], addedAt: "2026-01-01" }); + expect(entries[1]).toEqual({ login: "b" }); + expect(entries[2]?.reason?.length).toBe(200); + expect(entries[2]?.evidence).toHaveLength(10); + }); +}); + +describe("findBlacklistEntry / isAuthorBlacklisted [engine]", () => { + const list: ContributorBlacklistEntry[] = [{ login: "Mona", reason: "farming" }, { login: "octocat" }]; + + it("matches case-insensitively and returns the entry", () => { + expect(findBlacklistEntry("mona", list)?.reason).toBe("farming"); + expect(findBlacklistEntry("OCTOCAT", list)?.login).toBe("octocat"); + expect(isAuthorBlacklisted("Mona", list)).toBe(true); + }); + + it("returns null/false for a non-match or a missing login", () => { + expect(findBlacklistEntry("stranger", list)).toBeNull(); + expect(findBlacklistEntry(null, list)).toBeNull(); + expect(findBlacklistEntry(undefined, list)).toBeNull(); + expect(isAuthorBlacklisted("stranger", list)).toBe(false); + expect(isAuthorBlacklisted(null, list)).toBe(false); + }); + + it("tolerates an absent list (treated as empty) so callers can pass the optional setting directly", () => { + expect(findBlacklistEntry("anyone", undefined)).toBeNull(); + expect(isAuthorBlacklisted("anyone", undefined)).toBe(false); + }); +}); + +describe("mergeContributorBlacklists (global ∪ per-repo) [engine]", () => { + it("unions by case-insensitive login, first source's entry wins on a dup", () => { + const global: ContributorBlacklistEntry[] = [{ login: "Mona", reason: "global" }, { login: "abuser" }]; + const perRepo: ContributorBlacklistEntry[] = [{ login: "mona", reason: "repo" }, { login: "repo-only" }]; + const merged = mergeContributorBlacklists(global, perRepo); + expect(merged.map((e) => e.login.toLowerCase())).toEqual(["mona", "abuser", "repo-only"]); + expect(findBlacklistEntry("mona", merged)?.reason).toBe("global"); + }); + + it("returns [] for no sources / all-empty sources", () => { + expect(mergeContributorBlacklists()).toEqual([]); + expect(mergeContributorBlacklists([], [])).toEqual([]); + }); +}); diff --git a/test/unit/linked-issue-hard-rules-config-engine.test.ts b/test/unit/linked-issue-hard-rules-config-engine.test.ts new file mode 100644 index 0000000000..861c354db4 --- /dev/null +++ b/test/unit/linked-issue-hard-rules-config-engine.test.ts @@ -0,0 +1,111 @@ +// Engine-owned coverage for the linked-issue hard-rules normalizer (#2280). +import { describe, expect, it } from "vitest"; +import { + DEFAULT_LINKED_ISSUE_HARD_RULES, + isLinkedIssueHardRuleMode, + normalizeLinkedIssueHardRulesConfig, +} from "../../packages/gittensory-engine/src/review/linked-issue-hard-rules-config"; + +describe("isLinkedIssueHardRuleMode [engine]", () => { + it("accepts the valid modes and rejects everything else", () => { + expect(isLinkedIssueHardRuleMode("block")).toBe(true); + expect(isLinkedIssueHardRuleMode("off")).toBe(true); + expect(isLinkedIssueHardRuleMode("warn")).toBe(false); + expect(isLinkedIssueHardRuleMode(123)).toBe(false); + expect(isLinkedIssueHardRuleMode(undefined)).toBe(false); + }); +}); + +describe("normalizeLinkedIssueHardRulesConfig [engine]", () => { + it("returns the all-off default for undefined input", () => { + const warnings: string[] = []; + expect(normalizeLinkedIssueHardRulesConfig(undefined, warnings)).toEqual({ + ...DEFAULT_LINKED_ISSUE_HARD_RULES, + pointBearingLabels: [], + maintainerOnlyLabels: [], + }); + expect(warnings).toEqual([]); + }); + + it("warns and falls back to default for non-object / null / array input", () => { + for (const bad of ["nope", null, [] as unknown]) { + const warnings: string[] = []; + expect(normalizeLinkedIssueHardRulesConfig(bad, warnings)).toEqual({ + ...DEFAULT_LINKED_ISSUE_HARD_RULES, + pointBearingLabels: [], + maintainerOnlyLabels: [], + }); + expect(warnings.some((w) => w.includes("must be an object"))).toBe(true); + } + }); + + it("parses a fully valid object", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueHardRulesConfig( + { + ownerAssignedClose: "block", + assignedIssueClose: "off", + missingPointLabelClose: "block", + maintainerOnlyLabelClose: "block", + pointBearingLabels: [" points ", "size"], + maintainerOnlyLabels: ["maintainer"], + defaultLabelRepo: true, + verifyBeforeClose: false, + closeDelaySeconds: 90, + }, + warnings, + ); + expect(result).toEqual({ + ownerAssignedClose: "block", + assignedIssueClose: "off", + missingPointLabelClose: "block", + maintainerOnlyLabelClose: "block", + pointBearingLabels: ["points", "size"], + maintainerOnlyLabels: ["maintainer"], + defaultLabelRepo: true, + verifyBeforeClose: false, + closeDelaySeconds: 90, + }); + expect(warnings).toEqual([]); + }); + + it("warns on an invalid mode and falls back to the field default", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueHardRulesConfig({ ownerAssignedClose: "sometimes" }, warnings); + expect(result.ownerAssignedClose).toBe("off"); + expect(warnings.some((w) => w.includes("ownerAssignedClose"))).toBe(true); + }); + + it("warns on a non-array label list and drops invalid entries within a valid list", () => { + const nonArray: string[] = []; + expect(normalizeLinkedIssueHardRulesConfig({ pointBearingLabels: "points" }, nonArray).pointBearingLabels).toEqual([]); + expect(nonArray.some((w) => w.includes("pointBearingLabels must be an array"))).toBe(true); + + const withEntries: string[] = []; + const result = normalizeLinkedIssueHardRulesConfig({ maintainerOnlyLabels: ["keep", "", 5, " "] }, withEntries); + expect(result.maintainerOnlyLabels).toEqual(["keep"]); + expect(withEntries.some((w) => w.includes("must be a non-empty string"))).toBe(true); + }); + + it("warns on a non-boolean flag and falls back to the field default", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueHardRulesConfig({ defaultLabelRepo: "yes", verifyBeforeClose: 0 }, warnings); + expect(result.defaultLabelRepo).toBe(false); + expect(result.verifyBeforeClose).toBe(true); + expect(warnings.filter((w) => w.includes("must be a boolean")).length).toBe(2); + }); + + it("clamps closeDelaySeconds and warns on invalid values", () => { + expect(normalizeLinkedIssueHardRulesConfig({ closeDelaySeconds: 10.9 }, []).closeDelaySeconds).toBe(10); + expect(normalizeLinkedIssueHardRulesConfig({ closeDelaySeconds: 5000 }, []).closeDelaySeconds).toBe(300); + + const warnings: string[] = []; + const result = normalizeLinkedIssueHardRulesConfig({ closeDelaySeconds: -1 }, warnings); + expect(result.closeDelaySeconds).toBe(30); + expect(warnings.some((w) => w.includes("closeDelaySeconds"))).toBe(true); + + const nan: string[] = []; + expect(normalizeLinkedIssueHardRulesConfig({ closeDelaySeconds: Number.NaN }, nan).closeDelaySeconds).toBe(30); + expect(nan.some((w) => w.includes("closeDelaySeconds"))).toBe(true); + }); +}); diff --git a/test/unit/linked-issue-label-propagation-engine.test.ts b/test/unit/linked-issue-label-propagation-engine.test.ts new file mode 100644 index 0000000000..82d532412d --- /dev/null +++ b/test/unit/linked-issue-label-propagation-engine.test.ts @@ -0,0 +1,130 @@ +// Mirror of the app suite pointed at the gittensory-engine copy so the extracted module owns its branch coverage (#2280). +import { describe, expect, it } from "vitest"; +import { DEFAULT_LINKED_ISSUE_LABEL_PROPAGATION, normalizeLinkedIssueLabelPropagationConfig } from "../../packages/gittensory-engine/src/review/linked-issue-label-propagation"; + +describe("normalizeLinkedIssueLabelPropagationConfig (#priority-linked-issue-gate)", () => { + it("returns the disabled default when the input is omitted", () => { + const warnings: string[] = []; + expect(normalizeLinkedIssueLabelPropagationConfig(undefined, warnings)).toEqual(DEFAULT_LINKED_ISSUE_LABEL_PROPAGATION); + expect(warnings).toEqual([]); + }); + + it("warns and returns the disabled default for a non-object input", () => { + const warnings: string[] = []; + expect(normalizeLinkedIssueLabelPropagationConfig("nope", warnings)).toEqual(DEFAULT_LINKED_ISSUE_LABEL_PROPAGATION); + expect(warnings.some((w) => w.includes("settings.linkedIssueLabelPropagation"))).toBe(true); + }); + + it("warns and returns the disabled default for an array input", () => { + const warnings: string[] = []; + expect(normalizeLinkedIssueLabelPropagationConfig([1, 2], warnings)).toEqual(DEFAULT_LINKED_ISSUE_LABEL_PROPAGATION); + expect(warnings.length).toBeGreaterThan(0); + }); + + it("passes through a full, valid config unchanged", () => { + const warnings: string[] = []; + const input = { + enabled: true, + mode: "exclusive_type_label", + mappings: [{ issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: true }], + }; + expect(normalizeLinkedIssueLabelPropagationConfig(input, warnings)).toEqual(input); + expect(warnings).toEqual([]); + }); + + it("warns and falls back to the default mode for an unrecognized mode value", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueLabelPropagationConfig({ enabled: true, mode: "something_else", mappings: [] }, warnings); + expect(result.mode).toBe("exclusive_type_label"); + expect(warnings.some((w) => w.includes("mode"))).toBe(true); + }); + + it("warns and falls back to the default mode for a non-string mode value", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueLabelPropagationConfig({ enabled: true, mode: 42, mappings: [] }, warnings); + expect(result.mode).toBe("exclusive_type_label"); + expect(warnings.some((w) => w.includes("mode"))).toBe(true); + }); + + it("warns and falls back to the disabled default for a non-boolean enabled value", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueLabelPropagationConfig({ enabled: "true", mappings: [] }, warnings); + expect(result.enabled).toBe(false); + expect(warnings.some((w) => w.includes("settings.linkedIssueLabelPropagation.enabled"))).toBe(true); + }); + + it("does not warn when enabled is omitted (a normal, unset default)", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueLabelPropagationConfig({ mappings: [] }, warnings); + expect(result.enabled).toBe(false); + expect(warnings).toEqual([]); + }); + + it("defaults mappings to an empty list when the key is omitted entirely", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueLabelPropagationConfig({ enabled: true }, warnings); + expect(result.mappings).toEqual([]); + expect(warnings).toEqual([]); + }); + + it("drops a malformed mapping entry (missing prLabel) with a warning, keeping the other valid entries", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueLabelPropagationConfig( + { + enabled: true, + mappings: [ + { issueLabel: "gittensor:priority" }, + { issueLabel: "customer:vip", prLabel: "triage:vip", removeOtherTypeLabels: false }, + ], + }, + warnings, + ); + expect(result.mappings).toEqual([{ issueLabel: "customer:vip", prLabel: "triage:vip", removeOtherTypeLabels: false }]); + expect(warnings.some((w) => w.includes("mappings[0]"))).toBe(true); + }); + + it("drops a mapping entry with a non-string issueLabel, with a warning", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueLabelPropagationConfig({ enabled: true, mappings: [{ issueLabel: 42, prLabel: "triage:vip" }] }, warnings); + expect(result.mappings).toEqual([]); + expect(warnings.some((w) => w.includes("mappings[0]"))).toBe(true); + }); + + it("drops a non-object mapping entry with a warning", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueLabelPropagationConfig({ enabled: true, mappings: ["not-an-object"] }, warnings); + expect(result.mappings).toEqual([]); + expect(warnings.some((w) => w.includes("mappings[0]"))).toBe(true); + }); + + it("warns and uses no mappings when mappings is not an array", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueLabelPropagationConfig({ enabled: true, mappings: "nope" }, warnings); + expect(result.mappings).toEqual([]); + expect(warnings.some((w) => w.includes("settings.linkedIssueLabelPropagation.mappings"))).toBe(true); + }); + + it("defaults removeOtherTypeLabels to false when omitted from a mapping", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueLabelPropagationConfig({ enabled: true, mappings: [{ issueLabel: "a", prLabel: "b" }] }, warnings); + expect(result.mappings).toEqual([{ issueLabel: "a", prLabel: "b", removeOtherTypeLabels: false }]); + }); + + it("drops a mapping entry with a non-boolean removeOtherTypeLabels, with a warning, keeping other valid entries", () => { + const warnings: string[] = []; + const result = normalizeLinkedIssueLabelPropagationConfig( + { + enabled: true, + mappings: [ + { issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: "true" }, + { issueLabel: "customer:vip", prLabel: "triage:vip", removeOtherTypeLabels: false }, + ], + }, + warnings, + ); + // A quoted "true" string must never silently coerce to `false` (flipping an intended-exclusive + // mapping to additive) -- the whole entry is dropped instead, with the other valid entry kept. + expect(result.mappings).toEqual([{ issueLabel: "customer:vip", prLabel: "triage:vip", removeOtherTypeLabels: false }]); + expect(warnings.some((w) => w.includes("mappings[0].removeOtherTypeLabels"))).toBe(true); + }); +}); diff --git a/test/unit/moderation-rules-engine.test.ts b/test/unit/moderation-rules-engine.test.ts new file mode 100644 index 0000000000..472ed1c366 --- /dev/null +++ b/test/unit/moderation-rules-engine.test.ts @@ -0,0 +1,147 @@ +// Mirror of the app suite pointed at the gittensory-engine copy so the extracted module owns its branch coverage (#2280). +import { describe, expect, it } from "vitest"; +import { + DEFAULT_GLOBAL_MODERATION_CONFIG, + DEFAULT_MODERATION_BANNED_LABEL, + DEFAULT_MODERATION_BAN_THRESHOLD, + DEFAULT_MODERATION_WARNING_LABEL, + MODERATION_VIOLATION_EVENT_TYPE, + moderationTierForViolationCount, + normalizeModerationLabel, + normalizeModerationRules, + resolveEffectiveModerationRules, + resolveModerationGateEnabled, +} from "../../packages/gittensory-engine/src/settings/moderation-rules"; + +describe("normalizeModerationRules (#selfhost-mod-engine)", () => { + it("returns [] for null/undefined and a non-array (with a warning)", () => { + expect(normalizeModerationRules(undefined).rules).toEqual([]); + expect(normalizeModerationRules(null).rules).toEqual([]); + const notArray = normalizeModerationRules("contributor_cap"); + expect(notArray.rules).toEqual([]); + expect(notArray.warnings[0]).toMatch(/must be a list/); + }); + + it("accepts every known rule type", () => { + const { rules, warnings } = normalizeModerationRules(["contributor_cap", "blacklist", "review_nag"]); + expect(rules).toEqual(["contributor_cap", "blacklist", "review_nag"]); + expect(warnings).toEqual([]); + }); + + it("accepts review_evasion (#review-evasion-protection)", () => { + const { rules, warnings } = normalizeModerationRules(["review_evasion"]); + expect(rules).toEqual(["review_evasion"]); + expect(warnings).toEqual([]); + }); + + it("drops unrecognized entries with a warning, keeping the valid ones", () => { + const { rules, warnings } = normalizeModerationRules(["contributor_cap", "not-a-rule", 42, null]); + expect(rules).toEqual(["contributor_cap"]); + expect(warnings.length).toBe(3); + }); + + it("de-duplicates repeated rule types", () => { + const { rules } = normalizeModerationRules(["blacklist", "blacklist", "review_nag"]); + expect(rules).toEqual(["blacklist", "review_nag"]); + }); + + it("returns [] (not the default rule set) for an intentional empty array — an explicit opt-out-of-everything must survive, not be coerced back to a default", () => { + expect(normalizeModerationRules([]).rules).toEqual([]); + }); +}); + +describe("normalizeModerationLabel (#selfhost-mod-engine)", () => { + it("returns undefined for a non-string, empty, or whitespace-only value", () => { + expect(normalizeModerationLabel(undefined)).toBeUndefined(); + expect(normalizeModerationLabel(null)).toBeUndefined(); + expect(normalizeModerationLabel(42)).toBeUndefined(); + expect(normalizeModerationLabel("")).toBeUndefined(); + expect(normalizeModerationLabel(" ")).toBeUndefined(); + }); + + it("trims and returns a valid label", () => { + expect(normalizeModerationLabel(" mod:custom ")).toBe("mod:custom"); + }); + + it("truncates an overlong label", () => { + const long = "x".repeat(200); + expect(normalizeModerationLabel(long)?.length).toBe(100); + }); +}); + +describe("resolveEffectiveModerationRules (#selfhost-mod-engine)", () => { + const globalRules = ["contributor_cap", "blacklist", "review_nag"] as const; + + it("inherits the global list when no per-repo override is given", () => { + expect(resolveEffectiveModerationRules(globalRules, undefined)).toEqual([...globalRules]); + expect(resolveEffectiveModerationRules(globalRules, null)).toEqual([...globalRules]); + }); + + it("REPLACES (not unions) the global list with an explicit per-repo override", () => { + expect(resolveEffectiveModerationRules(globalRules, ["blacklist"])).toEqual(["blacklist"]); + }); + + it("an explicit EMPTY per-repo override opts this repo out of every rule, distinct from 'inherit'", () => { + expect(resolveEffectiveModerationRules(globalRules, [])).toEqual([]); + }); +}); + +describe("resolveModerationGateEnabled (#selfhost-mod-engine)", () => { + it("'off' force-disables regardless of the global default", () => { + expect(resolveModerationGateEnabled(true, "off")).toBe(false); + expect(resolveModerationGateEnabled(false, "off")).toBe(false); + }); + + it("'enabled' still requires the global master switch", () => { + expect(resolveModerationGateEnabled(true, "enabled")).toBe(true); + expect(resolveModerationGateEnabled(false, "enabled")).toBe(false); + }); + + it("'inherit' defers to the global default", () => { + expect(resolveModerationGateEnabled(true, "inherit")).toBe(true); + expect(resolveModerationGateEnabled(false, "inherit")).toBe(false); + }); +}); + +describe("moderationTierForViolationCount (#selfhost-mod-engine)", () => { + it("returns 'none' for a non-positive count", () => { + expect(moderationTierForViolationCount(0, 5)).toBe("none"); + expect(moderationTierForViolationCount(-1, 5)).toBe("none"); + }); + + it("returns 'warning' for 1..threshold-1", () => { + expect(moderationTierForViolationCount(1, 5)).toBe("warning"); + expect(moderationTierForViolationCount(4, 5)).toBe("warning"); + }); + + it("returns 'banned' at and above the threshold", () => { + expect(moderationTierForViolationCount(5, 5)).toBe("banned"); + expect(moderationTierForViolationCount(6, 5)).toBe("banned"); + }); + + it("degrades a malformed non-positive threshold to 'always banned once any violation exists' rather than throwing", () => { + expect(moderationTierForViolationCount(1, 0)).toBe("banned"); + expect(moderationTierForViolationCount(1, -1)).toBe("banned"); + }); +}); + +describe("constants + event-type map (#selfhost-mod-engine)", () => { + it("default labels/threshold match the documented defaults", () => { + expect(DEFAULT_MODERATION_WARNING_LABEL).toBe("mod:warning"); + expect(DEFAULT_MODERATION_BANNED_LABEL).toBe("mod:banned"); + expect(DEFAULT_MODERATION_BAN_THRESHOLD).toBe(5); + expect(DEFAULT_GLOBAL_MODERATION_CONFIG.enabled).toBe(false); + expect(DEFAULT_GLOBAL_MODERATION_CONFIG.violationDecayDays).toBeNull(); + expect(DEFAULT_GLOBAL_MODERATION_CONFIG.autoBlacklistOnBan).toBe(true); + }); + + it("every rule type has a distinct, namespaced event type", () => { + const values = Object.values(MODERATION_VIOLATION_EVENT_TYPE); + expect(new Set(values).size).toBe(values.length); + for (const eventType of values) expect(eventType).toMatch(/^moderation\.violation\./); + }); + + it("review_evasion has its own namespaced event type (#review-evasion-protection)", () => { + expect(MODERATION_VIOLATION_EVENT_TYPE.review_evasion).toBe("moderation.violation.review_evasion"); + }); +}); diff --git a/test/unit/pr-type-label-engine.test.ts b/test/unit/pr-type-label-engine.test.ts new file mode 100644 index 0000000000..56a0f12e4e --- /dev/null +++ b/test/unit/pr-type-label-engine.test.ts @@ -0,0 +1,320 @@ +// Mirror of the app suite pointed at the gittensory-engine copy so the extracted module owns its branch coverage (#2280). +import { describe, expect, it } from "vitest"; +import { DEFAULT_TYPE_LABELS, MAX_TYPE_LABEL_CATEGORIES, MAX_TYPE_LABEL_NAME_LENGTH, deriveKindFromTitle, normalizeTypeLabelSet, resolvePrTypeLabel } from "../../packages/gittensory-engine/src/settings/pr-type-label"; +import type { LinkedIssueLabelPropagationConfig } from "../../packages/gittensory-engine/src/types/manifest-deps-types"; + +describe("deriveKindFromTitle", () => { + it("maps substantial feat/feature titles to feature and keeps small feat-style work as bug", () => { + expect(deriveKindFromTitle("feat: add provider fallback")).toBe("feature"); + expect(deriveKindFromTitle("feature(api): support board exports")).toBe("feature"); + expect(deriveKindFromTitle("feat(signals): recognize Conan dependency manifests")).toBe("bug"); + expect(deriveKindFromTitle("feature(api): boards")).toBe("bug"); + expect(deriveKindFromTitle("fix: bug")).toBe("bug"); + expect(deriveKindFromTitle("test: add coverage")).toBe("bug"); + expect(deriveKindFromTitle("docs: readme")).toBe("bug"); + expect(deriveKindFromTitle("chore: deps")).toBe("bug"); + expect(deriveKindFromTitle("refactor: cleanup")).toBe("bug"); + expect(deriveKindFromTitle(undefined)).toBe("bug"); + expect(deriveKindFromTitle("")).toBe("bug"); + }); + + it("downgrades an action-bearing feat title to bug when it also reads like maintenance", () => { + // "add" is a feature action, but the "cache"/"refactor" downgrade cue wins → bug. + expect(deriveKindFromTitle("feat: add cache layer")).toBe("bug"); + expect(deriveKindFromTitle("feature(api): implement refactor helper")).toBe("bug"); + }); +}); + +function propagation(overrides: Partial = {}): LinkedIssueLabelPropagationConfig { + return { enabled: true, mode: "exclusive_type_label", mappings: [], ...overrides }; +} + +describe("resolvePrTypeLabel (#priority-linked-issue-gate)", () => { + it("returns the feature label by title when propagation is not configured", () => { + const result = resolvePrTypeLabel({ title: "feat: add provider fallback" }); + expect(result).toEqual({ applyLabels: [DEFAULT_TYPE_LABELS.feature], removeLabels: [DEFAULT_TYPE_LABELS.bug, DEFAULT_TYPE_LABELS.priority], source: "title" }); + }); + + it("returns the bug label by title for any non-feat/feature prefix when propagation is not configured", () => { + const result = resolvePrTypeLabel({ title: "fix: y" }); + expect(result).toEqual({ applyLabels: [DEFAULT_TYPE_LABELS.bug], removeLabels: [DEFAULT_TYPE_LABELS.feature, DEFAULT_TYPE_LABELS.priority], source: "title" }); + }); + + it("applies the configured priority label (exclusive) when a linked issue already carries the configured issue label", () => { + const result = resolvePrTypeLabel({ + title: "fix: y", + linkedIssueLabels: ["gittensor:priority"], + propagation: propagation({ mappings: [{ issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: true }] }), + }); + expect(result).toEqual({ applyLabels: ["gittensor:priority"], removeLabels: [DEFAULT_TYPE_LABELS.bug, DEFAULT_TYPE_LABELS.feature], source: "propagation_exclusive" }); + }); + + it("never invents priority: falls through to the title-based label when no linked issue carries the configured issue label", () => { + const result = resolvePrTypeLabel({ + title: "fix: y", + linkedIssueLabels: ["unrelated-label"], + propagation: propagation({ mappings: [{ issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: true }] }), + }); + expect(result.applyLabels).toEqual([DEFAULT_TYPE_LABELS.bug]); + expect(result.source).toBe("title"); + }); + + it("never invents priority: falls through to title-based even with matching linked-issue labels when propagation is disabled", () => { + const result = resolvePrTypeLabel({ + title: "fix: y", + linkedIssueLabels: ["gittensor:priority"], + propagation: propagation({ enabled: false, mappings: [{ issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: true }] }), + }); + expect(result.applyLabels).toEqual([DEFAULT_TYPE_LABELS.bug]); + expect(result.source).toBe("title"); + }); + + it("matches the configured issue label case-insensitively", () => { + const result = resolvePrTypeLabel({ + title: "fix: y", + linkedIssueLabels: ["Gittensor:Priority"], + propagation: propagation({ mappings: [{ issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: true }] }), + }); + expect(result.applyLabels).toEqual(["gittensor:priority"]); + expect(result.source).toBe("propagation_exclusive"); + }); + + it("supports fully custom, non-gittensor label names (exclusive mapping)", () => { + const result = resolvePrTypeLabel({ + title: "fix: y", + linkedIssueLabels: ["customer:vip"], + propagation: propagation({ mappings: [{ issueLabel: "customer:vip", prLabel: "triage:vip", removeOtherTypeLabels: true }] }), + }); + expect(result).toEqual({ applyLabels: ["triage:vip"], removeLabels: [DEFAULT_TYPE_LABELS.bug, DEFAULT_TYPE_LABELS.feature, DEFAULT_TYPE_LABELS.priority], source: "propagation_exclusive" }); + }); + + it("applies an additive mapping alongside the normal title-based label, without removing it", () => { + const result = resolvePrTypeLabel({ + title: "fix: y", + linkedIssueLabels: ["customer:vip"], + propagation: propagation({ mappings: [{ issueLabel: "customer:vip", prLabel: "triage:vip", removeOtherTypeLabels: false }] }), + }); + expect(result.applyLabels).toEqual([DEFAULT_TYPE_LABELS.bug, "triage:vip"]); + expect(result.removeLabels).toEqual([DEFAULT_TYPE_LABELS.feature, DEFAULT_TYPE_LABELS.priority]); + expect(result.removeLabels).not.toContain(DEFAULT_TYPE_LABELS.bug); + expect(result.source).toBe("propagation_additive"); + }); + + it("does not crash on an empty mappings array and falls through to title-based", () => { + const result = resolvePrTypeLabel({ title: "feat: add provider fallback", linkedIssueLabels: ["anything"], propagation: propagation({ mappings: [] }) }); + expect(result.applyLabels).toEqual([DEFAULT_TYPE_LABELS.feature]); + expect(result.source).toBe("title"); + }); + + it("does not crash when linkedIssueLabels is omitted entirely (propagation enabled with mappings configured)", () => { + const result = resolvePrTypeLabel({ + title: "feat: add provider fallback", + propagation: propagation({ mappings: [{ issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: true }] }), + }); + expect(result.applyLabels).toEqual([DEFAULT_TYPE_LABELS.feature]); + expect(result.source).toBe("title"); + }); + + it("resolves the FIRST matching mapping when multiple linked-issue labels are present", () => { + const result = resolvePrTypeLabel({ + title: "fix: y", + linkedIssueLabels: ["customer:vip", "gittensor:priority"], + propagation: propagation({ + mappings: [ + { issueLabel: "customer:vip", prLabel: "triage:vip", removeOtherTypeLabels: true }, + { issueLabel: "gittensor:priority", prLabel: "gittensor:priority", removeOtherTypeLabels: true }, + ], + }), + }); + expect(result.applyLabels).toEqual(["triage:vip"]); + expect(result.source).toBe("propagation_exclusive"); + }); + + it("respects a custom typeLabels set for both the title fallback and the removal set", () => { + const custom = { bug: "kind:bug", feature: "kind:feature", priority: "kind:priority" }; + const result = resolvePrTypeLabel({ title: "feat: add provider fallback", labels: custom }); + expect(result).toEqual({ applyLabels: ["kind:feature"], removeLabels: ["kind:bug", "kind:priority"], source: "title" }); + }); + + describe("arbitrary configured categories (#label-modularity)", () => { + it("includes arbitrary extra categories in the removal set without ever choosing them by title", () => { + const custom = { bug: "gittensor:bug", feature: "gittensor:feature", priority: "gittensor:priority", security: "area:security", docs: "area:docs" }; + const result = resolvePrTypeLabel({ title: "fix: y", labels: custom }); + expect(result.applyLabels).toEqual(["gittensor:bug"]); + expect(result.removeLabels.slice().sort()).toEqual(["area:docs", "area:security", "gittensor:feature", "gittensor:priority"]); + }); + + it("never removes a label that isn't part of the configured type-label set (invariant: unrelated maintainer labels are untouched)", () => { + const result = resolvePrTypeLabel({ title: "fix: y", labels: { bug: "gittensor:bug" } }); + expect(result.removeLabels).toEqual([]); + expect(result.removeLabels).not.toContain("needs-review"); + expect(result.removeLabels).not.toContain("gittensor"); + }); + + it("applies nothing and removes nothing when the configured type-label set is empty", () => { + const result = resolvePrTypeLabel({ title: "fix: y", labels: {} }); + expect(result).toEqual({ applyLabels: [], removeLabels: [], source: "title" }); + }); + + it("still applies a propagated custom-category label additively when the base type-label set is empty", () => { + const result = resolvePrTypeLabel({ + title: "fix: y", + labels: {}, + linkedIssueLabels: ["needs-security-review"], + propagation: propagation({ mappings: [{ issueLabel: "needs-security-review", prLabel: "area:security", removeOtherTypeLabels: false }] }), + }); + expect(result).toEqual({ applyLabels: ["area:security"], removeLabels: [], source: "propagation_additive" }); + }); + + it("caps cleanup to the bounded type-label category set", () => { + const labels = Object.fromEntries(Array.from({ length: MAX_TYPE_LABEL_CATEGORIES + 20 }, (_, index) => [`custom${index}`, `area:${index}`])); + + const result = resolvePrTypeLabel({ title: "fix: y", labels }); + + expect(result.applyLabels).toEqual([]); + expect(result.removeLabels).toHaveLength(MAX_TYPE_LABEL_CATEGORIES); + expect(result.removeLabels).toEqual(Array.from({ length: MAX_TYPE_LABEL_CATEGORIES }, (_, index) => `area:${index}`)); + }); + + it("ignores overlong labels when computing cleanup", () => { + const overlong = "x".repeat(MAX_TYPE_LABEL_NAME_LENGTH + 1); + + const result = resolvePrTypeLabel({ title: "fix: y", labels: { bug: "kind:bug", feature: overlong, priority: "kind:priority" } }); + + expect(result).toEqual({ applyLabels: ["kind:bug"], removeLabels: ["kind:priority"], source: "title" }); + }); + + it("only cleans up categories actually configured when a repo drops down to a subset of the built-in triad", () => { + // A self-hoster who only wants a bug/feature split, no priority category at all. + const result = resolvePrTypeLabel({ title: "feat: add provider fallback", labels: { bug: "gittensor:bug", feature: "gittensor:feature" } }); + expect(result).toEqual({ applyLabels: ["gittensor:feature"], removeLabels: ["gittensor:bug"], source: "title" }); + }); + }); +}); + +describe("normalizeTypeLabelSet (#priority-linked-issue-gate)", () => { + it("returns the full default set when the input is omitted", () => { + const warnings: string[] = []; + expect(normalizeTypeLabelSet(undefined, warnings)).toEqual(DEFAULT_TYPE_LABELS); + expect(warnings).toEqual([]); + }); + + it("warns and returns defaults for a non-object input", () => { + const warnings: string[] = []; + expect(normalizeTypeLabelSet("gittensor:bug", warnings)).toEqual(DEFAULT_TYPE_LABELS); + expect(warnings.some((w) => w.includes("settings.typeLabels"))).toBe(true); + }); + + it("warns and returns defaults for an array input", () => { + const warnings: string[] = []; + expect(normalizeTypeLabelSet(["gittensor:bug"], warnings)).toEqual(DEFAULT_TYPE_LABELS); + expect(warnings.length).toBeGreaterThan(0); + }); + + it("overrides just one label name and keeps the other two at their default", () => { + const warnings: string[] = []; + expect(normalizeTypeLabelSet({ priority: "custom:priority" }, warnings)).toEqual({ + bug: DEFAULT_TYPE_LABELS.bug, + feature: DEFAULT_TYPE_LABELS.feature, + priority: "custom:priority", + }); + expect(warnings).toEqual([]); + }); + + it("warns and falls back to the default for a non-string field value", () => { + const warnings: string[] = []; + expect(normalizeTypeLabelSet({ priority: 42 }, warnings)).toEqual(DEFAULT_TYPE_LABELS); + expect(warnings.some((w) => w.includes("settings.typeLabels.priority"))).toBe(true); + }); + + it("trims whitespace and rejects an empty-string field value", () => { + const warnings: string[] = []; + expect(normalizeTypeLabelSet({ bug: " kind:bug ", feature: " " }, warnings)).toEqual({ + bug: "kind:bug", + feature: DEFAULT_TYPE_LABELS.feature, + priority: DEFAULT_TYPE_LABELS.priority, + }); + }); + + it("returns the full default set for an explicitly empty object, matching an omitted/legacy value (backward compat: legacy type_labels_json rows default to '{}')", () => { + const warnings: string[] = []; + expect(normalizeTypeLabelSet({}, warnings)).toEqual(DEFAULT_TYPE_LABELS); + expect(warnings).toEqual([]); + }); + + describe("arbitrary custom categories (#label-modularity)", () => { + it("includes an arbitrary custom category alongside the defaults-filled built-in categories", () => { + const warnings: string[] = []; + expect(normalizeTypeLabelSet({ security: "area:security" }, warnings)).toEqual({ + bug: DEFAULT_TYPE_LABELS.bug, + feature: DEFAULT_TYPE_LABELS.feature, + priority: DEFAULT_TYPE_LABELS.priority, + security: "area:security", + }); + expect(warnings).toEqual([]); + }); + + it("trims and keeps multiple custom categories at once", () => { + const warnings: string[] = []; + expect(normalizeTypeLabelSet({ security: " area:security ", docs: "area:docs" }, warnings)).toEqual({ + bug: DEFAULT_TYPE_LABELS.bug, + feature: DEFAULT_TYPE_LABELS.feature, + priority: DEFAULT_TYPE_LABELS.priority, + security: "area:security", + docs: "area:docs", + }); + }); + + it("caps custom categories to a bounded set and warns for overflow entries", () => { + const warnings: string[] = []; + const input = Object.fromEntries(Array.from({ length: MAX_TYPE_LABEL_CATEGORIES + 5 }, (_, index) => [`custom${index}`, `area:${index}`])); + + const result = normalizeTypeLabelSet(input, warnings); + + expect(Object.keys(result)).toHaveLength(MAX_TYPE_LABEL_CATEGORIES); + expect(result.custom28).toBe("area:28"); + expect(result.custom29).toBeUndefined(); + expect(warnings.some((w) => w.includes("more than 32 categories") && w.includes("custom29"))).toBe(true); + }); + + it("silently drops an overflow entry whose value is undefined, without warning", () => { + const warnings: string[] = []; + // 3 built-ins + 29 valid customs exactly fill the 32-category cap; a further key present with an + // explicit `undefined` value hits the overflow branch but must not warn, mirroring how an absent + // built-in value is dropped silently elsewhere in this function. + const input: Record = Object.fromEntries(Array.from({ length: MAX_TYPE_LABEL_CATEGORIES - 3 }, (_, index) => [`custom${index}`, `area:${index}`])); + input.overflowUndefined = undefined; + + const result = normalizeTypeLabelSet(input, warnings); + + expect(Object.keys(result)).toHaveLength(MAX_TYPE_LABEL_CATEGORIES); + expect(result.overflowUndefined).toBeUndefined(); + expect(warnings.some((w) => w.includes("overflowUndefined"))).toBe(false); + }); + + it("rejects overlong label names and warns", () => { + const warnings: string[] = []; + const overlong = "x".repeat(MAX_TYPE_LABEL_NAME_LENGTH + 1); + + expect(normalizeTypeLabelSet({ security: overlong, bug: overlong }, warnings)).toEqual({ + bug: DEFAULT_TYPE_LABELS.bug, + feature: DEFAULT_TYPE_LABELS.feature, + priority: DEFAULT_TYPE_LABELS.priority, + }); + expect(warnings.some((w) => w.includes("settings.typeLabels.security") && w.includes("no longer than 50"))).toBe(true); + expect(warnings.some((w) => w.includes("settings.typeLabels.bug") && w.includes("no longer than 50") && w.includes(DEFAULT_TYPE_LABELS.bug!))).toBe(true); + }); + + it("drops an invalid custom category entirely (no built-in default to fall back to) and warns", () => { + const warnings: string[] = []; + expect(normalizeTypeLabelSet({ security: 42 }, warnings)).toEqual(DEFAULT_TYPE_LABELS); + expect(warnings.some((w) => w.includes("settings.typeLabels.security") && w.includes("ignoring"))).toBe(true); + }); + + it("drops an empty-string custom category and warns, without touching the built-in defaults", () => { + const warnings: string[] = []; + expect(normalizeTypeLabelSet({ security: " " }, warnings)).toEqual(DEFAULT_TYPE_LABELS); + expect(warnings.some((w) => w.includes("settings.typeLabels.security"))).toBe(true); + }); + }); +}); diff --git a/test/unit/safe-url-engine.test.ts b/test/unit/safe-url-engine.test.ts new file mode 100644 index 0000000000..272b78de6f --- /dev/null +++ b/test/unit/safe-url-engine.test.ts @@ -0,0 +1,142 @@ +// Mirror of the app suite pointed at the gittensory-engine copy so the extracted module owns its branch coverage (#2280). +import { describe, expect, it } from "vitest"; +import { isSafeEndpointUrl, isSafeHttpUrl } from "../../packages/gittensory-engine/src/review/safe-url"; + +describe("isSafeHttpUrl", () => { + it("accepts public https hosts", () => { + expect(isSafeHttpUrl("https://example.com")).toBe(true); + expect(isSafeHttpUrl("https://docs.anthropic.com/path")).toBe(true); + }); + + it("rejects non-https", () => { + expect(isSafeHttpUrl("http://example.com")).toBe(false); + expect(isSafeHttpUrl("ftp://example.com")).toBe(false); + expect(isSafeHttpUrl("wss://example.com")).toBe(false); + }); + + it("rejects loopback / localhost / private-range hosts", () => { + expect(isSafeHttpUrl("https://localhost")).toBe(false); + expect(isSafeHttpUrl("https://127.0.0.1")).toBe(false); + expect(isSafeHttpUrl("https://10.0.0.1")).toBe(false); + expect(isSafeHttpUrl("https://192.168.1.1")).toBe(false); + expect(isSafeHttpUrl("https://172.16.0.1")).toBe(false); + expect(isSafeHttpUrl("https://169.254.169.254")).toBe(false); // cloud metadata + expect(isSafeHttpUrl("https://service.internal")).toBe(false); + expect(isSafeHttpUrl("https://printer.local")).toBe(false); + }); + + it("rejects the RFC 6761 *.localhost loopback namespace (not just bare localhost)", () => { + // RFC 6761 makes every `*.localhost` name loopback (systemd-resolved, browsers), so the bare + // `=== "localhost"` check leaked sub-labelled forms; `.endsWith(".localhost")` closes them. + expect(isSafeHttpUrl("https://test.localhost")).toBe(false); + expect(isSafeHttpUrl("https://foo.bar.localhost")).toBe(false); + expect(isSafeEndpointUrl("wss://api.localhost")).toBe(false); + }); + + it("rejects trailing-dot FQDN forms of named loopback hosts (SSRF bypass regression)", () => { + // The parser keeps the root dot on named hosts: `new URL("https://localhost./").hostname` === + // "localhost.", which still resolves to loopback — so the guard must strip it before its checks. + expect(isSafeHttpUrl("https://localhost./")).toBe(false); + expect(isSafeHttpUrl("https://foo.local./")).toBe(false); + expect(isSafeHttpUrl("https://bar.internal./")).toBe(false); + expect(isSafeHttpUrl("https://localhost../")).toBe(false); // strip the whole run, not one dot + expect(isSafeHttpUrl("https://db.localhost./")).toBe(false); // subdomain + trailing dot + expect(isSafeEndpointUrl("wss://localhost./")).toBe(false); // shared guard → wss hardened too + }); + + it("rejects encoded-IP SSRF bypasses that a dotted-quad regex misses", () => { + expect(isSafeHttpUrl("https://2130706433")).toBe(false); // decimal 127.0.0.1 + expect(isSafeHttpUrl("https://0x7f000001")).toBe(false); // hex 127.0.0.1 + expect(isSafeHttpUrl("https://127.1")).toBe(false); // short form + }); + + it("rejects IPv6 loopback / ULA / link-local", () => { + expect(isSafeHttpUrl("https://[::1]")).toBe(false); + expect(isSafeHttpUrl("https://[fc00::1]")).toBe(false); + expect(isSafeHttpUrl("https://[fe80::1]")).toBe(false); + }); + + it("rejects the all-zeros IPv6 unspecified address [::]", () => { + // [::] is NOT caught by hostIsPrivateOrLocal's literal "::1"/"[::1]" guard (line 69), + // so it falls through to ipv6IsPrivateOrLocal where `addr === "::"` matches (line 51). + // The fd-prefix check would also pass it, but the "::" equality fires first. + expect(isSafeHttpUrl("https://[::]")).toBe(false); + expect(isSafeEndpointUrl("wss://[::]")).toBe(false); + }); + + it("accepts an fd00-prefixed ULA only when... it never does — fd is always private", () => { + // fd00::/8 (ULA) — first hextet starts with "fd" → ipv6IsPrivateOrLocal line 61 true. + expect(isSafeHttpUrl("https://[fd12:3456:789a::1]")).toBe(false); + }); + + it("returns false for unparseable input", () => { + expect(isSafeHttpUrl("not a url")).toBe(false); + expect(isSafeHttpUrl("")).toBe(false); + }); + + it("treats hex/octal-prefixed labels in a non-IP host as a public domain", () => { + // `0x7f.example` survives the WHATWG parser as a hostname (not a whole-IP), so it reaches + // ipv4ToInt: the first label exercises parseIpv4Component's hex branch (line 12), the second + // ("example") returns null and ipv4ToInt bails — host is not a private IP literal → public. + expect(isSafeHttpUrl("https://0x7f.example")).toBe(true); + // `0177.example` exercises the octal branch (line 13) the same way. + expect(isSafeHttpUrl("https://0177.example")).toBe(true); + // A large hex label still bails on the trailing non-numeric label → public domain. + expect(isSafeHttpUrl("https://0xffffffff.example")).toBe(true); + }); + + it("rejects nothing for a 5-label host (ipv4ToInt's >4-parts guard)", () => { + // `a.b.c.d.e` has 5 dot-separated labels → ipv4ToInt returns null at the parts.length>4 + // guard (line 20) → not a private IP literal → treated as a public host. + expect(isSafeHttpUrl("https://a.b.c.d.e")).toBe(true); + }); + + it("accepts public IPv4 literals (the non-private fall-through)", () => { + // Exercises ipv4IsPrivateOrLocal's final `return false` for a routable public IP. + expect(isSafeHttpUrl("https://8.8.8.8")).toBe(true); + expect(isSafeHttpUrl("https://1.1.1.1")).toBe(true); + // 172.x outside the 16-31 private band is public. + expect(isSafeHttpUrl("https://172.15.0.1")).toBe(true); + expect(isSafeHttpUrl("https://172.32.0.1")).toBe(true); + }); + + it("rejects an IPv4-mapped IPv6 in ::ffff:HHHH:HHHH hex form pointing at a private IP", () => { + // ::ffff:7f00:0001 == 127.0.0.1 — exercises the hex-mapped IPv6 branch. + expect(isSafeHttpUrl("https://[::ffff:7f00:0001]")).toBe(false); + // ::ffff:c0a8:0101 == 192.168.1.1 + expect(isSafeHttpUrl("https://[::ffff:c0a8:0101]")).toBe(false); + }); + + it("accepts an IPv4-mapped IPv6 (hex form) pointing at a public IP", () => { + // ::ffff:0808:0808 == 8.8.8.8 — hex branch returns the public verdict. + expect(isSafeHttpUrl("https://[::ffff:0808:0808]")).toBe(true); + }); + + it("accepts a public IPv6 literal (the IPv6 non-private fall-through)", () => { + // Exercises ipv6IsPrivateOrLocal's final `return false` (not loopback/ULA/link-local/mapped). + expect(isSafeHttpUrl("https://[2001:4860:4860::8888]")).toBe(true); + }); +}); + +describe("isSafeEndpointUrl", () => { + it("additionally permits wss / ws for chain endpoints", () => { + expect(isSafeEndpointUrl("wss://entrypoint.example.com")).toBe(true); + expect(isSafeEndpointUrl("ws://node.example.com")).toBe(true); + expect(isSafeEndpointUrl("https://api.example.com")).toBe(true); + }); + + it("still applies the SSRF host guard to wss endpoints", () => { + expect(isSafeEndpointUrl("wss://127.0.0.1")).toBe(false); + expect(isSafeEndpointUrl("wss://localhost")).toBe(false); + }); + + it("rejects non-ws/https protocols", () => { + expect(isSafeEndpointUrl("http://example.com")).toBe(false); + expect(isSafeEndpointUrl("ftp://example.com")).toBe(false); + }); + + it("returns false for unparseable endpoint input (the URL-parse catch)", () => { + expect(isSafeEndpointUrl("not a url")).toBe(false); + expect(isSafeEndpointUrl("")).toBe(false); + }); +}); diff --git a/test/unit/screenshot-table-gate-engine.test.ts b/test/unit/screenshot-table-gate-engine.test.ts new file mode 100644 index 0000000000..8fc8977843 --- /dev/null +++ b/test/unit/screenshot-table-gate-engine.test.ts @@ -0,0 +1,313 @@ +// Mirror of the app suite pointed at the gittensory-engine copy so the extracted module owns its branch coverage (#2280). +import { describe, expect, it } from "vitest"; +import { + DEFAULT_SCREENSHOT_CONTRACT_MESSAGE, + DEFAULT_SCREENSHOT_TABLE_GATE, + evaluateScreenshotTableGate, + hasCommittedImageFile, + hasImageBearingMarkdownTable, + hasImageOutsideTable, + isScreenshotTableGateAction, + isScreenshotTableGateInScope, + normalizeScreenshotTableGateConfig, +} from "../../packages/gittensory-engine/src/review/screenshot-table-gate"; +import type { ScreenshotTableGateConfig } from "../../packages/gittensory-engine/src/types/manifest-deps-types"; + +function config(overrides: Partial = {}): ScreenshotTableGateConfig { + return { ...DEFAULT_SCREENSHOT_TABLE_GATE, whenLabels: [], whenPaths: [], ...overrides }; +} + +const TABLE_BODY = ["| Before | After |", "| --- | --- |", "| ![before](https://x/before.png) | ![after](https://x/after.png) |"].join("\n"); + +describe("isScreenshotTableGateAction", () => { + it("accepts every valid action", () => { + expect(isScreenshotTableGateAction("close")).toBe(true); + expect(isScreenshotTableGateAction("request_changes")).toBe(true); + expect(isScreenshotTableGateAction("comment")).toBe(true); + }); + + it("rejects a non-string or unknown value", () => { + expect(isScreenshotTableGateAction("hold")).toBe(false); + expect(isScreenshotTableGateAction(123)).toBe(false); + expect(isScreenshotTableGateAction(undefined)).toBe(false); + }); +}); + +describe("hasImageBearingMarkdownTable", () => { + it("detects a markdown table with image cells (before/after markup)", () => { + expect(hasImageBearingMarkdownTable(TABLE_BODY)).toBe(true); + }); + + it("detects an tag inside a table cell too", () => { + const body = ["| Before | After |", "| --- | --- |", '| | |'].join("\n"); + expect(hasImageBearingMarkdownTable(body)).toBe(true); + }); + + it("returns false for a table with no image markup in any row", () => { + const body = ["| Before | After |", "| --- | --- |", "| looks the same | looks the same |"].join("\n"); + expect(hasImageBearingMarkdownTable(body)).toBe(false); + }); + + it("returns false when there is no table at all", () => { + expect(hasImageBearingMarkdownTable("Just a plain description, no table here.")).toBe(false); + }); + + it("returns false for a header row with no valid separator row beneath it", () => { + const body = ["| Before | After |", "not a separator", "| ![a](x.png) | ![b](y.png) |"].join("\n"); + expect(hasImageBearingMarkdownTable(body)).toBe(false); + }); + + it("returns false for null/undefined/empty body", () => { + expect(hasImageBearingMarkdownTable(null)).toBe(false); + expect(hasImageBearingMarkdownTable(undefined)).toBe(false); + expect(hasImageBearingMarkdownTable("")).toBe(false); + }); + + it("supports an aligned separator row (:---:, ---:, etc.)", () => { + const body = ["| Before | After |", "|:---:|:---:|", "| ![a](x.png) | ![b](y.png) |"].join("\n"); + expect(hasImageBearingMarkdownTable(body)).toBe(true); + }); + + it("rejects long whitespace-only separator candidates without hanging", () => { + const whitespace = " ".repeat(8_000); + const body = ["| Before | After |", whitespace, "| ![a](x.png) | ![b](y.png) |"].join("\n"); + const started = performance.now(); + expect(hasImageBearingMarkdownTable(body)).toBe(false); + expect(performance.now() - started).toBeLessThan(50); + }); + + it("rejects a separator candidate that has dashes but a non-separator cell", () => { + const body = ["| Before | After |", "| --- | notasep |", "| ![a](x.png) | ![b](y.png) |"].join("\n"); + expect(hasImageBearingMarkdownTable(body)).toBe(false); + }); +}); + +describe("hasImageOutsideTable", () => { + it("detects a bare inline image outside any table", () => { + expect(hasImageOutsideTable("Here is my before screenshot: ![before](https://x/before.png)")).toBe(true); + }); + + it("returns false when the only image markup is inside a table row", () => { + expect(hasImageOutsideTable(TABLE_BODY)).toBe(false); + }); + + it("returns false for a body with no image markup at all", () => { + expect(hasImageOutsideTable("No images here.")).toBe(false); + }); + + it("returns false for null/undefined/empty body", () => { + expect(hasImageOutsideTable(null)).toBe(false); + expect(hasImageOutsideTable(undefined)).toBe(false); + expect(hasImageOutsideTable("")).toBe(false); + }); +}); + +describe("hasCommittedImageFile", () => { + it("flags a committed image file under a scoped path", () => { + expect(hasCommittedImageFile(["apps/ui/src/screenshot.png"], ["apps/ui/**"])).toBe(true); + }); + + it("does not flag an image file OUTSIDE the scoped paths", () => { + expect(hasCommittedImageFile(["docs/logo.png"], ["apps/ui/**"])).toBe(false); + }); + + it("checks every changed path when scopedPaths is empty", () => { + expect(hasCommittedImageFile(["random/screenshot.jpg"], [])).toBe(true); + }); + + it("does not flag a non-image file", () => { + expect(hasCommittedImageFile(["apps/ui/src/component.tsx"], ["apps/ui/**"])).toBe(false); + }); + + it("never flags a committed SVG (excluded from the image-extension set)", () => { + expect(hasCommittedImageFile(["apps/ui/src/icon.svg"], [])).toBe(false); + }); + + it("matches every accepted raster extension case-insensitively", () => { + for (const ext of [".png", ".jpg", ".jpeg", ".gif", ".webp", ".PNG"]) { + expect(hasCommittedImageFile([`apps/ui/shot${ext}`], [])).toBe(true); + } + }); +}); + +describe("isScreenshotTableGateInScope", () => { + it("is in scope for every PR when both whenLabels and whenPaths are empty", () => { + expect(isScreenshotTableGateInScope(config(), [], [])).toBe(true); + }); + + it("matches on label (case-insensitive)", () => { + expect(isScreenshotTableGateInScope(config({ whenLabels: ["Frontend"] }), ["frontend"], [])).toBe(true); + }); + + it("matches on path glob", () => { + expect(isScreenshotTableGateInScope(config({ whenPaths: ["apps/ui/**"] }), [], ["apps/ui/src/App.tsx"])).toBe(true); + }); + + it("is out of scope when neither labels nor paths match (both configured)", () => { + expect(isScreenshotTableGateInScope(config({ whenLabels: ["frontend"], whenPaths: ["apps/ui/**"] }), ["backend"], ["src/api/routes.ts"])).toBe(false); + }); + + it("label match alone is sufficient even when whenPaths is also configured and doesn't match", () => { + expect(isScreenshotTableGateInScope(config({ whenLabels: ["frontend"], whenPaths: ["apps/ui/**"] }), ["frontend"], ["src/api/routes.ts"])).toBe(true); + }); + + it("path match alone is sufficient even when whenLabels is also configured and doesn't match", () => { + expect(isScreenshotTableGateInScope(config({ whenLabels: ["frontend"], whenPaths: ["apps/ui/**"] }), ["backend"], ["apps/ui/src/App.tsx"])).toBe(true); + }); + + it("only whenLabels configured (whenPaths empty) -- scope decided purely by label", () => { + expect(isScreenshotTableGateInScope(config({ whenLabels: ["frontend"] }), ["backend"], ["apps/ui/src/App.tsx"])).toBe(false); + }); + + it("only whenPaths configured (whenLabels empty) -- scope decided purely by path", () => { + expect(isScreenshotTableGateInScope(config({ whenPaths: ["apps/ui/**"] }), ["frontend"], ["src/api/routes.ts"])).toBe(false); + }); +}); + +describe("normalizeScreenshotTableGateConfig", () => { + it("returns the disabled default for undefined/null input", () => { + expect(normalizeScreenshotTableGateConfig(undefined, [])).toEqual(config()); + expect(normalizeScreenshotTableGateConfig(null, [])).toEqual(config()); + }); + + it("warns and falls back to default for a non-object input", () => { + const warnings: string[] = []; + expect(normalizeScreenshotTableGateConfig("nope", warnings)).toEqual(config()); + expect(warnings).toEqual(["settings.requireScreenshotTable must be an object; using the default (disabled)."]); + }); + + it("warns and falls back to default for an array input", () => { + const warnings: string[] = []; + expect(normalizeScreenshotTableGateConfig([], warnings)).toEqual(config()); + expect(warnings.length).toBeGreaterThan(0); + }); + + it("parses a fully valid object", () => { + const result = normalizeScreenshotTableGateConfig( + { enabled: true, whenLabels: ["frontend", "visual"], whenPaths: ["apps/ui/**"], action: "comment", message: "custom text" }, + [], + ); + expect(result).toEqual({ enabled: true, whenLabels: ["frontend", "visual"], whenPaths: ["apps/ui/**"], action: "comment", message: "custom text" }); + }); + + it("rejects a non-boolean enabled with a warning, falling back to false", () => { + const warnings: string[] = []; + expect(normalizeScreenshotTableGateConfig({ enabled: "yes" }, warnings).enabled).toBe(false); + expect(warnings.some((w) => w.includes("enabled"))).toBe(true); + }); + + it("rejects an invalid action with a warning, falling back to close", () => { + const warnings: string[] = []; + expect(normalizeScreenshotTableGateConfig({ action: "delete" }, warnings).action).toBe("close"); + expect(warnings.some((w) => w.includes("action"))).toBe(true); + }); + + it("rejects a non-string/empty message with a warning, falling back to undefined", () => { + const warnings: string[] = []; + const result = normalizeScreenshotTableGateConfig({ message: " " }, warnings); + expect(result.message).toBeUndefined(); + expect(warnings.some((w) => w.includes("message"))).toBe(true); + }); + + it("accepts a valid non-empty message and trims it", () => { + expect(normalizeScreenshotTableGateConfig({ message: " hi " }, []).message).toBe("hi"); + }); + + it("rejects a non-array whenLabels/whenPaths with a warning, falling back to []", () => { + const warnings: string[] = []; + const result = normalizeScreenshotTableGateConfig({ whenLabels: "frontend", whenPaths: "apps/ui" }, warnings); + expect(result.whenLabels).toEqual([]); + expect(result.whenPaths).toEqual([]); + expect(warnings.length).toBe(2); + }); + + it("drops non-string/empty entries within whenLabels/whenPaths with a warning per entry", () => { + const warnings: string[] = []; + const result = normalizeScreenshotTableGateConfig({ whenLabels: ["frontend", "", 5, " "], whenPaths: [42] }, warnings); + expect(result.whenLabels).toEqual(["frontend"]); + expect(result.whenPaths).toEqual([]); + expect(warnings.length).toBeGreaterThan(0); + }); + + it("caps whenLabels/whenPaths at their max entry count", () => { + const warnings: string[] = []; + const many = Array.from({ length: 60 }, (_, i) => `label-${i}`); + const result = normalizeScreenshotTableGateConfig({ whenLabels: many }, warnings); + expect(result.whenLabels.length).toBe(50); + expect(warnings.some((w) => w.includes("capped"))).toBe(true); + }); +}); + +describe("evaluateScreenshotTableGate", () => { + it("no violation when the gate is disabled, regardless of everything else", () => { + const result = evaluateScreenshotTableGate({ + config: config({ enabled: false, whenLabels: ["frontend"] }), + prBody: "no table here", + prLabels: ["frontend"], + changedFiles: ["apps/ui/src/App.tsx"], + }); + expect(result).toEqual({ violated: false, reason: null }); + }); + + it("no violation when enabled but the PR is out of scope", () => { + const result = evaluateScreenshotTableGate({ + config: config({ enabled: true, whenLabels: ["frontend"] }), + prBody: "no table here", + prLabels: ["backend"], + changedFiles: [], + }); + expect(result).toEqual({ violated: false, reason: null }); + }); + + it("no violation when in scope AND a valid table is present (no stray images, no committed image)", () => { + const result = evaluateScreenshotTableGate({ + config: config({ enabled: true }), + prBody: TABLE_BODY, + prLabels: [], + changedFiles: ["apps/ui/src/App.tsx"], + }); + expect(result).toEqual({ violated: false, reason: null }); + }); + + it("violates when in scope and there is no table at all", () => { + const result = evaluateScreenshotTableGate({ + config: config({ enabled: true }), + prBody: "Just changed some CSS, trust me.", + prLabels: [], + changedFiles: [], + }); + expect(result.violated).toBe(true); + expect(result.reason).toBe(DEFAULT_SCREENSHOT_CONTRACT_MESSAGE); + }); + + it("violates when a valid table exists but an image is ALSO pasted outside it", () => { + const bodyWithStray = `${TABLE_BODY}\n\nAlso here's a bonus shot: ![bonus](https://x/bonus.png)`; + const result = evaluateScreenshotTableGate({ config: config({ enabled: true }), prBody: bodyWithStray, prLabels: [], changedFiles: [] }); + expect(result.violated).toBe(true); + }); + + it("violates when a valid table exists but a screenshot was committed to the repo under a scoped path", () => { + const result = evaluateScreenshotTableGate({ + config: config({ enabled: true, whenPaths: ["apps/ui/**"] }), + prBody: TABLE_BODY, + prLabels: [], + changedFiles: ["apps/ui/src/App.tsx", "apps/ui/public/screenshot.png"], + }); + expect(result.violated).toBe(true); + }); + + it("uses the repo-configured message override instead of the default", () => { + const result = evaluateScreenshotTableGate({ + config: config({ enabled: true, message: "Please add screenshots, thanks!" }), + prBody: "no table", + prLabels: [], + changedFiles: [], + }); + expect(result.reason).toBe("Please add screenshots, thanks!"); + }); + + it("handles a null/undefined PR body without throwing (treated as no table)", () => { + expect(evaluateScreenshotTableGate({ config: config({ enabled: true }), prBody: null, prLabels: [], changedFiles: [] }).violated).toBe(true); + expect(evaluateScreenshotTableGate({ config: config({ enabled: true }), prBody: undefined, prLabels: [], changedFiles: [] }).violated).toBe(true); + }); +}); diff --git a/test/unit/screenshot-table-gate.test.ts b/test/unit/screenshot-table-gate.test.ts index 96be6fdc97..1516050e8f 100644 --- a/test/unit/screenshot-table-gate.test.ts +++ b/test/unit/screenshot-table-gate.test.ts @@ -74,6 +74,11 @@ describe("hasImageBearingMarkdownTable", () => { expect(hasImageBearingMarkdownTable(body)).toBe(false); expect(performance.now() - started).toBeLessThan(50); }); + + it("rejects a separator candidate that has dashes but a non-separator cell", () => { + const body = ["| Before | After |", "| --- | notasep |", "| ![a](x.png) | ![b](y.png) |"].join("\n"); + expect(hasImageBearingMarkdownTable(body)).toBe(false); + }); }); describe("hasImageOutsideTable", () => { diff --git a/test/unit/unlinked-issue-guardrail-config-engine.test.ts b/test/unit/unlinked-issue-guardrail-config-engine.test.ts new file mode 100644 index 0000000000..3e467b14fa --- /dev/null +++ b/test/unit/unlinked-issue-guardrail-config-engine.test.ts @@ -0,0 +1,85 @@ +// Mirror of the app suite pointed at the gittensory-engine copy so the extracted module owns its branch coverage (#2280). +import { describe, expect, it } from "vitest"; +import { + DEFAULT_UNLINKED_ISSUE_GUARDRAIL, + isUnlinkedIssueGuardrailMode, + normalizeUnlinkedIssueGuardrailConfig, +} from "../../packages/gittensory-engine/src/review/unlinked-issue-guardrail-config"; + +describe("isUnlinkedIssueGuardrailMode", () => { + it("accepts the two valid modes", () => { + expect(isUnlinkedIssueGuardrailMode("hold")).toBe(true); + expect(isUnlinkedIssueGuardrailMode("off")).toBe(true); + }); + + it("rejects an invalid string and a non-string value", () => { + expect(isUnlinkedIssueGuardrailMode("block")).toBe(false); + expect(isUnlinkedIssueGuardrailMode(1)).toBe(false); + }); +}); + +describe("normalizeUnlinkedIssueGuardrailConfig", () => { + it("returns the all-off default when input is undefined (no warnings)", () => { + const warnings: string[] = []; + expect(normalizeUnlinkedIssueGuardrailConfig(undefined, warnings)).toEqual(DEFAULT_UNLINKED_ISSUE_GUARDRAIL); + expect(warnings).toEqual([]); + }); + + it("normalizes a fully-valid config", () => { + const warnings: string[] = []; + expect(normalizeUnlinkedIssueGuardrailConfig({ mode: "hold", minConfidence: 0.9 }, warnings)).toEqual({ + mode: "hold", + minConfidence: 0.9, + }); + expect(warnings).toEqual([]); + }); + + it("defaults mode when omitted", () => { + const warnings: string[] = []; + expect(normalizeUnlinkedIssueGuardrailConfig({ minConfidence: 0.5 }, warnings).mode).toBe("off"); + expect(warnings).toEqual([]); + }); + + it("falls back to the default mode and warns on an invalid mode value", () => { + const warnings: string[] = []; + const cfg = normalizeUnlinkedIssueGuardrailConfig({ mode: "block" }, warnings); + expect(cfg.mode).toBe("off"); + expect(warnings).toEqual([`settings.unlinkedIssueGuardrail.mode must be one of hold, off; using the default "off".`]); + }); + + it("defaults minConfidence when omitted", () => { + const warnings: string[] = []; + expect(normalizeUnlinkedIssueGuardrailConfig({ mode: "hold" }, warnings).minConfidence).toBe(0.85); + expect(warnings).toEqual([]); + }); + + it.each([ + ["a non-number", "not-a-number"], + ["a negative number", -0.1], + ["a number above 1", 1.5], + ["NaN", Number.NaN], + ])("falls back to the default minConfidence and warns on %s", (_label, badValue) => { + const warnings: string[] = []; + const cfg = normalizeUnlinkedIssueGuardrailConfig({ minConfidence: badValue }, warnings); + expect(cfg.minConfidence).toBe(0.85); + expect(warnings).toEqual([`settings.unlinkedIssueGuardrail.minConfidence must be a number between 0 and 1; using the default "0.85".`]); + }); + + it("accepts the minConfidence boundary values 0 and 1", () => { + const warnings: string[] = []; + expect(normalizeUnlinkedIssueGuardrailConfig({ minConfidence: 0 }, warnings).minConfidence).toBe(0); + expect(normalizeUnlinkedIssueGuardrailConfig({ minConfidence: 1 }, warnings).minConfidence).toBe(1); + expect(warnings).toEqual([]); + }); + + it.each([ + ["an array", []], + ["null", null], + ["a string", "hold"], + ["a number", 1], + ])("normalizes a malformed top-level value (%s) back to the all-off default", (_label, badInput) => { + const warnings: string[] = []; + expect(normalizeUnlinkedIssueGuardrailConfig(badInput, warnings)).toEqual(DEFAULT_UNLINKED_ISSUE_GUARDRAIL); + expect(warnings).toEqual(["settings.unlinkedIssueGuardrail must be an object; using the default off policy."]); + }); +});