diff --git a/.loopover.yml.example b/.loopover.yml.example index 736ddf271e..3f63ec3f77 100644 --- a/.loopover.yml.example +++ b/.loopover.yml.example @@ -315,6 +315,20 @@ gate: # spelling has the same real effect; this one wins if both are set. linkedIssueSatisfaction: off + # Content-lane linked-issue deliverable gate — only meaningful for a repo with + # a registry content-lane spec resolved (see review/content-lane/spec-resolver.ts); + # a no-op otherwise. When the PR's primary linked issue's own text names a path + # matching that spec's entry/provider file pattern, the PR's changed files must + # touch at least one matching file -- a PR that never touches the content lane + # at all (e.g. adds only a regression test) cannot silently close a content- + # delivery issue via a bare "Closes #N" reference. off = never checked; + # advisory = a miss renders as a warning but never blocks; block = a miss + # ALSO becomes a hard blocker (content_lane_deliverable_missing). Fully + # deterministic (a text/path match, no AI call). off | advisory | block. + # Default: off. DB-backed (dashboard-settable too); this overrides the stored + # value. + contentLaneDeliverable: off + # Gate-check dry-run. When true, the posted check conclusion remains the real # non-enforcing verdict while comments/check text may also show the would-be # stricter verdict for AI-review blocker mode. It does not disable downstream diff --git a/apps/loopover-ui/content/docs/github-app.mdx b/apps/loopover-ui/content/docs/github-app.mdx index d6ced9b2b0..c318da37da 100644 --- a/apps/loopover-ui/content/docs/github-app.mdx +++ b/apps/loopover-ui/content/docs/github-app.mdx @@ -124,6 +124,11 @@ concern from the gate's own merge/close decision. its primary linked issue's intent, distinct from `linkedIssueGateMode` (which only checks a link exists). Default `off`; `advisory` renders in the review comment without blocking, `block` additionally lets a confidence-floor-passing "unaddressed" verdict become a blocker. +- `contentLaneDeliverableGateMode` — only meaningful for a repo with a registry content-lane spec + configured; a no-op otherwise. Fully deterministic (a text/path match, no AI call): when the PR's + primary linked issue's own text names a path matching the content lane's entry/provider file + pattern, the PR's changed files must touch at least one matching file. Default `off`; `advisory` + renders a miss as a warning, `block` additionally makes a miss a blocker. - `moderationGateMode` — whether the moderation-rules engine (contributor cap, blacklist, review-nag feeding a shared cross-repo violation tally) runs on this repo. Default `inherit` (defers to the instance-wide default); `off`/`enabled` force it per repo. diff --git a/apps/loopover-ui/content/docs/how-reviews-work.mdx b/apps/loopover-ui/content/docs/how-reviews-work.mdx index ae3069c898..ba11c2d0c2 100644 --- a/apps/loopover-ui/content/docs/how-reviews-work.mdx +++ b/apps/loopover-ui/content/docs/how-reviews-work.mdx @@ -44,6 +44,7 @@ These are the deterministic rules the gate runs, with their default modes: - **CLA / license gate** (`claGateMode`, default `off`) — CLA / license-compatibility check. - **Self-authored-linked-issue gate** (`selfAuthoredLinkedIssueGateMode`, default `advisory`) — flags or blocks a PR whose author also opened the linked issue. - **Linked-issue satisfaction gate** (`linkedIssueSatisfactionGateMode`, default `off`) — an AI assessment of whether the PR's diff actually satisfies its primary linked issue's intent (distinct from the linked-issue gate above, which only checks that a link exists). `advisory` renders the assessment in the review comment without ever blocking; `block` additionally lets a confidence-floor-passing "unaddressed" verdict become a hard blocker. +- **Content-lane deliverable gate** (`contentLaneDeliverableGateMode`, default `off`) — only meaningful for a repo with a registry content-lane spec configured (see `contentLane` under Advanced config); a no-op otherwise. Fully deterministic (a text/path match, no AI call): when the PR's primary linked issue's own text names a path matching the content lane's entry/provider file pattern, the PR's changed files must touch at least one matching file, so a PR that never touches the content lane at all (e.g. adds only a regression test) cannot close a content-delivery issue via a bare "Closes #N" reference. `advisory` renders a miss as a warning; `block` additionally makes a miss a hard blocker. - **Moderation-rules engine** (`moderationGateMode`, default `inherit`) — whether the contributor-cap / blacklist / review-nag mechanisms feed a shared, cross-repo violation tally on this repo; `inherit` defers to the instance-wide default, `off`/`enabled` force it per repo. Which deterministic rules even apply is set by the **policy pack** (`gatePack`): `gittensor` (registry-aware, tracks confirmed-Gittensor- contributor status for scoring) or `oss-anti-slop` (runs the rules against any author on any repo, with no confirmed-contributor tracking at all). diff --git a/apps/loopover-ui/content/docs/tuning.mdx b/apps/loopover-ui/content/docs/tuning.mdx index 0bec2f884f..2a1e0486e1 100644 --- a/apps/loopover-ui/content/docs/tuning.mdx +++ b/apps/loopover-ui/content/docs/tuning.mdx @@ -259,6 +259,13 @@ forward and wins whenever both are set for the same field. `advisory` renders the assessment in the review comment without blocking; `block` additionally lets a confidence-floor-passing "unaddressed" verdict become a blocker. +- `gate.contentLaneDeliverable` — only meaningful for a repo with a registry + content-lane spec configured (`contentLane`); a no-op otherwise. Fully + deterministic (a text/path match, no AI call): when the PR's primary linked + issue's own text names a path matching the content lane's entry/provider + file pattern, the PR's changed files must touch at least one matching file. + Default `off`. `advisory` renders a miss as a warning; `block` additionally + makes a miss a blocker. - `settings.moderationGateMode` — whether the moderation-rules engine (contributor cap, blacklist, review-nag feeding a shared cross-repo violation tally) runs on this repo at all. `inherit` (default) defers to the instance-wide diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 4a8bfd6ef3..3162ead20f 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -9745,6 +9745,14 @@ "nullable": true, "minimum": 0, "exclusiveMinimum": true + }, + "contentLaneDeliverableGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] } }, "required": [ @@ -9765,6 +9773,7 @@ "manifestPolicyGateMode", "selfAuthoredLinkedIssueGateMode", "linkedIssueSatisfactionGateMode", + "contentLaneDeliverableGateMode", "slopAiAdvisory", "aiReviewMode", "aiReviewByok", @@ -10467,6 +10476,14 @@ "defaultAllowed", "commandOverrides" ] + }, + "contentLaneDeliverableGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] } }, "required": [ @@ -10487,6 +10504,7 @@ "manifestPolicyGateMode", "selfAuthoredLinkedIssueGateMode", "linkedIssueSatisfactionGateMode", + "contentLaneDeliverableGateMode", "autoLabelEnabled", "typeLabelsEnabled", "gittensorLabel", diff --git a/config/examples/loopover.full.yml b/config/examples/loopover.full.yml index 9b17a3da5f..f91a848423 100644 --- a/config/examples/loopover.full.yml +++ b/config/examples/loopover.full.yml @@ -329,6 +329,20 @@ gate: # spelling has the same real effect; this one wins if both are set. linkedIssueSatisfaction: off + # Content-lane linked-issue deliverable gate — only meaningful for a repo with + # a registry content-lane spec resolved (see review/content-lane/spec-resolver.ts); + # a no-op otherwise. When the PR's primary linked issue's own text names a path + # matching that spec's entry/provider file pattern, the PR's changed files must + # touch at least one matching file -- a PR that never touches the content lane + # at all (e.g. adds only a regression test) cannot silently close a content- + # delivery issue via a bare "Closes #N" reference. off = never checked; + # advisory = a miss renders as a warning but never blocks; block = a miss + # ALSO becomes a hard blocker (content_lane_deliverable_missing). Fully + # deterministic (a text/path match, no AI call). off | advisory | block. + # Default: off. DB-backed (dashboard-settable too); this overrides the stored + # value. + contentLaneDeliverable: off + # Gate-check dry-run. When true, the posted check conclusion remains the real # non-enforcing verdict while comments/check text may also show the would-be # stricter verdict for AI-review blocker mode. It does not disable downstream diff --git a/migrations/0170_gate_content_lane_deliverable.sql b/migrations/0170_gate_content_lane_deliverable.sql new file mode 100644 index 0000000000..8f2a0c2981 --- /dev/null +++ b/migrations/0170_gate_content_lane_deliverable.sql @@ -0,0 +1,6 @@ +-- Content-lane linked-issue deliverable gate (#content-lane-deliverable): off by default -- byte-identical +-- behavior for every existing row. Only meaningful for a repo with a registry content-lane spec resolved +-- (see review/content-lane/spec-resolver.ts). When set to advisory/block, a PR whose primary linked issue's +-- own text names a path matching the resolved spec's entry/provider file pattern must touch at least one +-- matching file; block additionally lets a miss become a gate blocker. +ALTER TABLE repository_settings ADD COLUMN content_lane_deliverable_gate_mode TEXT NOT NULL DEFAULT 'off'; diff --git a/packages/loopover-engine/src/advisory/gate-advisory.ts b/packages/loopover-engine/src/advisory/gate-advisory.ts index 934bfbfec8..95116fbcd9 100644 --- a/packages/loopover-engine/src/advisory/gate-advisory.ts +++ b/packages/loopover-engine/src/advisory/gate-advisory.ts @@ -84,6 +84,21 @@ export type GateCheckPolicy = { * the PR author also filed the linked issue — becomes a hard blocker. Defaults to `advisory` — the * finding is surfaced but never blocks unless the maintainer opts in. */ selfAuthoredLinkedIssueGateMode?: GateRuleMode | undefined; + /** Linked-issue satisfaction gate (#1961/#3906). When `block`, a `linked_issue_scope_mismatch` finding — + * raised when the AI assessment judged (above its confidence floor) that the PR's diff does NOT satisfy + * its primary linked issue's intent — becomes a hard blocker. Defaults to `advisory` — the finding is + * never even produced under `advisory`/`off` (the caller gates the assessment itself on this mode; see + * runLinkedIssueSatisfactionForAdvisory, src/queue/processors.ts on the host side), so this branch only + * matters once a repo has explicitly opted into `block`. */ + linkedIssueSatisfactionGateMode?: GateRuleMode | undefined; + /** Content-lane linked-issue deliverable gate (#content-lane-deliverable). When `block`, a + * `content_lane_deliverable_missing` finding — raised when the PR's linked issue's own text names a + * content-lane path the PR's changed files never touch — becomes a hard blocker. Fully deterministic (a + * text/path match, no AI call), so this finding is exempt from the AI-judgment close-precision breaker. + * Defaults to `off` — the finding is never even produced under `off` (the caller gates the check itself + * on this mode; see runContentLaneDeliverableCheckForAdvisory, src/queue/processors.ts on the host side), + * so this branch only matters once a repo has explicitly opted into `advisory`/`block`. */ + contentLaneDeliverableGateMode?: GateRuleMode | undefined; /** CLA / license-compatibility gate (#2564). When `block`, a `cla_consent_missing` finding — raised when * neither configured detection method (a consent phrase in the PR body, or a named CLA-bot check-run * conclusion) confirms consent — becomes a hard blocker. `off` (default) = no finding at all; `advisory` = @@ -613,6 +628,14 @@ function isConfiguredGateBlocker(finding: AdvisoryFinding, policy: GateCheckPoli // Self-authored linked-issue gate: blocks only when the maintainer opts in with `block`. Defaults to // advisory — the finding surfaces in the panel without ever closing the PR unless explicitly configured. if (code === "self_authored_linked_issue") return gatePolicyBlocks(policy.selfAuthoredLinkedIssueGateMode, "advisory"); + // Linked-issue satisfaction gate (#1961/#3906): blocks only when the maintainer opts in with `block`. The + // finding itself is only ever produced when the caller already resolved `block` mode (host side), so this + // is a defense-in-depth mirror of that gate, not the primary enforcement point. + if (code === "linked_issue_scope_mismatch") return gatePolicyBlocks(policy.linkedIssueSatisfactionGateMode, "advisory"); + // Content-lane linked-issue deliverable gate (#content-lane-deliverable): blocks only when the maintainer + // opts in with `block`. Fully deterministic (no AI judgment involved), so it is exempt from the + // close-precision circuit breaker on the host side. + if (code === "content_lane_deliverable_missing") return gatePolicyBlocks(policy.contentLaneDeliverableGateMode, "off"); // Lockfile-tamper-risk gate (#2563): blocks only when the maintainer opts in with `block`. Defaults to `off` // (the finding is never even produced — see maybeAddLockfileTamperFinding's mode gate in queue/processors.ts), // so this branch only matters once a repo has explicitly turned the scan on. diff --git a/packages/loopover-engine/src/focus-manifest.ts b/packages/loopover-engine/src/focus-manifest.ts index c4f64b6703..e3032acd82 100644 --- a/packages/loopover-engine/src/focus-manifest.ts +++ b/packages/loopover-engine/src/focus-manifest.ts @@ -154,6 +154,17 @@ export type FocusManifestGateConfig = { * merge/close decision. See `src/types.ts`'s `linkedIssueSatisfactionGateMode` doc for the authoritative * cross-reference. */ linkedIssueSatisfaction: GateRuleMode | null; + /** `gate.contentLaneDeliverable` (#content-lane-deliverable): off|advisory|block, off by default. Only + * meaningful for a repo with a registry content-lane spec resolved (see content-lane/spec-resolver.ts) — + * no-op otherwise. When not off, and the PR's primary linked issue's own text names a path matching that + * spec's entry/provider file pattern, the PR's changed files must touch AT LEAST ONE matching file — a PR + * that never touches the content lane at all (e.g. adds only a regression test) cannot silently close a + * content-delivery issue via a bare "Closes #N" reference. Fully deterministic (a text/path match, no AI + * call) — `block` treats a miss as a hard, zero-hallucination blocker, exempt from the AI-judgment close- + * precision breaker. DB-backed (dashboard-settable too); this overrides the stored value — mirrors + * `linkedIssueSatisfaction` immediately above in shape, but is a purely structural check, not an AI + * opinion, so it carries none of that feature's AI-budget/confidence-floor machinery. */ + contentLaneDeliverable: GateRuleMode | null; dryRun: 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 @@ -1253,6 +1264,7 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = { manifestPolicy: null, selfAuthoredLinkedIssue: null, linkedIssueSatisfaction: null, + contentLaneDeliverable: null, dryRun: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, @@ -1723,6 +1735,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu manifestPolicy: normalizeOptionalGateMode(record.manifestPolicy, "gate.manifestPolicy", warnings), selfAuthoredLinkedIssue: normalizeOptionalGateMode(record.selfAuthoredLinkedIssue, "gate.selfAuthoredLinkedIssue", warnings), linkedIssueSatisfaction: normalizeOptionalGateMode(record.linkedIssueSatisfaction, "gate.linkedIssueSatisfaction", warnings), + contentLaneDeliverable: normalizeOptionalGateMode(record.contentLaneDeliverable, "gate.contentLaneDeliverable", warnings), dryRun: normalizeOptionalBoolean(record.dryRun, "gate.dryRun", warnings), premergeContentRecheck: normalizeOptionalBoolean(record.premergeContentRecheck, "gate.premergeContentRecheck", warnings), requireFreshRebaseWindowMinutes: normalizeOptionalPositiveInteger(record.requireFreshRebaseWindow, "gate.requireFreshRebaseWindow", warnings), @@ -1779,6 +1792,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu gate.manifestPolicy !== null || gate.selfAuthoredLinkedIssue !== null || gate.linkedIssueSatisfaction !== null || + gate.contentLaneDeliverable !== null || gate.dryRun !== null || gate.premergeContentRecheck !== null || gate.requireFreshRebaseWindowMinutes !== null || @@ -1861,6 +1875,7 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue { if (gate.manifestPolicy !== null) out.manifestPolicy = gate.manifestPolicy; if (gate.selfAuthoredLinkedIssue !== null) out.selfAuthoredLinkedIssue = gate.selfAuthoredLinkedIssue; if (gate.linkedIssueSatisfaction !== null) out.linkedIssueSatisfaction = gate.linkedIssueSatisfaction; + if (gate.contentLaneDeliverable !== null) out.contentLaneDeliverable = gate.contentLaneDeliverable; if (gate.dryRun !== null) out.dryRun = gate.dryRun; if (gate.premergeContentRecheck !== null) out.premergeContentRecheck = gate.premergeContentRecheck; if (gate.requireFreshRebaseWindowMinutes !== null) out.requireFreshRebaseWindow = gate.requireFreshRebaseWindowMinutes; diff --git a/scripts/check-docs-drift.mjs b/scripts/check-docs-drift.mjs index 06b064ac9b..223d56d8b4 100644 --- a/scripts/check-docs-drift.mjs +++ b/scripts/check-docs-drift.mjs @@ -233,6 +233,7 @@ export const GATE_MODE_MANIFEST = [ { field: "manifestPolicyGateMode", aliases: ["manifestPolicyGateMode", "gate.manifestPolicy"], pages: ["how-reviews-work.mdx", "tuning.mdx"] }, { field: "selfAuthoredLinkedIssueGateMode", aliases: ["selfAuthoredLinkedIssueGateMode", "gate.selfAuthoredLinkedIssue"], pages: ["how-reviews-work.mdx", "tuning.mdx", "github-app.mdx"] }, { field: "linkedIssueSatisfactionGateMode", aliases: ["linkedIssueSatisfactionGateMode", "gate.linkedIssueSatisfaction"], pages: ["how-reviews-work.mdx", "tuning.mdx", "github-app.mdx"] }, + { field: "contentLaneDeliverableGateMode", aliases: ["contentLaneDeliverableGateMode", "gate.contentLaneDeliverable"], pages: ["how-reviews-work.mdx", "tuning.mdx", "github-app.mdx"] }, { field: "moderationGateMode", aliases: ["moderationGateMode", "settings.moderationGateMode"], pages: ["how-reviews-work.mdx", "tuning.mdx", "github-app.mdx"] }, ]; diff --git a/src/api/routes.ts b/src/api/routes.ts index 6df0b5be06..029b3e78fe 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -877,6 +877,7 @@ const maintainerSettingsSchema = z mergeReadinessGateMode: z.enum(["off", "advisory", "block"]), manifestPolicyGateMode: z.enum(["off", "advisory", "block"]), linkedIssueSatisfactionGateMode: z.enum(["off", "advisory", "block"]), + contentLaneDeliverableGateMode: z.enum(["off", "advisory", "block"]), // #6443: mergeTrainMode/gittensorLabel/blacklistLabel/createMissingLabel removed -- no longer DB-backed, // config-as-code only via .loopover.yml's settings: block now. // #6446: firstTimeContributorGrace removed -- a dead, never-wired RESERVED/INERT field (#2266); deleted diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 5b87923f95..068f897539 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -591,6 +591,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise manifestPolicyGateMode: "off", selfAuthoredLinkedIssueGateMode: "advisory", linkedIssueSatisfactionGateMode: "off", + contentLaneDeliverableGateMode: "off", slopGateMinScore: null, slopAiAdvisory: false, aiReviewMode: "off", @@ -683,6 +684,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise manifestPolicyGateMode: parseGateRuleMode(row.manifestPolicyGateMode), selfAuthoredLinkedIssueGateMode: "advisory", linkedIssueSatisfactionGateMode: parseGateRuleMode(row.linkedIssueSatisfactionGateMode), + contentLaneDeliverableGateMode: parseGateRuleMode(row.contentLaneDeliverableGateMode), slopGateMinScore: normalizeQualityGateMinScore(row.slopGateMinScore), slopAiAdvisory: row.slopAiAdvisory, aiReviewMode: "off", @@ -824,6 +826,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial>; + installationId: number; + }, +): Promise { + if (args.mode === "paused" || args.settings.contentLaneDeliverableGateMode === "off") return; + const primaryIssueNumber = args.pr.linkedIssues[0]; + if (primaryIssueNumber === undefined) return; + try { + const manifest = await loadRepoFocusManifest(env, args.repoFullName).catch(() => null); + const spec = resolveRegistryLaneSpec(env, manifest, args.repoFullName); + if (!spec) return; + const token = (await createInstallationToken(env, args.installationId).catch(() => undefined)) ?? env.GITHUB_PUBLIC_TOKEN; + const admissionKey = githubAdmissionKeyForToken(env, args.installationId, token); + const issueFetch = await fetchLinkedIssueFacts(env, args.repoFullName, primaryIssueNumber, token, admissionKey); + if (issueFetch.status !== "found") return; + const issueText = [issueFetch.facts.title, issueFetch.facts.body] + .filter((part): part is string => Boolean(part?.trim())) + .join("\n\n"); + if (!issueText.trim()) return; + const changedFiles = args.files.map((file) => file.path); + const result = checkContentLaneDeliverable(spec, issueText, changedFiles); + if (result.verdict !== "missing") return; + args.advisory.findings.push({ + code: "content_lane_deliverable_missing", + severity: "warning", + title: "Linked issue's expected content was never delivered", + detail: `The linked issue names ${result.mentionedPath}, but this PR's changed files never touch it.`, + action: `Edit ${result.mentionedPath} to deliver the issue's actual ask, or link the correct issue.`, + publicText: `This PR's linked issue names \`${result.mentionedPath}\`, but the PR's changed files never touch it -- the issue's actual content deliverable does not appear to have been added.`, + }); + } catch (error) { + /* v8 ignore next -- defense-in-depth: every call in the try block above is already self-guarded + * (loadRepoFocusManifest/.catch, createInstallationToken/.catch, fetchLinkedIssueFacts's own internal + * try/catch that never rethrows) or pure (resolveRegistryLaneSpec, checkContentLaneDeliverable), so this + * branch is currently unreachable by any realistic input -- kept so a future change to one of those + * calls (e.g. a DB-backed cache layer) degrades to "no finding" instead of an unhandled rejection. */ + console.error( + JSON.stringify({ + level: "warn", + event: "content_lane_deliverable_check_failed", + repository: args.repoFullName, + pullNumber: primaryIssueNumber, + error: errorMessage(error), + }), + ); + } +} + /** * Map a PR's realized terminal state + the gate verdict to the {@link SubmissionOutcome} the reputation table * records — or `undefined` when there is no terminal signal to record yet. Pure + total; uses ONLY the PR @@ -9245,6 +9324,21 @@ async function maybePublishPrPublicSurface( }); } } + // Content-lane linked-issue deliverable check (#content-lane-deliverable, opt-in via + // contentLaneDeliverableGateMode). Independent gate mode from the AI-based satisfaction assessment above -- + // `off` (default) short-circuits before any fetch, so this is byte-identical to before this feature existed + // for every repo that hasn't opted in. See runContentLaneDeliverableCheckForAdvisory's own doc comment. + if (settings.contentLaneDeliverableGateMode !== "off" && pr.linkedIssues.length > 0) { + await runContentLaneDeliverableCheckForAdvisory(env, { + mode, + settings, + advisory, + repoFullName, + pr, + files: await getReviewFiles(), + installationId, + }); + } // Focus-manifest policy gate (#555) -- see maybeApplyManifestPolicyGate's own doc comment. await maybeApplyManifestPolicyGate(env, { repoFullName, diff --git a/src/review/content-lane/registry-logic.ts b/src/review/content-lane/registry-logic.ts index 7ab80ac28b..6017b9c9d4 100644 --- a/src/review/content-lane/registry-logic.ts +++ b/src/review/content-lane/registry-logic.ts @@ -829,6 +829,46 @@ export function findDuplicateAppendedEntry( return null; } +// ── Linked-issue content-lane deliverable check (generic, config-as-code — #content-lane-deliverable) ──── +// +// A content-lane repo's contribution issues typically name the specific entry/provider file the PR is meant to +// touch (e.g. "add the missing surfaces to registry/subnets/foo.json"). A PR whose diff never touches ANY file +// matching the spec's own patterns can still close that issue via a bare "Closes #N" reference — the classic +// "test-only PR closes a content issue without ever delivering the content" gap. This check is entirely driven +// by the CALLER's own already-resolved RegistryLaneSpec (metagraphed's or any other registry's) and the free- +// form issue text — it hardcodes no registry's own path shape or issue-template wording. + +/** Path-like tokens in free-form text: word characters, dots, slashes, hyphens, ending in a dot-extension — + * e.g. "registry/subnets/foo-bar.json" inside an issue body's prose. Generic text scanning, not anchored to + * any one registry's path shape; the caller's own spec.entryFilePattern/providerFilePattern narrows the + * candidates down to ones that actually matter for that registry. */ +const PATH_TOKEN_PATTERN = /[\w][\w./-]*\.[A-Za-z0-9]+/g; +export function extractPathTokens(text: string): string[] { + return [...new Set(text.match(PATH_TOKEN_PATTERN) ?? [])]; +} + +/** `not-applicable`: the issue text names no path matching this spec's own patterns at all — nothing to check + * (an unrelated issue, e.g. a docs or code-fix issue, on the same content-lane repo). `delivered`: the issue + * names such a path AND the PR's changed files touch at least one file matching the spec — the common, + * expected case for a real contribution. `missing`: the issue names such a path but the PR touches NONE + * matching — the gap this check exists to catch, independent of AI judgment, CI status, or a closing keyword. */ +export type ContentLaneDeliverableCheck = { verdict: "not-applicable" } | { verdict: "delivered" } | { verdict: "missing"; mentionedPath: string }; + +/** + * Determine whether a PR's changed files deliver the content-lane file its linked issue's own text names. + * PURE — no I/O, no registry-specific hardcoding; entirely driven by `spec` (the caller's already-resolved + * RegistryLaneSpec) and the two text inputs. `changedFiles` are matched the SAME way classifyRegistryPrScope + * matches them (canonicalized: lowercased + `./`-stripped + `\`→`/`), so an uppercase / `./`-prefixed / + * `\`-separated changed path is recognized identically to how the rest of the content lane already treats it. + */ +export function checkContentLaneDeliverable(spec: RegistryLaneSpec, issueText: string, changedFiles: readonly string[]): ContentLaneDeliverableCheck { + const matchesSpec = (candidate: string): boolean => spec.entryFilePattern.test(candidate) || (spec.providerFilePattern?.test(candidate) ?? false); + const mentionedPath = extractPathTokens(issueText).find(matchesSpec); + if (!mentionedPath) return { verdict: "not-applicable" }; + const delivered = changedFiles.some((file) => matchesSpec(canonicalize(file))); + return delivered ? { verdict: "delivered" } : { verdict: "missing", mentionedPath }; +} + // metagraphed's spec — the first RegistryLaneSpec. surfaces[] live in registry/subnets/.json; providers // are FLAT registry/providers/.json (the community/ subdir was retired). A PR touching the old // registry/candidates/community/* path matches none of these → mixed-files / not-direct (correctly not adopted diff --git a/src/rules/advisory.ts b/src/rules/advisory.ts index 78880d93c5..7026379da9 100644 --- a/src/rules/advisory.ts +++ b/src/rules/advisory.ts @@ -91,6 +91,14 @@ export type GateCheckPolicy = { * runLinkedIssueSatisfactionForAdvisory, src/queue/processors.ts), so this branch only matters once a * repo has explicitly opted into `block`. */ linkedIssueSatisfactionGateMode?: GateRuleMode | undefined; + /** Content-lane linked-issue deliverable gate (#content-lane-deliverable). When `block`, a + * `content_lane_deliverable_missing` finding — raised when the PR's linked issue's own text names a + * content-lane path the PR's changed files never touch — becomes a hard blocker. Fully deterministic (a + * text/path match, no AI call), so this finding is exempt from the AI-judgment close-precision breaker. + * Defaults to `off` — the finding is never even produced under `off` (the caller gates the check itself + * on this mode; see runContentLaneDeliverableCheckForAdvisory, src/queue/processors.ts), so this branch + * only matters once a repo has explicitly opted into `advisory`/`block`. */ + contentLaneDeliverableGateMode?: GateRuleMode | undefined; /** CLA / license-compatibility gate (#2564). When `block`, a `cla_consent_missing` finding — raised when * neither configured detection method (a consent phrase in the PR body, or a named CLA-bot check-run * conclusion) confirms consent — becomes a hard blocker. `off` (default) = no finding at all; `advisory` = @@ -999,6 +1007,11 @@ function isConfiguredGateBlocker(finding: AdvisoryFinding, policy: GateCheckPoli // runLinkedIssueSatisfactionForAdvisory), so this is a defense-in-depth mirror of that gate, not the // primary enforcement point. if (code === "linked_issue_scope_mismatch") return gateMode(policy.linkedIssueSatisfactionGateMode ?? "advisory") === "block"; + // Content-lane linked-issue deliverable gate (#content-lane-deliverable): blocks only when the maintainer + // opts in with `block`. The finding itself is only ever produced when the caller already resolved a non- + // "off" mode (see runContentLaneDeliverableCheckForAdvisory), so this is a defense-in-depth mirror of that + // gate, not the primary enforcement point -- mirrors linked_issue_scope_mismatch immediately above. + if (code === "content_lane_deliverable_missing") return gateMode(policy.contentLaneDeliverableGateMode ?? "off") === "block"; // Lockfile-tamper-risk gate (#2563): blocks only when the maintainer opts in with `block`. Defaults to `off` // (the finding is never even produced — see maybeAddLockfileTamperFinding's mode gate in queue/processors.ts), // so this branch only matters once a repo has explicitly turned the scan on. diff --git a/src/settings/agent-actions.ts b/src/settings/agent-actions.ts index c4f7c5a3b1..c5b6dccfcf 100644 --- a/src/settings/agent-actions.ts +++ b/src/settings/agent-actions.ts @@ -200,7 +200,7 @@ export type PlannedAgentAction = { // scoring/model.ts), and spreading/reading another module's export INTO A TOP-LEVEL ARRAY LITERAL evaluates it // eagerly at module-load time, before that module has necessarily finished initializing on this cycle's first // pass -- confirmed by a real "X is not iterable" failure when that was tried. A plain literal has no such -// hazard. A source-text parity test in the test file below guards all nine against producer-side drift instead. +// hazard. A source-text parity test in the test file below guards all ten against producer-side drift instead. const CONCRETE_EVIDENCE_BLOCKER_CODES = new Set([ "secret_leak", "duplicate_pr_risk", @@ -211,6 +211,9 @@ const CONCRETE_EVIDENCE_BLOCKER_CODES = new Set([ "lockfile_tamper_risk", "missing_linked_issue", "self_authored_linked_issue", + // #content-lane-deliverable: a text/path match against the resolved RegistryLaneSpec, no AI judgment involved -- + // same deterministic footing as surface_lane_reject immediately above. + "content_lane_deliverable_missing", ]); /** True when a would-CLOSE is justified by at least one piece of concrete, non-judgment evidence: red CI, a diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 72cc7d4a40..6381238822 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -520,6 +520,7 @@ function applyGateConfigOverrides(effective: RepositorySettings, gate: FocusMani if (gate.manifestPolicy !== null) effective.manifestPolicyGateMode = gate.manifestPolicy; if (gate.selfAuthoredLinkedIssue !== null) effective.selfAuthoredLinkedIssueGateMode = gate.selfAuthoredLinkedIssue; if (gate.linkedIssueSatisfaction !== null) effective.linkedIssueSatisfactionGateMode = gate.linkedIssueSatisfaction; + if (gate.contentLaneDeliverable !== null) effective.contentLaneDeliverableGateMode = gate.contentLaneDeliverable; if (gate.dryRun !== null) effective.gateDryRun = gate.dryRun; if (gate.premergeContentRecheck !== null) effective.premergeContentRecheck = gate.premergeContentRecheck; if (gate.requireFreshRebaseWindowMinutes !== null) effective.requireFreshRebaseWindowMinutes = gate.requireFreshRebaseWindowMinutes; diff --git a/src/signals/settings-preview.ts b/src/signals/settings-preview.ts index d89cbddcd0..dbae539a89 100644 --- a/src/signals/settings-preview.ts +++ b/src/signals/settings-preview.ts @@ -246,6 +246,7 @@ export type RepoSettingsPreview = { manifestPolicyGateMode: RepositorySettings["manifestPolicyGateMode"]; selfAuthoredLinkedIssueGateMode: RepositorySettings["selfAuthoredLinkedIssueGateMode"]; linkedIssueSatisfactionGateMode: RepositorySettings["linkedIssueSatisfactionGateMode"]; + contentLaneDeliverableGateMode: RepositorySettings["contentLaneDeliverableGateMode"]; slopGateMinScore?: number | null | undefined; autoLabelEnabled: boolean; typeLabelsEnabled: boolean; @@ -382,6 +383,7 @@ export function buildRepoSettingsPreview(args: { manifestPolicyGateMode: settings.manifestPolicyGateMode, selfAuthoredLinkedIssueGateMode: settings.selfAuthoredLinkedIssueGateMode, linkedIssueSatisfactionGateMode: settings.linkedIssueSatisfactionGateMode, + contentLaneDeliverableGateMode: settings.contentLaneDeliverableGateMode, slopGateMinScore: settings.slopGateMinScore ?? null, autoLabelEnabled: settings.autoLabelEnabled, typeLabelsEnabled: settings.typeLabelsEnabled ?? true, diff --git a/src/types.ts b/src/types.ts index 8ec298c3fc..61fface11d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -937,6 +937,17 @@ export type RepositorySettings = { * `signals/focus-manifest.ts` — so setting either spelling has the same real effect. Default `off` — * opt-in. */ linkedIssueSatisfactionGateMode: GateRuleMode; + /** Content-lane linked-issue deliverable gate (#content-lane-deliverable). Only meaningful for a repo with + * a registry content-lane spec resolved (see `review/content-lane/spec-resolver.ts`) — a no-op otherwise. + * `off` = never checked (byte-identical to today). `advisory`/`block`: when the PR's primary linked + * issue's own text names a path matching the resolved spec's entry/provider file pattern, the PR's + * changed files must touch AT LEAST ONE matching file; `block` additionally lets a miss become a gate + * blocker (`content_lane_deliverable_missing`) — fully deterministic (a text/path match, no AI call), so + * it is exempt from the AI-judgment close-precision breaker, like `surface_lane_reject`. This is the + * DB-backed, dashboard-settable counterpart; `.loopover.yml gate.contentLaneDeliverable` overrides it + * exactly like every other `gate:` field overrides its `RepositorySettings` counterpart. Default `off` — + * opt-in. */ + contentLaneDeliverableGateMode: GateRuleMode; /** 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 diff --git a/test/unit/agent-actions.test.ts b/test/unit/agent-actions.test.ts index be5debb3b6..c436e8055f 100644 --- a/test/unit/agent-actions.test.ts +++ b/test/unit/agent-actions.test.ts @@ -1803,7 +1803,7 @@ describe("module-load cycle safety (#module-cycle-regression)", () => { }); }); -// #hard-blockers-not-ai-judgment parity guard (nit): CONCRETE_EVIDENCE_BLOCKER_CODES hand-types all 9 of its +// #hard-blockers-not-ai-judgment parity guard (nit): CONCRETE_EVIDENCE_BLOCKER_CODES hand-types all 10 of its // literals rather than importing any of them from their producers, even where a producer DOES export a // reusable constant (advisory.ts's DUPLICATE_ONLY_BLOCKER_CODES, pre-merge-checks.ts's // PRE_MERGE_CHECK_BLOCKING_CODE) -- see the doc comment on CONCRETE_EVIDENCE_BLOCKER_CODES for why: this module @@ -1823,6 +1823,7 @@ describe("CONCRETE_EVIDENCE_BLOCKER_CODES parity — hand-typed literals still m { code: "lockfile_tamper_risk", file: "src/review/lockfile-tamper.ts" }, { code: "missing_linked_issue", file: "src/rules/advisory.ts" }, { code: "self_authored_linked_issue", file: "src/rules/advisory.ts" }, + { code: "content_lane_deliverable_missing", file: "src/queue/processors.ts" }, ]; // Requires the actual producer shape (a `code: "..."` finding property, or a `SOME_CONST = "..."` exported diff --git a/test/unit/check-docs-drift-script.test.ts b/test/unit/check-docs-drift-script.test.ts index 8513734800..76ac82e044 100644 --- a/test/unit/check-docs-drift-script.test.ts +++ b/test/unit/check-docs-drift-script.test.ts @@ -327,10 +327,10 @@ describe("check-docs-drift script", () => { const result = checkDocsDrift({ root: "/fake", readFile: makeReadFile(files) }); expect(result.failures).toEqual([]); - // gateModes bumped 12 -> 13 for copycatGateMode (#1969, currently inert config scaffold). - // settingsFields = 13 GATE_MODE_MANIFEST fields + 20 synthetic extras; focusManifestFields = 18 + // gateModes bumped 13 -> 14 for contentLaneDeliverableGateMode (#content-lane-deliverable). + // settingsFields = 14 GATE_MODE_MANIFEST fields + 20 synthetic extras; focusManifestFields = 18 // synthetic review fields + the nested review.visual.productionUrl leaf (#4617). - expect(result.counts).toEqual({ flags: 10, commands: 19, gateModes: 13, settingsFields: 33, focusManifestFields: 19 }); + expect(result.counts).toEqual({ flags: 10, commands: 19, gateModes: 14, settingsFields: 34, focusManifestFields: 19 }); }); it("catches an unmapped *GateMode field missing from GATE_MODE_MANIFEST", () => { diff --git a/test/unit/content-lane-deliverable-run.test.ts b/test/unit/content-lane-deliverable-run.test.ts new file mode 100644 index 0000000000..ee2f8567df --- /dev/null +++ b/test/unit/content-lane-deliverable-run.test.ts @@ -0,0 +1,193 @@ +// #content-lane-deliverable: processor wiring for runContentLaneDeliverableCheckForAdvisory. The pure +// detection logic (checkContentLaneDeliverable) is exhaustively covered in +// test/unit/content-lane-registry-logic.test.ts; this file covers the host wiring: mode gating, spec +// resolution (a no-op for a repo with no content-lane spec, matching the "never metagraphed-specific" +// design), the issue fetch, and the finding push. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runContentLaneDeliverableCheckForAdvisory } from "../../src/queue/processors"; +import { clearInstallationTokenCacheForTest } from "../../src/github/app"; +import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; +import type { Advisory, PullRequestFileRecord, RepositorySettings } from "../../src/types"; +import { createTestEnv } from "../helpers/d1"; + +describe("runContentLaneDeliverableCheckForAdvisory (processor wiring, #content-lane-deliverable)", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + clearInstallationTokenCacheForTest(); + }); + + function advisory(over: Partial = {}): Advisory { + return { + id: "adv-deliverable", + targetType: "pull_request", + targetKey: "acme/widgets#7", + repoFullName: "acme/widgets", + pullNumber: 7, + headSha: "sha7", + conclusion: "neutral", + severity: "info", + title: "LoopOver advisory available", + summary: "ok", + findings: [], + generatedAt: "2026-07-21T00:00:00.000Z", + ...over, + }; + } + + const files: PullRequestFileRecord[] = [ + { repoFullName: "acme/widgets", pullNumber: 7, path: "tests/foo-verify.test.mjs", status: "added", additions: 40, deletions: 0, changes: 40, payload: {} }, + ]; + const pr = { linkedIssues: [1275] }; + const blockMode = { contentLaneDeliverableGateMode: "block" } as RepositorySettings; + const advisoryMode = { contentLaneDeliverableGateMode: "advisory" } as RepositorySettings; + const offMode = { contentLaneDeliverableGateMode: "off" } as RepositorySettings; + + async function seedContentLaneRepo(env: Awaited>): Promise { + await upsertRepoFocusManifest(env, "acme/widgets", { + contentLane: { entryFileGlob: "registry/subnets/*.json", collectionField: "surfaces" }, + }); + } + + function stubIssueFetch(issue: { title?: string; body?: string } = {}): void { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/issues/1275")) { + return Response.json({ + number: 1275, + state: "open", + title: issue.title ?? "Add missing surfaces", + body: issue.body ?? "Missing surfaces to add to registry/subnets/foo.json.", + }); + } + return new Response("not found", { status: 404 }); + }); + } + + it("no-ops when contentLaneDeliverableGateMode is off (default) — no fetch attempted", async () => { + const env = createTestEnv({ LOOPOVER_REVIEW_CONTENT_LANE: "true" }); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const adv = advisory(); + await runContentLaneDeliverableCheckForAdvisory(env, { mode: "live", settings: offMode, advisory: adv, repoFullName: "acme/widgets", pr, files, installationId: 1 }); + expect(adv.findings).toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("REGRESSION (#token-bleed-spend-gate): a paused mode never fetches, even with block mode configured", async () => { + const env = createTestEnv({ LOOPOVER_REVIEW_CONTENT_LANE: "true" }); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const adv = advisory(); + await runContentLaneDeliverableCheckForAdvisory(env, { mode: "paused", settings: blockMode, advisory: adv, repoFullName: "acme/widgets", pr, files, installationId: 1 }); + expect(adv.findings).toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("no-ops when the PR has no linked issues (defense-in-depth; the call site itself also gates on this)", async () => { + const env = createTestEnv({ LOOPOVER_REVIEW_CONTENT_LANE: "true" }); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const adv = advisory(); + await runContentLaneDeliverableCheckForAdvisory(env, { mode: "live", settings: blockMode, advisory: adv, repoFullName: "acme/widgets", pr: { linkedIssues: [] }, files, installationId: 1 }); + expect(adv.findings).toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("no-ops when no content-lane spec resolves for this repo (never metagraphed-specific — a repo with no contentLane: config is simply a no-op)", async () => { + // LOOPOVER_REVIEW_REPOS explicitly cleared -- the test helper's own default allowlist includes + // "acme/widgets" (matching other content-lane tests' fixtures), which this ONE test must NOT ride on: + // no contentLane: config and no allowlist entry is exactly the "not a content-lane repo at all" case. + const env = createTestEnv({ LOOPOVER_REVIEW_CONTENT_LANE: "true", LOOPOVER_REVIEW_REPOS: "" }); + stubIssueFetch(); + const adv = advisory(); + await runContentLaneDeliverableCheckForAdvisory(env, { mode: "live", settings: blockMode, advisory: adv, repoFullName: "acme/widgets", pr, files, installationId: 1 }); + expect(adv.findings).toEqual([]); + }); + + it("no-ops when the content-lane flag itself is off, even with a contentLane: config present", async () => { + const env = createTestEnv({}); // LOOPOVER_REVIEW_CONTENT_LANE not set + await seedContentLaneRepo(env); + stubIssueFetch(); + const adv = advisory(); + await runContentLaneDeliverableCheckForAdvisory(env, { mode: "live", settings: blockMode, advisory: adv, repoFullName: "acme/widgets", pr, files, installationId: 1 }); + expect(adv.findings).toEqual([]); + }); + + it("no-ops when the linked issue cannot be fetched (fail-safe, never asserts a miss on missing data)", async () => { + const env = createTestEnv({ LOOPOVER_REVIEW_CONTENT_LANE: "true" }); + await seedContentLaneRepo(env); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + return new Response("not found", { status: 404 }); + }); + const adv = advisory(); + await runContentLaneDeliverableCheckForAdvisory(env, { mode: "live", settings: blockMode, advisory: adv, repoFullName: "acme/widgets", pr, files, installationId: 1 }); + expect(adv.findings).toEqual([]); + }); + + it("no-ops when the issue text names no content-lane path at all (not-applicable, an unrelated issue)", async () => { + const env = createTestEnv({ LOOPOVER_REVIEW_CONTENT_LANE: "true" }); + await seedContentLaneRepo(env); + stubIssueFetch({ title: "Fix a flaky CI timeout", body: "The Worker API test suite times out intermittently." }); + const adv = advisory(); + await runContentLaneDeliverableCheckForAdvisory(env, { mode: "live", settings: blockMode, advisory: adv, repoFullName: "acme/widgets", pr, files, installationId: 1 }); + expect(adv.findings).toEqual([]); + }); + + it("no-ops when the PR actually delivers the content-lane file the issue names", async () => { + const env = createTestEnv({ LOOPOVER_REVIEW_CONTENT_LANE: "true" }); + await seedContentLaneRepo(env); + stubIssueFetch(); + const adv = advisory(); + const deliveredFiles: PullRequestFileRecord[] = [...files, { repoFullName: "acme/widgets", pullNumber: 7, path: "registry/subnets/foo.json", status: "modified", additions: 3, deletions: 0, changes: 3, payload: {} }]; + await runContentLaneDeliverableCheckForAdvisory(env, { mode: "live", settings: blockMode, advisory: adv, repoFullName: "acme/widgets", pr, files: deliveredFiles, installationId: 1 }); + expect(adv.findings).toEqual([]); + }); + + it("regression: pushes a warning finding under advisory mode when the PR never touches the named content-lane file (the reported incident's exact shape)", async () => { + const env = createTestEnv({ LOOPOVER_REVIEW_CONTENT_LANE: "true" }); + await seedContentLaneRepo(env); + stubIssueFetch(); + const adv = advisory(); + await runContentLaneDeliverableCheckForAdvisory(env, { mode: "live", settings: advisoryMode, advisory: adv, repoFullName: "acme/widgets", pr, files, installationId: 1 }); + expect(adv.findings).toHaveLength(1); + expect(adv.findings[0]).toMatchObject({ + code: "content_lane_deliverable_missing", + severity: "warning", + detail: expect.stringContaining("registry/subnets/foo.json"), + }); + }); + + it("also pushes the same finding under block mode (the promotion to a hard blocker happens downstream via isConfiguredGateBlocker)", async () => { + const env = createTestEnv({ LOOPOVER_REVIEW_CONTENT_LANE: "true" }); + await seedContentLaneRepo(env); + stubIssueFetch(); + const adv = advisory(); + await runContentLaneDeliverableCheckForAdvisory(env, { mode: "live", settings: blockMode, advisory: adv, repoFullName: "acme/widgets", pr, files, installationId: 1 }); + expect(adv.findings.map((f) => f.code)).toEqual(["content_lane_deliverable_missing"]); + }); + + it("no-ops when the found issue has no usable title/body text at all (empty after trimming)", async () => { + const env = createTestEnv({ LOOPOVER_REVIEW_CONTENT_LANE: "true" }); + await seedContentLaneRepo(env); + stubIssueFetch({ title: " ", body: "" }); + const adv = advisory(); + await runContentLaneDeliverableCheckForAdvisory(env, { mode: "live", settings: blockMode, advisory: adv, repoFullName: "acme/widgets", pr, files, installationId: 1 }); + expect(adv.findings).toEqual([]); + }); + + it("fetch_error from fetchLinkedIssueFacts (e.g. the issue request rejecting outright) degrades to no-op, never throws (fetchLinkedIssueFacts's own internal try/catch never rethrows)", async () => { + const env = createTestEnv({ LOOPOVER_REVIEW_CONTENT_LANE: "true" }); + await seedContentLaneRepo(env); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + throw new Error("network down"); + }); + const adv = advisory(); + await expect( + runContentLaneDeliverableCheckForAdvisory(env, { mode: "live", settings: blockMode, advisory: adv, repoFullName: "acme/widgets", pr, files, installationId: 1 }), + ).resolves.toBeUndefined(); + expect(adv.findings).toEqual([]); + }); +}); diff --git a/test/unit/content-lane-registry-logic.test.ts b/test/unit/content-lane-registry-logic.test.ts index 8a4b9c7305..56effcd2b4 100644 --- a/test/unit/content-lane-registry-logic.test.ts +++ b/test/unit/content-lane-registry-logic.test.ts @@ -4,10 +4,14 @@ import { assessSubnetDocument, assessFreshness, assessProviderDocument, + checkContentLaneDeliverable, classifyRegistryPrScope, + extractPathTokens, findDuplicateAppendedEntry, + FLAT_PROVIDER_PATTERN, isRegistrySubmissionScope, METAGRAPHED_LANE_SPEC, + SUBNET_ENTRY_PATTERN, type RegistryLaneSpec, computeGrounding, containsSecretLikeText, @@ -501,6 +505,81 @@ describe("findDuplicateAppendedEntry (generic, spec-driven duplicate detection }); }); +describe("extractPathTokens", () => { + it("extracts path-like tokens from free-form prose", () => { + expect(extractPathTokens("Add the missing surfaces to registry/subnets/nepher-robotics.json please.")).toEqual(["registry/subnets/nepher-robotics.json"]); + }); + + it("dedupes repeated mentions of the same token", () => { + expect(extractPathTokens("See registry/subnets/foo.json. Edit registry/subnets/foo.json again.")).toEqual(["registry/subnets/foo.json"]); + }); + + it("returns [] for text with no path-shaped tokens", () => { + expect(extractPathTokens("This issue has no file paths mentioned at all")).toEqual([]); + }); + + it("extracts multiple distinct tokens in declaration order", () => { + expect(extractPathTokens("Touch registry/subnets/a.json and registry/providers/b.json")).toEqual(["registry/subnets/a.json", "registry/providers/b.json"]); + }); +}); + +// #content-lane-deliverable: the metagraphed #7385/#7382/#7379-class incident this check exists to catch — a +// PR that adds only a regression test file and closes an issue whose entire ask is a registry/subnets/*.json +// edit, with the registry file itself never touched. +describe("checkContentLaneDeliverable (generic, spec-driven — #content-lane-deliverable)", () => { + const spec: RegistryLaneSpec = { entryFilePattern: SUBNET_ENTRY_PATTERN, providerFilePattern: FLAT_PROVIDER_PATTERN, collectionField: "surfaces" }; + + it("is not-applicable when the issue text names no path matching this spec at all (an unrelated issue)", () => { + const result = checkContentLaneDeliverable(spec, "Fix the flaky CI timeout in the Worker API.", ["src/api/routes.ts"]); + expect(result).toEqual({ verdict: "not-applicable" }); + }); + + it("regression: is 'missing' when the issue names the entry file but the PR only adds a test (the reported incident's exact shape)", () => { + const issueText = [ + "MCP execute: verify + wire SN49 (Nepher Robotics) once Phase 1 ships", + "", + "Missing surfaces to add to registry/subnets/nepher-robotics.json:", + "- subnet-api at https://api.nepher.example/v1", + ].join("\n"); + const changedFiles = ["tests/nepher-robotics-call-subnet-surface-verify.test.mjs"]; + expect(checkContentLaneDeliverable(spec, issueText, changedFiles)).toEqual({ + verdict: "missing", + mentionedPath: "registry/subnets/nepher-robotics.json", + }); + }); + + it("is 'delivered' when the PR actually touches the entry file the issue names", () => { + const issueText = "Add the missing surfaces to registry/subnets/nepher-robotics.json."; + const changedFiles = ["tests/nepher-robotics-call-subnet-surface-verify.test.mjs", "registry/subnets/nepher-robotics.json"]; + expect(checkContentLaneDeliverable(spec, issueText, changedFiles)).toEqual({ verdict: "delivered" }); + }); + + it("is 'delivered' via the provider file pattern too, not just the entry pattern", () => { + const issueText = "Register the new provider at registry/providers/acme.json."; + expect(checkContentLaneDeliverable(spec, issueText, ["registry/providers/acme.json"])).toEqual({ verdict: "delivered" }); + }); + + it("is 'missing' when the issue names a provider path but the PR touches an unrelated file", () => { + const issueText = "Register the new provider at registry/providers/acme.json."; + const result = checkContentLaneDeliverable(spec, issueText, ["tests/acme-verify.test.mjs"]); + expect(result).toEqual({ verdict: "missing", mentionedPath: "registry/providers/acme.json" }); + }); + + it("canonicalizes changed-file paths the same way classifyRegistryPrScope does (case/./-prefix/backslash-insensitive)", () => { + const issueText = "Add registry/subnets/foo.json."; + expect(checkContentLaneDeliverable(spec, issueText, ["REGISTRY/SUBNETS/FOO.JSON"]).verdict).toBe("delivered"); + expect(checkContentLaneDeliverable(spec, issueText, ["./registry/subnets/foo.json"]).verdict).toBe("delivered"); + expect(checkContentLaneDeliverable(spec, issueText, ["registry\\subnets\\foo.json"]).verdict).toBe("delivered"); + }); + + it("is not-applicable for a spec with no providerFilePattern configured, even if the issue mentions an entry-shaped path (guards the optional-chaining branch)", () => { + const entryOnlySpec: RegistryLaneSpec = { entryFilePattern: SUBNET_ENTRY_PATTERN, collectionField: "surfaces" }; + const issueText = "Add registry/subnets/foo.json."; + expect(checkContentLaneDeliverable(entryOnlySpec, issueText, ["registry/subnets/foo.json"]).verdict).toBe("delivered"); + expect(checkContentLaneDeliverable(entryOnlySpec, issueText, ["tests/foo.test.mjs"]).verdict).toBe("missing"); + }); +}); + describe("isBaseLayerKind", () => { it("recognizes the chain base-layer kinds", () => { expect(isBaseLayerKind("subtensor-wss")).toBe(true); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 6d68d6e88b..8f141f1814 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -284,6 +284,7 @@ describe(".loopover.yml.example field-exhaustiveness (#1670)", () => { manifestPolicy: "manifestPolicy:", selfAuthoredLinkedIssue: "selfAuthoredLinkedIssue:", linkedIssueSatisfaction: "linkedIssueSatisfaction:", + contentLaneDeliverable: "contentLaneDeliverable:", dryRun: "dryRun:", premergeContentRecheck: "premergeContentRecheck:", requireFreshRebaseWindowMinutes: "requireFreshRebaseWindow:", @@ -937,7 +938,7 @@ describe("compileFocusManifestPolicy", () => { issueDiscoveryPolicy: "neutral", maintainerNotes: [], publicNotes: ["Keep PRs focused.", "Maximize your reward payout"], - gate: { present: false, enabled: null, checkMode: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, sizeMaxFiles: null, sizeMaxLines: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewLowConfidenceDisposition: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, linkedIssueSatisfaction: null, manifestPolicy: null, dryRun: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, staleBaseAheadByThreshold: null, claMode: null, claConsentPhrase: null, claCheckRunName: null, claCheckRunAppSlug: null, expectedCiContexts: null, advisoryCheckRuns: null, aiJudgmentBlockersMode: null, copycatMode: null, copycatMinScore: null }, + gate: { present: false, enabled: null, checkMode: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, sizeMaxFiles: null, sizeMaxLines: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewLowConfidenceDisposition: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, linkedIssueSatisfaction: null, contentLaneDeliverable: null, manifestPolicy: null, dryRun: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, staleBaseAheadByThreshold: null, claMode: null, claConsentPhrase: null, claCheckRunName: null, claCheckRunAppSlug: null, expectedCiContexts: null, advisoryCheckRuns: null, aiJudgmentBlockersMode: null, copycatMode: null, copycatMinScore: null }, settings: {}, review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, sweepWatchdog: null, prReconciliation: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: null, e2eTestDelivery: null, e2eTestAutoTrigger: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { ...EMPTY_AUTO_REVIEW_CONFIG }, aiModel: { ...EMPTY_SELF_HOST_AI_MODEL_CONFIG }, visual: { ...EMPTY_VISUAL_CONFIG }, linkedIssueSatisfaction: null, sharedConfigSource: null }, features: { present: false, rag: null, reputation: null, safety: null, grounding: null, e2eTests: null, screenshots: null, improvementSignal: null, amsReputationBridge: null }, @@ -1102,7 +1103,7 @@ describe("parseFocusManifest gate config", () => { // the block→advisory deprecation-downgrade behavior itself is covered separately below. const m = parseFocusManifest({ gate: { linkedIssue: "block", duplicates: "advisory", readiness: { mode: "advisory", minScore: 70 } } }); expect(m.present).toBe(true); - expect(m.gate).toEqual({ present: true, enabled: null, checkMode: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "advisory", readinessMinScore: 70, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, sizeMaxFiles: null, sizeMaxLines: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewLowConfidenceDisposition: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, linkedIssueSatisfaction: null, manifestPolicy: null, dryRun: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, staleBaseAheadByThreshold: null, claMode: null, claConsentPhrase: null, claCheckRunName: null, claCheckRunAppSlug: null, expectedCiContexts: null, advisoryCheckRuns: null, aiJudgmentBlockersMode: null, copycatMode: null, copycatMinScore: null }); + expect(m.gate).toEqual({ present: true, enabled: null, checkMode: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "advisory", readinessMinScore: 70, slopMode: null, slopMinScore: null, slopAiAdvisory: null, sizeMode: null, sizeMaxFiles: null, sizeMaxLines: null, lockfileIntegrityMode: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, aiReviewCloseConfidence: null, aiReviewLowConfidenceDisposition: null, aiReviewCombine: null, aiReviewOnMerge: null, aiReviewReviewers: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, linkedIssueSatisfaction: null, contentLaneDeliverable: null, manifestPolicy: null, dryRun: null, premergeContentRecheck: null, requireFreshRebaseWindowMinutes: null, staleBaseAheadByThreshold: null, claMode: null, claConsentPhrase: null, claCheckRunName: null, claCheckRunAppSlug: null, expectedCiContexts: null, advisoryCheckRuns: null, aiJudgmentBlockersMode: null, copycatMode: null, copycatMinScore: null }); }); it("parses gate.mergeReadiness, round-trips it, and warns on a bad value (#822)", () => { diff --git a/test/unit/gate-check-policy.test.ts b/test/unit/gate-check-policy.test.ts index e8eaaab9b2..fddc61d92a 100644 --- a/test/unit/gate-check-policy.test.ts +++ b/test/unit/gate-check-policy.test.ts @@ -989,6 +989,33 @@ describe("linked-issue satisfaction gate blocker (#1961/#3906)", () => { }); }); +describe("content-lane linked-issue deliverable gate blocker (#content-lane-deliverable)", () => { + const deliverableAdvisory = (): Advisory => ({ + ...missingIssueAdvisory(), + findings: [{ code: "content_lane_deliverable_missing", title: "Linked issue's expected content was never delivered", severity: "warning", detail: "The linked issue names registry/subnets/nepher-robotics.json, but this PR's changed files never touch it.", action: "Edit registry/subnets/nepher-robotics.json to deliver the issue's actual ask, or link the correct issue." }], + }); + + it("blocks (failure) under contentLaneDeliverableGateMode: block, confirmed contributor", () => { + const result = evaluateGateCheck(deliverableAdvisory(), { contentLaneDeliverableGateMode: "block", confirmedContributor: true }); + expect(result.conclusion).toBe("failure"); + expect(result.blockers.map((b) => b.code)).toContain("content_lane_deliverable_missing"); + }); + + it("stays advisory (never blocks) under off/unset (default) or advisory mode, even if a finding exists", () => { + expect(evaluateGateCheck(deliverableAdvisory(), {}).conclusion).toBe("success"); // unset ⇒ defaults to off + expect(evaluateGateCheck(deliverableAdvisory(), { contentLaneDeliverableGateMode: "off" }).conclusion).toBe("success"); + const advisoryResult = evaluateGateCheck(deliverableAdvisory(), { contentLaneDeliverableGateMode: "advisory" }); + expect(advisoryResult.conclusion).toBe("success"); + expect(advisoryResult.warnings.map((w) => w.code)).toContain("content_lane_deliverable_missing"); + }); + + it("resolveEffectiveSettings maps gate.contentLaneDeliverable → contentLaneDeliverableGateMode, and gateCheckPolicy threads it", () => { + const eff = resolveEffectiveSettings(settings({}), parseFocusManifest({ gate: { contentLaneDeliverable: "block" } })); + expect(eff.contentLaneDeliverableGateMode).toBe("block"); + expect(gateCheckPolicy(settings({ contentLaneDeliverableGateMode: "block" }), null, true).contentLaneDeliverableGateMode).toBe("block"); + }); +}); + describe("dry-run disposition (#gate-dryrun): would-be verdict without enforcing", () => { // #disposition-redesign: the dry-run shadow promotes ONLY the AI sub-gate. CLOSE is driven by AI confidence; the // advisory signals (linked issue, readiness/quality, slop, duplicates) can NEVER drive a would-be close. diff --git a/test/unit/maintainer-activation.test.ts b/test/unit/maintainer-activation.test.ts index 8fce9e7c21..ae8335ba5e 100644 --- a/test/unit/maintainer-activation.test.ts +++ b/test/unit/maintainer-activation.test.ts @@ -38,6 +38,7 @@ function settings(overrides: Partial = {}): RepositorySettin manifestPolicyGateMode: "off", selfAuthoredLinkedIssueGateMode: "advisory", linkedIssueSatisfactionGateMode: "off", + contentLaneDeliverableGateMode: "off", slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, diff --git a/test/unit/policy-sanitizer.test.ts b/test/unit/policy-sanitizer.test.ts index 6f3e68bfaf..baefe4bc77 100644 --- a/test/unit/policy-sanitizer.test.ts +++ b/test/unit/policy-sanitizer.test.ts @@ -71,6 +71,7 @@ function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettin manifestPolicyGateMode: "off", selfAuthoredLinkedIssueGateMode: "advisory", linkedIssueSatisfactionGateMode: "off", + contentLaneDeliverableGateMode: "off", slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, diff --git a/test/unit/repository-settings-enforcement.test.ts b/test/unit/repository-settings-enforcement.test.ts index 70dd9caf70..8dc00807a5 100644 --- a/test/unit/repository-settings-enforcement.test.ts +++ b/test/unit/repository-settings-enforcement.test.ts @@ -26,6 +26,7 @@ function settings(over: Partial = {}): RepositorySettings { manifestPolicyGateMode: "off", selfAuthoredLinkedIssueGateMode: "advisory", linkedIssueSatisfactionGateMode: "off", + contentLaneDeliverableGateMode: "off", slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, diff --git a/test/unit/self-dogfood-registration-pack.test.ts b/test/unit/self-dogfood-registration-pack.test.ts index c1fed6e0a7..a22ba76e9b 100644 --- a/test/unit/self-dogfood-registration-pack.test.ts +++ b/test/unit/self-dogfood-registration-pack.test.ts @@ -65,6 +65,7 @@ function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettin manifestPolicyGateMode: "off", selfAuthoredLinkedIssueGateMode: "advisory", linkedIssueSatisfactionGateMode: "off", + contentLaneDeliverableGateMode: "off", slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index 352454af85..778af041ee 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -2388,6 +2388,7 @@ function repoSettings(repoFullName: string): RepositorySettings { manifestPolicyGateMode: "off", selfAuthoredLinkedIssueGateMode: "advisory", linkedIssueSatisfactionGateMode: "off", + contentLaneDeliverableGateMode: "off", slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, diff --git a/test/unit/signals-v2.test.ts b/test/unit/signals-v2.test.ts index 6d9f6f211a..3d981461af 100644 --- a/test/unit/signals-v2.test.ts +++ b/test/unit/signals-v2.test.ts @@ -2115,6 +2115,7 @@ describe("v2 signal builders", () => { manifestPolicyGateMode: "off", selfAuthoredLinkedIssueGateMode: "advisory", linkedIssueSatisfactionGateMode: "off", + contentLaneDeliverableGateMode: "off", slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, diff --git a/test/unit/signals.test.ts b/test/unit/signals.test.ts index 75fa0c5dbe..cab3c6edb0 100644 --- a/test/unit/signals.test.ts +++ b/test/unit/signals.test.ts @@ -542,6 +542,7 @@ describe("world-class backend signals", () => { manifestPolicyGateMode: "off" as const, selfAuthoredLinkedIssueGateMode: "advisory" as const, linkedIssueSatisfactionGateMode: "off" as const, + contentLaneDeliverableGateMode: "off" as const, slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, @@ -602,6 +603,7 @@ describe("world-class backend signals", () => { manifestPolicyGateMode: "off", selfAuthoredLinkedIssueGateMode: "advisory", linkedIssueSatisfactionGateMode: "off", + contentLaneDeliverableGateMode: "off", slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, @@ -684,6 +686,7 @@ describe("world-class backend signals", () => { manifestPolicyGateMode: "off", selfAuthoredLinkedIssueGateMode: "advisory", linkedIssueSatisfactionGateMode: "off", + contentLaneDeliverableGateMode: "off", slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, @@ -799,6 +802,7 @@ describe("world-class backend signals", () => { manifestPolicyGateMode: "off", selfAuthoredLinkedIssueGateMode: "advisory", linkedIssueSatisfactionGateMode: "off", + contentLaneDeliverableGateMode: "off", slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true, diff --git a/test/unit/unified-comment-parity.test.ts b/test/unit/unified-comment-parity.test.ts index 325b111ee3..403a723e52 100644 --- a/test/unit/unified-comment-parity.test.ts +++ b/test/unit/unified-comment-parity.test.ts @@ -63,6 +63,7 @@ const settings: RepositorySettings = { manifestPolicyGateMode: "off", selfAuthoredLinkedIssueGateMode: "advisory", linkedIssueSatisfactionGateMode: "off", + contentLaneDeliverableGateMode: "off", slopAiAdvisory: false, qualityGateMinScore: null, autoLabelEnabled: true,