/** * The ready gate (#1040, #1041): the one pure answer to "may this durable * PROMOTABLE verdict become a queue label without a human looking at it". * * Promotion used to be an orchestrator act, so the checks that made it safe * lived wherever the act happened — the lane comparison inside the promotion * verb (#1036), the rest in the orchestrator's own judgement. Phase 2 makes * promotion mechanical: the daemon promotes a passing verdict itself and wakes * dispatch, and the tick audits afterwards. A judgement that is about to run * without a reader has to be one function, testable in isolation, and it has * to name everything it refuses for — a gate that reports only the first * problem turns one grooming pass into four. * * Pure and IO-free on purpose. The caller supplies the issue text, its comment * thread and its labels; the gate reads nothing, fetches nothing and has no * clock. That is what lets the same function run inside the promotion verb * (which already read the issue for its lane echo) and inside the daemon's * grooming pass (which has the snapshot in hand) without either growing a * second copy of the policy. * * Fail-closed, unlike admission. Admission fails *open* on a lane it cannot * parse — it must never refuse work for wanting a declaration, because a * human queued that work deliberately. Nothing here was queued by a human, so * every unknown is a refusal: an unreadable issue, a write-lane heading that * parsed to nothing, a routing label that resolves nowhere. The failure mode * of a fail-open promotion gate is dispatching a worker at an unverified * brief, which is exactly the incident (#1036) that produced the lane * comparison in the first place. */ import { effectiveLane, writeLaneSectionHeading } from "./admission.ts"; import { repoSlugFor } from "./gitops.ts"; import type { ToSpecResult } from "./to-spec.ts"; import type { IssueComment, ProjectConfig } from "./types.ts"; /** * Everything the gate judges, and nothing it could fetch itself. * * `comments` distinguishes "the thread is empty" from "the thread could not be * read": behind a durable promotable verdict the second is a refusal, not an * empty lane (#1036). `"unread"` means the whole read failed — body included — * so the gate reports that and stops rather than judging a blank issue. */ export interface ReadyGateInput { result: ToSpecResult; issueBody: string; comments: readonly IssueComment[] | "unread"; labels: readonly string[]; project: ProjectConfig; } /** Pass, or every element that is missing — one short human-readable reason * each, in a fixed order, so a refusal reads the same twice. */ export type ReadyGateVerdict = { ok: true } | { ok: false; missing: string[] }; /** * The acceptance-criteria section of an issue: the heading plus the contiguous * bullet run beneath it. Deliberately the same grammar as admission's * write-lane section ({@link writeLaneSectionHeading}) — a `##`-to-`######` * heading whose text is exactly the section name, optional trailing colon, and * a bullet run that any non-bullet line ends. One discipline for both sections * means an issue that renders correctly for one renders correctly for both. * * It lives here rather than in admission.ts because admission has no business * with acceptance criteria: it gates dispatch on lanes and budgets, and a * human-queued issue without checkable criteria is still dispatched. Only * mechanical promotion needs to know. * * Returns `undefined` when there is no such heading, and an empty `criteria` * when the heading is there but nothing checkable is under it — the two are * different refusals. */ export function acceptanceCriteriaSection(text: string): { heading: string; criteria: string[] } | undefined { const lines = text.split("\n"); let start = -1; for (let i = 0; i < lines.length; i++) { if (/^[ \t]*#{1,6}[ \t]+acceptance[-\s]criteria[ \t]*[:.]?[ \t]*$/i.test(lines[i]!)) { start = i; break; } } if (start < 0) return undefined; const criteria: string[] = []; for (let i = start + 1; i < lines.length; i++) { const line = lines[i]!; if (line.trim() === "") continue; // A markdown bullet marker (`- `, `* `, `+ `, `1. `), with an optional task // checkbox — the shape GitHub renders as a checklist. Anything else ends // the run, so a caveat paragraph or the next section bounds the section. const marker = line.match(/^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?/); if (marker === null) break; const rest = line.slice(marker[0].length).trim(); if (rest.length > 0) criteria.push(rest); } return { heading: lines[start]!.trim(), criteria }; } /** The leading run of backticked tokens in a bullet: `` `a.ts`, `b/c.ts` `` * reads as two paths, while a backticked token after prose is prose, never a * declaration. Mirrors admission's private helper of the same shape — the * gate cannot import it, and a second grammar beside the first would make * issues that render correctly for one parse wrongly for the other. */ function leadingBacktickedRun(text: string): string[] { const trimmed = text.trim(); if (!trimmed.startsWith("`")) return []; const run: string[] = []; let pos = 0; while (true) { if (trimmed[pos] !== "`") break; const close = trimmed.indexOf("`", pos + 1); if (close < 0) break; const token = trimmed.slice(pos + 1, close).trim(); if (token === "") break; run.push(token); pos = close + 1; const sep = /^[,\s]+/.exec(trimmed.slice(pos)); if (sep === null) break; pos += sep[0].length; } return run; } /** * The read-only entry points an issue declares: files its worker must read — * the caller to understand, the file that proves the convention — but never * write (#1108). The grammar mirrors the write-lane section: a `#`-to-`######` * heading spelling `Read-only` optionally followed by `entry points`, an * optional trailing colon, then the contiguous bullet run any non-bullet line * ends; each bullet contributes its leading backticked run, so an issue that * renders correctly for the lane sections renders correctly here too. Unlike * the lane there is no inline form and no bare-token fallback: marking a file * read-only is a deliberate act, and the backticked bullet is the whole shape. * * Every occurrence in the text counts, where a lane declaration replaces: * read-only lists accumulate, so a correction comment adds references without * restating the body's. */ export function readOnlyEntryPoints(text: string): string[] { const files: string[] = []; let inSection = false; for (const line of text.split("\n")) { if (/^[ \t]*#{1,6}[ \t]+read[-\s]only(?:[ \t]+entry[ \t]+points)?[ \t]*[:.]?[ \t]*$/i.test(line)) { inSection = true; continue; } if (!inSection || line.trim() === "") continue; const marker = line.match(/^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?/); if (marker === null) { inSection = false; continue; } files.push(...leadingBacktickedRun(line.slice(marker[0].length))); } return files; } /** Trimmed non-empty strings out of a value the type system claims is a string * array. Defensive because the gate is the last thing between a hand-edited * store row and a dispatched worker: a malformed field is a refusal, never a * throw. */ function strings(value: unknown): string[] { if (!Array.isArray(value)) return []; const out: string[] = []; for (const item of value) { if (typeof item !== "string") continue; const trimmed = item.trim(); if (trimmed.length > 0) out.push(trimmed); } return out; } /** * May this verdict be promoted mechanically? * * Every check is one named miss. The order is fixed — verdict, readability, * acceptance criteria, write lane, entry points, proof commands, sizing evidence, * dependencies, premise, routing, state labels — so the same input always * produces the same list and a digest line is stable across ticks. * * Two checks short-circuit, because everything after them would be noise: a * verdict that is not PROMOTABLE is not a partly-ready spec, and an unreadable * issue tells you nothing about criteria, lanes or labels. */ export function readyGate(input: ReadyGateInput): ReadyGateVerdict { const { result, project } = input; if (result.verdict !== "PROMOTABLE") { return { ok: false, missing: [`the durable verdict is ${result.verdict}, and only PROMOTABLE may be promoted mechanically`], }; } if (input.comments === "unread") { return { ok: false, missing: [ "the issue could not be read, so neither its acceptance criteria nor its write lane can be compared with the verdict", ], }; } const body = typeof input.issueBody === "string" ? input.issueBody : ""; const comments = [...input.comments]; const missing: string[] = []; // Acceptance criteria may arrive in the body or later in the thread — the // same two surfaces the lane declaration reads. Presence is the bar: a gate // cannot judge whether a criterion is a good one, only whether the worker // will be handed something to check itself against. const sections = [body, ...comments.map((comment) => comment.body)] .map((text) => acceptanceCriteriaSection(text)) .filter((section) => section !== undefined); // A later section with real bullets beats an earlier empty one; an empty one // is still reported (as its own miss) when nothing better exists. const criteria = sections.find((section) => section.criteria.length > 0) ?? sections[0]; if (criteria === undefined) { missing.push( "no acceptance-criteria section: add an `## Acceptance criteria` heading with one checkable bullet per criterion", ); } else if (criteria.criteria.length === 0) { missing.push( `the acceptance-criteria section (${criteria.heading}) has no bullets under it, so the worker has nothing to check itself against`, ); } // The lane admission will enforce, against the lane the verdict was computed // over — exact in both directions, order-insensitive (#1036). Both sides go // through `strings` so they dedupe and trim in lockstep: a set comparison // whose halves normalize differently lies silently. // // A write-lane heading that parsed nothing gets its own miss rather than // reading as an empty lane. Admission fails open on exactly this shape // (#825) because it must never refuse human-queued work; a mechanical // promotion has no such excuse, and the actionable fact is the syntax, not // the set difference. const verdictLane = [...new Set(strings(result.fileLane))].sort(); const lane = effectiveLane(body, comments); const heading = lane !== undefined ? undefined : (writeLaneSectionHeading(body) ?? comments.map((comment) => writeLaneSectionHeading(comment.body)).find((found) => found !== undefined)); const declared = lane === undefined ? [] : [...new Set(strings(lane.files))].sort(); if (verdictLane.length === 0) { // The schema requires at least one path, so this is a hand-edited or // corrupt row. An empty lane on both sides would otherwise *match*, and // match into a dispatch with nothing to serialise concurrent work on. missing.push("the verdict names no file lane, so admission would have nothing to serialise concurrent work on"); } else if (heading !== undefined) { missing.push( `the write-lane section (${heading}) parsed no path-like files — name them as backticked bullets directly under the heading`, ); } else if (declared.length !== verdictLane.length || declared.some((path, index) => path !== verdictLane[index])) { missing.push( `the issue's write lane [${declared.join(", ")}] disagrees with the verdict's file lane [${verdictLane.join(", ")}]`, ); } // An entry point outside the lane is usually legitimate — the reference // file, the caller to understand first — so the gate refuses the *unstated* // one, not the idea (#1108): eight settlement flags in one day were lane // escapes, several into files the issue itself had already named as an // entry point. Putting the file in the lane it is meant to be written in, // or listing it under a `## Read-only entry points` heading, both satisfy // the check; a hard refusal for every reference would push promoters into // stopping the one field workers most need. // // Judged only when the two lane lists agree: a disagreement is refused // anyway, and entry points computed against a lane about to be rewritten // would be noise stacked on the actionable fact. No parsed lane means // nothing to stand outside of. const entryPoints = [...new Set(strings(result.entryPoints))]; const lanesAgree = verdictLane.length > 0 && declared.length === verdictLane.length && declared.every((path, index) => path === verdictLane[index]); if (entryPoints.length > 0 && lanesAgree) { const readOnly = [ ...new Set([body, ...comments.map((comment) => comment.body)].flatMap((text) => readOnlyEntryPoints(text))), ]; // Exact member or anything under a trailing-slash directory entry — the // containment the lane enforcement itself applies to written files // (diff-flags.ts:withinLane). const escaped = entryPoints.filter( (path) => !declared.includes(path) && !declared.some((entry) => entry.endsWith("/") && path.startsWith(entry)) && !readOnly.includes(path), ); if (escaped.length > 0) { missing.push( `entry points ${escaped.join(", ")} are neither in the issue's write lane [${declared.join(", ")}] nor marked read-only — put each in the \`## Exact write lane\` bullets meant for writing, or declare it under \`## Read-only entry points\``, ); } } if (strings(result.proofCommands).length === 0) { missing.push("the verdict names no proof commands, so nothing would prove the work"); } if (typeof result.sizingEvidence !== "string" || result.sizingEvidence.trim().length === 0) { missing.push( "the verdict carries no sizingEvidence: the one-budget claim has nothing behind it that could falsify it (#1041)", ); } const dependencies = Array.isArray(result.dependencies) ? result.dependencies.filter((dep) => typeof dep === "number" || (typeof dep === "string" && dep.trim().length > 0)) : []; if (dependencies.length > 0) { missing.push(`open prerequisites are still named: ${dependencies.join(", ")}`); } // A verdict that says later work retired the premise cannot also be a // promotion: the schema allows the combination because a groomer may want to // record what it found, and nothing else reads it (#883). Mechanically, // promoting it dispatches a worker at work the verdict itself disowned. if (result.laterWorkInvalidates) { missing.push("the verdict reports that later work invalidated the candidate's premise, so it must be re-groomed, not promoted"); } const { labelPrefix, repos } = project.routing; const labels = strings(input.labels); const matched = [...new Set(labels.filter((label) => label.startsWith(labelPrefix)))]; if (matched.length === 0) { missing.push(`no ${labelPrefix}* routing label, so dispatch could not pick a checkout`); } else if (matched.length > 1) { missing.push(`${matched.length} routing labels (${matched.join(", ")}) — exactly one is routable`); } else { const key = matched[0]!.slice(labelPrefix.length); // hasOwn, not truthiness: a `repo:constructor` label would otherwise // resolve off Object.prototype and route work into a bogus target // (routing.ts:route). if (!Object.hasOwn(repos, key)) { const known = Object.keys(repos).map((name) => `${labelPrefix}${name}`).join(", "); missing.push(`routing label ${matched[0]} names no repository ${project.name} routes (${known || "none"})`); } else { const slug = repoSlugFor(repos[key]!); if (slug !== result.routing) { missing.push( result.routing === "MULTI" ? `the verdict routes to MULTI, a split across repositories that no single ${labelPrefix}* label can carry — file the split as children first` : `routing label ${matched[0]} routes to ${slug}, but the verdict routed this work to ${result.routing}`, ); } } } // The queue label is about to land, so the gate owes the other half of // eligibility (routing.ts:isEligible): a state label already on the issue // would make the promotion a no-op the daemon silently skips, and the park // label beats the queue label outright (#734). const { inProgress, blocked, failed, backlog } = project.stateLabels; const held = [inProgress, blocked, failed, backlog].filter( // The typeof guard is runtime armour, not typing: an older config file can // reach here without every state label set. (label) => typeof label === "string" && label.length > 0 && labels.includes(label), ); if (held.length > 0) { missing.push(`the issue still carries ${held.join(", ")}, which would keep it ineligible once the queue label lands`); } return missing.length === 0 ? { ok: true } : { ok: false, missing }; }