diff --git a/.github/scripts/arch-lint.ts b/.github/scripts/arch-lint.ts index fc230d100..011b7aa11 100644 --- a/.github/scripts/arch-lint.ts +++ b/.github/scripts/arch-lint.ts @@ -23,7 +23,10 @@ export const ARCHITECTURE_PATH = "architecture.yml"; export interface Architecture { /** layer -> the src/ paths it owns (a `/` suffix means a directory). */ readonly layers: Readonly>; + + /** Glob patterns of the src/ files outside the lint. */ readonly exclude: readonly string[]; + /** from -> the layers it may import. */ readonly edges: Readonly>; } @@ -38,13 +41,16 @@ const ARCHITECTURE = z.strictObject({ /** `layers.engine[0]`, `edges["plain-data"]`: the yaml key as a reader would write it in code. */ function keyPath(path: readonly PropertyKey[]): string { return path - .map((segment, index) => - typeof segment === "number" - ? `[${segment}]` - : /^[A-Za-z_]\w*$/.test(String(segment)) - ? `${index === 0 ? "" : "."}${String(segment)}` - : `[${JSON.stringify(String(segment))}]`, - ) + .map((segment, index) => { + if (typeof segment === "number") { + return `[${segment}]`; + } + const name = String(segment); + if (/^[A-Za-z_]\w*$/.test(name)) { + return index === 0 ? name : `.${name}`; + } + return `[${JSON.stringify(name)}]`; + }) .join(""); } @@ -179,17 +185,21 @@ function isModuleLoad(node: Node): boolean { } } +/** The expression naming the module a load reads: `require`'s first argument, else the node's source. */ +function requestExpression(node: Node): Node | null | undefined { + if (node.type === "TSExternalModuleReference") { + return node.expression; + } + if (node.type === "CallExpression") { + return node.arguments[0]; + } + return "source" in node ? node.source : undefined; +} + /** The module a load names, or undefined when the specifier is computed (`import(x)`, `require(x)`, a template * with expressions), which the graph cannot follow. */ function literalModuleRequest(node: Node): string | undefined { - const raw = - node.type === "TSExternalModuleReference" - ? node.expression - : node.type === "CallExpression" - ? node.arguments[0] - : "source" in node - ? node.source - : undefined; + const raw = requestExpression(node); const request = raw === undefined || raw === null ? undefined : unwrapped(raw); if (request?.type === "Literal" && typeof request.value === "string") { return request.value; @@ -255,7 +265,11 @@ function* sourceFiles(root: string, arch: Architecture): Generator { } } -export function lintArchitecture(root: string, arch = readArchitecture(root)): string[] { +/** The cross-layer edges src/ draws, keyed `from -> to` with the imports drawing each, and the files no layer owns. */ +function drawnEdges( + root: string, + arch: Architecture, +): { drawn: Map; problems: string[] } { const drawn = new Map(); const problems: string[] = []; for (const file of sourceFiles(root, arch)) { @@ -272,12 +286,20 @@ export function lintArchitecture(root: string, arch = readArchitecture(root)): s problems.push( `${target} (imported by ${file}) belongs to no layer in ${ARCHITECTURE_PATH}`, ); - } else if (to !== from) { - const key = `${from} -> ${to}`; - drawn.set(key, [...(drawn.get(key) ?? []), `${file} -> ${target}`]); + continue; + } + if (to === from) { + continue; } + const key = `${from} -> ${to}`; + drawn.set(key, [...(drawn.get(key) ?? []), `${file} -> ${target}`]); } } + return { drawn, problems }; +} + +export function lintArchitecture(root: string, arch = readArchitecture(root)): string[] { + const { drawn, problems } = drawnEdges(root, arch); const declared = new Set( Object.entries(arch.edges).flatMap(([from, targets]) => targets.map((to) => `${from} -> ${to}`), diff --git a/.github/scripts/auto-fix-steps.ts b/.github/scripts/auto-fix-steps.ts index cd87c8c73..e04a57c39 100644 --- a/.github/scripts/auto-fix-steps.ts +++ b/.github/scripts/auto-fix-steps.ts @@ -22,8 +22,7 @@ import { dispatch } from "./lib/entry.js"; import { configureBotIdentity, leasePush } from "./lib/pr-branch.js"; import { capture, requireEnv, run, setOutput, status } from "./lib/workflow-step.js"; -/** In generated.ts table order: the later generators import the descriptor-derived files and the gaps index - * through src/, and the inputs table reads action.yml, so each source renders first. */ +/** In generated.ts table order, each generator after its sources: the descriptor-derived files and the gaps index are imported through src/, and the inputs table reads action.yml. */ export const GENERATORS = [ "build:openapi", "build:gaps-index", @@ -40,6 +39,7 @@ const GENERATED_FILES = [ "src/generated/upstream-gaps.ts", ] as const; +/** With its slash: a directory entry of FIX_OWNED_PATHS, and the pathspec the pruned verdict reads. */ export const GAPS_DIR = "src/upstream-gaps/"; /** A `dir/` entry covers everything under it, deletions included (a graduated gap file is a deletion). */ diff --git a/.github/scripts/check-compat-markers.ts b/.github/scripts/check-compat-markers.ts index 616980d4b..9220e18c6 100644 --- a/.github/scripts/check-compat-markers.ts +++ b/.github/scripts/check-compat-markers.ts @@ -17,9 +17,7 @@ import { lstatSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { runMain } from "./lib/entry.js"; -/** Built output, dependencies, release-please's changelog (it quotes PR titles, so a removal PR's title would - * outlive the marker it deleted), and the two files that spell the syntax to define and test it. - * A trailing slash skips a directory; anything else is one exact path. */ +/** A trailing slash skips a directory, anything else one exact path; CHANGELOG.md quotes PR titles, so a removal PR's title would outlive the marker it deleted, and the last two spell the syntax to define and test it. */ const SKIPPED_PATHS = [ "lib/", "node_modules/", @@ -32,14 +30,22 @@ function skipped(path: string): boolean { return SKIPPED_PATHS.some((skip) => (skip.endsWith("/") ? path.startsWith(skip) : path === skip)); } +/** Every candidate on a line, well-formed or not, so a malformed marker is reported rather than missed. */ const OCCURRENCE = /COMPAT\(/g; + +/** The major as digits only, so a marker cannot dodge the gate by leaving it out. */ const MARKER = /^COMPAT\(v(0|[1-9]\d*)\):(.*)$/; + /** The placeholder inside an inline-code span: a backtick before it (checked at the call) and one after it. */ const PLACEHOLDER = /^COMPAT\(vN\)[^`]*`/; -// A description ends at its block comment's closer; in a line comment or Markdown prose it runs to the end of the -// line. A `#` or `//` before the marker is not an opener: "/* See #123. COMPAT(v3): */" sits in the block comment. + +// A description ends at its block comment's closer, else at the end of the line; a `#` or `//` before the marker is no opener, so "/* See #123. COMPAT(v3): */" sits in the block comment. const BLOCK_OPENER = /\/\*|" }; + +/** Quoted in every malformed-marker failure. */ export const SYNTAX = "COMPAT(v): "; export interface Marker { @@ -143,7 +149,9 @@ function packageVersion(cwd: string): { version: string; major: number } { } export interface CheckOptions { + /** The repository root, where package.json and the git tree are read. */ cwd: string; + /** The major a release cuts; defaults to package.json's. Never below it: a release never targets an older major. */ targetMajor?: number; } diff --git a/.github/scripts/coverage-data.ts b/.github/scripts/coverage-data.ts index 15e43c558..d5981fc8e 100644 --- a/.github/scripts/coverage-data.ts +++ b/.github/scripts/coverage-data.ts @@ -26,10 +26,10 @@ const GapRow = z why: z.string().min(1), }) .readonly(); + export type GapRow = z.infer; -// The Repo-scoped gaps table: either no known gap, with the paragraph shown in place of rows, -// or the rows. One arm at a time, so a note cannot go stale behind rows that hide it. +/** One arm at a time, the empty-table paragraph or the rows, so a note cannot go stale behind rows that hide it. */ const CoverageGaps = z.union([ z.strictObject({ emptyNote: z.string().min(1), rows: z.undefined().optional() }).readonly(), z @@ -40,8 +40,10 @@ const CoverageGaps = z.union([ .readonly(), ]); +/** The page's paragraph runs and bullet lists, one line per item, never empty. */ const Items = z.tuple([z.string().min(1)], z.string().min(1)).readonly(); +/** The authored half of the page, as coverage-data.yml declares it. */ export const CoverageData = z .strictObject({ /** The paragraphs under the page title, one line each. */ @@ -59,6 +61,7 @@ export const CoverageData = z outOfScope: z.strictObject({ items: Items }).readonly(), }) .readonly(); + export type CoverageData = z.infer; export const COVERAGE_DATA = readDocsYaml( diff --git a/.github/scripts/endpoint-docs.ts b/.github/scripts/endpoint-docs.ts index 5ff36a991..f8309c397 100644 --- a/.github/scripts/endpoint-docs.ts +++ b/.github/scripts/endpoint-docs.ts @@ -13,8 +13,6 @@ import { allEndpoints, allGraphqlOps } from "../../src/sections/registry.js"; import { loadSpec } from "../../test/e2e/openapi/validate.js"; const DOCS_URL = z.string().url().startsWith("https://docs.github.com/en/"); - -/** A route as a declaration spells it ("METHOD /path/{param}") and a GraphQL operation's wire name. */ const ROUTE = /^(?:GET|POST|PUT|PATCH|DELETE) \/\S+$/; const OPERATION = /^[A-Z][A-Za-z0-9]*$/; @@ -53,6 +51,12 @@ export type SpecOperations = Readonly< Record>> >; +/** One kind's pages by call, and every problem met resolving them. */ +interface Resolved { + readonly pages: Record; + readonly problems: string[]; +} + /** The descriptor's link in the spelling docs.github.com serves: the locale added, the Pages placeholder rendered. */ export function renderedDocsUrl(url: string): string { return url @@ -60,14 +64,14 @@ export function renderedDocsUrl(url: string): string { .replace("apiname", "github"); } -export function resolveAnchors( +/** Each declared route's page: the descriptor's, else the hand entry; both or neither is a problem, as is a hand entry no section declares. */ +function resolveRest( spec: SpecOperations, hand: EndpointDocs, routes: readonly string[], - operations: readonly string[], -): EndpointAnchors { +): Resolved { + const pages: Record = {}; const problems: string[] = []; - const rest: Record = {}; for (const route of routes) { const space = route.indexOf(" "); const documented = @@ -77,15 +81,19 @@ export function resolveAnchors( problems.push( `"${route}" has a page in both the descriptor and ${ENDPOINT_DOCS_PATH}; delete the hand entry`, ); - } else if (documented !== undefined) { - rest[route] = renderedDocsUrl(documented); - } else if (byHand !== undefined) { - rest[route] = byHand; - } else { - problems.push( - `"${route}" has no page: the descriptor does not document it, so add one under rest: in ${ENDPOINT_DOCS_PATH}`, - ); + continue; + } + if (documented !== undefined) { + pages[route] = renderedDocsUrl(documented); + continue; } + if (byHand !== undefined) { + pages[route] = byHand; + continue; + } + problems.push( + `"${route}" has no page: the descriptor does not document it, so add one under rest: in ${ENDPOINT_DOCS_PATH}`, + ); } for (const route of Object.keys(hand.rest)) { if (!routes.includes(route)) { @@ -94,16 +102,22 @@ export function resolveAnchors( ); } } - const graphql: Record = {}; + return { pages, problems }; +} + +/** Each declared operation's page, which only the hand file holds; a missing one and a hand entry no section declares are problems. */ +function resolveGraphql(hand: EndpointDocs, operations: readonly string[]): Resolved { + const pages: Record = {}; + const problems: string[] = []; for (const name of operations) { const byHand = hand.graphql[name]; if (byHand === undefined) { problems.push( `the GraphQL operation "${name}" has no page; add one under graphql: in ${ENDPOINT_DOCS_PATH}`, ); - } else { - graphql[name] = byHand; + continue; } + pages[name] = byHand; } for (const name of Object.keys(hand.graphql)) { if (!operations.includes(name)) { @@ -112,10 +126,22 @@ export function resolveAnchors( ); } } + return { pages, problems }; +} + +export function resolveAnchors( + spec: SpecOperations, + hand: EndpointDocs, + routes: readonly string[], + operations: readonly string[], +): EndpointAnchors { + const rest = resolveRest(spec, hand, routes); + const graphql = resolveGraphql(hand, operations); + const problems = [...rest.problems, ...graphql.problems]; if (problems.length > 0) { throw new Error(`endpoint-docs:\n ${problems.join("\n ")}`); } - return { rest, graphql }; + return { rest: rest.pages, graphql: graphql.pages }; } /** The distinct routes and operation names the sections declare, in registry order. */ diff --git a/.github/scripts/gen-action-docs.ts b/.github/scripts/gen-action-docs.ts index f5c45cdce..69c4466f7 100644 --- a/.github/scripts/gen-action-docs.ts +++ b/.github/scripts/gen-action-docs.ts @@ -37,11 +37,10 @@ import { runMain } from "./lib/entry.js"; import { block, blockLine, blockLines, GeneratedRegion } from "./lib/generated-regions.js"; import { type GeneratedFiles, regenerateFiles } from "./lib/region-driver.js"; +/** The repository root, two levels above this script. */ const ROOT = join(import.meta.dir, "..", ".."); -/** The schema the scalars are rendered and read back under: a key YAML 1.1 would re-type (on, y, null, ...) comes out - * quoted, for the 1.1 readers of action.yml still in the wild. Its merge tag is removed: it claims the string "<<" - * itself, whatever style the scalar asks for, and action.yml carries no merges. */ +/** YAML 1.1, so a key it would re-type (on, y, null) comes out quoted for action.yml's 1.1 readers; the merge tag is removed, since it claims the string "<<" whatever style the scalar asks for and action.yml carries no merges. */ const YAML_DOCUMENT: DocumentOptions & SchemaOptions & ParseOptions = { version: "1.1", merge: false, @@ -51,25 +50,28 @@ const YAML_DOCUMENT: DocumentOptions & SchemaOptions & ParseOptions = { ), }; -/** Column budget for a folded description line, indent included; the library takes it as a soft limit (a line one - * column over stays whole). Quoted scalars are JSON strings: one line however long, with JSON's escapes. */ +/** The library takes lineWidth as a soft limit for a folded description (a line one column over stays whole); quoted scalars are JSON strings, one line however long, with JSON's escapes. */ const YAML_STYLE: ToStringOptions = { lineWidth: 78, singleQuote: false, doubleQuotedAsJSON: true }; +/** A name that may stay an unquoted YAML key. */ const PLAIN_NAME = /^[a-z][a-z0-9-]*$/; -/** One line not starting with whitespace: any other description would fold into a form action.yml does not carry (a - * blank line, an indentation indicator, a kept newline), so the generator refuses it instead of writing it. */ +/** One line not starting with whitespace: any other description would fold into a form action.yml does not carry (a blank line, an indentation indicator, a kept newline), so the generator refuses it instead of writing it. */ const DESCRIPTION = z .string() .regex(/^\S[^\r\n]*$/, "one line of text, not starting with whitespace"); +/** An input as action.yml spells it; every input is optional. */ const INPUT_ENTRY = z.strictObject({ description: DESCRIPTION, required: z.literal(false), default: z.string(), }); + +/** An output as action.yml spells it. */ const OUTPUT_ENTRY = z.strictObject({ description: DESCRIPTION }); +/** The one field every declaration renders; the rest is the entry's own. */ const DESCRIBED = z.object({ description: DESCRIPTION }); /** Each declaration parsed with `schema`, every own name kept (a record schema would drop `__proto__`, a name the @@ -339,6 +341,7 @@ const SECRET_ROW_PROSE: PolicyRowProse = { override: "prune stale secrets - a deleted secret's value is unrecoverable", }; +/** The Defaults-per-section prose by section key; a knobbed section without a row stops the build naming it. */ export const POLICY_ROW_PROSE: Record = { labels: { caveat: "Probot parity", @@ -551,30 +554,44 @@ const EVERY_GATED = const SOME_GATED = /^- GitHub gates the (.+) reads at write, so `([a-z_]+)` needs its (.+) write grant in check mode to verify what they return\.$/; +/** One gated-reads bullet read back, or why the line is not one; the caller prefixes the line number. */ +function gatedRead(line: string): Result { + const every = EVERY_GATED.exec(line); + if (every !== null) { + return proseItems(every[1] ?? "").map( + (labels): GatedRead => ({ + key: every[2] ?? "", + gated: "every", + labels, + ownGrant: every[3] === "its write grant", + }), + ); + } + const some = SOME_GATED.exec(line); + if (some === null) { + return err("is not a gated-reads bullet"); + } + // A route is listed as a code span, so the span is the item the prose rule judges. + const spans = proseListItems([...(some[1] ?? "").matchAll(/`[^`]+`/g)].map((m) => m[0])); + return Result.combine([proseItems(some[3] ?? ""), spans]).map( + ([labels = [], routeSpans = []]): GatedRead => ({ + key: some[2] ?? "", + gated: "some", + routes: routeSpans.map((span) => span.slice(1, -1)), + labels, + }), + ); +} + /** `lines` read back as gated-reads bullets, the first sitting at line `first`. */ function parseGatedBullets(lines: readonly string[], first: number): Result { const reads: GatedRead[] = []; for (const [index, line] of lines.entries()) { - const every = EVERY_GATED.exec(line); - const some = SOME_GATED.exec(line); - if (every === null && some === null) { - return err(`line ${first + index} is not a gated-reads bullet`); - } - const labels = proseItems((every ?? some)?.[every !== null ? 1 : 3] ?? ""); - // A route is listed as a code span, so the span is the item the prose rule judges. - const spans = proseListItems([...(some?.[1] ?? "").matchAll(/`[^`]+`/g)].map((m) => m[0])); - const read = Result.combine([labels, spans]); + const read = gatedRead(line); if (read.isErr()) { return err(`line ${first + index} ${read.error}`); } - const [readLabels = [], routeSpans = []] = read.value; - if (every !== null) { - const ownGrant = every[3] === "its write grant"; - reads.push({ key: every[2] ?? "", gated: "every", labels: readLabels, ownGrant }); - } else if (some !== null) { - const routes = routeSpans.map((span) => span.slice(1, -1)); - reads.push({ key: some[2] ?? "", gated: "some", routes, labels: readLabels }); - } + reads.push(read.value); } return ok(reads); } @@ -617,6 +634,35 @@ function knobbedSections(): KnobbedSection[] { }); } +/** The region's YAML under the `key` line as data, or why it does not parse or convert. The library counts the + * prepended key line as line 1, so a parse error's line is counted in the body instead. */ +function yamlData(key: string, body: string): Result { + const doc = parseDocument(`${key}:${body}`, { ...YAML_DOCUMENT, prettyErrors: false }); + const error = doc.errors[0]; + if (error !== undefined) { + const line = body.slice(0, error.pos[0] - `${key}:`.length).split("\n").length - 1; + return err(`line ${line} does not parse as YAML (${error.message})`); + } + // An alias without its anchor parses but does not convert; to the guard that is a refusal. + return Result.fromThrowable( + (): unknown => doc.toJS(), + (failure) => + `it does not convert from YAML (${failure instanceof Error ? failure.message : String(failure)})`, + )(); +} + +/** The `key` mapping of the converted YAML, or why the body is not one. */ +function keyMapping(root: unknown, key: string): Result, string> { + const mapping = + typeof root === "object" && root !== null + ? ((root as Record)[key] ?? {}) + : root; + if (typeof mapping !== "object" || mapping === null || Array.isArray(mapping)) { + return err(`it is not a mapping of ${key} declarations`); + } + return ok(mapping as Record); +} + /** * A YAML region's body read back as the `key` mapping's declarations under `entry`, every own name kept; the * emitter is its own grammar, so a spelling it does not write fails the byte compare that follows. @@ -625,41 +671,18 @@ function yamlDeclarations( key: string, entry: z.ZodType, ): (body: string) => Result, string> { - return (body) => { - const doc = parseDocument(`${key}:${body}`, { ...YAML_DOCUMENT, prettyErrors: false }); - const error = doc.errors[0]; - if (error !== undefined) { - // The library counts the prepended key line as line 1, so the line is counted in the body instead. - const line = body.slice(0, error.pos[0] - `${key}:`.length).split("\n").length - 1; - return err(`line ${line} does not parse as YAML (${error.message})`); - } - let root: unknown; - try { - root = doc.toJS(); - } catch (failure) { - // An alias without its anchor parses but does not convert; to the guard that is a refusal. - return err( - `it does not convert from YAML (${failure instanceof Error ? failure.message : String(failure)})`, - ); - } - const mapping = - typeof root === "object" && root !== null - ? ((root as Record)[key] ?? {}) - : root; - if (typeof mapping !== "object" || mapping === null || Array.isArray(mapping)) { - return err(`it is not a mapping of ${key} declarations`); - } - const declared = declarations(entry, mapping as Record); - return typeof declared === "string" - ? err(`it is not a set of ${key} declarations (${declared})`) - : ok(declared); - }; -} - -/** - * Each region reads its body back and re-renders it, so a marker moved elsewhere fails instead of regenerating - * in the wrong place or erasing authored text. - */ + return (body) => + yamlData(key, body) + .andThen((root) => keyMapping(root, key)) + .andThen((mapping) => { + const declared = declarations(entry, mapping); + return typeof declared === "string" + ? err(`it is not a set of ${key} declarations (${declared})`) + : ok(declared); + }); +} + +/** Each region reads its body back and re-renders it, so a marker moved elsewhere fails instead of regenerating in the wrong place or erasing authored text. */ export const FILES: GeneratedFiles = { "action.yml": { regions: [ diff --git a/.github/scripts/gen-docs.ts b/.github/scripts/gen-docs.ts index 192320ad1..2991c9e5b 100644 --- a/.github/scripts/gen-docs.ts +++ b/.github/scripts/gen-docs.ts @@ -32,11 +32,13 @@ import { } from "./lib/generated-regions.js"; import { type GeneratedFile, type GeneratedFiles, regenerateFiles } from "./lib/region-driver.js"; +/** The repository root, two levels above this script. */ const ROOT = join(import.meta.dir, "..", ".."); /** The repository these pages document; the token form's name and description derive from it. */ const REPO_SLUG = "Vivswan/github-settings-as-code"; +/** The Undeclared default cell by policy; sectionsRow() recognizes a row of the table by it. */ const UNDECLARED_DEFAULT_DISPLAY: Record = { delete: "deleted (settable)", keep: "kept (settable)", @@ -188,13 +190,13 @@ const OUT_OF_SCOPE_HEADING = "## Out of scope (user or org account surface)"; const SUPPORTED_HEADER = "| Area | Key in settings.yml | Endpoints |\n|---|---|---|"; const GAPS_HEADER = "| Area | Endpoints | Why it matters |\n|---|---|---|"; -/** The Sections table's page, the one reference page a settings key has; the coverage page sits beside it. */ const SECTIONS_PAGE = "sections.md"; + /** The Endpoints cell of a row that lists no calls of its own, naming the row whose calls it rides. */ function sharedCalls(label: string): string { return `shares the calls of the ${label} row`; } -/** The calls in a cell, one per rendered line. */ + const CALL_SEPARATOR = "
"; /** What the coverage page reads off a section module: its key and the routes and operations it declares. */ @@ -414,7 +416,7 @@ function coveragePage( }; } -/** The key cell of a Supported row: the section key linking to the Sections page, then the row's keys in a code span. */ +/** The key cell of a Supported row: the section key linking to the Sections page (the one reference page a settings key has; the coverage page sits beside it), then the row's keys in a code span. */ function keyCell(row: Pick): string { return `[\`${row.key}\`](${SECTIONS_PAGE})${row.keys === undefined ? "" : ` (\`${row.keys}\`)`}`; } @@ -468,10 +470,7 @@ export function renderCoverage( return renderCoveragePage(coveragePage(sections, docs, data, anchors)); } -/** - * The key cell as keyCell() writes it, read back. - * The dotAll flag is for the two Unicode line separators cellFault() admits inside the keys: without it `.` would not match them. - */ +/** The key cell as keyCell() writes it, read back; the dotAll flag is for the two Unicode line separators cellFault() admits inside the keys, which `.` would not match without it. */ const KEY_CELL = new RegExp( String.raw`^\[\x60([a-z_]+)\x60\]\(${RegExp.escape(SECTIONS_PAGE)}\)(?: \(\x60(.*)\x60\))?$`, "s", @@ -490,135 +489,172 @@ function supportedRow(cells: readonly string[]): Result { - const lines = body.split("\n"); - let at = 0; +/** A cursor over a page body's lines: each read admits what the printer writes there, or refuses naming the line as + * renderedMismatch() numbers it. */ +class PageReader { + private at = 0; + + constructor(private readonly lines: readonly string[]) {} + /** The line under the cursor, or "" past the end: the printer never writes past the body. */ - const current = (): string => lines[at] ?? ""; - const refusal = (what: string): Result => err(`line ${at} ${what}`); - const literal = (text: string, what: string): Result => { - if (current() !== text) { - return refusal(what); + current(): string { + return this.lines[this.at] ?? ""; + } + + refusal(what: string): Result { + return err(`line ${this.at} ${what}`); + } + + literal(text: string, what: string): Result { + if (this.current() !== text) { + return this.refusal(what); } - at += 1; + this.at += 1; return ok(); - }; - const blank = (): Result => - literal("", "is not the blank line the generator writes there"); - const heading = (text: string): Result => - literal(text, `is not the "${text}" heading`); - const prose = (what: string): Result => { - const line = current(); + } + + blank(): Result { + return this.literal("", "is not the blank line the generator writes there"); + } + + heading(text: string): Result { + return this.literal(text, `is not the "${text}" heading`); + } + + prose(what: string): Result { + const line = this.current(); const fault = proseFault(line); if (fault !== undefined) { - return refusal(`should hold ${what} but ${fault}`); + return this.refusal(`should hold ${what} but ${fault}`); } - at += 1; + this.at += 1; return ok(line); - }; + } + /** The `- ` bullets up to the next blank line, at least one. */ - const items = (what: string): Result => { + items(what: string): Result { const found: string[] = []; - while (current().startsWith("- ")) { - const text = current().slice(2); + while (this.current().startsWith("- ")) { + const text = this.current().slice(2); const fault = proseFault(text); if (fault !== undefined) { - return refusal(`should hold ${what} bullet but ${fault}`); + return this.refusal(`should hold ${what} bullet but ${fault}`); } found.push(text); - at += 1; + this.at += 1; } - return found.length === 0 ? refusal(`is not the first ${what} bullet`) : ok(found); - }; + return found.length === 0 ? this.refusal(`is not the first ${what} bullet`) : ok(found); + } + /** `header`'s lines, then the rows up to the next blank line under the cell rule, each through `parseRow`. */ - const table = ( + table( header: string, what: string, parseRow: (cells: readonly string[]) => Result, - ): Result => { + ): Result { for (const line of header.split("\n")) { - if (current() !== line) { - return refusal(`is not the ${what} table's header`); + if (this.current() !== line) { + return this.refusal(`is not the ${what} table's header`); } - at += 1; + this.at += 1; } - const start = at; - while (current() !== "") { - at += 1; + const start = this.at; + while (this.current() !== "") { + this.at += 1; } const line = (row: number): string => `line ${start + row}`; - return tableRows(header, lines.slice(start, at), "cells") + return tableRows(header, this.lines.slice(start, this.at), "cells") .mapErr((faulty) => `${line(faulty.row)} ${faulty.fault}`) .andThen((rows) => Result.combine( rows.map((cells, row) => parseRow(cells).mapErr((fault) => `${line(row)} ${fault}`)), ), ); - }; + } + /** Whether `texts` sit at the cursor, line for line: a paragraph may repeat one structural line, never a run of them. */ - const ahead = (...texts: string[]): boolean => - texts.every((text, offset) => lines[at + offset] === text); - const [gapsHeaderLine, gapsSeparator] = GAPS_HEADER.split("\n"); + ahead(...texts: string[]): boolean { + return texts.every((text, offset) => this.lines[this.at + offset] === text); + } + + /** Whether the cursor sits on the body's last line and that line is empty: the line break that closes the body. */ + atClose(): boolean { + return this.at === this.lines.length - 1 && this.current() === ""; + } +} + +/** The gaps section read back: the rows, or the empty-state note over a bare header, under which a row is refused. */ +function parseGaps(page: PageReader): Result { + const [gapsHeaderLine = "", gapsSeparator = ""] = GAPS_HEADER.split("\n"); + if (page.ahead(gapsHeaderLine, gapsSeparator)) { + return page + .table(GAPS_HEADER, "gaps", (cells) => ok(cells)) + .andThen( + (rows): Result => + rows.length === 0 + ? page.refusal("is not the first gap row, and no empty-state note precedes the table") + : ok({ rows }), + ); + } + return safeTry(function* () { + const emptyNote = yield* page.prose("the gaps section's empty-state note"); + yield* page.blank(); + yield* page.table(GAPS_HEADER, "gaps", () => + err("is a gap row under the empty-state note, which stands only over an empty table"), + ); + return ok({ emptyNote }); + }); +} + +/** + * A committed page read back into the shape renderCoveragePage() prints from, or why it is not one the printer + * wrote, naming the line as renderedMismatch() numbers it. Prose slots (a paragraph, a bullet, an Area or an + * Endpoints cell) are opaque and kept, so what the guard refuses here is structure the printer never writes. + */ +function parseCoveragePage(body: string): Result { + const page = new PageReader(body.split("\n")); return safeTry(function* () { - yield* blank(); + yield* page.blank(); const intro: string[] = []; do { - intro.push(yield* prose("an intro paragraph")); - yield* blank(); - } while (!ahead(SUPPORTED_HEADING, "", ...SUPPORTED_HEADER.split("\n"))); - yield* heading(SUPPORTED_HEADING); - yield* blank(); - const rows = yield* table(SUPPORTED_HEADER, "Supported", supportedRow); + intro.push(yield* page.prose("an intro paragraph")); + yield* page.blank(); + } while (!page.ahead(SUPPORTED_HEADING, "", ...SUPPORTED_HEADER.split("\n"))); + yield* page.heading(SUPPORTED_HEADING); + yield* page.blank(); + const rows = yield* page.table(SUPPORTED_HEADER, "Supported", supportedRow); if (rows.length === 0) { - return refusal("is not the first Supported row"); + return page.refusal("is not the first Supported row"); } - yield* blank(); - yield* heading(NOTES_HEADING); - yield* blank(); + yield* page.blank(); + yield* page.heading(NOTES_HEADING); + yield* page.blank(); const supported: SupportedRow[] = []; for (const row of rows) { - yield* literal( + yield* page.literal( notesLabel(row), `is not the notes label of the "${areaLabel(row.area)}" Supported row`, ); - yield* blank(); - const notes = yield* items("a note"); - yield* blank(); + yield* page.blank(); + const notes = yield* page.items("a note"); + yield* page.blank(); supported.push({ ...row, notes }); } - yield* heading(GAPS_HEADING); - yield* blank(); - let gaps: Gaps; - if (ahead(gapsHeaderLine ?? "", gapsSeparator ?? "")) { - const gapRows = yield* table(GAPS_HEADER, "gaps", (cells) => ok(cells)); - if (gapRows.length === 0) { - return refusal("is not the first gap row, and no empty-state note precedes the table"); - } - gaps = { rows: gapRows }; - } else { - gaps = { emptyNote: yield* prose("the gaps section's empty-state note") }; - yield* blank(); - yield* table(GAPS_HEADER, "gaps", () => - err("is a gap row under the empty-state note, which stands only over an empty table"), - ); - } - yield* blank(); - yield* heading(NO_API_HEADING); - yield* blank(); - const noApiIntro = yield* prose("the no-public-API intro"); - yield* blank(); - const noApiItems = yield* items("a no-public-API"); - yield* blank(); - yield* heading(OUT_OF_SCOPE_HEADING); - yield* blank(); - const outOfScope = yield* items("an out-of-scope"); - if (at !== lines.length - 1 || current() !== "") { - return refusal("is not the line break that closes the body"); + yield* page.heading(GAPS_HEADING); + yield* page.blank(); + const gaps = yield* parseGaps(page); + yield* page.blank(); + yield* page.heading(NO_API_HEADING); + yield* page.blank(); + const noApiIntro = yield* page.prose("the no-public-API intro"); + yield* page.blank(); + const noApiItems = yield* page.items("a no-public-API"); + yield* page.blank(); + yield* page.heading(OUT_OF_SCOPE_HEADING); + yield* page.blank(); + const outOfScope = yield* page.items("an out-of-scope"); + if (!page.atClose()) { + return page.refusal("is not the line break that closes the body"); } return ok({ intro, @@ -761,44 +797,54 @@ function patUrlRegion(name: string): GeneratedRegion { const MERMAID_OPEN = "```mermaid"; const FENCE = "```"; const GRAPH_LINE = "graph TD"; -// A node id is whatever the renderer makes of a layer name (only "-" is rewritten), so the byte compare judges it. const NODE_LINE = /^ {2}(\S+)\["([^"]*)"\]$/; const EDGE_LINE = /^ {2}(\S+) --> (\S+)$/; /** What the module map draws: the layers with their paths and the import edges; the lint's exclusions are not drawn. */ type ArchitectureMap = Pick; -/** - * The diagram read back into the layers and edges it draws, under the node ids the renderer writes; a line that is - * neither a node nor an edge is authored. Entries are collected before the records are built, so an id such as - * `__proto__` is a key and not a prototype write. - */ -function parseArchitectureMap(body: string): Result { - return blockLines(body).andThen((lines) => { - if (lines[0] !== MERMAID_OPEN) { - return err("line 1 does not open the mermaid fence"); - } - if (lines[1] !== GRAPH_LINE) { - return err(`line 2 is not the "${GRAPH_LINE}" line`); - } - if (lines.length < 3 || lines.at(-1) !== FENCE) { - return err(`line ${lines.length} does not close the mermaid fence`); +/** Why `lines` are not a mermaid fence around a graph, or undefined. */ +function fenceFault(lines: readonly string[]): string | undefined { + if (lines[0] !== MERMAID_OPEN) { + return "line 1 does not open the mermaid fence"; + } + if (lines[1] !== GRAPH_LINE) { + return `line 2 is not the "${GRAPH_LINE}" line`; + } + if (lines.length < 3 || lines.at(-1) !== FENCE) { + return `line ${lines.length} does not close the mermaid fence`; + } + return undefined; +} + +/** The layers and edges the lines between the fences draw (the first of them is line 3); a line that is neither a + * node nor an edge is authored. Entries are collected before the records are built, so an id such as `__proto__` is + * a key and not a prototype write. */ +function mapEntries(lines: readonly string[]): Result { + const layers: Array<[string, readonly string[]]> = []; + const edges = new Map(); + for (const [index, line] of lines.entries()) { + const node = NODE_LINE.exec(line); + if (node !== null) { + layers.push([node[1] ?? "", (node[2] ?? "").split("
")]); + continue; } - const layers: Array<[string, readonly string[]]> = []; - const edges = new Map(); - for (const [index, line] of lines.slice(2, -1).entries()) { - const node = NODE_LINE.exec(line); - const edge = EDGE_LINE.exec(line); - if (node !== null) { - layers.push([node[1] ?? "", (node[2] ?? "").split("
")]); - } else if (edge !== null) { - const from = edge[1] ?? ""; - edges.set(from, [...(edges.get(from) ?? []), edge[2] ?? ""]); - } else { - return err(`line ${index + 3} is neither a layer node nor an import edge`); - } + const edge = EDGE_LINE.exec(line); + if (edge === null) { + return err(`line ${index + 3} is neither a layer node nor an import edge`); } - return ok({ layers: Object.fromEntries(layers), edges: Object.fromEntries(edges) }); + const from = edge[1] ?? ""; + edges.set(from, [...(edges.get(from) ?? []), edge[2] ?? ""]); + } + return ok({ layers: Object.fromEntries(layers), edges: Object.fromEntries(edges) }); +} + +/** The diagram read back into the layers and edges it draws, under the node ids the renderer writes: whatever it + * makes of a layer name (only "-" is rewritten), so the byte compare judges them. */ +function parseArchitectureMap(body: string): Result { + return blockLines(body).andThen((lines) => { + const fault = fenceFault(lines); + return fault === undefined ? mapEntries(lines.slice(2, -1)) : err(fault); }); } diff --git a/.github/scripts/gen-inputs-table.ts b/.github/scripts/gen-inputs-table.ts index 05c589e7f..f3f36cd27 100644 --- a/.github/scripts/gen-inputs-table.ts +++ b/.github/scripts/gen-inputs-table.ts @@ -52,9 +52,13 @@ export function renderInputsTable(inputs: Inputs): string { return renderTable(INPUTS_TABLE_HEADER, cells); } +/** The name cell: an input name in a code span. */ const NAME_SPAN = /^`([a-z][a-z0-9-]*)`$/; -// The dotAll flag is for the two Unicode line separators cellFault() admits inside a cell. + +/** The dotAll flag is for the two Unicode line separators cellFault() admits inside a cell. */ const DESCRIPTION_CELL = /^

(.*)<\/p>$/s; + +/** The default cell, a code span; dotAll as above. */ const DEFAULT_SPAN = /^`(.*)`$/s; /** A row read back to the declaration it renders from; every input is optional, so the required cell is fixed. */ diff --git a/.github/scripts/gen-openapi.ts b/.github/scripts/gen-openapi.ts index 4c0ade124..2a43681f0 100644 --- a/.github/scripts/gen-openapi.ts +++ b/.github/scripts/gen-openapi.ts @@ -109,18 +109,19 @@ export function enumAt(descriptor: Descriptor, path: readonly string[]): string[ return members; } +/** The banner every output opens with; its first line is what marks the file generated to the platform's checks. */ const header = (what: string) => `/** * GENERATED by gen-openapi.ts - do not edit. ${what} * descriptor; regenerate with \`bun run build:openapi\` after a descriptor bump. */ `; +/** Values as a TypeScript list body, each a JSON string literal. */ const stringList = (values: readonly string[]) => values.map((v) => JSON.stringify(v)).join(", "); -/** The platform caps a source line and biome never breaks a string, so a long description is spelled in word - * pieces joined back with the one space they were split at; a word longer than a piece (a URL) stands alone, whole, - * since a word cut in two reads as a misspelling to the spelling gate. */ +/** Word pieces this long rebuild a long description with the one space they split at: the platform caps a source line, biome never breaks a string, and a word cut in two would read as a misspelling to the spelling gate (a URL stands alone, whole). */ const DESCRIPTION_PIECE = 80; + export function descriptionLiteral(text: string): string { if (text.length <= DESCRIPTION_PIECE) { return JSON.stringify(text); @@ -198,16 +199,16 @@ export const REPORTED_INVITATION_ROLES: ReadonlySet = new Set([${stringL // --- Rules ---------------------------------------------------------------------------------------------------- +/** Where the rule rows are written. */ export const RULES_PATH = "src/generated/spec-rules.ts"; +/** The POST route whose body's rules the rows render. */ const RULESETS_PATH = "/repos/{owner}/{repo}/rulesets"; + +/** The PUT route, whose rules must agree with the POST's. */ const RULESET_PATH = "/repos/{owner}/{repo}/rulesets/{ruleset_id}"; -/** - * The parameter components the rows reference, by the definition id published for each (docs/sections/rulesets.docs.yml - * describes them); null for a shape the published schema inlines, whose const takes the component's title. A - * component outside this table ends the run naming it. - */ +/** The parameter components the rows reference, by the published definition id (docs/sections/rulesets.docs.yml describes them), or null for a shape inlined under the component's title; a component outside this table ends the run naming it. */ const COMPONENT_IDS: Readonly> = { "repository-rule-params-status-check-configuration": "StatusCheckConfig", "repository-rule-params-workflow-file-reference": "WorkflowFileConfig", @@ -218,17 +219,12 @@ const COMPONENT_IDS: Readonly> = { "repository-rule-params-reviewer": null, }; -/** An inline parameter shape several rule types spell alike, by its sorted field names, and the one definition - * published for it; such a shape outside this table ends the run naming the rule types. */ +/** An inline parameter shape several rule types spell alike, by its sorted field names, and the one definition published for it; such a shape outside this table ends the run naming the rule types. */ const SHARED_PARAMETER_IDS: Readonly> = { "name,negate,operator,pattern": "PatternRuleParameters", }; -/** - * Not shape: `description` reaches the published schema through `.describe()`, the rest is dropped. `default` is - * what GitHub fills in for an omitted key; a section spells the one default its parsed entry must carry by hand - * (a ruleset's target), and every other key stays omitted so the live default is never drift. - */ +/** Not shape: description reaches the published schema through .describe(), the rest is dropped; default stays omitted (except the one a section spells by hand, a ruleset's target) so GitHub's live default is never drift. */ const METADATA_KEYWORDS: ReadonlySet = new Set([ "title", "description", @@ -247,6 +243,7 @@ const KEYWORDS: Readonly>> = { array: new Set(["type", "items"]), }; +/** What the rules' oneOf node may carry beside its variants. */ const UNION_KEYWORDS: ReadonlySet = new Set(["type", "oneOf"]); /** The node's type, once every keyword on it is one the emitter renders for that type; anything else refuses by path. */ @@ -303,8 +300,12 @@ type Field = (name: string, child: SchemaNode, path: string) => string | undefin /** One render's state: the components and shared shapes met, in dependency order. */ class ZodEmitter { + /** Each const's body by name, in the order the render met them. */ readonly consts = new Map(); + + /** How every mapping closes. */ private readonly objects: Objects; + /** The check parameter spelled into every bound, or nothing. */ private readonly abort: string; @@ -582,23 +583,23 @@ ${rows.map((row) => ` ${row},`).join("\n")} // --- Vocabularies ------------------------------------------------------------------------------------------------- +/** Where the vocabularies, bounds, and bodies are written. */ export const ENUMS_PATH = "src/generated/spec-enums.ts"; +/** The repository route prefix every body() route hangs off. */ const REPO = "/repos/{owner}/{repo}"; /** The request body of a route's method, through its `$ref` where the body is one. */ const body = (method: string, route: string) => ["paths", `${REPO}${route}`, method, "requestBody", ...JSON_BODY] as const; +/** A component schema's path. */ const schema = (name: string) => ["components", "schemas", name] as const; +/** The repository PATCH body, where most vocabularies live. */ const REPO_PATCH = body("patch", ""); -/** - * The string enums the sections refuse by, each rendered as a readonly tuple in the descriptor's order (the order a - * refusal's "expected one of" lists and the published schema's enum carry). A GET's vocabulary stands in where the - * write body types the field as a bare string; the importing section says so. - */ +/** Each a readonly tuple in the descriptor's order, the order a refusal's "expected one of" and the published enum list; where a write body types the field as a bare string, a GET's vocabulary stands in and the importing section says so. */ const SPEC_ENUMS: ReadonlyArray<{ readonly name: string; readonly path: readonly string[] }> = [ { name: "REVIEWER_TYPES", @@ -725,11 +726,7 @@ const SPEC_BOUNDS: ReadonlyArray<{ readonly name: string; readonly path: readonl }, ]; -/** - * The small bodies a section sends to GitHub verbatim, each one zod const: typed and described as the descriptor - * types and describes it, with the published id where the schema publishes one (an inlined shape takes the - * descriptor's own prose). The refusals the descriptor cannot express are the section's, attached at the import. - */ +/** The small bodies a section sends to GitHub verbatim, one zod const each, typed and described as the descriptor does, under the published id where there is one; refusals the descriptor cannot express are the section's, at the import. */ const SPEC_SHAPES: ReadonlyArray<{ readonly name: string; readonly id?: string; diff --git a/.github/scripts/gen-settings-schema.ts b/.github/scripts/gen-settings-schema.ts index 3490e2b41..f982c191a 100644 --- a/.github/scripts/gen-settings-schema.ts +++ b/.github/scripts/gen-settings-schema.ts @@ -85,9 +85,10 @@ function encodeRefs(node: unknown): void { } encodeRefs(generated); -// No layout assumption is guarded here: a future zod's shape change or a broken emission fails the -// published-schema tests (ajv compile plus fixture round-trips), which load the file this script writes. +// No layout assumption is guarded here: a future zod's shape change or a broken emission fails the published-schema tests (ajv compile plus fixture round-trips), which load the file this script writes. const { definitions, ...rest } = generated; + +// Sorted so the built file is deterministic. const sortedDefinitions = Object.fromEntries( Object.entries(definitions ?? {}).sort(([a], [b]) => (a < b ? -1 : 1)), ); diff --git a/.github/scripts/generated.ts b/.github/scripts/generated.ts index 12cd64a55..921806004 100644 --- a/.github/scripts/generated.ts +++ b/.github/scripts/generated.ts @@ -1,6 +1,10 @@ /** * The one table of committed generated output, derived from the generators' own registries, and the drift check * behind `bun run build:check`: every generator script runs, then every registered path must be tracked and unchanged. + * + * The table is in dependency order, each artifact before its readers, or a new gap file would leave a run stale: + * the descriptor-derived files -> imported through src/ by everything below + * the gaps index -> imported through src/ by the docs and action.yml generators */ import { join } from "node:path"; @@ -16,8 +20,10 @@ const ROOT = join(import.meta.dir, "..", ".."); export interface GeneratedOutput { /** The committed output, repo-relative. */ readonly path: string; + /** The package.json script that writes it; `bun run ` regenerates it in place. */ readonly generator: string; + /** Marker-delimited regions inside an authored file (lib/generated-regions.ts), or the whole file. */ readonly kind: "regions" | "file"; } @@ -26,12 +32,7 @@ function regions(generator: string, paths: readonly string[]): GeneratedOutput[] return paths.map((path) => ({ path, generator, kind: "regions" })); } -/** - * A page two generators write into (docs/reference/inputs.md) has one row per generator. Rows stay in dependency - * order, each artifact before its readers, or a new gap file would leave a run stale. - * the descriptor-derived files -> imported through src/ by everything below - * the gaps index -> imported through src/ by the docs and action.yml generators - */ +/** In the module doc's dependency order; a page two generators write into (docs/reference/inputs.md) has one row per generator. */ export const GENERATED_OUTPUTS: readonly GeneratedOutput[] = [ ...OPENAPI_OUTPUTS.map( ({ path }): GeneratedOutput => ({ path, generator: "build:openapi", kind: "file" }), diff --git a/.github/scripts/graduate-upstream-gaps.ts b/.github/scripts/graduate-upstream-gaps.ts index f0f4fafd3..b226ecf08 100644 --- a/.github/scripts/graduate-upstream-gaps.ts +++ b/.github/scripts/graduate-upstream-gaps.ts @@ -24,6 +24,7 @@ const GAPS_DIR = "src/upstream-gaps"; export interface GraduationPlan { /** Gap-file paths (repo-relative, deduplicated, sorted) with a TS2344. */ gapFiles: string[]; + /** Diagnostics the script must not fix: wrong code, or outside a gap file. */ foreign: Diagnostic[]; } diff --git a/.github/scripts/lib/count-word.ts b/.github/scripts/lib/count-word.ts index 1c4e38a7d..50e3873cc 100644 --- a/.github/scripts/lib/count-word.ts +++ b/.github/scripts/lib/count-word.ts @@ -5,6 +5,7 @@ * the table and the build fails instead of the prose silently falling back to a numeral. */ +/** The count is the index; the module doc says why a count past the table's end fails the build. */ const COUNT_WORDS = [ "zero", "one", diff --git a/.github/scripts/lib/generated-regions.ts b/.github/scripts/lib/generated-regions.ts index 302dc8c43..a4b3c4a39 100644 --- a/.github/scripts/lib/generated-regions.ts +++ b/.github/scripts/lib/generated-regions.ts @@ -33,6 +33,8 @@ export function markerSyntaxFor(path: string): MarkerSyntax { // The name is spliced into a regex unescaped, so the grammar admits only regex-literal characters. const REGION_NAME_CLASS = "[a-z0-9-]+"; + +// A whole name, as regionBounds() admits it. const REGION_NAME = new RegExp(`^${REGION_NAME_CLASS}$`); function markerText(kind: "BEGIN" | "END", name: string): string { @@ -150,16 +152,19 @@ export type RegionPlacement = * usually wraps in block(). */ export interface RegionDeclaration { + /** The markers' name: lowercase letters, digits, and dashes. */ readonly name: string; + + /** Where the markers must sit, checked before any splice. */ readonly placement: RegionPlacement; + /** The data the committed file is regenerated from. */ readonly data: () => Data; + /** The body for `data`. */ readonly render: (data: Data) => string; - /** - * A body read back into the data it renders from, or why it is not one `render` wrote; a line it - * names counts from the BEGIN marker's line as line 0. - */ + + /** A body read back into the data it renders from, or why it is not one `render` wrote; a line it names counts from the BEGIN marker's line as line 0. */ readonly parse: (body: string) => Result; } @@ -252,12 +257,16 @@ type MarkdownNode = MarkdownTree | MarkdownTree["children"][number]; interface MarkdownScan { /** Code blocks, fenced or indented, as CommonMark closes them: an unclosed fence runs to the end of its container. */ readonly code: readonly MarkerSpan[]; + /** Inline code spans, backticks included. */ readonly codeSpans: readonly MarkerSpan[]; + /** HTML nodes, block or inline; a block runs to the line its kind ends on, or the end of its container. */ readonly html: readonly MarkerSpan[]; + /** Blockquotes, whole. */ readonly quoted: readonly MarkerSpan[]; + /** Headings outside every blockquote, each as its trimmed source. */ readonly headings: ReadonlyArray<{ readonly offset: number; readonly text: string }>; } @@ -280,16 +289,16 @@ function scanMarkdown(text: string): MarkdownScan { const html: MarkerSpan[] = []; const quoted: MarkerSpan[] = []; const headings: Array<{ offset: number; text: string }> = []; + // The span list each node type feeds; a heading is read for its text instead. + const spansByType: Readonly> = { + code, + inlineCode: codeSpans, + html, + blockquote: quoted, + }; const walk = (node: MarkdownNode, inQuote: boolean): void => { - if (node.type === "code") { - code.push(nodeSpan(node)); - } else if (node.type === "inlineCode") { - codeSpans.push(nodeSpan(node)); - } else if (node.type === "html") { - html.push(nodeSpan(node)); - } else if (node.type === "blockquote") { - quoted.push(nodeSpan(node)); - } else if (node.type === "heading" && !inQuote) { + spansByType[node.type]?.push(nodeSpan(node)); + if (node.type === "heading" && !inQuote) { const [start, end] = nodeSpan(node); headings.push({ offset: start, text: text.slice(start, end).trim() }); } @@ -307,6 +316,39 @@ function scanMarkdown(text: string): MarkdownScan { return { code, codeSpans, html, quoted, headings }; } +/** The span among `spans` holding offset `at`, if any. */ +function enclosing(spans: readonly MarkerSpan[], at: number): MarkerSpan | undefined { + return spans.find(([start, stop]) => start <= at && at < stop); +} + +/** What the marker at `at` is content of, or undefined for one that is a block of its own. A marker is its own + * HTML node (a comment block, or inline HTML); inside any other node it is that node's content. Two markers on one + * line share the BEGIN marker's comment block, at `beginAt`. */ +function markerInside( + scan: MarkdownScan, + text: string, + at: number, + beginAt: number, +): string | undefined { + if (enclosing(scan.code, at) !== undefined) { + return "a code block"; + } + if (enclosing(scan.codeSpans, at) !== undefined) { + return "a code span"; + } + /** Whether the marker at `markerAt` opens `node`, nothing but indentation before it. */ + const opens = (node: MarkerSpan, markerAt: number): boolean => + node[0] <= markerAt && text.slice(node[0], markerAt).trim() === ""; + const html = enclosing(scan.html, at); + if (html !== undefined && !opens(html, at) && !opens(html, beginAt)) { + return "a raw HTML block"; + } + if (enclosing(scan.quoted, at) !== undefined) { + return "a blockquote"; + } + return undefined; +} + function assertMarkdownPlacement( text: string, name: string, @@ -316,26 +358,10 @@ function assertMarkdownPlacement( path: string, ): void { const scan = scanMarkdown(text); - const enclosing = (spans: readonly MarkerSpan[], at: number): MarkerSpan | undefined => - spans.find(([start, stop]) => start <= at && at < stop); - /** Whether the marker at `at` opens `node`, nothing but indentation before it. */ - const opens = (node: MarkerSpan, at: number): boolean => - node[0] <= at && text.slice(node[0], at).trim() === ""; for (const at of [begin[0], end[0]]) { - if (enclosing(scan.code, at) !== undefined) { - throw new Error(`the ${name} region sits inside a code block in ${path}`); - } - if (enclosing(scan.codeSpans, at) !== undefined) { - throw new Error(`the ${name} region sits inside a code span in ${path}`); - } - // A marker is its own HTML node (a comment block, or inline HTML); inside any other node it is - // that node's content. Two markers on one line share the BEGIN marker's comment block. - const html = enclosing(scan.html, at); - if (html !== undefined && !opens(html, at) && !opens(html, begin[0])) { - throw new Error(`the ${name} region sits inside a raw HTML block in ${path}`); - } - if (enclosing(scan.quoted, at) !== undefined) { - throw new Error(`the ${name} region sits inside a blockquote in ${path}`); + const inside = markerInside(scan, text, at, begin[0]); + if (inside !== undefined) { + throw new Error(`the ${name} region sits inside ${inside} in ${path}`); } } if (placement.kind === "tail") { diff --git a/.github/scripts/lib/region-driver.ts b/.github/scripts/lib/region-driver.ts index fb17d7842..693011a32 100644 --- a/.github/scripts/lib/region-driver.ts +++ b/.github/scripts/lib/region-driver.ts @@ -7,9 +7,12 @@ import { type GeneratedRegion, regenerateRegions } from "./generated-regions.js" /** One file a generator writes into: its regions, and the whole-file rules no region's placement can carry. */ export interface GeneratedFile { + /** The regions, re-rendered in this order. */ readonly regions: readonly GeneratedRegion[]; + /** Why the file as read is not one the generator writes into, judged before any region is; `path` names the file. */ readonly shape?: (text: string, path: string) => string | undefined; + /** Why the file reads wrong once every region is re-rendered, judged over the regenerated text. */ readonly rendered?: (text: string, path: string) => string | undefined; } diff --git a/.github/scripts/lib/schema-descriptions.ts b/.github/scripts/lib/schema-descriptions.ts index 810eca06e..6d8a9a167 100644 --- a/.github/scripts/lib/schema-descriptions.ts +++ b/.github/scripts/lib/schema-descriptions.ts @@ -83,6 +83,60 @@ function keyMatches(pattern: string, site: string): boolean { return /^<[^<>]+>$/.test(instance); } +/** The sites one description claims (overlapping brace alternatives claim a site once), and each alternative that + * names nothing: every alternative must name a site of its own, so a removed factory member cannot hide behind its + * siblings. */ +function sitesClaimedBy( + description: SchemaDescription, + sites: ReadonlyMap, +): { claimed: Set; problems: string[] } { + const claimed = new Set(); + const problems: string[] = []; + for (const pattern of expandBraces(description.key)) { + const matched = [...sites.keys()].filter((site) => keyMatches(pattern, site)); + if (matched.length === 0) { + const spelled = + pattern === description.key ? `"${pattern}"` : `"${pattern}" (from "${description.key}")`; + problems.push(`${description.source}: ${spelled} describes nothing in the schema`); + } + for (const site of matched) { + claimed.add(site); + } + } + return { claimed, problems }; +} + +/** One site's description written in, or the problem: none where one is required, or more than one. The + * description goes first, whatever order the generator emitted: one convention, stable diffs. A bare $ref takes no + * siblings in draft-7, so it moves under allOf, as zod itself emits a described reference. */ +function describeSite( + site: string, + { node, required }: Site, + claimed: readonly SchemaDescription[], +): string | undefined { + const [only, ...more] = claimed; + if (only === undefined) { + return required && !node.description + ? `${site} has no description in any docs file` + : undefined; + } + if (more.length > 0) { + const sources = claimed.map((d) => `${d.source} ("${d.key}")`).join(", "); + return `${site} is described more than once: ${sources}`; + } + const rest = Object.fromEntries(Object.entries(node).filter(([k]) => k !== "description")); + for (const key of Object.keys(node)) { + delete node[key]; + } + node.description = only.text; + if (typeof rest.$ref === "string") { + node.allOf = [rest]; + } else { + Object.assign(node, rest); + } + return undefined; +} + /** In place. Throws one error listing every problem, so a docs sweep fixes them in one pass. */ export function attachDescriptions( definitions: Record, @@ -92,49 +146,16 @@ export function attachDescriptions( const claims = new Map(); const problems: string[] = []; for (const description of descriptions) { - // Each brace alternative must name a site of its own, so a removed factory member cannot - // hide behind its siblings; overlapping alternatives still claim a site once. - const claimedHere = new Set(); - for (const pattern of expandBraces(description.key)) { - let matched = 0; - for (const site of sites.keys()) { - if (keyMatches(pattern, site)) { - matched++; - if (!claimedHere.has(site)) { - claimedHere.add(site); - claims.set(site, [...(claims.get(site) ?? []), description]); - } - } - } - if (matched === 0) { - const spelled = - pattern === description.key ? `"${pattern}"` : `"${pattern}" (from "${description.key}")`; - problems.push(`${description.source}: ${spelled} describes nothing in the schema`); - } + const found = sitesClaimedBy(description, sites); + problems.push(...found.problems); + for (const site of found.claimed) { + claims.set(site, [...(claims.get(site) ?? []), description]); } } - for (const [site, { node, required }] of sites) { - const claimed = claims.get(site) ?? []; - if (claimed.length === 0) { - if (required && !node.description) { - problems.push(`${site} has no description in any docs file`); - } - } else if (claimed.length > 1) { - const sources = claimed.map((d) => `${d.source} ("${d.key}")`).join(", "); - problems.push(`${site} is described more than once: ${sources}`); - } else { - // Description first, whatever order the generator emitted: one convention, stable diffs. A bare $ref takes no - // siblings in draft-7, so it moves under allOf, as zod itself emits a described reference. - const rest = Object.fromEntries(Object.entries(node).filter(([k]) => k !== "description")); - for (const key of Object.keys(node)) { - delete node[key]; - } - node.description = claimed[0]?.text; - if (typeof rest.$ref === "string") { - node.allOf = [rest]; - } else { - Object.assign(node, rest); - } + for (const [site, at] of sites) { + const problem = describeSite(site, at, claims.get(site) ?? []); + if (problem !== undefined) { + problems.push(problem); } } if (problems.length > 0) { diff --git a/.github/scripts/lib/schema-id.ts b/.github/scripts/lib/schema-id.ts index 0671db9b8..e57f17ab8 100644 --- a/.github/scripts/lib/schema-id.ts +++ b/.github/scripts/lib/schema-id.ts @@ -1,7 +1,3 @@ -/** - * The `$id` stamped into lib/settings.schema.json: the raw copy at HEAD, naming no release. Editors pin a version - * through the same URL at a release ref (see the README), and a versioned $id would need the schema regenerated on - * every major bump's release PR. - */ +/** The raw copy at HEAD, naming no release: a versioned $id would need the schema regenerated on every major bump's release PR, and editors pin a version through the same URL at a release ref (see the README). */ export const SCHEMA_ID = "https://raw.githubusercontent.com/Vivswan/github-settings-as-code/HEAD/lib/settings.schema.json"; diff --git a/.github/scripts/lib/workflow-step.ts b/.github/scripts/lib/workflow-step.ts index b7564ad3a..fa32f1357 100644 --- a/.github/scripts/lib/workflow-step.ts +++ b/.github/scripts/lib/workflow-step.ts @@ -10,9 +10,13 @@ import { appendFileSync, closeSync, openSync } from "node:fs"; import { constants } from "node:os"; export interface RunOptions { + /** Added over the step's own environment. */ env?: Record; - /** stdout goes to this file instead of the step's log, as `> file` would; `stderrToo` sends stderr there as well. */ + + /** stdout goes to this file instead of the step's log, as `> file` would. */ stdoutFile?: string; + + /** stderr goes to `stdoutFile` as well, as `> file 2>&1` would. */ stderrToo?: boolean; } diff --git a/.github/scripts/nightly-steps.ts b/.github/scripts/nightly-steps.ts index 0186d6d21..9691aa882 100644 --- a/.github/scripts/nightly-steps.ts +++ b/.github/scripts/nightly-steps.ts @@ -25,10 +25,16 @@ import { configureBotIdentity, leasePush } from "./lib/pr-branch.js"; import { parseDiagnostics } from "./lib/tsc-diagnostics.js"; import { capture, requireEnv, run, status } from "./lib/workflow-step.js"; +/** The packages describing GitHub's API that the lockstep tests read. */ const SCHEMA_PACKAGES = ["@octokit/openapi", "@octokit/graphql-schema"] as const; + +/** The packages the typecheck reads. */ const TYPES_PACKAGES = ["@octokit/types", "@octokit/openapi-types"] as const; + /** Both named in every typecheck verdict: a diagnostic from either release reads the same in the log. */ const TYPES_LATEST = TYPES_PACKAGES.map((pkg) => `${pkg}@latest`).join(" and "); + +/** The tests that hold the sections in lockstep with the descriptor and the GraphQL schema. */ const LOCKSTEP_TESTS = [ "test/e2e/openapi/validate.test.ts", "test/e2e/openapi/vocabulary-lockstep.test.ts", @@ -160,21 +166,15 @@ function bumpBody(from: string, to: string, changed: string): string { ].join("\n"); } -function refreshOpenapi(): number | undefined { - const pinned = pinnedVersion(OPENAPI); - const latest = capture(["bun", "info", OPENAPI, "version"]); +/** The line to print instead of bumping: the pin is already the latest, the registry rolled back behind it, or the bump PR is open. */ +function noBumpReason(pinned: string, latest: string, branch: string): string | undefined { if (pinned === latest) { - console.log(`${OPENAPI}@${pinned} is the latest; nothing to bump`); - return; + return `${OPENAPI}@${pinned} is the latest; nothing to bump`; } // A registry tag rolled back behind the pin would otherwise read as a release and open a downgrade PR. if (Bun.semver.order(latest, pinned) < 0) { - console.log( - `::notice::${OPENAPI}@${pinned} is ahead of the registry's latest ${latest}; nothing to bump`, - ); - return; + return `::notice::${OPENAPI}@${pinned} is ahead of the registry's latest ${latest}; nothing to bump`; } - const branch = `nightly/openapi-${latest}`; // The PR is the postcondition, not the branch: a night whose push landed but whose gh call failed leaves the // branch alone, and the next night must not read that as done. --head matches a fork's branch of the same name // too, so only this repository's PRs count. @@ -192,9 +192,13 @@ function refreshOpenapi(): number | undefined { "[.[] | select(.isCrossRepository | not)] | length", ]); if (open !== "0") { - console.log(`::notice::the bump PR for ${OPENAPI}@${latest} is already open from ${branch}`); - return; + return `::notice::the bump PR for ${OPENAPI}@${latest} is already open from ${branch}`; } + return undefined; +} + +/** The bump as one bot commit: the pin moved, the descriptor-derived files and the schema regenerated, all staged; `changed` is git's stat of it. */ +function commitBump(latest: string): { subject: string; changed: string } { // --ignore-scripts: the install under the write token runs no lifecycle script; the checkout is the default // branch's, so the manifest it reads is main's own. run(["bun", "add", "-d", "--exact", "--ignore-scripts", `${OPENAPI}@${latest}`]); @@ -206,16 +210,16 @@ function refreshOpenapi(): number | undefined { configureBotIdentity(); const subject = `build(deps): bump ${OPENAPI} to ${latest}`; run(["git", "commit", "--no-verify", "-m", subject]); - // The whole gate, on the committed bump (build:check compares the generated files against the index): bump nights - // are rare and the minutes free, so the PR arrives green and a red night names the command in the log above. - if (status(["bun", "run", "check"]) !== 0) { - console.log( - `::error::bun run check fails against ${OPENAPI}@${latest}; the log above names the failing script, and nothing was pushed`, - ); - return 1; - } - const token = requireEnv("TOKEN"); - const canRetrigger = requireEnv("CAN_RETRIGGER"); + return { subject, changed }; +} + +/** The push under an empty lease, then the draft PR from the branch; 1 with the refusal on the log when the push is refused. */ +function openBumpPr( + branch: string, + subject: string, + body: string, + token: string, +): number | undefined { // An empty lease: the branch must not exist, so two runs racing for one night cannot push over each other, and // a branch a failed gh call stranded is never rewritten. Which of the two refused is read afresh: ls-remote // answers 0 for a present ref and 2 for an absent one. @@ -230,18 +234,33 @@ function refreshOpenapi(): number | undefined { ); return 1; } - run([ - "gh", - "pr", - "create", - "--draft", - "--head", - branch, - "--title", - subject, - "--body", - bumpBody(pinned, latest, changed), - ]); + run(["gh", "pr", "create", "--draft", "--head", branch, "--title", subject, "--body", body]); +} + +function refreshOpenapi(): number | undefined { + const pinned = pinnedVersion(OPENAPI); + const latest = capture(["bun", "info", OPENAPI, "version"]); + const branch = `nightly/openapi-${latest}`; + const reason = noBumpReason(pinned, latest, branch); + if (reason !== undefined) { + console.log(reason); + return; + } + const { subject, changed } = commitBump(latest); + // The whole gate, on the committed bump (build:check compares the generated files against the index): bump nights + // are rare and the minutes free, so the PR arrives green and a red night names the command in the log above. + if (status(["bun", "run", "check"]) !== 0) { + console.log( + `::error::bun run check fails against ${OPENAPI}@${latest}; the log above names the failing script, and nothing was pushed`, + ); + return 1; + } + const token = requireEnv("TOKEN"); + const canRetrigger = requireEnv("CAN_RETRIGGER"); + const refused = openBumpPr(branch, subject, bumpBody(pinned, latest, changed), token); + if (refused !== undefined) { + return refused; + } if (canRetrigger === "true") { return; } diff --git a/.github/scripts/package-smoke.ts b/.github/scripts/package-smoke.ts index 7f12fba6b..049b918c0 100644 --- a/.github/scripts/package-smoke.ts +++ b/.github/scripts/package-smoke.ts @@ -24,20 +24,13 @@ import { SCHEMA_ID } from "./lib/schema-id.js"; /** This script lives at .github/scripts/, two levels below the repository root. */ const REPO_ROOT = join(import.meta.dir, "..", ".."); +/** The name the consumer imports, as package.json publishes it. */ const PACKAGE = "@vivswan/github-settings-as-code"; -/** - * The node that runs the consumer. The build tooling (tsdown) needs a newer - * node than the engines floor package.json advertises, so CI builds on the - * PATH node and names the floor's binary here for the consumer alone. - */ +/** The consumer's node: tsdown needs a newer node than the engines floor package.json advertises, so CI builds on the PATH node and names the floor's binary here for the consumer alone. */ const CONSUMER_NODE = process.env.SMOKE_CONSUMER_NODE ?? "node"; -/** - * The consumer's runtime import. The section list and the schema id are - * pinned from this checkout's source, so the tarball's bundle must expose - * exactly what src/ declares, not merely something. - */ +/** The consumer's runtime import; the section list and the schema id are pinned from this checkout's source, so the tarball's bundle must expose exactly what src/ declares. */ const NODE_CONSUMER = `import { deepStrictEqual } from "node:assert/strict"; import { SECTION_KEYS, validateSettings } from "${PACKAGE}"; import { INPUT_DECLS } from "${PACKAGE}/internal"; @@ -51,11 +44,7 @@ deepStrictEqual(INPUT_DECLS["on-missing-permission"].default, "fail"); console.log("imported " + SECTION_KEYS.length + " section keys, the internal entry, and the schema"); `; -/** - * The consumer compiled against index.d.ts and internal.d.ts, no client behind it. The expect-error lines are the - * controls that the bundle keeps DenialPolicy nominal (the literal is its public shape, so only the - * private member rejects it) and keeps the context branded with its section's key. - */ +/** Compiled against index.d.ts and internal.d.ts with no client behind it; the expect-error lines are the controls that the bundle keeps DenialPolicy nominal (only its private member rejects the literal) and the context branded with its section's key. */ const TS_CONSUMER = `import { type GitHubClient, planContext, diff --git a/.github/scripts/post-green-steps.ts b/.github/scripts/post-green-steps.ts index c8d6b1154..0a2d612dd 100644 --- a/.github/scripts/post-green-steps.ts +++ b/.github/scripts/post-green-steps.ts @@ -11,13 +11,10 @@ import { randomBytes } from "node:crypto"; import { dispatch, type Handler } from "./lib/entry.js"; import { attempt, requireEnv, setOutput } from "./lib/workflow-step.js"; -/** What each subcommand writes to GITHUB_OUTPUT; test/workflows/post-green-workflow.test.ts judges the job's wiring - * from it, as it does from the shell steps' echo lines. */ +/** What each subcommand writes to GITHUB_OUTPUT; test/workflows/post-green-workflow.test.ts judges the job's wiring from it, as from the shell steps' echo lines. */ export const STEP_OUTPUTS = { probe: ["proceed"] } as const; -/** A dry-run push asks origin for its receive-pack advertisement over the channel the real push uses, which a token - * without write access is refused; nothing is created. The target is a ref that never exists, so the dry run judges - * the token alone: against an existing tag git would refuse the non-fast-forward locally whatever the token. */ +/** Refused for a token without write access and creates nothing, against a ref that never exists, so git cannot refuse a non-fast-forward locally whatever the token. */ const DRY_RUN_PUSH = [ "git", "push", diff --git a/.github/scripts/release-pipeline.ts b/.github/scripts/release-pipeline.ts index e40d50c51..18db15fdf 100644 --- a/.github/scripts/release-pipeline.ts +++ b/.github/scripts/release-pipeline.ts @@ -32,14 +32,27 @@ import { resolveSource, STEP_OUTPUTS, } from "./release-pipeline/draft.js"; -import { NPM_FLOOR, npmFloor, npmPublish } from "./release-pipeline/npm.js"; +import { + type ConfirmVerdict, + NPM_FLOOR, + type NpmPublishOptions, + npmFloor, + npmPublish, +} from "./release-pipeline/npm.js"; import { packageCommit, packageRelease, retagMajor } from "./release-pipeline/tags.js"; +/** Where npm-publish publishes unless NPM_REGISTRY_URL names another registry, as the tests do with a local one. */ const DEFAULT_REGISTRY = "https://registry.npmjs.org"; + /** 15 reads 20 s apart (up to 280 s): three of the first five publishes were still unreadable after 80 s. */ const CONFIRM_READS = 15; + +/** The pause between confirm reads; NPM_CONFIRM_PAUSE_MS overrides it for the test against a local registry. */ const CONFIRM_PAUSE_MS = 20_000; +/** The step's working directory: the checkout every subcommand works in. */ +const cwd = process.cwd(); + /** NPM_CONFIRM_PAUSE_MS exists for the test that confirms against a local registry without the wait. */ function confirmPauseMs(value: string | undefined): number { if (value === undefined || value === "") { @@ -53,7 +66,42 @@ function confirmPauseMs(value: string | undefined): number { return Number(value); } -const cwd = process.cwd(); +/** The next channel confirms the registry's record after publishing; stable publishes under the release's tag. */ +function publishOptions(channel: "next" | "stable"): NpmPublishOptions { + const shared = { + cwd, + sourceSha: requireEnv("GITHUB_SHA"), + registry: process.env.NPM_REGISTRY_URL || DEFAULT_REGISTRY, + }; + if (channel === "next") { + return { + ...shared, + channel, + confirm: { + attempts: CONFIRM_READS, + delayMs: confirmPauseMs(process.env.NPM_CONFIRM_PAUSE_MS), + }, + }; + } + return { ...shared, channel, tag: requireEnv("TAG") }; +} + +/** The confirmation as a workflow annotation; 1 when this run moved next back behind a descendant's pre-release. */ +function reportConfirmed(confirmed: ConfirmVerdict): number | undefined { + if (confirmed.outcome === "settled") { + console.log( + `::notice::${confirmed.version} is on the registry after ${confirmed.reads} ` + + `${confirmed.reads === 1 ? "read" : "reads"}; next is not behind a descendant's pre-release`, + ); + return; + } + if (confirmed.outcome === "unsettled") { + console.log(`::warning::${confirmed.reason}`); + return; + } + console.log(`::error::${confirmed.reason}`); + return 1; +} /** The subcommands by name; test/workflows reads the keys as the set a step may run. */ export const COMMANDS: Record = { @@ -101,41 +149,14 @@ export const COMMANDS: Record = { `npm-publish takes the channel, next or stable, not ${JSON.stringify(channel ?? null)}`, ); } - const result = await npmPublish( - { - cwd, - sourceSha: requireEnv("GITHUB_SHA"), - registry: process.env.NPM_REGISTRY_URL || DEFAULT_REGISTRY, - ...(channel === "next" - ? { - channel, - confirm: { - attempts: CONFIRM_READS, - delayMs: confirmPauseMs(process.env.NPM_CONFIRM_PAUSE_MS), - }, - } - : { channel, tag: requireEnv("TAG") }), - }, - (line) => console.error(line), - ); + const result = await npmPublish(publishOptions(channel), (line) => console.error(line)); if (!result.published) { // A skipped pre-release is routine (a stale retry); a skipped release is a rerun worth a look. console.log(`::${channel === "next" ? "notice" : "warning"}::${result.reason}`); return; } if (result.channel === "next") { - const { confirmed } = result; - if (confirmed.outcome === "settled") { - console.log( - `::notice::${confirmed.version} is on the registry after ${confirmed.reads} ` + - `${confirmed.reads === 1 ? "read" : "reads"}; next is not behind a descendant's pre-release`, - ); - } else if (confirmed.outcome === "unsettled") { - console.log(`::warning::${confirmed.reason}`); - } else { - console.log(`::error::${confirmed.reason}`); - return 1; - } + return reportConfirmed(result.confirmed); } }, "npm-floor": () => { diff --git a/.github/scripts/release-pipeline/anchor.ts b/.github/scripts/release-pipeline/anchor.ts index 1a35eb714..9ca3bbb50 100644 --- a/.github/scripts/release-pipeline/anchor.ts +++ b/.github/scripts/release-pipeline/anchor.ts @@ -17,26 +17,25 @@ import { tryGit, } from "./git.js"; +/** release-please's config at the checkout root, the file the boundary is read from and written into. */ const CONFIG_FILE = "release-please-config.json"; -/** A squash-merged release-please PR's subject on main. Anchored at both ends wherever release merges are recognized: - * a prefix match would let "chore(main): release pipeline documentation" impersonate one and park the boundary check. */ +/** A squash-merged release-please PR's subject on main, anchored at both ends wherever release merges are recognized: a prefix match would let "chore(main): release pipeline documentation" impersonate one and park the boundary check. */ const RELEASE_SUBJECT = /^chore\(main\): release (\d+\.\d+\.\d+)(?: \(#\d+\))?$/; /** checks.yml's head_ref conditions spell this by hand; test/workflows/checks-workflow.test.ts pins them to it. */ export const RELEASE_PR_BRANCH_PREFIX = "release-please--"; + +/** release-please's branch for main's release PR. */ const RELEASE_PR_BRANCH = `${RELEASE_PR_BRANCH_PREFIX}branches--main`; /** release-please's config with the one key this pipeline reads and writes; the rest is release-please's. */ type ReleaseConfig = Record & { "last-release-sha"?: string }; -/** The config as the checkout holds it. The file is the user's and release-please's, so nothing here is repaired: a - * missing or malformed one, or a boundary that is no commit sha, is refused naming the file (an absent boundary is - * the pre-first-release state and passes). */ -function readReleaseConfig(cwd: string): ReleaseConfig { - let text: string; +/** The config's text; a missing file is refused naming it, any other read failure is thrown as it came. */ +function readConfigText(cwd: string): string { try { - text = readFileSync(join(cwd, CONFIG_FILE), "utf8"); + return readFileSync(join(cwd, CONFIG_FILE), "utf8"); } catch (error) { if ((error as { code?: unknown }).code !== "ENOENT") { throw error; @@ -45,6 +44,21 @@ function readReleaseConfig(cwd: string): ReleaseConfig { `${CONFIG_FILE} is missing from ${cwd}; the release pipeline reads release-please's boundary (last-release-sha) from it. Restore it by PR.`, ); } +} + +/** What a JSON value that is no object holds, as the refusal names it. */ +function jsonKind(parsed: unknown): string { + if (Array.isArray(parsed)) { + return "an array"; + } + if (parsed === null) { + return "null"; + } + return `a ${typeof parsed}`; +} + +/** The config's JSON as an object; anything else is refused naming the file. */ +function parseConfigObject(text: string): Record { let parsed: unknown; try { parsed = JSON.parse(text); @@ -54,14 +68,16 @@ function readReleaseConfig(cwd: string): ReleaseConfig { ); } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - const kind = Array.isArray(parsed) - ? "an array" - : parsed === null - ? "null" - : `a ${typeof parsed}`; - throw new Error(`${CONFIG_FILE} holds ${kind}, not a JSON object; fix it by PR.`); - } - const config = parsed as Record; + throw new Error(`${CONFIG_FILE} holds ${jsonKind(parsed)}, not a JSON object; fix it by PR.`); + } + return parsed as Record; +} + +/** The config as the checkout holds it. The file is the user's and release-please's, so nothing here is repaired: a + * missing or malformed one, or a boundary that is no commit sha, is refused naming the file (an absent boundary is + * the pre-first-release state and passes). */ +function readReleaseConfig(cwd: string): ReleaseConfig { + const config = parseConfigObject(readConfigText(cwd)); const boundary = config["last-release-sha"]; if (boundary !== undefined && (typeof boundary !== "string" || !FULL_SHA.test(boundary))) { throw new Error( @@ -72,9 +88,13 @@ function readReleaseConfig(cwd: string): ReleaseConfig { } export interface AnchorOptions { + /** The checkout the release PR branch is fetched into and the anchor commit made in. */ cwd: string; + /** Main's head this run tested; the merge parent the boundary records. */ sourceSha: string; + + /** How many pushes a release-please refresh may overtake before the run gives up; 3 by default. */ attempts?: number; } @@ -83,6 +103,63 @@ export interface AnchorResult { reason: string; } +/** The anchor commit on the checked-out branch: last-release-sha set to `sourceSha`, committed under the bot identity. */ +function commitAnchor(cwd: string, config: ReleaseConfig, sourceSha: string): void { + config["last-release-sha"] = sourceSha; + writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`); + git(cwd, "add", CONFIG_FILE); + gitWithEnv( + cwd, + BOT_IDENTITY, + "commit", + "-m", + "chore: anchor release-please to this release cycle's base", + "-m", + "last-release-sha records the merge parent so the squash merge itself lands the next cycle's boundary on main: version tags live on packaged commits that are not on main, so release-please cannot find the boundary by tag.", + ); +} + +/** One attempt: the release PR branch fetched and checked out detached, anchored, and pushed. Undefined when a + * release-please refresh overtook the push, so the caller re-reads the branch; a push refused with the branch + * unmoved is thrown. */ +function anchorAttempt(cwd: string, sourceSha: string): AnchorResult | undefined { + const branchRef = `refs/heads/${RELEASE_PR_BRANCH}`; + const observed = git(cwd, "ls-remote", "origin", branchRef).split("\t")[0] ?? ""; + if (observed === "") { + return { changed: false, reason: "no release PR branch to anchor" }; + } + // Detached, never a local branch: a retry after an overtaken push re-fetches, and git refuses to fetch into a checked-out ref. + git(cwd, "fetch", "--quiet", "--force", "origin", branchRef); + git(cwd, "checkout", "--quiet", "--force", "--detach", "FETCH_HEAD"); + // A branch still built from an older main must not be stamped with this head. A shallow history that cannot + // prove ancestry no-ops the same way; the refresh that follows anchors. + if (!isAncestor(cwd, sourceSha, "FETCH_HEAD")) { + return { + changed: false, + reason: "the release PR branch is not built on this head; its own refresh anchors", + }; + } + const config = readReleaseConfig(cwd); + if (config["last-release-sha"] === sourceSha) { + return { changed: false, reason: "already anchored" }; + } + commitAnchor(cwd, config, sourceSha); + // A newer push's run may already have refreshed and anchored the branch before the fetch above: this run's commit + // then descends from that anchor, pushes cleanly, and regresses the boundary to the older head. + const headNow = git(cwd, "ls-remote", "origin", "refs/heads/main").split("\t")[0]; + if (headNow !== sourceSha) { + return { changed: false, reason: `main moved to ${headNow ?? "?"}; the newer run anchors` }; + } + const refused = push(cwd, "origin", `HEAD:${branchRef}`); + if (refused === null) { + return { changed: true, reason: `${RELEASE_PR_BRANCH}: anchored at ${sourceSha}` }; + } + if ((git(cwd, "ls-remote", "origin", branchRef).split("\t")[0] ?? "") === observed) { + throw refused; + } + return undefined; +} + /** The boundary rides INSIDE the release PR as last-release-sha = main's current head, the future merge commit's * PARENT (known now, unlike the merge sha), so the squash merge lands it on main with no push to main and no admin credential. * the parent, not the merge -> the changelog walk then also includes the merge itself, a chore commit it hides anyway @@ -94,51 +171,10 @@ export function anchorReleasePr(options: AnchorOptions): AnchorResult { if (head !== sourceSha) { return { changed: false, reason: `main moved to ${head ?? "?"}; the newer run anchors` }; } - const branchRef = `refs/heads/${RELEASE_PR_BRANCH}`; for (let attempt = 1; attempt <= attempts; attempt++) { - const observed = git(cwd, "ls-remote", "origin", branchRef).split("\t")[0] ?? ""; - if (observed === "") { - return { changed: false, reason: "no release PR branch to anchor" }; - } - // Detached, never a local branch: a retry after an overtaken push re-fetches, and git refuses to fetch into a checked-out ref. - git(cwd, "fetch", "--quiet", "--force", "origin", branchRef); - git(cwd, "checkout", "--quiet", "--force", "--detach", "FETCH_HEAD"); - // A branch still built from an older main must not be stamped with this head. A shallow history that cannot - // prove ancestry no-ops the same way; the refresh that follows anchors. - if (!isAncestor(cwd, sourceSha, "FETCH_HEAD")) { - return { - changed: false, - reason: "the release PR branch is not built on this head; its own refresh anchors", - }; - } - const config = readReleaseConfig(cwd); - if (config["last-release-sha"] === sourceSha) { - return { changed: false, reason: "already anchored" }; - } - config["last-release-sha"] = sourceSha; - writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`); - git(cwd, "add", CONFIG_FILE); - gitWithEnv( - cwd, - BOT_IDENTITY, - "commit", - "-m", - "chore: anchor release-please to this release cycle's base", - "-m", - "last-release-sha records the merge parent so the squash merge itself lands the next cycle's boundary on main: version tags live on packaged commits that are not on main, so release-please cannot find the boundary by tag.", - ); - // A newer push's run may already have refreshed and anchored the branch before the fetch above: this run's commit - // then descends from that anchor, pushes cleanly, and regresses the boundary to the older head. - const headNow = git(cwd, "ls-remote", "origin", "refs/heads/main").split("\t")[0]; - if (headNow !== sourceSha) { - return { changed: false, reason: `main moved to ${headNow ?? "?"}; the newer run anchors` }; - } - const refused = push(cwd, "origin", `HEAD:${branchRef}`); - if (refused === null) { - return { changed: true, reason: `${RELEASE_PR_BRANCH}: anchored at ${sourceSha}` }; - } - if ((git(cwd, "ls-remote", "origin", branchRef).split("\t")[0] ?? "") === observed) { - throw refused; + const result = anchorAttempt(cwd, sourceSha); + if (result !== undefined) { + return result; } // release-please force-pushed a refresh mid-anchor; reapply on it. console.error( @@ -150,6 +186,34 @@ export function anchorReleasePr(options: AnchorOptions): AnchorResult { ); } +/** The newest release merge reachable from HEAD, or "" for none. The grep only narrows (it matches any message line + * with the prefix); RELEASE_SUBJECT decides, so "chore(main): release pipeline documentation" can neither become the + * boundary nor hide the real newest merge. */ +function newestReleaseMerge(cwd: string): string { + const listed = git(cwd, "log", "--grep", "^chore(main): release ", "--format=%H%x09%s"); + for (const line of listed.split("\n")) { + const [sha, subject] = line.split("\t"); + if (sha !== undefined && subject !== undefined && RELEASE_SUBJECT.test(subject)) { + return sha; + } + } + return ""; +} + +/** The verdict with no release merge on the history: the pre-first-release state when nothing is recorded, else a + * refusal saying why the recorded boundary has no merge. */ +function noMergeVerdict(cwd: string, recorded: string | undefined): { boundary: string } { + if (recorded === undefined) { + return { boundary: "none (no release merge on this history yet)" }; + } + const cause = isAncestor(cwd, recorded, "HEAD") + ? `this history holds it, so release-please's merge subject no longer matches ${RELEASE_SUBJECT} (investigate RELEASE_SUBJECT)` + : "it is not on this history at all (not main, or a boundary that never landed)"; + throw new Error( + `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, but no release merge is reachable from HEAD: ${cause}. Refusing to read a recorded boundary as a pre-first-release history.`, + ); +} + /** On main, last-release-sha must be the newest release merge or its parent; anything else means an anchor was lost * or a release merge slipped past the pipeline, and every release PR refresh would compute from a stale boundary. * A boundary NEWER than every recognized merge is a missed merge or a hand edit; either way it is never rolled back. */ @@ -160,27 +224,9 @@ export function boundaryCheck(cwd: string): { boundary: string } { "a release merge or the recorded boundary can sit beyond its depth, and no verdict on a truncated history holds.", ); const recorded = readReleaseConfig(cwd)["last-release-sha"]; - // The grep only narrows (it matches any message line with the prefix); RELEASE_SUBJECT decides, so - // "chore(main): release pipeline documentation" can neither become the boundary nor hide the real newest merge. - const listed = git(cwd, "log", "--grep", "^chore(main): release ", "--format=%H%x09%s"); - let latest = ""; - for (const line of listed.split("\n")) { - const [sha, subject] = line.split("\t"); - if (sha !== undefined && subject !== undefined && RELEASE_SUBJECT.test(subject)) { - latest = sha; - break; - } - } + const latest = newestReleaseMerge(cwd); if (latest === "") { - if (recorded === undefined) { - return { boundary: "none (no release merge on this history yet)" }; - } - const cause = isAncestor(cwd, recorded, "HEAD") - ? `this history holds it, so release-please's merge subject no longer matches ${RELEASE_SUBJECT} (investigate RELEASE_SUBJECT)` - : "it is not on this history at all (not main, or a boundary that never landed)"; - throw new Error( - `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, but no release merge is reachable from HEAD: ${cause}. Refusing to read a recorded boundary as a pre-first-release history.`, - ); + return noMergeVerdict(cwd, recorded); } const parent = tryGit(cwd, "rev-parse", `${latest}^`); if (recorded !== undefined) { diff --git a/.github/scripts/release-pipeline/draft.ts b/.github/scripts/release-pipeline/draft.ts index fe223011b..daffb8ce6 100644 --- a/.github/scripts/release-pipeline/draft.ts +++ b/.github/scripts/release-pipeline/draft.ts @@ -6,9 +6,9 @@ import { capture } from "../lib/workflow-step.js"; import { FULL_SHA } from "./git.js"; -/** What each subcommand writes to GITHUB_OUTPUT: the release hook's later steps and jobs read the resolved merge - * commit from it, and test/workflows/post-green-workflow.test.ts judges those reads against this table. */ +/** What each subcommand writes to GITHUB_OUTPUT; test/workflows/post-green-workflow.test.ts judges the release hook's later reads of the resolved merge commit against this table. */ export const STEP_OUTPUTS = { "resolve-source": ["sha"] } as const; + /** What the package job uploads to the draft; the managed publish stage attaches attestation.json after the check. */ export const RELEASE_ASSETS = ["index.js", "settings.schema.json"]; diff --git a/.github/scripts/release-pipeline/git.ts b/.github/scripts/release-pipeline/git.ts index 0c8f67d4c..9e50f330f 100644 --- a/.github/scripts/release-pipeline/git.ts +++ b/.github/scripts/release-pipeline/git.ts @@ -74,8 +74,7 @@ export function push(cwd: string, ...pushArgs: string[]): Error | null { } } -/** The identity the pipeline's own commits carry, passed per invocation: written into a checkout's config it would - * outlive the run and stamp every later commit made from that repository, or any worktree sharing it. */ +/** Passed per invocation: written into a checkout's config it would outlive the run and stamp every later commit made from that repository, or any worktree sharing it. */ export const BOT_IDENTITY = { GIT_AUTHOR_NAME: "settings-as-code-release", GIT_AUTHOR_EMAIL: "settings-as-code-release@users.noreply.github.com", @@ -117,6 +116,7 @@ export function manifestVersionAt(cwd: string, treeish: string): string { export interface MainPosition { /** Commits reachable from it along first parents: one more per merge to main, whatever a merged PR's branch held. */ count: number; + /** Its committer date in UTC, YYYYMMDD. */ date: string; } diff --git a/.github/scripts/release-pipeline/npm.ts b/.github/scripts/release-pipeline/npm.ts index 06600238a..027d0cb64 100644 --- a/.github/scripts/release-pipeline/npm.ts +++ b/.github/scripts/release-pipeline/npm.ts @@ -51,7 +51,9 @@ export function prereleaseVersion( } export interface PrereleaseVersionOptions { + /** The checkout the manifest and the position on main are read from. */ cwd: string; + /** The commit whose build is published; the checkout must be at it. */ sourceSha: string; } @@ -109,11 +111,6 @@ function parseMinted(version: string): MintedVersion { return minted; } -/** Whether release `a` sorts above `b`: by major, minor, patch. */ -function newerRelease(a: [number, number, number], b: [number, number, number]): boolean { - return a[0] !== b[0] ? a[0] > b[0] : a[1] !== b[1] ? a[1] > b[1] : a[2] > b[2]; -} - /** What the registry holds for the package: every published version, and where each dist-tag points. */ export interface Packument { versions: Record; @@ -128,7 +125,9 @@ export type NextVerdict = PublishVerdict & { notices: string[] }; /** A published pre-release whose source is a strict descendant of this run's: newer on main, whatever its numbers say. */ interface Descendant { + /** The published pre-release version. */ version: string; + /** The full sha the version's sha7 resolved to. */ sha: string; } @@ -258,7 +257,10 @@ export function stablePublishVerdict(version: string, packument: Packument | nul // pre-release): a packument always carries that key (npm/registry REGISTRY-API.md, "dist-tags: an object with at // least one key, latest"), whatever --tag asked for. A release must take latest over, so only a newer RELEASE holds one back. const held = latest === undefined ? null : parseMinted(latest); - if (held?.sha7 === null && newerRelease(held.release, parseMinted(version).release)) { + if ( + held?.sha7 === null && + Bun.semver.order(held.release.join("."), parseMinted(version).release.join(".")) > 0 + ) { return { publish: false, version, @@ -326,9 +328,12 @@ function releaseVersionAt(cwd: string, treeish: string, tag: string): string { } export type NpmVerdictOptions = { + /** The checkout the manifest and package.json are read from. */ cwd: string; + /** The commit whose build is published; the checkout must be at it. */ sourceSha: string; + /** The registry's base URL, where the package's record is read. */ registry: string; } & ({ channel: "next" } | { channel: "stable"; tag: string }); @@ -359,16 +364,47 @@ export type ConfirmVerdict = | { outcome: "behind"; version: string; reason: string }; export interface NpmConfirmOptions { + /** The checkout the version and the package name are read from. */ cwd: string; + /** The commit whose build this run published; the checkout must be at it. */ sourceSha: string; + /** The registry's base URL, where the package's record is read. */ registry: string; - /** How many times the record is read while it lacks the version, and the pause between reads. */ + + /** How many times the record is read while it lacks the version. */ attempts: number; + + /** The pause between reads. */ delayMs: number; } +/** The verdict once the record shows the version: settled, or behind when the record also holds a descendant's + * pre-release that next does not name. */ +function shownVerdict( + options: NpmConfirmOptions, + name: string, + version: string, + packument: Packument, + reads: number, +): ConfirmVerdict { + const { cwd, sourceSha } = options; + const { descendants, newest } = placePublished(cwd, sourceSha, packument); + const next = packument["dist-tags"].next; + if (newest !== null && !descendants.some((descendant) => descendant.version === next)) { + return { + outcome: "behind", + version, + reason: + `the registry's next is ${next ?? "unset"} while it holds ${newest.version}, whose source ${newest.sha.slice(0, 7)} ` + + `is a descendant of ${sourceSha.slice(0, 7)} on main; this stale run moved next back, and the next release-PR ` + + `refresh moves it forward (npm dist-tag add ${name}@${newest.version} next repairs it by hand)`, + }; + } + return { outcome: "settled", version, reads }; +} + /** * After `npm publish --tag next`: the record is read until it shows the version, so the job holds the npm-publish * lane until the next holder's verdict can see this publish (npm makes a publish readable asynchronously; a verdict @@ -398,19 +434,7 @@ export async function npmConfirm(options: NpmConfirmOptions): Promise descendant.version === next)) { - return { - outcome: "behind", - version, - reason: - `the registry's next is ${next ?? "unset"} while it holds ${newest.version}, whose source ${newest.sha.slice(0, 7)} ` + - `is a descendant of ${sourceSha.slice(0, 7)} on main; this stale run moved next back, and the next release-PR ` + - `refresh moves it forward (npm dist-tag add ${name}@${newest.version} next repairs it by hand)`, - }; - } - return { outcome: "settled", version, reads: read }; + return shownVerdict(options, name, version, packument, read); } if (read === attempts) { return { @@ -436,9 +460,12 @@ function npm(cwd: string, env: Record, ...args: string[]): void } export type NpmPublishOptions = { + /** The checkout npm publishes from. */ cwd: string; + /** The commit whose build is published; the checkout must be at it. */ sourceSha: string; + /** The registry's base URL, where the package's record is read. */ registry: string; } & ( diff --git a/.github/scripts/release-pipeline/tags.ts b/.github/scripts/release-pipeline/tags.ts index 7afe9d0df..682614894 100644 --- a/.github/scripts/release-pipeline/tags.ts +++ b/.github/scripts/release-pipeline/tags.ts @@ -19,25 +19,38 @@ import { tryGit, } from "./git.js"; +/** The file whose preparation scripts a packaged commit drops. */ const MANIFEST = "package.json"; + /** What a packaged commit carries beyond its source (a directory entry stages every file under it). */ const PACKAGED_PATHS = ["lib/index.js", "lib/settings.schema.json", "lib/pkg/"] as const; + /** What every packaged commit must carry as non-empty regular files. */ const REQUIRED_BUILT_FILES = [ "lib/index.js", "lib/settings.schema.json", "lib/pkg/index.js", ] as const; + +/** PACKAGED_PATHS as the refusals spell them. */ const PACKAGED = "lib/index.js, lib/settings.schema.json, and lib/pkg/"; + +/** The pointer every green main commit's package moves forward. */ const LATEST_REF = "refs/tags/latest"; + +/** Under it, `.` names a main commit's package. */ const BUILD_TAG_PREFIX = "refs/tags/build/"; + +/** A build tag this pipeline names; the prune stops at anything else under the prefix. */ const BUILD_TAG = /^refs\/tags\/build\/([1-9]\d*)\.[0-9a-f]{7}$/; + /** How many build tags stay; releases and latest keep their own commits. */ export const KEPT_BUILD_TAGS = 10; -/** Never --depth: a depth-limited fetch marks the commit it lands on shallow and cuts the parent link every package - * check reads. Blobs arrive on demand, or with the commit on a server without filter support. */ + +/** Never --depth: a depth-limited fetch marks the commit it lands on shallow and cuts the parent link every package check reads; blobs arrive on demand, or with the commit on a server without filter support. */ const TAG_FETCH = ["--filter=blob:none"]; +/** Reads of a ref that keeps moving before a run gives up. */ const PUSH_ATTEMPTS = 3; /** Every ref derivation passes through this parse, so a malformed tag stops the pipeline instead of minting "v2" @@ -66,8 +79,7 @@ function assertCarries(cwd: string, treeish: string, what: string, remedy: strin } } -/** The scripts npm's git fetcher (pacote) takes as a signal to run `npm install --include=dev` and the prepare - * lifecycle in a `github:` dependency's checkout; the packaged commit ships the build already. */ +/** The scripts npm's git fetcher (pacote) takes as a signal to run `npm install --include=dev` and the prepare lifecycle in a `github:` dependency's checkout; the packaged commit ships the build already. */ const PREPARATION_SCRIPTS = ["prepare", "prepack", "build", "preinstall", "install", "postinstall"]; /** sourceSha's tree plus `addBuild`'s entries, with package.json's preparation scripts removed, assembled in a @@ -325,13 +337,58 @@ export function pruneBuildTags(cwd: string, keep = KEPT_BUILD_TAGS): string[] { } export interface PointerMove { + /** The pointer, as refs/tags/... */ ref: string; + /** Where the pointer is when the move ends. */ sha: string; + + /** Whether this run pushed it. */ changed: boolean; + + /** The one line the step logs. */ reason: string; } +/** The pointer left where it is: `at` packages a main commit at or past the candidate's source. Undefined when there + * is no pointer yet, when the candidate is newer, or when the pointer's commit is no child of a main commit (the + * retired chain's tip, a hand-pushed bare root), which is replaced, not judged. Main's head is read here, AFTER the + * pointer: a pointer a rival moved to a newer commit's package has that commit on main by then. */ +function alreadyPast( + cwd: string, + ref: string, + at: string, + candidate: Packaged, +): PointerMove | undefined { + if (at === "") { + return undefined; + } + const mainHead = fetchMainHead(cwd); + const parents = git(cwd, "log", "-1", "--format=%P", at).split(" ").filter(Boolean); + const current = parents.length === 1 ? parents[0] : undefined; + if (current === undefined || !isAncestor(cwd, current, mainHead)) { + return undefined; + } + if (!isAncestor(cwd, candidate.source, current)) { + return undefined; + } + assertPackageOf(cwd, at, current, `${ref} (${at})`, "inspect it by hand."); + if ( + current === candidate.source && + git(cwd, "rev-parse", `${at}^{tree}`) !== git(cwd, "rev-parse", `${candidate.commit}^{tree}`) + ) { + throw new Error( + `${ref} is at ${at}, another package of ${candidate.source} than ${candidate.commit} with another tree; two builds of one main commit exist - inspect both by hand.`, + ); + } + return { + ref, + sha: at, + changed: false, + reason: `${ref} already at ${at}, packaging ${current}, which is ${candidate.source} or past it`, + }; +} + /** * The one way a pointer (latest, vX) moves: forward along main, never back. A pointer's source is its commit's * parent when that parent is on main; the candidate's source is on main by its caller's check. @@ -344,34 +401,12 @@ export function movePointer(cwd: string, ref: string, candidate: Packaged): Poin for (let attempt = 1; attempt <= PUSH_ATTEMPTS; attempt++) { const observed = observeRemote(cwd, ref); const at = observed.peeled; - if (observed.id !== "") { - if (!fetchObserved(cwd, ref, observed.id)) { - continue; - } - const mainHead = fetchMainHead(cwd); - const parents = git(cwd, "log", "-1", "--format=%P", at).split(" ").filter(Boolean); - const current = parents.length === 1 ? parents[0] : undefined; - // A pointer whose commit is no child of a main commit (the retired chain's tip, a hand-pushed bare root) is replaced, not judged. - if (current !== undefined && isAncestor(cwd, current, mainHead)) { - if (isAncestor(cwd, candidate.source, current)) { - assertPackageOf(cwd, at, current, `${ref} (${at})`, "inspect it by hand."); - if ( - current === candidate.source && - git(cwd, "rev-parse", `${at}^{tree}`) !== - git(cwd, "rev-parse", `${candidate.commit}^{tree}`) - ) { - throw new Error( - `${ref} is at ${at}, another package of ${candidate.source} than ${candidate.commit} with another tree; two builds of one main commit exist - inspect both by hand.`, - ); - } - return { - ref, - sha: at, - changed: false, - reason: `${ref} already at ${at}, packaging ${current}, which is ${candidate.source} or past it`, - }; - } - } + if (observed.id !== "" && !fetchObserved(cwd, ref, observed.id)) { + continue; + } + const past = alreadyPast(cwd, ref, at, candidate); + if (past !== undefined) { + return past; } const refused = push( cwd, @@ -397,9 +432,12 @@ export function movePointer(cwd: string, ref: string, candidate: Packaged): Poin } interface PackageCommitOptions { + /** The checkout, at `sourceSha` with the bundle built. */ cwd: string; + /** The green main commit this run judged; the checkout must be at it with the bundle built. */ sourceSha: string; + /** Provenance trailer for a packaged commit this run mints (the workflow run URL). */ runUrl?: string; } @@ -407,6 +445,8 @@ interface PackageCommitOptions { interface PackageCommitResult extends EnsuredTag { /** The build tags this run deleted, beyond the kept window. */ pruned: string[]; + + /** latest's move: forward to this package, or left where it is. */ latest: PointerMove; } @@ -426,19 +466,30 @@ export function packageCommit(options: PackageCommitOptions): PackageCommitResul } interface PackageOptions { + /** The checkout, at the merge commit with the bundle built. */ cwd: string; + + /** The release's vX.Y.Z tag, as release-please cut the draft. */ tag: string; + /** The release's merge commit, resolved from the draft (not necessarily this run's own push); the tag's recorded source. */ sourceSha: string; + /** Provenance trailer for a packaged commit this run has to mint (the workflow run URL). */ runUrl?: string; } interface PackagedRelease { + /** Whether this run minted the version tag, or found and verified it. */ created: boolean; + + /** The packaged commit the version tag names. */ packagedSha: string; + /** The build tags this run deleted, beyond the kept window. */ pruned: string[]; + + /** latest's move: forward to this package, or left where it is. */ latest: PointerMove; } diff --git a/biome.json b/biome.json index f01efd900..23efd7e4b 100644 --- a/biome.json +++ b/biome.json @@ -1,122 +1,33 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true - }, - "files": { - "includes": ["**", "!lib", "!.github/repo-platform-manifest.json"] - }, - "formatter": { - "enabled": true, - "includes": ["**", "!src/generated/upstream-gaps.ts"], - "indentStyle": "space", - "indentWidth": 2, - "lineWidth": 100 - }, + "$schema": "https://biomejs.dev/schemas/2.5.11/schema.json", + "extends": ["./biome.local.jsonc"], "linter": { "enabled": true, "rules": { - "preset": "recommended", - "correctness": { - "noUnusedImports": "error", - "noUnusedVariables": "error" + "complexity": { + "noExcessiveCognitiveComplexity": { + "level": "error", + "options": { "maxAllowedComplexity": 15 } + }, + "noExcessiveLinesPerFunction": { "level": "error", "options": { "maxLines": 80 } }, + "useMaxParams": { "level": "error", "options": { "max": 6 } } }, "style": { - "noNonNullAssertion": "error", - "noRestrictedImports": { - "level": "error", - "options": { - "paths": { - "@actions/core": "Only src/action/ implements the Io port over @actions/core; every other layer takes an Io." - }, - "patterns": [ - { - "group": ["**/private-open", "**/private-open.*"], - "message": "Only the redaction projections may open a Private seal; render through publicDetail/toPublicView, or compose the private report." - } - ] - } - } - } - } - }, - "javascript": { - "formatter": { - "quoteStyle": "double" + "noNestedTernary": "error", + "noUselessElse": "error", + "noNegationElse": "error", + "noParameterAssign": "error" + }, + "suspicious": { "noAssignInExpressions": "error", "noUnusedExpressions": "error" }, + "performance": { "noBarrelFile": "error", "noReExportAll": "error" }, + "correctness": { "noInvalidUseBeforeDeclaration": "error" } } }, "overrides": [ { - "includes": ["src/generated/upstream-gaps.ts"], - "assist": { - "actions": { - "source": { - "organizeImports": "off" - } - } - } - }, - { - "includes": ["src/**"], - "plugins": ["./lint/never-throw.grit"] - }, - { - "includes": ["src/**", "!src/sections/shared/schema-helpers.ts"], - "plugins": ["./lint/rule-gate.grit"] - }, - { - "includes": ["src/action/**"], - "linter": { - "rules": { - "style": { - "noRestrictedImports": { - "level": "error", - "options": { - "patterns": [ - { - "group": ["**/private-open", "**/private-open.*"], - "message": "Only the redaction projections may open a Private seal; render through publicDetail/toPublicView, or compose the private report." - } - ] - } - } - } - } - } - }, - { - "includes": ["src/flows/redact.ts"], - "linter": { - "rules": { - "style": { - "noRestrictedImports": "off" - } - } - } - }, - { - "includes": [ - "src/private.ts", - "src/discovery/discover.ts", - "src/report/delivery.ts", - "test/private.test.ts" - ], - "linter": { - "rules": { - "style": { - "noRestrictedImports": { - "level": "error", - "options": { - "paths": { - "@actions/core": "Only src/action/ implements the Io port over @actions/core; every other layer takes an Io." - } - } - } - } - } - } + "includes": ["biome.json", ".github/repo-platform-manifest.json"], + "formatter": { "enabled": false }, + "assist": { "enabled": false } } ] } diff --git a/biome.local.jsonc b/biome.local.jsonc new file mode 100644 index 000000000..bd847a9c7 --- /dev/null +++ b/biome.local.jsonc @@ -0,0 +1,160 @@ +// The repository's own biome layer: exclusions, the formatter, extra rules, and per-path overrides with their +// reasons. The managed biome.json extends this file and applies last, so the floor cannot be lowered here; the one +// relaxation is a per-path `overrides` entry below with its reason beside it (docs/modules/typescript.md). +{ + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!lib", "!.github/repo-platform-manifest.json"] + }, + "formatter": { + "enabled": true, + "includes": ["**", "!src/generated/upstream-gaps.ts"], + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "rules": { + "preset": "recommended", + "correctness": { + "noUnusedImports": "error", + "noUnusedVariables": "error" + }, + "style": { + "noNonNullAssertion": "error", + "noRestrictedImports": { + "level": "error", + "options": { + "paths": { + "@actions/core": "Only src/action/ implements the Io port over @actions/core; every other layer takes an Io." + }, + "patterns": [ + { + "group": ["**/private-open", "**/private-open.*"], + "message": "Only the redaction projections may open a Private seal; render through publicDetail/toPublicView, or compose the private report." + } + ] + } + } + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "overrides": [ + { + "includes": ["src/generated/upstream-gaps.ts"], + "assist": { + "actions": { + "source": { + "organizeImports": "off" + } + } + } + }, + { + "includes": ["src/**"], + "plugins": ["./lint/never-throw.grit"] + }, + { + "includes": ["src/**", "!src/sections/shared/schema-helpers.ts"], + "plugins": ["./lint/rule-gate.grit"] + }, + { + "includes": ["src/action/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["**/private-open", "**/private-open.*"], + "message": "Only the redaction projections may open a Private seal; render through publicDetail/toPublicView, or compose the private report." + } + ] + } + } + } + } + } + }, + { + "includes": ["src/flows/redact.ts"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": "off" + } + } + } + }, + { + "includes": [ + "src/private.ts", + "src/discovery/discover.ts", + "src/report/delivery.ts", + "test/private.test.ts" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "paths": { + "@actions/core": "Only src/action/ implements the Io port over @actions/core; every other layer takes an Io." + } + } + } + } + } + } + }, + // The package's public and internal entry points exist to re-export: a barrel is their whole job. + { + "includes": ["src/index.ts", "src/internal.ts"], + "linter": { + "rules": { + "performance": { + "noBarrelFile": "off", + "noReExportAll": "off" + } + } + } + }, + // A test table is data, not a function body. + { + "includes": ["test/**"], + "linter": { + "rules": { + "complexity": { + "noExcessiveLinesPerFunction": "off" + } + } + } + }, + // normalizePlainData keeps one stack frame per nesting level on purpose (a helper for the container body would halve the depth a valid payload may reach); a split keeping that triplicates the scalar and container guards in each loop for no reading gain. + { + "includes": ["src/github/secret-scan.ts"], + "linter": { + "rules": { + "complexity": { + "noExcessiveCognitiveComplexity": { + "level": "error", + "options": { "maxAllowedComplexity": 30 } + } + } + } + } + } + ] +} diff --git a/bun.lock b/bun.lock index 832302c59..c460a7967 100644 --- a/bun.lock +++ b/bun.lock @@ -23,14 +23,14 @@ "picocolors": "1.1.1", "picomatch": "4.0.7", "yaml": "2.9.1", - "zod": "4.6.5", + "zod": "4.6.7", }, "devDependencies": { "@actions/artifact": "6.2.1", "@actions/core": "3.0.1", "@actions/expressions": "0.3.61", "@arethetypeswrong/cli": "0.18.5", - "@biomejs/biome": "2.5.15", + "@biomejs/biome": "2.5.11", "@octokit/graphql-schema": "15.26.1", "@octokit/openapi": "24.0.0", "@octokit/openapi-webhooks": "12.1.0", @@ -102,23 +102,23 @@ "@azure/storage-common": ["@azure/storage-common@12.5.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-http-compat": "^2.4.0", "@azure/core-rest-pipeline": "^1.24.0", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.1.4", "events": "^3.3.0", "tslib": "^2.8.1" } }, "sha512-bttzuhQiCIwrkzjPDA+AtAR7dg19L/CC6ztcqJ5LfvWpXuys9mHp0UQ0udYnoUvv9SCT9KTR5kqFvFr0e6k0lQ=="], - "@biomejs/biome": ["@biomejs/biome@2.5.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.15", "@biomejs/cli-darwin-x64": "2.5.15", "@biomejs/cli-linux-arm64": "2.5.15", "@biomejs/cli-linux-arm64-musl": "2.5.15", "@biomejs/cli-linux-x64": "2.5.15", "@biomejs/cli-linux-x64-musl": "2.5.15", "@biomejs/cli-win32-arm64": "2.5.15", "@biomejs/cli-win32-x64": "2.5.15" }, "bin": { "biome": "bin/biome" } }, "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ=="], + "@biomejs/biome": ["@biomejs/biome@2.5.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.11", "@biomejs/cli-darwin-x64": "2.5.11", "@biomejs/cli-linux-arm64": "2.5.11", "@biomejs/cli-linux-arm64-musl": "2.5.11", "@biomejs/cli-linux-x64": "2.5.11", "@biomejs/cli-linux-x64-musl": "2.5.11", "@biomejs/cli-win32-arm64": "2.5.11", "@biomejs/cli-win32-x64": "2.5.11" }, "bin": { "biome": "bin/biome" } }, "sha512-Tj0dnkLPdW0ASjHfj2D/ZkkvPU2wrFmnE1jWTD2xzV1ycapV1DutbYXk4NDnR3rYTi1ZCbNFD4G2gRMEY65WaA=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6SGZxoKbXvUjMn1t6A98HqWISPnGNbYs0R/Rt2JarmXBSev+lva4QxUMWEBX9lX1Wo1XTJ78uk5xVDtG58SRZg=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-nYkXY7tLBEgnGbYapDKAyKzgt44ZEyG+AKalvTXtCWKYgepI9dw327q+cVgedxm+Udi1ZzHKUyZrIusHi/KQbw=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-3PVLSTD9RR73rvVPt5G3T1gc+ycggWEGfTD7RvzzbtcDPD27NxgxBbAFfpm7DXJKW6VLHWE1lLMGvFt2Qxjcow=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-qhyZUMyCbWYFV2bAwRNVvfMVZ+hv7WYl6mossGrxC+uiQQXhvsuWWU8zz6jYX0mChZd9MgQZbm4vozTmG/5iGw=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.11", "", { "os": "linux", "cpu": "x64" }, "sha512-JOytptlsgM33B2MMFUg8iBrb4IKpbD5JnJrSeYiaFEeAj4vuXx0iQSQZ4qK7sqyMtfjZxxPdNdMZZVL4y/mFyA=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.11", "", { "os": "linux", "cpu": "x64" }, "sha512-oRRlrchG5EfrEL/EmtT1qUjSNHk3/5LGeZhQqADBBAJF1b1ET6964xEKe7aGlGARzDfza8H/seEsFJl7S6Ql9w=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-e49E6K9hzH/ohJNx8Y26mY8HaV4I4ZViIeoqhKsmoXLKHhQnMeBAVqCgsGf2Wa3lXlS7RkporDXMHHWkzvZzFw=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.15", "", { "os": "win32", "cpu": "x64" }, "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.11", "", { "os": "win32", "cpu": "x64" }, "sha512-QSQr/KjOgXA7OzXJUWS+oguKyAZ3Q0l/lnlDGbu397eKo83atuWUjBPJrsqbKNF6CARGw8XXJLGzpHC8Ryhd4Q=="], "@braidai/lang": ["@braidai/lang@1.1.2", "", {}, "sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA=="], @@ -904,7 +904,7 @@ "zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="], - "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index 6020041cf..eb564d8b6 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -12,7 +12,7 @@ The [module map](#the-module-map) at the end is generated from [architecture.yml A Biome linter plugin, `lint/never-throw.grit`, enforces the never-throw rule: a function that can fail returns a neverthrow `Result` carrying a typed `Problem`, so an error is a value the caller handles. A section's `plan()`, `snapshot()`, and operation hooks carry a `SectionFailure` instead, which the engine loops match on by kind. -A `throw` is allowed as a `BUG:` invariant, as a bare rethrow inside its own `catch`, or where a third party's contract demands it, in a file the plugin's entry in `biome.json` exempts and the plugin's header names with its reason. +A `throw` is allowed as a `BUG:` invariant, as a bare rethrow inside its own `catch`, or where a third party's contract demands it, in a file the plugin's entry in `biome.local.jsonc` exempts and the plugin's header names with its reason. ## The journey of one settings file diff --git a/lint/never-throw.grit b/lint/never-throw.grit index 0fbe1379b..0941dfd55 100644 --- a/lint/never-throw.grit +++ b/lint/never-throw.grit @@ -2,7 +2,7 @@ // BUG: invariant (a programming error no user can cause) or a bare rethrow of the catch binding; anything else is a // failure the caller should receive as a Result. // -// Reach and exemptions are the plugin's `overrides` entry in biome.json, with `includes` relative to the repository +// Reach and exemptions are the plugin's `overrides` entry in biome.local.jsonc, with `includes` relative to the repository // root; a top-level `plugins[].includes` matches the absolute path (biomejs/biome#11082), so the checkout's parent // directories would decide the reach. The rule covers src/** only; .github/scripts/ stays exempt by policy, since a // build script may throw on a user-caused failure. diff --git a/lint/rule-gate.grit b/lint/rule-gate.grit index 6de8ba890..8917a40a0 100644 --- a/lint/rule-gate.grit +++ b/lint/rule-gate.grit @@ -1,6 +1,6 @@ // Every refinement under src/ is authored through rule() (src/sections/shared/schema-helpers.ts): zod skips a bare // .refine() or .superRefine() once a nested value failed, so a section's own rule would go silent exactly when the -// document has a second mistake. Reach and the one exemption are the plugin's `overrides` entry in biome.json (see +// document has a second mistake. Reach and the one exemption are the plugin's `overrides` entry in biome.local.jsonc (see // never-throw.grit for why): // src/sections/shared/schema-helpers.ts -> rule() itself is the one superRefine diff --git a/package.json b/package.json index 1b4a16f22..ef324d83e 100644 --- a/package.json +++ b/package.json @@ -80,14 +80,14 @@ "picocolors": "1.1.1", "picomatch": "4.0.7", "yaml": "2.9.1", - "zod": "4.6.5" + "zod": "4.6.7" }, "devDependencies": { "@actions/artifact": "6.2.1", "@actions/core": "3.0.1", "@actions/expressions": "0.3.61", "@arethetypeswrong/cli": "0.18.5", - "@biomejs/biome": "2.5.15", + "@biomejs/biome": "2.5.11", "@octokit/graphql-schema": "15.26.1", "@octokit/openapi": "24.0.0", "@octokit/openapi-webhooks": "12.1.0", diff --git a/src/action/io.ts b/src/action/io.ts index c73c3fef4..c985b2e60 100644 --- a/src/action/io.ts +++ b/src/action/io.ts @@ -21,8 +21,7 @@ export const OUTPUT_DECLS = { }, } as const satisfies Record; -// @actions/core owns workflow-command escaping (%, CR, LF). The static map keeps the namespace access tree-shakeable -// (biome noDynamicNamespaceImportAccess); the references are captured at module load, so a spy installed after import is not seen. +// The static map keeps the namespace access tree-shakeable (biome noDynamicNamespaceImportAccess) and captures the references at module load, so a spy installed after import is not seen; @actions/core owns the workflow-command escaping (%, CR, LF). const annotators = { notice: core.notice, warning: core.warning, error: core.error } as const; export function annotate(level: keyof typeof annotators, message: string): void { diff --git a/src/cli/actions.ts b/src/cli/actions.ts index b81a267fb..f3cefdcdc 100644 --- a/src/cli/actions.ts +++ b/src/cli/actions.ts @@ -12,6 +12,7 @@ import type { AnnotationLevel, OutputName } from "../index.js"; export interface ActionsRunner { /** GITHUB_OUTPUT; the runner always sets it, so an unset one only drops the record, as the action's Io does. */ readonly outputFile: string | undefined; + /** GITHUB_STEP_SUMMARY; `--summary ` takes precedence over it. */ readonly summaryFile: string | undefined; } diff --git a/src/cli/init.ts b/src/cli/init.ts index ce89160ca..d3309e87b 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -34,11 +34,9 @@ export interface InitConfig { readonly token: string; readonly apiVersion: string; readonly repo: RepoRef; - /** Where the document is written: the file apply and check read. */ readonly settingsFile: string; readonly sections: SectionSelection; readonly onMissingPermission: "fail" | "warn"; - /** Replace an existing settings file instead of refusing. */ readonly force: boolean; } @@ -60,7 +58,6 @@ export type InitProblem = readonly code: "init-empty-document"; readonly repository: string; readonly settingsFile: string; - /** Why each selected section declares nothing, keyed by the outcome's status. */ readonly reasons: ReadonlyArray; }; diff --git a/src/cli/inputs.ts b/src/cli/inputs.ts index d34fd0931..bdd3b007a 100644 --- a/src/cli/inputs.ts +++ b/src/cli/inputs.ts @@ -27,10 +27,7 @@ const INPUT_NAMES = Object.keys(INPUT_DECLS) as InputName[]; /** The two inputs that are not subcommand flags: the mode is the subcommand, the token is global. */ const PROGRAM_INPUTS = ["mode", "token"] as const satisfies readonly InputName[]; -/** - * Inputs no subcommand exposes: the artifact report channel needs the Actions - * artifact service, which a terminal has no upload for, so its key has no use. - */ +/** Inputs no subcommand exposes: the artifact report channel needs the Actions artifact service, which a terminal has no upload for, so its key has no use. */ export const CLI_UNSUPPORTED_INPUTS = ["report-public-key"] as const satisfies readonly InputName[]; /** The flags a mode's subcommand takes: the inputs its mode reads, in declaration order. */ @@ -62,10 +59,7 @@ export function modeSubcommand(mode: Mode): Subcommand { return { flags: new Set(inputsForMode(mode)) }; } -/** - * The init subcommand: the snapshot inputs of one repository, with - * settings-file as the destination in place of snapshot-file. - */ +/** The init subcommand: the snapshot inputs of one repository, with settings-file as the destination in place of snapshot-file. */ export const INIT_SUBCOMMAND: Subcommand = { flags: new Set([ "repository", @@ -81,10 +75,7 @@ export const INIT_INPUTS: readonly InputName[] = INPUT_NAMES.filter((name) => INIT_SUBCOMMAND.flags.has(name), ); -/** - * init's one reworded flag: the declaration describes the file apply and check - * READ, and init WRITES it; every other init flag keeps its declared text. - */ +/** init's one reworded flag: the declaration describes the file apply and check READ, and init WRITES it; every other init flag keeps its declared text. */ export const INIT_SETTINGS_FILE_DESCRIPTION = "Where the settings document is written: the file apply and check read. One path; an existing file is kept unless --force is passed."; @@ -109,10 +100,15 @@ export function declared(input: InputName, text: string): string { * removed verbatim from the help of a subcommand that lacks one of them. */ interface Clause { + /** The input whose description holds the clause. */ readonly input: InputName; + + /** The clause, verbatim from the declaration. */ readonly text: string; + /** What stands in for `text` when it is removed. */ readonly replacement: string; + /** Met when the subcommand accepts every one of these flags. */ readonly flags: readonly InputName[]; } diff --git a/src/cli/io.ts b/src/cli/io.ts index 51e1cfba8..0df63c717 100644 --- a/src/cli/io.ts +++ b/src/cli/io.ts @@ -34,6 +34,7 @@ export interface CliStreams { export interface MaskedStreams extends CliStreams, MaskPair { /** `text` with every masked value replaced, for the writes that bypass the streams. */ redact(text: string): string; + /** The runner this boundary reports to; none for a terminal. */ readonly runner: RunnerChannel | undefined; } @@ -103,18 +104,25 @@ export function maskedStreams(streams: CliStreams, runner?: ActionsRunner): Mask } export interface CliIoOptions { + /** The masked boundary every write goes through. */ readonly streams: MaskedStreams; + /** Print the outputs as one JSON object; log lines move to stderr so stdout is that object alone. */ readonly json: boolean; + /** Show the debug trace. */ readonly verbose: boolean; + /** The file summary blocks are appended to; none falls back to the runner's step summary, or drops them. */ readonly summaryFile?: string; + + /** Color the level labels. */ readonly colors: boolean; } export interface CliIo { readonly io: Io; + /** * Print the collected outputs to stdout, in the form the json option picked. `problem` is the fatal problem a run * ended in, worded for the terminal; the envelope carries it beside the outputs, the line form already has it on stderr. @@ -140,10 +148,7 @@ const LABEL: Partial> = { debug: { label: "debug", paint: (colors) => colors.dim }, }; -/** - * How each output reads inside the --json envelope: the action's outputs are strings (a comma list, a JSON document), - * and a JSON envelope carries the value itself, never a string a reader would parse again. - */ +/** Each output's form inside the --json envelope: the action's outputs are strings (a comma list, a JSON document), and a JSON envelope carries the value itself, never a string a reader would parse again. */ const JSON_OUTPUT: Record unknown> = { result: (value) => value, "skipped-sections": (value) => (value === "" ? [] : value.split(",")), diff --git a/src/cli/program.ts b/src/cli/program.ts index ba5db04f4..083d6c693 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -38,7 +38,7 @@ import { once, tokenValues, } from "./inputs.js"; -import { type CliStreams, cliIo, type MaskedStreams, maskedStreams } from "./io.js"; +import { type CliIo, type CliStreams, cliIo, type MaskedStreams, maskedStreams } from "./io.js"; /** Every subcommand, in help order; the package smoke asserts the installed help names each. */ export const CLI_COMMANDS = [ @@ -53,6 +53,7 @@ export const CLI_COMMANDS = [ type CliCommand = (typeof CLI_COMMANDS)[number]; +/** What `--help` says of each subcommand. */ const DESCRIPTION: Readonly> = { check: "Report drift between a settings file and the live repository; exits 1 on any drift", apply: "Apply a settings file to the repository", @@ -84,17 +85,33 @@ interface Globals { } export interface ProgramOptions { + /** The environment and the client factory. */ readonly host: CliHost; + /** The output boundary every writer, the parser included, goes through. */ readonly streams: MaskedStreams; + /** Color the level labels and the permissions table; defaults to picocolors' detection. */ readonly colors?: boolean; + /** Run a parsed config to its end; tests capture the config here instead of running it. */ readonly execute?: (cfg: RunConfig, io: Io) => Promise; + /** Run a parsed init config to its rendering; tests capture the config here instead of running it. */ readonly executeInit?: (cfg: InitConfig, io: Io) => Promise; } +/** What every subcommand's action shares; `exit` records the code main() returns. */ +interface Wiring { + readonly host: CliHost; + readonly streams: MaskedStreams; + readonly bold: (text: string) => string; + readonly execute: (cfg: RunConfig, io: Io) => Promise; + readonly executeInit: (cfg: InitConfig, io: Io) => Promise; + readonly openIo: (globals: Globals) => CliIo; + readonly exit: (code: number) => void; +} + /** The production host: process.env and the real client. */ export function processHost(): CliHost { return { @@ -106,32 +123,9 @@ export function processHost(): CliHost { /** A terminal has no Actions artifact service, so the artifact report channel is refused at the parse. */ const CLI_CAPABILITIES = { artifactUpload: false } as const; -/** - * The whole command tree, wired to `options`; the exit code lands in the - * returned holder. The environment's token is masked here, before any writer - * exists; the argv token is main()'s to register, before the parse. - */ -export function buildProgram(options: ProgramOptions): { - program: Command; - exitCode: () => number; -} { - const { host, streams } = options; - const colors = options.colors ?? pc.isColorSupported; - const paint = pc.createColors(colors); - const execute = - options.execute ?? - ((cfg, io) => - executeRun(cfg, { - io, - createClient: (token, io, apiVersion) => host.createClient(token, io, apiVersion), - })); - const executeInit = options.executeInit ?? ((cfg, io) => runInit(cfg, io, host, paint.bold)); - const envToken = host.env.GITHUB_TOKEN?.trim(); - if (envToken !== undefined && envToken !== "") { - streams.mask(envToken); - } - let exitCode = 0; - const program = new Command() +/** The root command with the four global flags, every line it prints going through the masked streams. */ +function rootProgram(streams: MaskedStreams): Command { + return new Command() .name("github-settings-as-code") .description( "Apply, check, render, and validate declarative GitHub repository settings (also installed as gsac)", @@ -155,42 +149,38 @@ export function buildProgram(options: ProgramOptions): { writeOut: (text) => streams.stdout.write(text), writeErr: (text) => streams.stderr.write(text), }); +} - const openIo = (globals: Globals) => - cliIo({ - streams, - json: globals.json === true, - verbose: globals.verbose === true, - summaryFile: globals.summary, - colors, - }); +/** Print a file-only command's result the way `--json` asks. */ +function present(streams: MaskedStreams, rendered: Rendered, globals: Globals): void { + if (globals.json === true) { + streams.stdout.write(`${JSON.stringify(rendered.json)}\n`); + return; + } - /** Print a file-only command's result the way `--json` asks. */ - const present = (rendered: Rendered, globals: Globals): void => { - if (globals.json === true) { - streams.stdout.write(`${JSON.stringify(rendered.json)}\n`); - return; - } - for (const line of rendered.lines) { - streams.stdout.write(`${line}\n`); - } - }; + for (const line of rendered.lines) { + streams.stdout.write(`${line}\n`); + } +} +/** check, apply, render, and snapshot: each reads its flags as the action's inputs and runs its mode. */ +function addModeCommands(program: Command, wiring: Wiring): void { for (const [name, mode] of Object.entries(MODE_COMMANDS) as [CliCommand, Mode][]) { const command = program.command(name).description(DESCRIPTION[name]); const subcommand = modeSubcommand(mode); for (const input of subcommand.flags) { command.addOption(inputOption(input, subcommand)); } + command.action(async function (this: Command) { const values = this.optsWithGlobals>(); - const { io, flush } = openIo(values); + const { io, flush } = wiring.openIo(values); const read = argvReader(mode, values); // The envelope carries the fatal problem's text beside the outputs; the line itself is already on stderr. let fatal: string | undefined; - exitCode = await parseConfig(read, host.env, CLI_CAPABILITIES).match( + const code = await parseConfig(read, wiring.host.env, CLI_CAPABILITIES).match( async (cfg) => { - const end = await execute(cfg, io); + const end = await wiring.execute(cfg, io); fatal = end.fatal === undefined ? undefined : describeProblem(end.fatal); return end.exitCode; }, @@ -199,10 +189,14 @@ export function buildProgram(options: ProgramOptions): { return failRun(io, problem); }, ); + wiring.exit(code); flush(fatal); }); } +} +/** init: the snapshot flags of one repository, with the settings file as the destination. */ +function addInitCommand(program: Command, wiring: Wiring): void { const init = program.command("init").description(DESCRIPTION.init); for (const input of INIT_INPUTS) { init.addOption( @@ -211,32 +205,36 @@ export function buildProgram(options: ProgramOptions): { : inputOption(input, INIT_SUBCOMMAND), ); } + init .option("--force", "Replace the settings file when it already exists; without it, init refuses") .action(async function (this: Command) { const values = this.optsWithGlobals< Globals & { force?: boolean } & Record >(); - const { io } = openIo(values); + const { io } = wiring.openIo(values); const read = argvReader("snapshot", values); - const rendered = await parseInitConfig(read, values.force === true, host.env).match( - (cfg) => executeInit(cfg, io), + const rendered = await parseInitConfig(read, values.force === true, wiring.host.env).match( + (cfg) => wiring.executeInit(cfg, io), async (problem) => failInit(io, problem, snapshotFileDestination(read)), ); - present(rendered, values); - exitCode = rendered.code; + present(wiring.streams, rendered, values); + wiring.exit(rendered.code); }); +} +/** validate and permissions: a settings file alone, with no token and no API call. */ +function addFileCommands(program: Command, wiring: Wiring): void { program .command("validate") .description(DESCRIPTION.validate) .argument("", "the settings file to validate") .action(function (this: Command, file: string) { const globals = this.optsWithGlobals(); - const { io } = openIo(globals); + const { io } = wiring.openIo(globals); const rendered = validateFile(file, io); - present(rendered, globals); - exitCode = rendered.code; + present(wiring.streams, rendered, globals); + wiring.exit(rendered.code); }); program @@ -245,15 +243,87 @@ export function buildProgram(options: ProgramOptions): { .argument("", "the settings file whose sections decide the grant") .action(function (this: Command, file: string) { const globals = this.optsWithGlobals(); - const { io } = openIo(globals); - const rendered = permissionsFor(file, io, paint.bold); - present(rendered, globals); - exitCode = rendered.code; + const { io } = wiring.openIo(globals); + const rendered = permissionsFor(file, io, wiring.bold); + present(wiring.streams, rendered, globals); + wiring.exit(rendered.code); }); +} + +/** + * The whole command tree, wired to `options`; the exit code lands in the + * returned holder. The environment's token is masked here, before any writer + * exists; the argv token is main()'s to register, before the parse. + */ +export function buildProgram(options: ProgramOptions): { + program: Command; + exitCode: () => number; +} { + const { host, streams } = options; + const colors = options.colors ?? pc.isColorSupported; + const paint = pc.createColors(colors); + const execute = + options.execute ?? + ((cfg, io) => + executeRun(cfg, { + io, + createClient: (token, io, apiVersion) => host.createClient(token, io, apiVersion), + })); + const executeInit = options.executeInit ?? ((cfg, io) => runInit(cfg, io, host, paint.bold)); + const envToken = host.env.GITHUB_TOKEN?.trim(); + if (envToken !== undefined && envToken !== "") { + streams.mask(envToken); + } + let exitCode = 0; + const program = rootProgram(streams); + const wiring: Wiring = { + host, + streams, + bold: paint.bold, + execute, + executeInit, + openIo: (globals) => + cliIo({ + streams, + json: globals.json === true, + verbose: globals.verbose === true, + summaryFile: globals.summary, + colors, + }), + exit: (code) => { + exitCode = code; + }, + }; + addModeCommands(program, wiring); + addInitCommand(program, wiring); + addFileCommands(program, wiring); return { program, exitCode: () => exitCode }; } +/** The parser's own exit: commander already wrote its line (or the usage) to stderr, and --help exits 0, no failure. */ +function parserExit(error: CommanderError, failedJson: (message: string) => void): number { + // A missing subcommand is the usage shown as an error, whose message is commander's "(outputHelp)" sentinel. + if (error.exitCode !== 0) { + failedJson( + error.code === "commander.help" + ? "no subcommand was given; the usage above lists them" + : error.message.replace(/^error: /, ""), + ); + } + + return error.exitCode; +} + +/** The crash line; under --verbose the stack is already printed, so asking for it again would loop. */ +function crashMessage(error: unknown, verbose: boolean): string { + const detail = verbose && error instanceof Error && error.stack ? error.stack : String(error); + const remedy = verbose + ? "The stack above is the report: if it recurs, file a bug with it attached" + : "Re-run with --verbose for the stack; if it recurs, file a bug with that output attached"; + return `github-settings-as-code stopped unexpectedly: ${detail}. ${remedy}`; +} + /** * Run `argv` (the full process.argv shape) to its exit code; every line, a crash's included, is masked. Under --json a * failure the parser or a crash ends in prints the same failed envelope a run prints, so stdout is always one object. @@ -282,25 +352,12 @@ export async function main( await program.parseAsync(argv); } catch (error) { if (error instanceof CommanderError) { - // Commander already wrote its line (or the usage) to stderr; --help exits 0 and is no failure. A missing - // subcommand is the usage shown as an error, whose message is commander's "(outputHelp)" sentinel. - if (error.exitCode !== 0) { - failedJson( - error.code === "commander.help" - ? "no subcommand was given; the usage above lists them" - : error.message.replace(/^error: /, ""), - ); - } - return error.exitCode; + return parserExit(error, failedJson); } + const globals = program.opts(); const verbose = globals.verbose === true; - const detail = verbose && error instanceof Error && error.stack ? error.stack : String(error); - // Under --verbose the stack is already printed, so asking for it again would loop. - const remedy = verbose - ? "The stack above is the report: if it recurs, file a bug with it attached" - : "Re-run with --verbose for the stack; if it recurs, file a bug with that output attached"; - const message = `github-settings-as-code stopped unexpectedly: ${detail}. ${remedy}`; + const message = crashMessage(error, verbose); // The crash line goes through the same renderer as every other error line, so its label and color match. cliIo({ streams, diff --git a/src/discovery/discover.ts b/src/discovery/discover.ts index 9a01d4f86..39733068a 100644 --- a/src/discovery/discover.ts +++ b/src/discovery/discover.ts @@ -45,18 +45,13 @@ function normalizeVisibility(repo: DiscoveredRepo): DiscoveredRepoRef["visibilit return visibility === "unknown" ? "private" : visibility; } -/** Allowed values per discovery-filter input; the single source the input validation and types derive from. */ +// Allowed values per discovery-filter input; the single source the input validation and types derive from. + export const VISIBILITY_FILTERS = ["all", "public", "private", "internal"] as const; export const ARCHIVED_FILTERS = ["skip", "include", "only"] as const; export const FORKS_FILTERS = ["include", "exclude", "only"] as const; export const AFFILIATIONS = ["owner", "collaborator", "organization_member"] as const; -/** - * Shared by the rule that emits it and formatSkipNotice, which special-cases it for the unarchive-to-manage prose; a - * literal in one place and not the other would silently drop that guidance. - */ -const ARCHIVED_REASON = "archived"; - /** Filters applied to repos: "*" discovery only, never to explicit targets. */ export interface DiscoveryFilters { visibility: (typeof VISIBILITY_FILTERS)[number]; @@ -81,6 +76,9 @@ export interface DiscoveryResult { filtered: Array<{ reason: string; repos: FilteredRepoRef[] }>; } +/** Shared by the rule that emits it and formatSkipNotice, which special-cases it for the unarchive-to-manage prose; a literal in one place and not the other would silently drop that guidance. */ +const ARCHIVED_REASON = "archived"; + export type DiscoveryProblem = ProblemOf< "discovery-request-failed" | "discovery-transport-failed" | "discovery-response-not-a-list" >; diff --git a/src/discovery/exclude-pattern.ts b/src/discovery/exclude-pattern.ts index e158ed898..d071fe5a7 100644 --- a/src/discovery/exclude-pattern.ts +++ b/src/discovery/exclude-pattern.ts @@ -1,6 +1,15 @@ /** * The `exclude` input's glob grammar: one pattern compiled once at the input boundary, every flaw named beside its * fix, and the match of a compiled pattern against a discovered repository's slug or name. + * + * The grammar is owned here, not left to picomatch, because picomatch accepts far more than a repository name can + * hold ("(a)+" a quantifier, "\d" a digit class, "{az..bz}" a range, "./" silently dropped, "***" losing the + * escape of a later dot) and compiles several such inputs to a wrong or never-matching regex with no option to + * refuse them. The pattern is the user's input, so a flaw is refused naming its fix, never repaired, and every + * flaw of one pattern is named at once, so N flaws cost one run to discover, not N: each step names one fix and + * hands the next step the view with it applied, in the order the sentences take. What is left for the user to + * write is named last with no fix applied: the length cap, an empty body, the sides around a "/", a side that is + * only dots, and a regex the engine itself refuses. */ import { err, ok, type Result } from "neverthrow"; @@ -15,38 +24,29 @@ import { quote } from "../text.js"; export interface ExcludePattern { /** The operator's text, for the skip notice. */ readonly pattern: string; + /** "slug" when the pattern has a "/" and is compared against owner/name, "name" when against the name alone. */ readonly scope: "slug" | "name"; + + /** picomatch's matcher for the pattern, run against the text the scope picks. */ readonly regex: RegExp; } -/** - * picomatch is built for file paths and a slug is a GitHub identifier, so each option is set for a name, none left to its default: - * nocase -> GitHub compares repository names case-insensitively - * dot -> a leading "." is an ordinary name character (".github"), not the hidden-file marker - * posix -> "[!ab]" is a negated class, as in a shell, not a class holding "!" - * fastpaths -> the star-led shortcut regexes demand a character after a literal dot, so "*.*" would miss "archive." - * debug -> an invalid class like "[z-a]" throws instead of compiling to a never-matching regex - * windows -> pinned: only picomatch's main callable reads the host OS; makeRe never does - * literalBrackets -> no alternation with the class's literal text (no name holds it); thousands compile in milliseconds, under the engine's limit - */ +/** picomatch is built for file paths and a slug is a GitHub identifier, so each option is set for a name, none left to its default. */ const EXCLUDE_GLOB_OPTIONS = { - nocase: true, - dot: true, - posix: true, - fastpaths: false, - debug: true, - windows: false, - literalBrackets: false, + nocase: true, // GitHub compares repository names case-insensitively + dot: true, // a leading "." is an ordinary name character (".github"), not the hidden-file marker + posix: true, // "[!ab]" is a negated class, as in a shell, not a class holding "!" + fastpaths: false, // the star-led shortcut regexes demand a character after a literal dot, so "*.*" would miss "archive." + debug: true, // an invalid class like "[z-a]" throws instead of compiling to a never-matching regex + windows: false, // pinned: only picomatch's main callable reads the host OS; makeRe never does + literalBrackets: false, // no alternation with the class's literal text (no name holds it); thousands compile in milliseconds, under the engine's limit } as const; /** Every character no owner or repository name holds and no glob operator here uses. */ const EXCLUDE_STRAYS = /[^\w.\-/*?[\]!^]/g; -/** - * A class: "!" or "^" may open it, its members are name characters, and a "-" is a range between two of them or - * a literal first or last. Nothing else, so turning a backwards range around can never pair a new one. - */ +/** A class: "!" or "^" may open it, its members are name characters, and a "-" is a range between two of them or a literal first or last, nothing else, so turning a backwards range around can never pair a new one. */ const EXCLUDE_CLASS = /^\[[!^]?(?:-?(?:[\w.](?:-[\w.])?)+-?|-)\]$/; /** One side of the "/": name characters, "*", "?", and classes. */ @@ -61,33 +61,37 @@ function unpairedBrackets(view: string): number[] { let open = -1; for (let i = 0; i < view.length; i++) { const char = view[i]; + if (char === "[" && open < 0) { + open = i; + continue; + } + if (char === "[") { - if (open >= 0) { - unpaired[i] = true; - } else { - open = i; - } - } else if (char === "]") { - if (open >= 0) { - open = -1; - } else { - unpaired[i] = true; - } - } else if (char === "/" && open >= 0) { + unpaired[i] = true; + continue; + } + + if (char === "]" && open < 0) { + unpaired[i] = true; + continue; + } + + if (char === "]") { + open = -1; + continue; + } + + if (char === "/" && open >= 0) { unpaired[open] = true; open = -1; } } + if (open >= 0) { unpaired[open] = true; } - const where: number[] = []; - for (let i = 0; i < view.length; i++) { - if (unpaired[i]) { - where.push(i); - } - } - return where; + + return Array.from({ length: view.length }, (_, i) => i).filter((i) => unpaired[i] === true); } /** `where` of every "!" or "^" that neither negates (a "!" first) nor opens a class (right after "["). */ @@ -149,9 +153,15 @@ const listed = (items: readonly T[], render: (item: T) => string): string => /** A step's result: the view with its fix applied and the sentence naming it; `done` ends the pass. */ interface Pass { + /** The pattern with this step's fix applied. */ readonly view: string; + + /** The sentence naming the fix, none when the view was already right. */ readonly flaws: readonly string[]; + + /** Ends the pass: no later step can judge the view. */ readonly done?: boolean; + /** The view compiled, from the step that probes the engine, so the clean path compiles once. */ readonly regex?: RegExp; } @@ -167,7 +177,7 @@ const applied = (view: string, fixed: string, flaw: string): Pass => const named = (view: string, flaw: string): Pass => ({ view, flaws: [flaw] }); /** The classes are rewritten in one walk, so a pattern of many flawed classes costs one pass, not one per class. */ -const rewriteClasses: Step = (view) => { +function rewriteClasses(view: string): Pass { const flaws: string[] = []; let rebuilt = ""; let cursor = 0; @@ -210,21 +220,9 @@ const rewriteClasses: Step = (view) => { rebuilt += cls; } return { view: rebuilt + view.slice(cursor), flaws }; -}; - -const ENGINE_REFUSAL = - "it compiles to a regular expression the runtime refuses, so shorten it or split it across several patterns"; +} -/** - * The grammar is owned here, not left to picomatch, because picomatch accepts far more than a repository name can - * hold ("(a)+" a quantifier, "\d" a digit class, "{az..bz}" a range, "./" silently dropped, "***" losing the - * escape of a later dot) and compiles several such inputs to a wrong or never-matching regex with no option to - * refuse them. The pattern is the user's input, so a flaw is refused naming its fix, never repaired, and every - * flaw of one pattern is named at once, so N flaws cost one run to discover, not N: each step names one fix and - * hands the next step the view with it applied, in the order the sentences take. What is left for the user to - * write is named last with no fix applied: the length cap, an empty body, the sides around a "/", a side that is - * only dots, and a regex the engine itself refuses. - */ +/** Each step names one fix and hands the next the view with it applied; the header says why the grammar is owned here. */ const EXCLUDE_STEPS: readonly Step[] = [ (view) => applied(view, view.trim(), "it is wrapped in whitespace, so trim it"), (view) => { @@ -316,7 +314,10 @@ const EXCLUDE_STEPS: readonly Step[] = [ regex.test(""); return { view, flaws: [], regex }; } catch { - return named(view, ENGINE_REFUSAL); + return named( + view, + "it compiles to a regular expression the runtime refuses, so shorten it or split it across several patterns", + ); } }, ]; diff --git a/src/discovery/targets.ts b/src/discovery/targets.ts index e4de96f95..5d009102c 100644 --- a/src/discovery/targets.ts +++ b/src/discovery/targets.ts @@ -7,15 +7,14 @@ import { err, ok, type Result } from "neverthrow"; import { SLUG_SEGMENT, type SlugKey, slugKey } from "../github/slug.js"; import type { ProblemOf } from "../problem.js"; +/** What every target carries; `origin` is where it came from, for messages: a file path or the input name. */ interface TargetBase { repo: RepoRef; - /** Where this target came from, for messages: a file path or the input name. */ origin: string; } export type CentralTarget = TargetBase & { source: "central"; - /** The checked-in settings file to read. */ filePath: string; }; @@ -23,8 +22,7 @@ export type RemoteTarget = TargetBase & { source: "remote" }; export type Target = CentralTarget | RemoteTarget; -// A "." or ".." owner or name is refused here: GitHub names nothing that way, and as a path segment either resolves -// the request elsewhere (/repos/../x/labels is /x/labels), so no consumer of a RepoRef guards for them. +// A "." or ".." owner or name is refused here: GitHub names nothing that way, and as a path segment either resolves the request elsewhere (/repos/../x/labels is /x/labels), so no consumer of a RepoRef guards for them. const SLUG_RE = new RegExp(String.raw`^(?!\.\.?/)${SLUG_SEGMENT}/(?!\.\.?$)${SLUG_SEGMENT}$`); /** PARSED ONCE at a validating boundary, so downstream code never re-splits a string; the only constructor derives all three from one value. */ diff --git a/src/engine/admit.ts b/src/engine/admit.ts index 7f857aa06..2d40ded51 100644 --- a/src/engine/admit.ts +++ b/src/engine/admit.ts @@ -61,19 +61,19 @@ function refuse(layer: string, site: string, refusal: Refusal): Result>; readonly entries: readonly Readonly>[]; readonly layering: Layering; readonly keyed: KeyedListLayering; } +/** A layer past the boundary; `undeclared` is its file-wide `_undeclared`, when it sets one. */ export interface AdmittedLayer { readonly name: string; readonly doc: Readonly>; readonly sections: ReadonlyMap; - /** The layer's file-wide `_undeclared`, when it sets one. */ readonly undeclared: UndeclaredPolicy | undefined; } @@ -177,6 +177,47 @@ function checkRemoval( return ok(); } +/** The refusal for an entry without its key, naming the composite's other paths when the module declares one. */ +function refuseNoKey( + layer: string, + site: string, + keyed: KeyedListLayering, +): Result { + const alongside = removalPaths(keyed).filter((path) => path !== keyed.keyField); + return refuse(layer, site, { + code: "layer-no-key", + keyField: keyed.keyField, + ...(keyed.keyKind === undefined ? {} : { keyKind: keyed.keyKind }), + ...(alongside.length === 0 ? {} : { alongside }), + }); +} + +/** An entry's nested keyed lists, each admitted and checked under the entry's site with no directive: the parent pair decides their fate. */ +function checkNestedLists( + layer: string, + entry: Readonly>, + keyed: KeyedListLayering, + site: string, +): Result { + for (const [field, nested] of Object.entries(keyed.nested ?? {})) { + const form = nestedForm(entry[field]); + if (form === null) { + continue; + } + + // The wrapper is transparent to the path, as in the rendered order: `environments[0].variables[1]` under both forms. + const nestedPath = `${site}.${field}`; + const checked = admitEntries(layer, nestedPath, form.entries).andThen((mappings) => + checkKeyed(layer, mappings, nested, nestedPath, undefined), + ); + if (checked.isErr()) { + return checked; + } + } + + return ok(); +} + /** * Two entries of one layer claiming a key (a label renaming into a sibling's name) are refused here, so unionKeyed * never meets them; a removal entry is checked for its shape here and for something to remove at the fold. `directive` @@ -196,16 +237,12 @@ function checkKeyed( if (removal.isErr()) { return removal; } + const keys = keyed.keys(entry); if (keys === null) { - const alongside = removalPaths(keyed).filter((path) => path !== keyed.keyField); - return refuse(layer, site, { - code: "layer-no-key", - keyField: keyed.keyField, - ...(keyed.keyKind === undefined ? {} : { keyKind: keyed.keyKind }), - ...(alongside.length === 0 ? {} : { alongside }), - }); + return refuseNoKey(layer, site, keyed); } + for (const key of keys) { const first = seen.get(key); if (first !== undefined) { @@ -218,21 +255,13 @@ function checkKeyed( } seen.set(key, index); } - for (const [field, nested] of Object.entries(keyed.nested ?? {})) { - const form = nestedForm(entry[field]); - if (form === null) { - continue; - } - // The wrapper is transparent to the path, as in the rendered order: `environments[0].variables[1]` under both forms. - const nestedPath = `${site}.${field}`; - const checked = admitEntries(layer, nestedPath, form.entries).andThen((mappings) => - checkKeyed(layer, mappings, nested, nestedPath, undefined), - ); - if (checked.isErr()) { - return checked; - } + + const nested = checkNestedLists(layer, entry, keyed, site); + if (nested.isErr()) { + return nested; } } + return ok(); } diff --git a/src/engine/canonical.ts b/src/engine/canonical.ts index 320c11f2e..127b7ef90 100644 --- a/src/engine/canonical.ts +++ b/src/engine/canonical.ts @@ -15,20 +15,20 @@ * unknown keys, everywhere -> by code point after the declared ones; JavaScript enumerates an integer-like * key ("10") ahead of every other key in numeric order, so those lead * scalars -> as written + * + * LIST_IDENTITY names each sorted list by its path: the section key, `[]` per entry level, `.field` per nested + * mapping, a knob wrapper's `entries` transparent (`labels`, not `labels.entries`). A list section's identity is + * what its declaration keys the planner by (test/engine/canonical.test.ts pins them); a bespoke section's is the + * field its planner pairs live items on. */ import { stringify as stringifyYaml } from "yaml"; import type { z } from "zod"; import { isPlainObject, own, put } from "../plain-data.js"; import { SECTION_KEYS, SettingsFile } from "../schema.js"; -import { schemaNode } from "../sections/shared/schema-node.js"; +import { type SchemaNode, schemaNode } from "../sections/shared/schema-node.js"; -/** - * The identity field of every mapping list the walk sorts, by list path: the section key, `[]` per entry level, - * `.field` per nested mapping; a knob wrapper's `entries` is transparent (`labels`, not `labels.entries`). A list - * section's entry is what its declaration keys the planner by (test/engine/canonical.test.ts pins them); a bespoke - * section's is the field its planner pairs live items on. A dotted value is a path into the entry (`config.url`). - */ +/** The identity field of every mapping list the walk sorts, by list path; a dotted value is a path into the entry (`config.url`). */ export const LIST_IDENTITY: Readonly> = { labels: "name", rulesets: "name", @@ -61,10 +61,7 @@ export const LIST_IDENTITY: Readonly> = { "branches[].protection.required_status_checks.checks": "context", }; -/** - * The mapping lists kept as written, each with the reason its order is content or has no key. The completeness - * test pins every mapping list the schema declares to LIST_IDENTITY or this table. - */ +/** The mapping lists kept as written, each with the reason its order is content or has no key; the completeness test pins every mapping list the schema declares to LIST_IDENTITY or this table. */ export const LISTS_AS_WRITTEN: Readonly> = { branches: "GitHub applies overlapping wildcard rules in creation order, and apply creates the entries in file order", @@ -225,6 +222,46 @@ function mappingNode( return out; } +/** A record's values under its one value schema, keys by code point. */ +function recordNode( + value: Readonly>, + valueSchema: z.ZodType, + path: string, +): Record { + const out: Record = {}; + for (const key of orderedKeys(value, [])) { + put(out, key, canonicalNode(value[key], valueSchema, `${path}.*`)); + } + return out; +} + +/** A union: the knob wrapper's two forms by shape, else the first option that admits the value, parsed only when two do. */ +function unionNode( + value: unknown, + node: Extract, + path: string, +): unknown { + const { knob } = node; + if (knob !== null) { + if (Array.isArray(value)) { + return canonicalNode(value, knob.list, path); + } + if (!isPlainObject(value)) { + return unknownNode(value); + } + const { shape } = knob.wrapper; + // The wrapper is transparent to the list path, so `labels` names the entries under both forms. + return mappingNode(value, shape, Object.keys(shape), (key) => + key === "entries" ? path : `${path}.${key}`, + ); + } + + const fits = node.options.filter((option) => admits(option, value)); + const option = + fits.length > 1 ? (fits.find((o) => o.safeParse(value).success) ?? fits[0]) : fits[0]; + return option === undefined ? unknownNode(value) : canonicalNode(value, option, path); +} + function canonicalNode(value: unknown, schema: z.ZodType | undefined, path: string): unknown { if (schema === undefined || value === null || value === undefined) { return unknownNode(value); @@ -247,42 +284,20 @@ function canonicalNode(value: unknown, schema: z.ZodType | undefined, path: stri path, value.map((item) => canonicalNode(item, node.element, `${path}[]`)), ); - case "record": { + case "record": if (!isPlainObject(value)) { return unknownNode(value); } - const out: Record = {}; - for (const key of orderedKeys(value, [])) { - put(out, key, canonicalNode(value[key], node.value, `${path}.*`)); - } - return out; - } - case "union": { - const { knob } = node; - if (knob !== null) { - if (Array.isArray(value)) { - return canonicalNode(value, knob.list, path); - } - if (!isPlainObject(value)) { - return unknownNode(value); - } - const { shape } = knob.wrapper; - // The wrapper is transparent to the list path, so `labels` names the entries under both forms. - return mappingNode(value, shape, Object.keys(shape), (key) => - key === "entries" ? path : `${path}.${key}`, - ); - } - const fits = node.options.filter((option) => admits(option, value)); - const option = - fits.length > 1 ? (fits.find((o) => o.safeParse(value).success) ?? fits[0]) : fits[0]; - return option === undefined ? unknownNode(value) : canonicalNode(value, option, path); - } + return recordNode(value, node.value, path); + case "union": + return unionNode(value, node, path); case "leaf": // The scalar leaves; a mapping under z.unknown has no declared order. return unknownNode(value); } } +/** The slice of each declared top-level key. */ const TOP_LEVEL_SHAPE: Readonly> = SettingsFile.shape; /** The sections in execution order, then the directives in the order the schema declares them. */ diff --git a/src/engine/diff.ts b/src/engine/diff.ts index 36102a86a..14a67e29b 100644 --- a/src/engine/diff.ts +++ b/src/engine/diff.ts @@ -18,7 +18,11 @@ type PathStep = string | number | { readonly key: string }; type ListMatch = "key" | "shape" | "value"; -/** A phantom is a non-empty declared field the live object has no key for: a typo, or a write-only field. */ +/** + * A phantom is a non-empty declared field the live object has no key for: a typo, or a write-only field. An omitted + * delta's `nullable` says the slice accepts `null` at its path, so `: null` is the clearing spelling (an object + * has no other). + */ export type Delta = | { readonly kind: "mismatch"; @@ -43,26 +47,28 @@ export type Delta = readonly kind: "omitted"; readonly path: readonly PathStep[]; readonly live: unknown; - /** The slice accepts `null` at this path, so `: null` is the clearing spelling (an object has no other). */ readonly nullable: boolean; }; /** One field, or the fields whose values together identify an item (a bypass actor's type and id). */ export type MatchKey = string | readonly string[]; +/** + * How a declaration is compared with the live object. `matchBy` names, per nested list (by dotted object path below + * the root, a list item spelled `[]`: `rules`, `rules[].checks`), the item key to pair by; a missing or repeated key + * is a declaration bug. + * + * a list not named here -> object items pair by shape, others by value + * matchBy omitted -> lists fall back to the default `type` pairing subsetDiff callers use + * + * `replace` says the write replaces the live object whole, so a non-empty live value at a path the declaration omits + * is an `omitted` delta; callers project the live object onto the write's keys first, or server-assigned fields count. + */ export interface DeltaOptions { - /** - * Per nested list (by dotted object path below the root, a list item spelled `[]`: `rules`, `rules[].checks`), the - * item key to pair by; a missing or repeated key is a declaration bug. - * - * a list not named here -> object items pair by shape, others by value - * matchBy omitted -> lists fall back to the default `type` pairing subsetDiff callers use - */ + /** The item key each nested list pairs by, under the rules above. */ readonly matchBy?: Readonly>; - /** - * The write replaces the live object whole, so a non-empty live value at a path the declaration omits is an - * `omitted` delta. Callers project the live object onto the write's keys first, or server-assigned fields count. - */ + + /** The write replaces the live object whole, so what the declaration omits is swept into `omitted` deltas. */ readonly replace?: ReplaceSweep; } @@ -77,6 +83,12 @@ export interface ReplaceSweep { readonly nullable: readonly string[]; } +/** Where the walk stands: `path` as renderPath spells it, `keyPath` as a matchBy or passthrough entry names it (dots, `[]` per list item). */ +interface Site { + readonly path: readonly PathStep[]; + readonly keyPath: string; +} + function isPassthrough(keyPath: string, passthrough: readonly string[]): boolean { return passthrough.some((path) => keyPath === path || keyPath.startsWith(`${path}.`)); } @@ -99,17 +111,100 @@ function isEmptySetting(value: unknown): boolean { /** Marks a live field the object has no own key for, as opposed to one holding undefined. */ const ABSENT: unique symbol = Symbol("no such live key"); +function fieldSite(site: Site, key: string): Site { + return { + path: [...site.path, key], + keyPath: site.keyPath === "" ? key : `${site.keyPath}.${key}`, + }; +} + +function itemSite(site: Site, step: PathStep): Site { + return { path: [...site.path, step], keyPath: `${site.keyPath}[]` }; +} + export function deltas(desired: unknown, live: unknown, opts: DeltaOptions = {}): Delta[] { const out: Delta[] = []; - walk(desired, live, [], "", opts, out); + walk(desired, live, { path: [], keyPath: "" }, opts, out); return out; } +/** The delta for a desired value the live side lacks (`phantom`) or holds in another shape or value (`mismatch`). */ +function unpaired( + absent: boolean, + path: readonly PathStep[], + desired: unknown, + live: unknown, +): Delta { + return absent ? { kind: "phantom", path, desired } : { kind: "mismatch", path, desired, live }; +} + +/** A scalar's delta, or null: "" tolerates null and an absent key (GitHub's empty), anything else must equal the live value. */ +function scalarDelta( + desired: unknown, + absent: boolean, + live: unknown, + path: readonly PathStep[], +): Delta | null { + if (desired === "" && (live === null || live === undefined)) { + return null; + } + return desired === live ? null : unpaired(absent, path, desired, live); +} + +/** Under `replace`, a non-empty live value at a key the declaration omits is an `omitted` delta, outside the passthrough paths. */ +function sweepOmitted( + declared: Readonly>, + live: Readonly>, + site: Site, + replace: ReplaceSweep, + out: Delta[], +): void { + if (isPassthrough(site.keyPath, replace.passthrough)) { + return; + } + + for (const [key, value] of Object.entries(live)) { + const child = fieldSite(site, key); + if ( + Object.hasOwn(declared, key) || + isEmptySetting(value) || + isPassthrough(child.keyPath, replace.passthrough) + ) { + continue; + } + + out.push({ + kind: "omitted", + path: child.path, + live: value, + nullable: replace.nullable.includes(child.keyPath), + }); + } +} + +/** A declared mapping against the live one: each declared key walked, then the replace sweep over the live keys. */ +function walkMapping( + declared: Readonly>, + live: Readonly>, + site: Site, + opts: DeltaOptions, + out: Delta[], +): void { + for (const [key, value] of Object.entries(declared)) { + // hasOwn, not indexing: a key named like a prototype member (toString) must read as absent, not as the inherited function. + const child = Object.hasOwn(live, key) ? live[key] : ABSENT; + walk(value, child, fieldSite(site, key), opts, out); + } + + if (opts.replace !== undefined) { + sweepOmitted(declared, live, site, opts.replace, out); + } +} + function walk( desired: unknown, live: unknown | typeof ABSENT, - path: readonly PathStep[], - keyPath: string, + site: Site, opts: DeltaOptions, out: Delta[], ): void { @@ -119,64 +214,31 @@ function walk( if (liveValue === null || liveValue === undefined || liveValue === "") { return; } - out.push({ kind: "mismatch", path, desired, live: liveValue }); + out.push({ kind: "mismatch", path: site.path, desired, live: liveValue }); return; } + if (Array.isArray(desired)) { if (!Array.isArray(liveValue)) { - out.push( - absent - ? { kind: "phantom", path, desired } - : { kind: "mismatch", path, desired, live: liveValue }, - ); + out.push(unpaired(absent, site.path, desired, liveValue)); return; } - walkList(desired, liveValue, path, keyPath, opts, out); + walkList(desired, liveValue, site, opts, out); return; } + if (typeof desired === "object") { if (!isPlainObject(liveValue)) { - out.push( - absent - ? { kind: "phantom", path, desired } - : { kind: "mismatch", path, desired, live: liveValue }, - ); + out.push(unpaired(absent, site.path, desired, liveValue)); return; } - const declared = desired as Record; - for (const [key, value] of Object.entries(declared)) { - // hasOwn, not indexing: a key named like a prototype member (toString) must read as absent, not as the inherited function. - const child = Object.hasOwn(liveValue, key) ? liveValue[key] : ABSENT; - walk(value, child, [...path, key], keyPath === "" ? key : `${keyPath}.${key}`, opts, out); - } - if (opts.replace !== undefined && !isPassthrough(keyPath, opts.replace.passthrough)) { - for (const [key, value] of Object.entries(liveValue)) { - const childPath = keyPath === "" ? key : `${keyPath}.${key}`; - if ( - !Object.hasOwn(declared, key) && - !isEmptySetting(value) && - !isPassthrough(childPath, opts.replace.passthrough) - ) { - out.push({ - kind: "omitted", - path: [...path, key], - live: value, - nullable: opts.replace.nullable.includes(childPath), - }); - } - } - } + walkMapping(desired as Record, liveValue, site, opts, out); return; } - if (desired === "" && (liveValue === null || liveValue === undefined)) { - return; - } - if (desired !== liveValue) { - out.push( - absent - ? { kind: "phantom", path, desired } - : { kind: "mismatch", path, desired, live: liveValue }, - ); + + const delta = scalarDelta(desired, absent, liveValue, site.path); + if (delta !== null) { + out.push(delta); } } @@ -211,73 +273,118 @@ function itemKey(item: unknown, key: MatchKey, keyPath: string, side: "desired" return parts.map((field) => String(item[field])).join(" "); } -function walkList( +/** The live items by their declared key; a repeated key is a declaration bug, not drift. */ +function liveByKey(live: unknown[], key: MatchKey, keyPath: string): Map { + const byKey = new Map(); + for (const item of live) { + const itemId = itemKey(item, key, keyPath, "live"); + if (byKey.has(itemId)) { + throw new Error( + `BUG: matchBy pairs the list "${keyPath}" by ${describeKey(key)}, but the live list repeats ${JSON.stringify(itemId)}`, + ); + } + byKey.set(itemId, item); + } + return byKey; +} + +/** The live items by `type`, the default pairing, only when types are unique on both sides (ruleset rules); null for a list that repeats them (environment reviewers), which pairs by shape instead. */ +function liveByType(desired: unknown[], live: unknown[]): Map | null { + const desiredTypes = desired.map(typeOf); + const liveTypes = live.map(typeOf); + const typed = + desired.length > 0 && + desiredTypes.every((t) => t !== null) && + new Set(desiredTypes).size === desiredTypes.length && + liveTypes.every((t) => t !== null) && + new Set(liveTypes).size === liveTypes.length; + if (!typed) { + return null; + } + + const byType = new Map(); + for (const item of live) { + byType.set(typeOf(item) as string, item); + } + return byType; +} + +/** Each declared item against the live item of its key, the rest of the live list undeclared; a repeated declared key is a declaration bug. */ +function walkKeyed( desired: unknown[], - live: unknown[], - path: readonly PathStep[], - keyPath: string, + byKey: ReadonlyMap, + key: MatchKey, + site: Site, opts: DeltaOptions, out: Delta[], ): void { - const declaredKey = opts.matchBy?.[keyPath]; - if (declaredKey !== undefined) { - const liveByKey = new Map(); - for (const item of live) { - const key = itemKey(item, declaredKey, keyPath, "live"); - if (liveByKey.has(key)) { - throw new Error( - `BUG: matchBy pairs the list "${keyPath}" by ${describeKey(declaredKey)}, but the live list repeats ${JSON.stringify(key)}`, - ); - } - liveByKey.set(key, item); + const declared = new Set(); + for (const item of desired) { + const itemId = itemKey(item, key, site.keyPath, "desired"); + if (declared.has(itemId)) { + throw new Error( + `BUG: matchBy pairs the list "${site.keyPath}" by ${describeKey(key)}, but the declared list repeats ${JSON.stringify(itemId)}`, + ); + } + declared.add(itemId); + const match = byKey.get(itemId); + const at = itemSite(site, { key: itemId }); + if (match === undefined) { + out.push({ kind: "missing", path: at.path, desired: item, match: "key" }); + } else { + walk(item, match, at, opts, out); } - walkKeyed(desired, liveByKey, declaredKey, path, keyPath, opts, out); - return; } - if (opts.matchBy === undefined) { - // The default pairing matches by `type` only when types are unique on both sides (ruleset rules); environment reviewers - // repeat types and fall through to shape pairing below. - const desiredTypes = desired.map(typeOf); - const liveTypes = live.map(typeOf); - const typed = - desired.length > 0 && - desiredTypes.every((t) => t !== null) && - new Set(desiredTypes).size === desiredTypes.length && - liveTypes.every((t) => t !== null) && - new Set(liveTypes).size === liveTypes.length; - if (typed) { - const liveByType = new Map(); - for (const item of live) { - liveByType.set(typeOf(item) as string, item); - } - walkKeyed(desired, liveByType, "type", path, keyPath, opts, out); - return; + + for (const [itemId, item] of byKey) { + if (!declared.has(itemId)) { + out.push({ + kind: "undeclared", + path: [...site.path, { key: itemId }], + live: item, + match: "key", + }); } } - const objectList = desired.length > 0 && desired.every(isPlainObject); - if (objectList) { - // Order-insensitive; a live item nothing pairs with is undeclared because a full-payload write would remove it. - // Items pair on the declared fields alone, so under `replace` a paired item's extra live fields read as omitted. - const { replace: _replace, ...subset } = opts; - const liveItems = [...live]; - for (const [index, item] of desired.entries()) { - const matchIndex = liveItems.findIndex( - (candidate) => deltas(item, candidate, subset).length === 0, - ); - if (matchIndex === -1) { - out.push({ kind: "missing", path: [...path, index], desired: item, match: "shape" }); - } else { - if (opts.replace !== undefined) { - walk(item, liveItems[matchIndex], [...path, index], `${keyPath}[]`, opts, out); - } - liveItems.splice(matchIndex, 1); - } +} + +/** Mapping items pair by shape, order-insensitive, on the declared fields alone: a live item nothing pairs with is undeclared because a full-payload write would remove it, and under `replace` a paired item's extra live fields read as omitted. */ +function walkByShape( + desired: unknown[], + live: unknown[], + site: Site, + opts: DeltaOptions, + out: Delta[], +): void { + const { replace: _replace, ...subset } = opts; + const liveItems = [...live]; + for (const [index, item] of desired.entries()) { + const matchIndex = liveItems.findIndex( + (candidate) => deltas(item, candidate, subset).length === 0, + ); + if (matchIndex === -1) { + out.push({ kind: "missing", path: [...site.path, index], desired: item, match: "shape" }); + continue; } - for (const leftover of liveItems) { - out.push({ kind: "undeclared", path, live: leftover, match: "shape" }); + + if (opts.replace !== undefined) { + walk(item, liveItems[matchIndex], itemSite(site, index), opts, out); } - return; + liveItems.splice(matchIndex, 1); + } + + for (const leftover of liveItems) { + out.push({ kind: "undeclared", path: site.path, live: leftover, match: "shape" }); } +} + +/** Any other list (scalars, an empty one, mixed items) pairs by value: the first occurrence of a value the other side lacks is one delta. */ +function walkByValue( + desired: unknown[], + live: unknown[], + path: readonly PathStep[], + out: Delta[], +): void { const desiredSet = new Set(desired.map((v) => JSON.stringify(v))); const liveSet = new Set(live.map((v) => JSON.stringify(v))); for (const [index, value] of desired.entries()) { @@ -286,6 +393,7 @@ function walkList( out.push({ kind: "missing", path, desired: value, match: "value" }); } } + for (const [index, value] of live.entries()) { const json = JSON.stringify(value); if (!desiredSet.has(json) && live.findIndex((v) => JSON.stringify(v) === json) === index) { @@ -294,37 +402,32 @@ function walkList( } } -function walkKeyed( +/** The pairing a list takes: its matchBy key, else `type` when no matchBy was given and types are unique, else shape for mapping items, else value. */ +function walkList( desired: unknown[], - liveByKey: ReadonlyMap, - key: MatchKey, - path: readonly PathStep[], - keyPath: string, + live: unknown[], + site: Site, opts: DeltaOptions, out: Delta[], ): void { - const declared = new Set(); - for (const item of desired) { - const itemId = itemKey(item, key, keyPath, "desired"); - if (declared.has(itemId)) { - throw new Error( - `BUG: matchBy pairs the list "${keyPath}" by ${describeKey(key)}, but the declared list repeats ${JSON.stringify(itemId)}`, - ); - } - declared.add(itemId); - const match = liveByKey.get(itemId); - const at = [...path, { key: itemId }]; - if (match === undefined) { - out.push({ kind: "missing", path: at, desired: item, match: "key" }); - } else { - walk(item, match, at, `${keyPath}[]`, opts, out); - } + const declaredKey = opts.matchBy?.[site.keyPath]; + if (declaredKey !== undefined) { + walkKeyed(desired, liveByKey(live, declaredKey, site.keyPath), declaredKey, site, opts, out); + return; } - for (const [itemId, item] of liveByKey) { - if (!declared.has(itemId)) { - out.push({ kind: "undeclared", path: [...path, { key: itemId }], live: item, match: "key" }); - } + + const byType = opts.matchBy === undefined ? liveByType(desired, live) : null; + if (byType !== null) { + walkKeyed(desired, byType, "type", site, opts, out); + return; } + + if (desired.length > 0 && desired.every(isPlainObject)) { + walkByShape(desired, live, site, opts, out); + return; + } + + walkByValue(desired, live, site.path, out); } /** @@ -332,15 +435,17 @@ function walkKeyed( * the path as a settings-file reader would spell it. */ export function renderPath(root: string, path: readonly PathStep[]): string { - return path.reduce( - (at, step) => - typeof step === "string" - ? at === "" - ? step - : `${at}.${step}` - : `${at}[${typeof step === "number" ? step : step.key}]`, - root, - ); + let at = root; + for (const step of path) { + if (typeof step === "number") { + at = `${at}[${step}]`; + } else if (typeof step === "string") { + at = at === "" ? step : `${at}.${step}`; + } else { + at = `${at}[${step.key}]`; + } + } + return at; } function mismatchLine(at: string, desired: unknown, live: unknown): string { @@ -377,6 +482,30 @@ function emptyFor(live: unknown): string | null { } } +/** The line for a declared item the live list lacks, worded by the pairing that looked for it. */ +function missingLine(at: string, match: ListMatch, desired: unknown): string { + switch (match) { + case "key": + return `${at}: missing live`; + case "shape": + return `${at}: no matching live entry for ${JSON.stringify(desired)}`; + case "value": + return `${at}: missing ${JSON.stringify(desired)}`; + } +} + +/** The line for a live item the declaration lacks, worded by the pairing that met it. */ +function undeclaredLine(at: string, match: ListMatch, live: unknown): string { + switch (match) { + case "key": + return `${at}: present live but not declared`; + case "shape": + return `${at}: live entry not declared: ${JSON.stringify(live)}`; + case "value": + return `${at}: unexpected ${JSON.stringify(live)}`; + } +} + export function renderDelta(root: string, delta: Delta): string { const at = renderPath(root, delta.path); switch (delta.kind) { @@ -385,17 +514,9 @@ export function renderDelta(root: string, delta: Delta): string { case "phantom": return mismatchLine(at, delta.desired, undefined); case "missing": - return delta.match === "key" - ? `${at}: missing live` - : delta.match === "shape" - ? `${at}: no matching live entry for ${JSON.stringify(delta.desired)}` - : `${at}: missing ${JSON.stringify(delta.desired)}`; + return missingLine(at, delta.match, delta.desired); case "undeclared": - return delta.match === "key" - ? `${at}: present live but not declared` - : delta.match === "shape" - ? `${at}: live entry not declared: ${JSON.stringify(delta.live)}` - : `${at}: unexpected ${JSON.stringify(delta.live)}`; + return undeclaredLine(at, delta.match, delta.live); case "omitted": { const key = renderPath("", delta.path); const empty = delta.nullable ? "null" : emptyFor(delta.live); diff --git a/src/engine/directives.ts b/src/engine/directives.ts index 2491320f2..aad783971 100644 --- a/src/engine/directives.ts +++ b/src/engine/directives.ts @@ -7,6 +7,7 @@ import { isPlainObject } from "../plain-data.js"; import { UNDECLARED_POLICY_SECTIONS } from "../schema.js"; +/** The merge directive's key: at a file's top level, or on a list section's wrapper. */ export const LAYERING_KEY = "_layering"; /** The policy knob's key: on a knobbed wrapper (top-level or nested) a value, at a file's top level a directive. */ @@ -15,12 +16,12 @@ export const UNDECLARED_KEY = "_undeclared"; /** The one entry-level directive: `_remove: true` names a lower entry by its key and drops it. */ export const REMOVE_KEY = "_remove"; +/** The list sections whose wrapper takes the policy knob. */ export const KNOBBED: ReadonlySet = new Set(UNDECLARED_POLICY_SECTIONS); -/** A nested keyed list in either form: the bare list, or the nested `{_undeclared, entries}` wrapper; null when neither. */ +/** A nested keyed list in either form, the bare list or the nested `{_undeclared, entries}` wrapper; `knobs` holds the wrapper's keys besides `entries`, null for the bare list, so the fold can tell the two forms apart. */ export interface NestedForm { readonly entries: readonly unknown[]; - /** The wrapper's keys besides `entries`; null for the bare list, so the fold can tell the two forms apart. */ readonly knobs: Readonly> | null; } diff --git a/src/engine/execute.ts b/src/engine/execute.ts index 94a669976..81741078a 100644 --- a/src/engine/execute.ts +++ b/src/engine/execute.ts @@ -4,10 +4,10 @@ * classifies, beside what landed before it. */ -import { ok } from "neverthrow"; +import { err, ok, type Result } from "neverthrow"; import type { RepoRef } from "../discovery/targets.js"; import type { GitHubClient } from "../github/api.js"; -import { endpointMethod } from "../sections/contract/endpoints.js"; +import { type EndpointDecl, endpointMethod } from "../sections/contract/endpoints.js"; import { type SectionFailure, sectionFailure, thrown } from "../sections/contract/errors.js"; import type { SectionContext, SectionMeta } from "../sections/contract/module.js"; import type { ExecTools, SectionPlan } from "../sections/contract/plan.js"; @@ -29,6 +29,19 @@ type PlanExecution = | (PlanExecutionBase & { readonly status: "applied" }) | (PlanExecutionBase & { readonly status: "failed"; readonly failure: SectionFailure }); +type PlannedOp = SectionPlan["ops"][number]; + +/** What the operations of one plan share: the section and the context its requests go out under. */ +interface PlanRun { + readonly ctx: SectionContext; + readonly section: SectionMeta; +} + +/** What one operation's request ended in: a response GitHub accepted, or a tolerated error's note in place of one. */ +type Requested = + | { readonly kind: "landed"; readonly response: unknown } + | { readonly kind: "tolerated"; readonly note: string }; + /** * OWN property only: an erased plan carries a bare string role, and an inherited name ("constructor") must read as * undeclared, never as a value to call. @@ -77,6 +90,152 @@ function secretRecorder(tools: ExecTools): { exec: ExecTools; resolved: () => bo return { exec, resolved: () => resolved }; } +/** The REST write under the operation's role: its `before` hook, its payload, then the call, a tolerated status answering with the operation's own outcome. */ +async function requestDeclared( + run: PlanRun, + endpoint: EndpointDecl, + op: PlannedOp, + exec: ExecTools, + resolved: () => boolean, +): Promise> { + if (endpointMethod(endpoint.route) === "GET") { + // Only the erased view can name a read; executing it would render a change line for a request that changed nothing. + throw new Error( + `BUG: ${run.section.key} planned an operation under role "${op.role}", which is a read endpoint (${endpoint.route}); only write roles are plannable`, + ); + } + + const before = await op.before?.(exec); + if (before?.isErr()) { + return err(before.error); + } + + const payload = typeof op.payload === "function" ? await op.payload(exec) : ok(op.payload); + if (payload.isErr()) { + return err(payload.error); + } + + const request = { + params: op.params, + query: op.query, + payload: payload.value, + carriesSecret: resolved(), + describe: op.describe, + }; + if (op.tolerate === undefined) { + const called = await callDeclared(run.ctx, run.section, endpoint, request); + return called.map((response): Requested => ({ kind: "landed", response })); + } + + const called = await tryCallDeclared(run.ctx, run.section, endpoint, { + ...request, + tolerated: declaredTolerance(endpoint, op.tolerate.statuses), + }); + if (called.isErr()) { + return err(called.error); + } + + const result = called.value; + if (!("error" in result)) { + return ok({ kind: "landed", response: result.data }); + } + + const outcome = op.tolerate.outcome(result.error); + if (outcome.failure !== undefined) { + return err(sectionFailure("refused", outcome.failure)); + } + + return ok({ kind: "tolerated", note: outcome.note }); +} + +/** The GraphQL write under the operation's role, once no REST endpoint claims it: its `before` hook, its variables, then the call. */ +async function requestGraphql( + run: PlanRun, + op: PlannedOp, + exec: ExecTools, + resolved: () => boolean, +): Promise> { + const graphqlOp = declared(run.section.graphql, op.role); + if (graphqlOp === undefined) { + throw new Error( + `BUG: ${run.section.key} planned an operation under role "${op.role}", which names no declared endpoint or GraphQL operation`, + ); + } + + if (graphqlOp.kind !== "write") { + throw new Error( + `BUG: ${run.section.key} planned an operation under role "${op.role}", which is a GraphQL ${graphqlOp.kind} operation; only write roles are plannable`, + ); + } + + const before = await op.before?.(exec); + if (before?.isErr()) { + return err(before.error); + } + + const variables = + typeof op.variables === "function" ? await op.variables(exec) : ok(op.variables ?? {}); + if (variables.isErr()) { + return err(variables.error); + } + + const called = await callGraphql(run.ctx, run.section, graphqlOp, variables.value, { + describe: op.describe, + carriesSecret: resolved(), + }); + return called.map((response): Requested => ({ kind: "landed", response })); +} + +/** One operation's request: the REST endpoint declared under its role, else the GraphQL write (the two role spaces are disjoint). */ +async function request( + run: PlanRun, + op: PlannedOp, + exec: ExecTools, + resolved: () => boolean, +): Promise> { + if (typeof op.role !== "string") { + // A number would coerce onto a matching key and a symbol would enter the property-key path. + throw new Error( + `BUG: ${run.section.key} planned an operation whose role is a ${typeof op.role}, not the name of a declared write`, + ); + } + + const endpoint = declared(run.section.endpoints, op.role); + if (endpoint !== undefined) { + return requestDeclared(run, endpoint, op, exec, resolved); + } + + return requestGraphql(run, op, exec, resolved); +} + +/** The change lines of a landed request, its capture hook run: a request that landed must render a line. */ +function changeLines( + section: SectionMeta, + op: PlannedOp, + response: unknown, +): Result { + const rendered = typeof op.change === "function" ? op.change(response) : ok(op.change); + rejectThenable(section, op.role, "change thunk", rendered); + if (rendered.isErr()) { + return err(rendered.error); + } + + const lines = rendered.value; + if (lines.length === 0) { + throw new Error( + `BUG: ${section.key}: operation "${op.role}" rendered no change line for a request that landed`, + ); + } + + const captured = op.capture?.(response); + rejectThenable(section, op.role, "capture hook", captured); + if (captured?.isErr()) { + return err(captured.error); + } + + return ok(typeof lines === "string" ? [lines] : lines); +} + export async function executePlan( plan: SectionPlan, section: SectionMeta, @@ -84,131 +243,45 @@ export async function executePlan( repo: RepoRef, tools: ExecTools, ): Promise { - const ctx: SectionContext = { - api, - repo, - check: false, - resolveSecret: (reference) => tools.resolveSecret(reference), + const run: PlanRun = { + ctx: { api, repo, check: false, resolveSecret: (reference) => tools.resolveSecret(reference) }, + section, }; const changes: string[] = []; const notes: string[] = []; let landed = 0; + const failed = (failure: SectionFailure): PlanExecution => ({ + status: "failed", + changes, + notes, + landed, + failure, + }); for (const op of plan.ops) { const { exec, resolved } = secretRecorder(tools); try { - let response: unknown; - if (typeof op.role !== "string") { - // A number would coerce onto a matching key and a symbol would enter the property-key path. - throw new Error( - `BUG: ${section.key} planned an operation whose role is a ${typeof op.role}, not the name of a declared write`, - ); + const requested = await request(run, op, exec, resolved); + if (requested.isErr()) { + return failed(requested.error); } - const endpoint = declared(section.endpoints, op.role); - if (endpoint !== undefined) { - if (endpointMethod(endpoint.route) === "GET") { - // Only the erased view can name a read; executing it would render a change line for a request that changed nothing. - throw new Error( - `BUG: ${section.key} planned an operation under role "${op.role}", which is a read endpoint (${endpoint.route}); only write roles are plannable`, - ); - } - const before = await op.before?.(exec); - if (before?.isErr()) { - return { status: "failed", changes, notes, landed, failure: before.error }; - } - const payload = typeof op.payload === "function" ? await op.payload(exec) : ok(op.payload); - if (payload.isErr()) { - return { status: "failed", changes, notes, landed, failure: payload.error }; - } - const request = { - params: op.params, - query: op.query, - payload: payload.value, - carriesSecret: resolved(), - describe: op.describe, - }; - if (op.tolerate === undefined) { - const called = await callDeclared(ctx, section, endpoint, request); - if (called.isErr()) { - return { status: "failed", changes, notes, landed, failure: called.error }; - } - response = called.value; - } else { - const called = await tryCallDeclared(ctx, section, endpoint, { - ...request, - tolerated: declaredTolerance(endpoint, op.tolerate.statuses), - }); - if (called.isErr()) { - return { status: "failed", changes, notes, landed, failure: called.error }; - } - const result = called.value; - if ("error" in result) { - const outcome = op.tolerate.outcome(result.error); - if (outcome.failure !== undefined) { - return { - status: "failed", - changes, - notes, - landed, - failure: sectionFailure("refused", outcome.failure), - }; - } - notes.push(outcome.note); - continue; - } - response = result.data; - } - } else { - const graphqlOp = declared(section.graphql, op.role); - if (graphqlOp === undefined) { - throw new Error( - `BUG: ${section.key} planned an operation under role "${op.role}", which names no declared endpoint or GraphQL operation`, - ); - } - if (graphqlOp.kind !== "write") { - throw new Error( - `BUG: ${section.key} planned an operation under role "${op.role}", which is a GraphQL ${graphqlOp.kind} operation; only write roles are plannable`, - ); - } - const before = await op.before?.(exec); - if (before?.isErr()) { - return { status: "failed", changes, notes, landed, failure: before.error }; - } - const variables = - typeof op.variables === "function" ? await op.variables(exec) : ok(op.variables ?? {}); - if (variables.isErr()) { - return { status: "failed", changes, notes, landed, failure: variables.error }; - } - const called = await callGraphql(ctx, section, graphqlOp, variables.value, { - describe: op.describe, - carriesSecret: resolved(), - }); - if (called.isErr()) { - return { status: "failed", changes, notes, landed, failure: called.error }; - } - response = called.value; + + if (requested.value.kind === "tolerated") { + notes.push(requested.value.note); + continue; } + landed++; - const rendered = typeof op.change === "function" ? op.change(response) : ok(op.change); - rejectThenable(section, op.role, "change thunk", rendered); + const rendered = changeLines(section, op, requested.value.response); if (rendered.isErr()) { - return { status: "failed", changes, notes, landed, failure: rendered.error }; + return failed(rendered.error); } - const lines = rendered.value; - if (lines.length === 0) { - throw new Error( - `BUG: ${section.key}: operation "${op.role}" rendered no change line for a request that landed`, - ); - } - const captured = op.capture?.(response); - rejectThenable(section, op.role, "capture hook", captured); - if (captured?.isErr()) { - return { status: "failed", changes, notes, landed, failure: captured.error }; - } - changes.push(...(typeof lines === "string" ? [lines] : lines)); + + changes.push(...rendered.value); } catch (error) { // The client's own throw on an unmarked request, or a BUG invariant: reported beside what landed. - return { status: "failed", changes, notes, landed, failure: thrown(error) }; + return failed(thrown(error)); } } + return { status: "applied", changes, notes, landed }; } diff --git a/src/engine/layers.ts b/src/engine/layers.ts index 6458c9ad8..0ec83600b 100644 --- a/src/engine/layers.ts +++ b/src/engine/layers.ts @@ -264,6 +264,79 @@ type Placement = } | { readonly item: unknown; readonly index: number; readonly slot: undefined }; +/** A higher entry that claims a lower one. */ +type Placed = Extract; + +/** What every lower entry of one keyed union is placed under. */ +interface Union { + readonly keyed: KeyedListLayering; + readonly directive: Uniting; + readonly path: string; + readonly step: Step; + readonly lowerKeys: readonly (readonly string[])[]; + readonly placed: readonly Placed[]; +} + +function intersect(a: readonly string[], b: readonly string[]): boolean { + return a.some((key) => b.includes(key)); +} + +/** How many of `among` share a key with `keys`. */ +function claims(keys: readonly string[], among: readonly (readonly string[])[]): number { + return among.filter((other) => intersect(keys, other)).length; +} + +/** Each higher entry with the lower entry it claims (`slot`), read off the lower list as it stood before this layer. */ +function placementsOf( + lowerKeys: readonly (readonly string[])[], + higher: readonly unknown[], + keyed: KeyedListLayering, +): Placement[] { + return higher.map((item, index): Placement => { + const keys = isPlainObject(item) ? keyed.keys(item) : null; + const slot = keys === null ? -1 : lowerKeys.findIndex((claims) => intersect(claims, keys)); + return isPlainObject(item) && keys !== null && slot !== -1 + ? { item, index, keys, slot } + : { item, index, slot: undefined }; + }); +} + +/** + * A lower entry under the higher entries placed on it: kept when none claims it; else, per claimant, dropped by a + * removal (noticed), merged field by field as a one-to-one pair under deep, or the claimant as written. + */ +function placeOver(union: Union, below: unknown, index: number): unknown[] { + const keys = union.lowerKeys[index] ?? []; + if (!union.placed.some((p) => intersect(keys, p.keys))) { + return [below]; + } + + const out: unknown[] = []; + for (const placement of union.placed) { + if (placement.slot !== index) { + continue; + } + + const site = `${union.path}[${placement.index}]`; + if (isRemoval(placement.item)) { + union.step.notices.push({ layer: union.step.layer, path: site }); + continue; + } + + const paired = + claims( + keys, + union.placed.map((p) => p.keys), + ) === 1 && claims(placement.keys, union.lowerKeys) === 1; + out.push( + union.directive === "deep" && paired + ? mergeValue(below, placement.item, site, union.step, entryScope(union.keyed)) + : copiedAsWritten(placement.item, union.keyed, site, "swapped", union.step), + ); + } + return out; +} + /** * Matching reads the lower list as it stood before this layer, so which entries result does not depend on the higher * entries' order. Only a one-to-one pair merges field by field under deep: an entry that claims, or is claimed by, more @@ -281,47 +354,15 @@ function unionKeyed( path: string, step: Step, ): unknown[] { - const intersect = (a: readonly string[], b: readonly string[]): boolean => - a.some((key) => b.includes(key)); const lowerKeys = lower.map((item) => (isPlainObject(item) ? keyed.keys(item) : null) ?? []); - const placements = higher.map((item, index): Placement => { - const keys = isPlainObject(item) ? keyed.keys(item) : null; - const slot = keys === null ? -1 : lowerKeys.findIndex((claims) => intersect(claims, keys)); - return isPlainObject(item) && keys !== null && slot !== -1 - ? { item, index, keys, slot } - : { item, index, slot: undefined }; - }); + const placements = placementsOf(lowerKeys, higher, keyed); const placed = placements.flatMap((p) => (p.slot === undefined ? [] : [p])); - const claims = (keys: readonly string[], among: readonly (readonly string[])[]): number => - among.filter((other) => intersect(keys, other)).length; + const union: Union = { keyed, directive, path, step, lowerKeys, placed }; const out: unknown[] = []; - lower.forEach((below, index) => { - const keys = lowerKeys[index] ?? []; - if (!placed.some((p) => intersect(keys, p.keys))) { - out.push(below); - return; - } - for (const placement of placed) { - if (placement.slot !== index) { - continue; - } - const site = `${path}[${placement.index}]`; - if (isRemoval(placement.item)) { - step.notices.push({ layer: step.layer, path: site }); - continue; - } - const paired = - claims( - keys, - placed.map((p) => p.keys), - ) === 1 && claims(placement.keys, lowerKeys) === 1; - out.push( - directive === "deep" && paired - ? mergeValue(below, placement.item, site, step, entryScope(keyed)) - : copiedAsWritten(placement.item, keyed, site, "swapped", step), - ); - } - }); + for (const [index, below] of lower.entries()) { + out.push(...placeOver(union, below, index)); + } + for (const { item, index, slot } of placements) { if (slot !== undefined) { continue; @@ -383,10 +424,9 @@ function mergeStep(acc: unknown, layer: AdmittedLayer, step: Step): unknown { return out; } -/** The layer as its own validation sees it, and the way back from what that validation says to the layer as written. */ +/** The layer as its own validation sees it, and `asWritten`, the way back from what that validation says to the layer as written (`SeparatedRemovals.asWritten`: the view dropped the removal entries, and validation counts what it sees). */ export interface StandaloneView { readonly doc: unknown; - /** `SeparatedRemovals.asWritten` for the layer: the view dropped its removal entries, and validation counts what it sees. */ readonly asWritten: (issue: string) => string; } diff --git a/src/engine/orchestrate.ts b/src/engine/orchestrate.ts index 6f9cfab4e..c7d9ba273 100644 --- a/src/engine/orchestrate.ts +++ b/src/engine/orchestrate.ts @@ -38,7 +38,7 @@ import { executePlan } from "./execute.js"; import { UNDECLARED_POLICIES } from "./layers.js"; import type { RunOutcome } from "./outcome.js"; import { resolveSecretRefs, type SettingsSource } from "./secret-refs.js"; -import { collectSecretReferences } from "./secrets.js"; +import { collectSecretReferences, type SectionSecretReference } from "./secrets.js"; import type { SectionSelection } from "./section-selection.js"; import { separateRemovals } from "./separate-removals.js"; import { resolveUndeclaredPolicies } from "./undeclared.js"; @@ -46,7 +46,8 @@ import { validateSectionShapes } from "./validate.js"; /** * `httpStatus` is the safe code of the permission-denied failure behind a failed or skipped section (the redacted view shows it - * as `HTTP 403` in place of the hidden detail); the `?: never` pin makes a code on a healthy row unrepresentable. + * as `HTTP 403` in place of the hidden detail); the `?: never` pin makes a code on a healthy row unrepresentable, and a + * generic (non-denial) failure legitimately carries none. */ export type SectionOutcome = | { @@ -59,17 +60,16 @@ export type SectionOutcome = key: SectionKey; status: "failed" | "skipped"; detail: string[]; - /** Optional: a generic (non-denial) failure legitimately carries none. */ httpStatus?: number; }; +/** The brand's one minting site is mintValidatedSettings (below validateSettingsDoc), so a RepoRunOptions built from an unvalidated document is a compile error. */ +declare const validatedSettings: unique symbol; + /** - * The brand has exactly one construction site (mintValidatedSettings, below validateSettingsDoc), so a RepoRunOptions - * built from an unvalidated document is a compile error. The value is the PARSED document zod built, never the - * caller's object. Each section's value reads back as ValidatedInput, the per-section proof every plan() takes, so - * the document is the only source of planner input. + * The PARSED document zod built, never the caller's object. Each section's value reads back as ValidatedInput, the + * per-section proof every plan() takes, so the document is the only source of planner input. */ -declare const validatedSettings: unique symbol; export type ValidatedSettings = { [K in keyof SettingsFile]: K extends SectionKey ? ValidatedInput : SettingsFile[K]; } & { readonly [validatedSettings]: true }; @@ -88,11 +88,11 @@ export type RepoResult = "applied" | "partial" | "clean" | "drift" | "failed" | type _UnrankedRepoResult = MustBeNever>; +/** One repository's apply or check; `preflightDenied` is non-empty when the preflight barrier refused to write anything. */ export interface RepoRunResult { repo: string; result: RepoResult; outcomes: SectionOutcome[]; - /** Non-empty when the preflight barrier refused to write anything. */ preflightDenied: string[]; } @@ -106,13 +106,41 @@ export function skippedSectionKeys( export interface ValidateOptions { /** The run's `undeclared` input: the fallback for a list whose wrapper and file set no policy, before the list's default. */ readonly undeclared?: UndeclaredPolicy | undefined; - /** - * Who authored the document; "operator" when omitted. The multi-repo flow passes "target" for a target's own - * settings.yml, so a secret reference in it is refused here, with the rest of the document's problems. - */ + + /** Who authored the document, "operator" when omitted; the multi-repo flow passes "target" for a target's own settings.yml, so a secret reference in it is refused here, with the rest of the document's problems. */ readonly secretSource?: SettingsSource | undefined; } +/** What one section's run ends in: the drift it found (check) or the changes it made (apply), with its notes. */ +type Outcome = + | { check: true; drift: string[]; notes: string[] } + | { check: false; changes: string[]; notes: string[] }; + +/** What a section produced before an operation failed, reported with the failure instead of vanishing; `landed` counts accepted requests, which the lines cannot stand in for, since a change thunk can fail after its request landed. */ +interface Produced { + readonly notes: readonly string[]; + readonly changes: readonly string[]; + readonly landed: number; +} + +/** A section's plan and execution as a value, beside what it produced on the way. */ +interface SectionStep { + readonly step: Result; + readonly produced: Produced; +} + +/** What the section loop shares across one repository; `tools` is null in check mode, which builds none. */ +interface SectionRun { + readonly api: GitHubClient; + readonly repo: RepoRef; + readonly io: Io; + readonly opts: RepoRunOptions; + readonly tools: ExecTools | null; +} + +/** A failure the on-missing-permission policy classifies. */ +type Denied = Extract; + /** * The ONE boundary that turns a raw parsed document into the ValidatedSettings the engine accepts. Unknown top-level * keys are errors, except outside a non-empty `sections` allowlist, where they downgrade to a warning; an unknown @@ -245,6 +273,261 @@ export async function preflightProbe( return denied; } +/** The ONE statement of what runs: the preflight filter and the section loop both read it, so they can never disagree. */ +function dispositionOf(opts: RepoRunOptions, key: SectionKey): "absent" | "excluded" | "active" { + if (opts.settings[key] === undefined) { + return "absent"; + } + if (opts.sections.only.size > 0 && !opts.sections.only.has(key)) { + return "excluded"; + } + return "active"; +} + +/** The failed rows for secret references the environment could not resolve, each error annotated under its section. */ +function secretFailure( + io: Io, + slug: string, + errorsBySection: ReadonlyMap, +): RepoRunResult { + const outcomes: SectionOutcome[] = []; + for (const [key, errors] of errorsBySection) { + for (const message of errors) { + io.annotate("error", `${key}: ${message}`); + } + outcomes.push({ key, status: "failed", detail: errors }); + } + return { repo: slug, result: "failed", outcomes, preflightDenied: [] }; +} + +/** The referenced variable names by the section declaring them. */ +function referencesBySection( + references: readonly SectionSecretReference[], +): Map { + const bySection = new Map(); + for (const { section, name } of references) { + const list = bySection.get(section) ?? []; + list.push(name); + bySection.set(section, list); + } + return bySection; +} + +/** The resolver the operations read plaintexts through; a reference it never met is a bug, since every declared one was resolved up front. */ +function secretResolver(resolved: Readonly>): ExecTools { + return { + resolveSecret: (reference: string): string => { + const plaintext = reference.startsWith("$") ? resolved[reference.slice(1)] : undefined; + if (plaintext === undefined) { + throw new Error( + `BUG: secret reference ${reference} was not resolved up front; the engine resolves every declared secret value before any section runs`, + ); + } + return plaintext; + }, + }; +} + +/** + * Apply resolves EVERY secret reference after the read-only preflight and before the first mutation, and masks each + * plaintext before the resolver exists; the errors come back by section. An empty map proves no legitimate lookup exists. + */ +function resolveTools( + references: readonly SectionSecretReference[], + secretEnv: Record | undefined, + io: Io, +): Result> { + const resolved: Record = {}; + if (references.length > 0) { + const env = secretEnv ?? process.env; + const resolutionErrors = new Map(); + const mask = new Set(); + for (const [key, names] of referencesBySection(references)) { + const resolution = resolveSecretRefs(names, env); + if (resolution.isErr()) { + resolutionErrors.set(key, resolution.error); + continue; + } + Object.assign(resolved, resolution.value.values); + for (const plaintext of resolution.value.mask) { + mask.add(plaintext); + } + } + + if (resolutionErrors.size > 0) { + return err(resolutionErrors); + } + + for (const plaintext of mask) { + io.mask(plaintext); + } + } + + return ok(secretResolver(resolved)); +} + +/** + * One section's plan and, in apply, its execution, each ending in a value; the catch is for what still throws (the + * client's own transport error on an unmarked request, a BUG invariant), reported like any other failure. + */ +async function runSection( + run: SectionRun, + section: (typeof SECTIONS)[number], +): Promise { + const desired = run.opts.settings[section.key]; + if (desired === undefined) { + // dispositionOf() classified this section active, which requires a declared value; planning on undefined would + // break plan()'s ValidatedInput contract. + throw new Error( + `BUG: section "${section.key}" was classified active but the settings document does not declare it`, + ); + } + + let produced: Produced = { notes: [], changes: [], landed: 0 }; + try { + const planned = await section.plan(planContext(section, run.api, run.repo), desired); + if (planned.isErr()) { + return { step: err(planned.error), produced }; + } + + const plan = planned.value; + if (run.tools === null) { + return { + step: ok({ check: true, drift: planDrift(plan), notes: planCheckNotes(plan) }), + produced, + }; + } + + const execution = await executePlan(plan, section, run.api, run.repo, run.tools); + const notes = [...plan.notes, ...plan.drift, ...execution.notes]; + produced = { notes, changes: execution.changes, landed: execution.landed }; + if (execution.status === "failed") { + return { step: err(execution.failure), produced }; + } + + return { step: ok({ check: false, changes: [...execution.changes], notes }), produced }; + } catch (error) { + return { step: err(thrown(error)), produced }; + } +} + +/** Why a denial fails the run whatever the policy: a request landed, or the section is required. */ +function denialWhy(landed: number, required: boolean): string { + if (landed > 0) { + return ` (${countNoun(landed, "request", "requests")} landed before the denial, so this fails the run whatever the on-missing-permission policy)`; + } + if (required) { + return " (listed in required-sections, so this fails the run)"; + } + return ""; +} + +/** A denied section: skipped under warn when nothing landed and the section is not required, else failed, the line saying why the policy did not spare it. */ +function deniedOutcome( + run: SectionRun, + key: SectionKey, + failure: Denied, + landed: number, + before: readonly string[], +): SectionOutcome { + const required = run.opts.sections.required.has(key); + // A denial after some operations landed is a partial mutation, never a skip: the warn policy applies only when nothing was written. + if (run.opts.onMissingPermission === "warn" && !required && landed === 0) { + run.io.annotate("warning", `${key}: skipped - ${failure.detail}`); + return { + key, + status: "skipped", + detail: [...before, failure.detail], + httpStatus: failure.status, + }; + } + + const applied = landed > 0 ? "partially applied" : "not applied"; + run.io.annotate("error", `${key}: ${applied}${denialWhy(landed, required)} - ${failure.detail}`); + return { key, status: "failed", detail: [...before, failure.detail], httpStatus: failure.status }; +} + +/** A section that failed: what it produced first (its notes and change lines), then the failure as its row. */ +function failedOutcome( + run: SectionRun, + key: SectionKey, + failure: SectionFailure, + produced: Produced, +): SectionOutcome { + for (const note of produced.notes) { + run.io.annotate("notice", `${key}: ${note}`); + } + for (const line of produced.changes) { + run.io.log(`${key}: ${line}`); + } + + const before = [...produced.notes, ...produced.changes]; + if (failure.kind === "permission-denied") { + return deniedOutcome(run, key, failure, produced.landed, before); + } + + // failureFor() messages already carry section, cause, and fix; anything else gets the section prefixed. A landed + // request is a real mutation with or without its line, so say so. + const prefixed = failure.message.startsWith(`${key}:`) + ? failure.message + : `${key}: ${failure.message}`; + const annotated = + produced.landed > 0 + ? `${prefixed} (${countNoun(produced.landed, "request", "requests")} landed before this failure, so the repository is partially applied)` + : prefixed; + run.io.annotate("error", annotated); + return { key, status: "failed", detail: [...before, annotated] }; +} + +/** A section that changed nothing but left notes (a tolerated 409, a personal-account skip) is NOT "already in the desired state"; the notes are shown instead of claiming no changes were needed. */ +function appliedDetail(changes: string[], notes: string[]): string[] { + if (changes.length > 0) { + return changes; + } + if (notes.length > 0) { + return notes; + } + return ["no changes needed"]; +} + +/** A section that ran: its notes, then its drift lines (check) or change lines (apply) as its row. */ +function ranOutcome(io: Io, key: SectionKey, outcome: Outcome): SectionOutcome { + for (const note of outcome.notes) { + io.annotate("notice", `${key}: ${note}`); + } + + if (outcome.check) { + if (outcome.drift.length === 0) { + return { key, status: "clean", detail: outcome.notes }; + } + for (const line of outcome.drift) { + io.log(`drift: ${line}`); + } + return { key, status: "drift", detail: outcome.drift }; + } + + for (const line of outcome.changes) { + io.log(`${key}: ${line}`); + } + return { key, status: "applied", detail: appliedDetail(outcome.changes, outcome.notes) }; +} + +/** The repository's result from its rows: a failure outranks everything, then drift (check only) or a skipped section, else the mode's healthy word. */ +function repoResult(outcomes: readonly SectionOutcome[], check: boolean): RepoResult { + const has = (status: SectionOutcome["status"]): boolean => + outcomes.some((outcome) => outcome.status === status); + if (has("failed")) { + return "failed"; + } + if (check && has("drift")) { + return "drift"; + } + if (has("skipped")) { + return "partial"; + } + return check ? "clean" : "applied"; +} + export async function runForRepo( api: GitHubClient, opts: RepoRunOptions, @@ -253,37 +536,11 @@ export async function runForRepo( const check = opts.mode === "check"; const repo = opts.repo; const settings = opts.settings; - - // The ONE statement of what runs: the preflight filter and the section loop both read it, so they can never disagree. - const disposition = (key: SectionKey): "absent" | "excluded" | "active" => { - if (settings[key] === undefined) { - return "absent"; - } - if (opts.sections.only.size > 0 && !opts.sections.only.has(key)) { - return "excluded"; - } - return "active"; - }; - const active = SECTIONS.filter((section) => disposition(section.key) === "active"); + const active = SECTIONS.filter((section) => dispositionOf(opts, section.key) === "active"); // Validation judged every secret value's syntax and provenance, in the excluded sections too, so what is collected // here is the ACTIVE sections' references, for apply to resolve; check mode reads no environment. const secretReferences = collectSecretReferences(settings, active); - const secretFailure = (errorsBySection: Map): RepoRunResult => { - const outcomes: SectionOutcome[] = []; - for (const [key, errors] of errorsBySection) { - for (const message of errors) { - io.annotate("error", `${key}: ${message}`); - } - outcomes.push({ key, status: "failed", detail: errors }); - } - return { - repo: opts.repo.slug, - result: "failed", - outcomes, - preflightDenied: [], - }; - }; // The API has no transactions, so a mid-apply permission failure would leave settings half-applied: under the strict // policy every active section is probed read-only FIRST. A token with read but not write access can still fail @@ -294,68 +551,23 @@ export async function runForRepo( for (const line of denied) { io.annotate("error", `preflight: ${line}`); } - return { - repo: opts.repo.slug, - result: "failed", - outcomes: [], - preflightDenied: denied, - }; + return { repo: repo.slug, result: "failed", outcomes: [], preflightDenied: denied }; } } - // Apply resolves EVERY secret reference after the read-only preflight and before the first mutation, and masks each - // plaintext before the resolver exists; check mode builds no tools. An empty map in apply proves no legitimate lookup exists. let tools: ExecTools | null = null; if (!check) { - const resolved: Record = {}; - if (secretReferences.length > 0) { - const env = opts.secretEnv ?? process.env; - const bySection = new Map(); - for (const { section, name } of secretReferences) { - const list = bySection.get(section) ?? []; - list.push(name); - bySection.set(section, list); - } - const resolutionErrors = new Map(); - const mask = new Set(); - for (const [key, names] of bySection) { - const resolution = resolveSecretRefs(names, env); - if (resolution.isErr()) { - resolutionErrors.set(key, resolution.error); - continue; - } - Object.assign(resolved, resolution.value.values); - for (const plaintext of resolution.value.mask) { - mask.add(plaintext); - } - } - if (resolutionErrors.size > 0) { - return secretFailure(resolutionErrors); - } - for (const plaintext of mask) { - io.mask(plaintext); - } + const resolved = resolveTools(secretReferences, opts.secretEnv, io); + if (resolved.isErr()) { + return secretFailure(io, repo.slug, resolved.error); } - tools = { - resolveSecret: (reference: string): string => { - const plaintext = reference.startsWith("$") ? resolved[reference.slice(1)] : undefined; - if (plaintext === undefined) { - throw new Error( - `BUG: secret reference ${reference} was not resolved up front; the engine resolves every declared secret value before any section runs`, - ); - } - return plaintext; - }, - }; + tools = resolved.value; } + const run: SectionRun = { api, repo, io, opts, tools }; const outcomes: SectionOutcome[] = []; - let failed = false; - let partial = false; - let drifted = false; - for (const section of SECTIONS) { - switch (disposition(section.key)) { + switch (dispositionOf(opts, section.key)) { case "absent": continue; case "excluded": @@ -364,149 +576,14 @@ export async function runForRepo( case "active": break; } - const desired = settings[section.key]; - if (desired === undefined) { - // disposition() classified this section active, which requires a declared value; planning on undefined would - // break plan()'s ValidatedInput contract. - throw new Error( - `BUG: section "${section.key}" was classified active but the settings document does not declare it`, - ); - } - type Outcome = - | { check: true; drift: string[]; notes: string[] } - | { check: false; changes: string[]; notes: string[] }; - // What the section produced before an operation failed, reported with the failure instead of vanishing. `landed` - // counts accepted requests: a change thunk can fail after its request landed, so the lines cannot stand in for it. - let produced: { notes: readonly string[]; changes: readonly string[]; landed: number } = { - notes: [], - changes: [], - landed: 0, - }; - // The section's plan and its execution, each ending in a value; the catch is for what still throws (the - // client's own transport error on an unmarked request, a BUG invariant), reported like any other failure. - const step = await (async (): Promise> => { - const planned = await section.plan(planContext(section, api, repo), desired); - if (planned.isErr()) { - return err(planned.error); - } - const plan = planned.value; - if (tools === null) { - return ok({ check: true, drift: planDrift(plan), notes: planCheckNotes(plan) }); - } - const execution = await executePlan(plan, section, api, repo, tools); - const notes = [...plan.notes, ...plan.drift, ...execution.notes]; - produced = { notes, changes: execution.changes, landed: execution.landed }; - if (execution.status === "failed") { - return err(execution.failure); - } - return ok({ check: false, changes: [...execution.changes], notes }); - })().catch((error: unknown) => err(thrown(error))); + + const { step, produced } = await runSection(run, section); if (step.isErr()) { - const failure = step.error; - for (const note of produced.notes) { - io.annotate("notice", `${section.key}: ${note}`); - } - for (const line of produced.changes) { - io.log(`${section.key}: ${line}`); - } - const before = [...produced.notes, ...produced.changes]; - if (failure.kind === "permission-denied") { - const required = opts.sections.required.has(section.key); - // A denial after some operations landed is a partial mutation, never a skip: the warn policy applies only when nothing was written. - const landed = produced.landed; - if (opts.onMissingPermission === "warn" && !required && landed === 0) { - io.annotate("warning", `${section.key}: skipped - ${failure.detail}`); - outcomes.push({ - key: section.key, - status: "skipped", - detail: [...before, failure.detail], - httpStatus: failure.status, - }); - partial = true; - continue; - } - const why = - landed > 0 - ? ` (${countNoun(landed, "request", "requests")} landed before the denial, so this fails the run whatever the on-missing-permission policy)` - : required - ? " (listed in required-sections, so this fails the run)" - : ""; - io.annotate( - "error", - `${section.key}: ${landed > 0 ? "partially applied" : "not applied"}${why} - ${failure.detail}`, - ); - outcomes.push({ - key: section.key, - status: "failed", - detail: [...before, failure.detail], - httpStatus: failure.status, - }); - failed = true; - continue; - } - // failureFor() messages already carry section, cause, and fix; anything else gets the section prefixed. A landed - // request is a real mutation with or without its line, so say so. - const prefixed = failure.message.startsWith(`${section.key}:`) - ? failure.message - : `${section.key}: ${failure.message}`; - const annotated = - produced.landed > 0 - ? `${prefixed} (${countNoun(produced.landed, "request", "requests")} landed before this failure, so the repository is partially applied)` - : prefixed; - io.annotate("error", annotated); - outcomes.push({ key: section.key, status: "failed", detail: [...before, annotated] }); - failed = true; + outcomes.push(failedOutcome(run, section.key, step.error, produced)); continue; } - const outcome = step.value; - for (const note of outcome.notes) { - io.annotate("notice", `${section.key}: ${note}`); - } - if (outcome.check) { - if (outcome.drift.length > 0) { - drifted = true; - for (const line of outcome.drift) { - io.log(`drift: ${line}`); - } - outcomes.push({ key: section.key, status: "drift", detail: outcome.drift }); - } else { - outcomes.push({ key: section.key, status: "clean", detail: outcome.notes }); - } - } else { - for (const line of outcome.changes) { - io.log(`${section.key}: ${line}`); - } - outcomes.push({ - key: section.key, - status: "applied", - // A section that changed nothing but left notes (a tolerated 409, a personal-account skip) is NOT "already in - // the desired state"; the notes are shown instead of claiming no changes were needed. - detail: - outcome.changes.length > 0 - ? outcome.changes - : outcome.notes.length > 0 - ? outcome.notes - : ["no changes needed"], - }); - } + outcomes.push(ranOutcome(io, section.key, step.value)); } - const result: RepoResult = failed - ? "failed" - : check - ? drifted - ? "drift" - : partial - ? "partial" - : "clean" - : partial - ? "partial" - : "applied"; - - return { - repo: opts.repo.slug, - result, - outcomes, - preflightDenied: [], - }; + return { repo: repo.slug, result: repoResult(outcomes, check), outcomes, preflightDenied: [] }; } diff --git a/src/engine/outcome.ts b/src/engine/outcome.ts index 5c01f02b2..8f9ab5bea 100644 --- a/src/engine/outcome.ts +++ b/src/engine/outcome.ts @@ -4,10 +4,7 @@ * a run's targets through worstOf(), so no mode can grow a word or a ranking of its own. */ -/** - * Worst-first. The healthy words never share a run (clean and drift belong to check, applied to apply, snapshot to - * snapshot, rendered to render), so their order against each other is never exercised; skipped appears only across a fleet. - */ +/** Worst-first; the healthy words never share a run (clean and drift are check's, applied apply's, snapshot snapshot's, rendered render's), so their order against each other is never exercised, and skipped appears only across a fleet. */ export const RUN_RESULTS = [ "failed", "drift", diff --git a/src/engine/secret-refs.ts b/src/engine/secret-refs.ts index 231c82d70..1c58f607a 100644 --- a/src/engine/secret-refs.ts +++ b/src/engine/secret-refs.ts @@ -19,14 +19,13 @@ export interface SecretRef { readonly name: string; } +/** A whole-value reference: `$` and nothing but an environment variable name. */ const REFERENCE_RE = /^\$[A-Z_][A-Z0-9_]*$/; +/** A reference anywhere inside a value, the shape that would ship verbatim. */ const EMBEDDED_REFERENCE_RE = /\$[A-Z_][A-Z0-9_]*/; -/** - * INPUT_* holds the action's own inputs (INPUT_TOKEN is the admin token); the rest are runner and workflow context. - * Routing any of them into a settings value would make the settings file an exfiltration channel. - */ +/** INPUT_* holds the action's own inputs (INPUT_TOKEN is the admin token) and the rest are runner and workflow context; routing any of them into a settings value would make the settings file an exfiltration channel. */ export const RESERVED_REF_PREFIXES = ["INPUT_", "GITHUB_", "ACTIONS_", "RUNNER_", "NODE_"] as const; /** @@ -68,6 +67,7 @@ export function validateSecretRef( export interface ResolvedSecretRefs { /** Resolved plaintext, keyed by env var name (two fields may share one). */ values: Record; + /** Every distinct plaintext value, for the caller to register with masking. */ mask: string[]; } diff --git a/src/engine/separate-removals.ts b/src/engine/separate-removals.ts index 605a33877..262b930e3 100644 --- a/src/engine/separate-removals.ts +++ b/src/engine/separate-removals.ts @@ -25,10 +25,40 @@ type SourceIndices = Map; interface Partition { /** The entries minus the removals, each entry's declared nested lists partitioned in turn. */ readonly kept: unknown[]; + /** Each removal by its marker's site (`labels[0]._remove`); a removal's own nested lists are not entered. */ readonly sites: string[]; } +/** A kept entry with each declared nested list partitioned in turn, and the sites of the removals below it. */ +function partitionNestedLists( + entry: Readonly>, + keyed: KeyedListLayering, + site: string, + viewSite: string, + sources: SourceIndices, +): { entry: Record; sites: string[] } { + const out: Record = { ...entry }; + const sites: string[] = []; + for (const [field, nested] of Object.entries(keyed.nested ?? {})) { + const form = nestedForm(entry[field]); + if (form === null) { + continue; + } + + const below = partitionRemovals( + form.entries, + nested, + `${site}.${field}`, + form.knobs === null ? `${viewSite}.${field}` : `${viewSite}.${field}.entries`, + sources, + ); + sites.push(...below.sites); + out[field] = form.knobs === null ? below.kept : { ...form.knobs, entries: below.kept }; + } + return { entry: out, sites }; +} + /** * `path` names the list as the sites do, by the document's own indices; `viewPath` as validation spells the kept * entries (`labels.entries`, `environments[1].variables`), the key under which `sources` records their origins. @@ -52,31 +82,20 @@ function partitionRemovals( kept.push(entry); continue; } + const site = `${path}[${index}]`; if (carriesRemoval(entry)) { sites.push(`${site}.${REMOVE_KEY}`); continue; } + const viewSite = `${viewPath}[${kept.length}]`; origins.push(index); - const out: Record = { ...entry }; - for (const [field, nested] of Object.entries(keyed.nested ?? {})) { - const form = nestedForm(entry[field]); - if (form === null) { - continue; - } - const below = partitionRemovals( - form.entries, - nested, - `${site}.${field}`, - form.knobs === null ? `${viewSite}.${field}` : `${viewSite}.${field}.entries`, - sources, - ); - sites.push(...below.sites); - out[field] = form.knobs === null ? below.kept : { ...form.knobs, entries: below.kept }; - } - kept.push(out); + const below = partitionNestedLists(entry, keyed, site, viewSite, sources); + sites.push(...below.sites); + kept.push(below.entry); } + if (origins.some((source, index) => source !== index)) { sources.set(viewPath, origins); } @@ -118,12 +137,11 @@ function renumbering(sources: SourceIndices): (issue: string) => string { export interface SeparatedRemovals { /** The document minus every entry carrying `_remove`, in either list form and at any depth; the wrappers' knobs stay. */ readonly rest: unknown; + /** Each removal's site (`labels[0]._remove`): list sections in LIST_SECTIONS order, each list as written, nested lists under their entry. */ readonly sites: readonly string[]; - /** - * A validation issue over `rest`, its leading path renumbered to the document as written: a removal dropped from - * the list shifts every entry after it, so `labels[0].color` in `rest` is the reader's `labels[1].color`. - */ + + /** A validation issue over `rest`, its leading path renumbered to the document as written: a removal dropped from the list shifts every entry after it, so `labels[0].color` in `rest` is the reader's `labels[1].color`. */ readonly asWritten: (issue: string) => string; } diff --git a/src/engine/snapshot.ts b/src/engine/snapshot.ts index 6f3635ae1..85a2cb6c7 100644 --- a/src/engine/snapshot.ts +++ b/src/engine/snapshot.ts @@ -14,7 +14,7 @@ import { endpointPath, matchesTemplate, } from "../sections/contract/endpoints.js"; -import { thrown } from "../sections/contract/errors.js"; +import { type SectionFailure, thrown } from "../sections/contract/errors.js"; import type { SectionSnapshot } from "../sections/contract/module.js"; import { concealedAbsenceNote, @@ -32,11 +32,11 @@ import { SectionSelection } from "./section-selection.js"; export interface SnapshotRunOptions { /** The target repository, parsed at the caller's validated boundary. */ repo: RepoRef; - /** - * The allowlist. Its required set goes unread: required-sections governs what must apply, and - * a snapshot never writes, so a denial classifies on onMissingPermission alone. - */ + + /** The allowlist; its required set goes unread, since required-sections governs what must apply and a snapshot never writes, so a denial classifies on onMissingPermission alone. */ sections: SectionSelection; + + /** Whether a denied section skips the run's document (warn) or fails it. */ onMissingPermission: OnMissingPermission; } @@ -69,6 +69,11 @@ type _UnrankedSnapshotResult = MustBeNever; +/** One section's snapshot: its row, and the document's value under its key when it declared one. */ +type SectionRead = + | { readonly outcome: SectionSnapshotOutcome; readonly declared: false } + | { readonly outcome: SectionSnapshotOutcome; readonly declared: true; readonly value: unknown }; + /** What a section without live state says for itself in the outcome and the file header. */ const NOTHING_TO_DECLARE = "nothing exists on the repository, so the section is omitted"; @@ -112,6 +117,121 @@ function watchingNotFound( }; } +/** A section whose read failed: a denial skips or fails it by the policy, anything else fails it with the section named. */ +function failedSnapshot( + io: Io, + key: SectionKey, + failure: SectionFailure, + policy: OnMissingPermission, +): SectionSnapshotOutcome { + // A denial ends the section from its primary read under both policies and from a sub-read (readOrNote) + // under fail, so this branch classifies every denial the run sees. + if (failure.kind === "permission-denied") { + if (policy === "warn") { + io.annotate("warning", `${key}: skipped - ${failure.detail}`); + return { key, status: "skipped", detail: [failure.detail] }; + } + io.annotate("error", `${key}: not snapshotted - ${failure.detail}`); + return { key, status: "failed", detail: [failure.detail] }; + } + + const prefixed = failure.message.startsWith(`${key}:`) + ? failure.message + : `${key}: ${failure.message}`; + io.annotate("error", prefixed); + return { key, status: "failed", detail: [prefixed] }; +} + +/** + * A section with nothing live. Chosen at the engine level so every absent-posture section behaves alike: the 404 + * keeps its "nothing exists" reading, and the note names the denial it could also be. Keyed on an observed 404, so + * an empty 200 listing earns no note. + */ +function absentSnapshot( + io: Io, + section: (typeof SECTIONS)[number], + absentRead: EndpointDecl | null, + notFound: boolean, + notes: readonly string[], +): SectionSnapshotOutcome { + const detail = [...notes]; + const concealed = + absentRead !== null && notFound ? concealedAbsenceNote(section, absentRead) : null; + if (concealed !== null) { + io.annotate("notice", concealed); + detail.push(concealed); + } + detail.push(NOTHING_TO_DECLARE); + return { key: section.key, status: "snapshot", detail }; +} + +/** One section read back: unsupported without a snapshot handler, else its row and the value its own schema accepted. */ +async function snapshotSection( + api: GitHubClient, + opts: SnapshotRunOptions, + io: Io, + section: (typeof SECTIONS)[number], +): Promise { + if (section.snapshot === undefined) { + const detail = [snapshotUnsupportedNote(section)]; + return { outcome: { key: section.key, status: "unsupported", detail }, declared: false }; + } + + const absentRead = gatedAbsentRead(section); + const seen = { notFound: false }; + // The catch is for what still throws (the client's own transport error on an unmarked request, a BUG invariant). + const read = await section + .snapshot( + snapshotContext( + section, + watchingNotFound(api, absentRead, seen), + opts.repo, + opts.onMissingPermission, + ), + ) + .catch((error: unknown) => err(thrown(error))); + if (read.isErr()) { + const outcome = failedSnapshot(io, section.key, read.error, opts.onMissingPermission); + return { outcome, declared: false }; + } + + const snapshot: SectionSnapshot = read.value; + // The section's notes in code-point order: a section notes its entries in the order GitHub listed them, and the + // file's header and the annotations must read the same on every run over the same repository. + const notes = [...snapshot.notes].sort(compareByCodePoint); + for (const note of notes) { + io.annotate("notice", underKey(section.key, note)); + } + + if (snapshot.value === undefined) { + const outcome = absentSnapshot(io, section, absentRead, seen.notFound, notes); + return { outcome, declared: false }; + } + + // Validated per section so the rejection names its producer: a value the section's own + // schema refuses is a bug in that section, never a document to hand out. + const verdict = validateSettingsDoc( + { [section.key]: snapshot.value }, + `the ${section.key} snapshot of ${opts.repo.slug}`, + SectionSelection.ALL, + io, + ); + if (verdict.isErr()) { + const detail = `BUG: ${section.key} produced a snapshot its own schema rejects - ${describeProblem(verdict.error)}`; + io.annotate("error", detail); + return { + outcome: { key: section.key, status: "failed", detail: [...notes, detail] }, + declared: false, + }; + } + + return { + outcome: { key: section.key, status: "snapshot", detail: [...notes] }, + declared: true, + value: verdict.value[section.key], + }; +} + /** Read one repository's supported sections back into a validated settings document. */ export async function snapshotRepository( api: GitHubClient, @@ -120,103 +240,22 @@ export async function snapshotRepository( ): Promise { const outcomes: SectionSnapshotOutcome[] = []; const document: Record = {}; - let partial = false; - let failed = false; - for (const section of SECTIONS) { if (opts.sections.only.size > 0 && !opts.sections.only.has(section.key)) { continue; } - if (section.snapshot === undefined) { - outcomes.push({ - key: section.key, - status: "unsupported", - detail: [snapshotUnsupportedNote(section)], - }); - continue; - } - const absentRead = gatedAbsentRead(section); - const seen = { notFound: false }; - // The catch is for what still throws (the client's own transport error on an unmarked request, a BUG invariant). - const read = await section - .snapshot( - snapshotContext( - section, - watchingNotFound(api, absentRead, seen), - opts.repo, - opts.onMissingPermission, - ), - ) - .catch((error: unknown) => err(thrown(error))); - if (read.isErr()) { - const failure = read.error; - // A denial ends the section from its primary read under both policies and from a sub-read (readOrNote) - // under fail, so this branch classifies every denial the run sees. - if (failure.kind === "permission-denied") { - const status = opts.onMissingPermission === "warn" ? "skipped" : "failed"; - if (status === "skipped") { - io.annotate("warning", `${section.key}: skipped - ${failure.detail}`); - partial = true; - } else { - io.annotate("error", `${section.key}: not snapshotted - ${failure.detail}`); - failed = true; - } - outcomes.push({ key: section.key, status, detail: [failure.detail] }); - continue; - } - const prefixed = failure.message.startsWith(`${section.key}:`) - ? failure.message - : `${section.key}: ${failure.message}`; - io.annotate("error", prefixed); - outcomes.push({ key: section.key, status: "failed", detail: [prefixed] }); - failed = true; - continue; - } - const snapshot: SectionSnapshot = read.value; - // The section's notes in code-point order: a section notes its entries in the order GitHub listed them, and the - // file's header and the annotations must read the same on every run over the same repository. - const notes = [...snapshot.notes].sort(compareByCodePoint); - for (const note of notes) { - io.annotate("notice", underKey(section.key, note)); - } - if (snapshot.value === undefined) { - // Chosen at the engine level so every absent-posture section behaves alike: the 404 keeps - // its "nothing exists" reading, and the note names the denial it could also be. Keyed on an - // observed 404, so an empty 200 listing earns no note. - const concealed = - absentRead !== null && seen.notFound ? concealedAbsenceNote(section, absentRead) : null; - if (concealed !== null) { - io.annotate("notice", concealed); - } - outcomes.push({ - key: section.key, - status: "snapshot", - detail: [...notes, ...(concealed === null ? [] : [concealed]), NOTHING_TO_DECLARE], - }); - continue; - } - // Validated per section so the rejection names its producer: a value the section's own - // schema refuses is a bug in that section, never a document to hand out. - const verdict = validateSettingsDoc( - { [section.key]: snapshot.value }, - `the ${section.key} snapshot of ${opts.repo.slug}`, - SectionSelection.ALL, - io, - ); - if (verdict.isErr()) { - const detail = `BUG: ${section.key} produced a snapshot its own schema rejects - ${describeProblem(verdict.error)}`; - io.annotate("error", detail); - outcomes.push({ key: section.key, status: "failed", detail: [...notes, detail] }); - failed = true; - continue; + + const read = await snapshotSection(api, opts, io, section); + outcomes.push(read.outcome); + if (read.declared) { + document[section.key] = read.value; } - document[section.key] = verdict.value[section.key]; - outcomes.push({ key: section.key, status: "snapshot", detail: [...notes] }); } - if (failed) { + if (outcomes.some((outcome) => outcome.status === "failed")) { return { repo: opts.repo.slug, result: "failed", outcomes }; } + // The brand's one mint, over the already-parsed fragments in the canonical order (so the document a library caller // reads is ordered as the file is): every section validated alone above, so the whole cannot fail. const verdict = validateSettingsDoc( @@ -230,6 +269,8 @@ export async function snapshotRepository( `BUG: the assembled snapshot of ${opts.repo.slug} failed validation after every section validated on its own: ${describeProblem(verdict.error)}`, ); } + + const partial = outcomes.some((outcome) => outcome.status === "skipped"); return { repo: opts.repo.slug, result: partial ? "partial" : "snapshot", diff --git a/src/engine/validate.ts b/src/engine/validate.ts index ad5cba3a3..f168e803b 100644 --- a/src/engine/validate.ts +++ b/src/engine/validate.ts @@ -78,50 +78,71 @@ function legalOfType(expected: string | undefined): string { } } -/** - * One walk over a section's value for what no shape can judge, so a new mapping or passthrough field needs no guard - * of its own; `offence` names the problem at a node. A YAML alias to an ancestor is a cycle JSON cannot carry: - * refused with its path under "refuse" (on zod's output, so a typed field keeps the shape's own message), passed over - * under "pass" (on the raw value, where the shape parse still runs). A shared alias between siblings is walked once. - */ -function findOffending( +/** The judgment one walk applies at every node, and what it does with a YAML alias cycle. */ +interface Judgment { + readonly offence: (value: unknown, at: "item" | "field") => string | null; + readonly cycles: "refuse" | "pass"; +} + +/** The objects one walk has entered: `ancestors` the current descent, `walked` every node, so a shared alias is walked once. */ +interface Visited { + readonly ancestors: Set; + readonly walked: WeakSet; +} + +function offendingBelow( value: unknown, path: string, - offence: (value: unknown, at: "item" | "field") => string | null, - cycles: "refuse" | "pass", - at: "item" | "field" = "field", - ancestors: Set = new Set(), - walked: WeakSet = new WeakSet(), + at: "item" | "field", + judgment: Judgment, + visited: Visited, ): string | null { - const own = offence(value, at); + const own = judgment.offence(value, at); if (own !== null) { return `${path} ${own}`; } if (value === null || typeof value !== "object") { return null; } - if (cycles === "refuse" && ancestors.has(value)) { + if (judgment.cycles === "refuse" && visited.ancestors.has(value)) { return `${path} refers back to one of its own containers (a YAML alias cycle), which JSON cannot carry; spell the value out instead`; } - if (walked.has(value)) { + if (visited.walked.has(value)) { return null; } - walked.add(value); - ancestors.add(value); + + visited.walked.add(value); + visited.ancestors.add(value); // Array.from, not map: map skips a hole. const children: [string, unknown, "item" | "field"][] = Array.isArray(value) ? Array.from(value, (entry, index) => [`${path}[${index}]`, entry, "item"]) : Object.entries(value).map(([key, entry]) => [`${path}.${key}`, entry, "field"]); for (const [childPath, child, childAt] of children) { - const hit = findOffending(child, childPath, offence, cycles, childAt, ancestors, walked); + const hit = offendingBelow(child, childPath, childAt, judgment, visited); if (hit !== null) { return hit; } } - ancestors.delete(value); + visited.ancestors.delete(value); return null; } +/** + * One walk over a section's value for what no shape can judge, so a new mapping or passthrough field needs no guard + * of its own; `offence` names the problem at a node. A YAML alias to an ancestor is a cycle JSON cannot carry: + * refused with its path under "refuse" (on zod's output, so a typed field keeps the shape's own message), passed over + * under "pass" (on the raw value, where the shape parse still runs). A shared alias between siblings is walked once. + */ +function findOffending( + value: unknown, + path: string, + offence: (value: unknown, at: "item" | "field") => string | null, + cycles: "refuse" | "pass", +): string | null { + const visited: Visited = { ancestors: new Set(), walked: new WeakSet() }; + return offendingBelow(value, path, "field", { offence, cycles }, visited); +} + /** * What the payload proof (contract/plan.ts plainData) would throw on mid-run, refused at one node in the words of * the fix: a YAML-tagged value (a Date, Set, or Uint8Array from !!timestamp, !!set, !!binary, which a zod object @@ -149,6 +170,75 @@ function parsedOffence(value: unknown, at: "item" | "field"): string | null { return nonPlainOffence(value, at) ?? nonFiniteOffence(value); } +/** zod's issues as the file's problems, five at most, a null the shape refused reworded to the values that exist. */ +function shapeProblems(key: string, declared: unknown, issues: z.ZodError["issues"]): string[] { + const problems: string[] = []; + for (const issue of issues.slice(0, 5)) { + const path = z.core.toDotPath([key, ...issue.path]); + // A null the shape refused is the author saying "empty" where GitHub has no empty state: name the values that + // exist. A shape's own diagnostic (a custom issue) already names the fix, unless it supplies the legal values itself. + if (valueAt(declared, issue.path) === null && rewritesForNull(issue as NullIssue)) { + problems.push(`${path} has no empty state; write ${legalValues(issue as NullIssue)}`); + continue; + } + problems.push(`${path}: ${issue.message}`); + } + + if (issues.length > 5) { + // A silently truncated list costs one fix-and-rerun cycle per hidden offender. + problems.push( + `${key}: ...and ${countNoun(issues.length - 5, "more issue", "more issues")} in this section`, + ); + } + return problems; +} + +/** One declared section judged: zod's output when every file-only check passed, else every problem they found. */ +function sectionProblems( + key: (typeof SECTION_KEYS)[number], + declared: unknown, + secretSource: SettingsSource, +): Result { + const shape = sectionShape(key); + // A null section is a value only where the section has an off state (Pages, interaction limits); elsewhere it + // declares nothing and the layered fold would write it as such, so it is refused before any shape parse. + if (declared === null && !shape.safeParse(null).success) { + return err([ + `${key}: null has no meaning; remove the section or declare its ${LIST_KEYS.has(key) ? "entries" : "fields"}`, + ]); + } + + // Before the shape parse: zod would accept the tagged value as an empty mapping and never report it. + const nonPlain = findOffending(declared, key, nonPlainOffence, "pass"); + if (nonPlain !== null) { + return err([nonPlain]); + } + + const parsed = shape.safeParse(declared); + if (!parsed.success) { + return err(shapeProblems(key, declared, parsed.error.issues)); + } + + const unplain = findOffending(parsed.data, key, parsedOffence, "refuse"); + if (unplain !== null) { + return err([unplain]); + } + + const problems: string[] = []; + if (isListSection(key)) { + problems.push(...closedSurfaceProblems(key, declaredEntries(parsed.data as ParsedList))); + } + problems.push( + ...fileOnlyProblems(key, parsed.data), + ...secretReferenceProblems(key, parsed.data, secretSource), + ); + if (problems.length > 0) { + return err(problems); + } + + return ok(parsed.data); +} + /** * The result is zod's output (fresh plain objects at every node the shape describes), never the caller's document, * branded ProvedPlain: this is the one place the plainness proof is minted, and the one place a document is walked @@ -169,56 +259,15 @@ export function validateSectionShapes( if (declared === undefined) { continue; } - const shape = sectionShape(key); - // A null section is a value only where the section has an off state (Pages, interaction limits); elsewhere it - // declares nothing and the layered fold would write it as such, so it is refused before any shape parse. - if (declared === null && !shape.safeParse(null).success) { - problems.push( - `${key}: null has no meaning; remove the section or declare its ${LIST_KEYS.has(key) ? "entries" : "fields"}`, - ); - continue; - } - // Before the shape parse: zod would accept the tagged value as an empty mapping and never report it. - const nonPlain = findOffending(declared, key, nonPlainOffence, "pass"); - if (nonPlain !== null) { - problems.push(nonPlain); - continue; - } - const parsed = shape.safeParse(declared); - if (!parsed.success) { - const issues = parsed.error.issues; - for (const issue of issues.slice(0, 5)) { - const path = z.core.toDotPath([key, ...issue.path]); - // A null the shape refused is the author saying "empty" where GitHub has no empty state: name the values that - // exist. A shape's own diagnostic (a custom issue) already names the fix, unless it supplies the legal values itself. - if (valueAt(declared, issue.path) === null && rewritesForNull(issue as NullIssue)) { - problems.push(`${path} has no empty state; write ${legalValues(issue as NullIssue)}`); - continue; - } - problems.push(`${path}: ${issue.message}`); - } - if (issues.length > 5) { - // A silently truncated list costs one fix-and-rerun cycle per hidden offender. - problems.push( - `${key}: ...and ${countNoun(issues.length - 5, "more issue", "more issues")} in this section`, - ); - } - continue; - } - const unplain = findOffending(parsed.data, key, parsedOffence, "refuse"); - if (unplain !== null) { - problems.push(unplain); + + const judged = sectionProblems(key, declared, secretSource); + if (judged.isErr()) { + problems.push(...judged.error); continue; } - problems.push( - ...(isListSection(key) - ? closedSurfaceProblems(key, declaredEntries(parsed.data as ParsedList)) - : []), - ...fileOnlyProblems(key, parsed.data), - ...secretReferenceProblems(key, parsed.data, secretSource), - ); - parsedSections[key] = parsed.data; + parsedSections[key] = judged.value; } + if (problems.length === 0) { // The brand's one cast: every section above passed both plainness walks. return ok(parsedSections as ProvedPlain); diff --git a/src/flows/deliver.ts b/src/flows/deliver.ts index 3313aa6ef..adfcf444c 100644 --- a/src/flows/deliver.ts +++ b/src/flows/deliver.ts @@ -43,13 +43,15 @@ import { } from "./redact.js"; import { writeMultiSummary, writeRenderSummary, writeSummary } from "./summary.js"; -/** One target's end state as its flow hands it over: the result word plus everything the channel seals. */ +/** + * One target's end state as its flow hands it over: the result word plus everything the channel seals. `note` is a + * human line for skips and failures that produced no section outcomes, or where a snapshot's file went; `file` is the + * snapshot file the target wrote, absent when nothing was written. + */ export interface TargetResult { result: RunOutcome; outcomes: ClosedOutcome[]; - /** Human line for skips/failures that produced no section outcomes, or where a snapshot's file went. */ note?: string; - /** The snapshot file the target wrote; absent when nothing was written. */ file?: string; } @@ -94,21 +96,32 @@ export function runOutcome( } export interface DeliveryConfig { + /** The mode the reports and the exit code are decided under. */ mode: "apply" | "check"; + + /** The channel a redacted target's full report goes through; none keeps it in the public line alone. */ privateReport: PrivateReportChannel; + /** The age recipient the `artifact` channel encrypts every report to; empty for the other channels. */ reportPublicKey: string; + /** The repository the run acts for; a target equal to it is never redacted. */ selfSlug: string; + /** Link to the workflow run, for the private report metadata; may be empty. */ runUrl: string; } export interface RunFlowConfig extends DeliveryConfig { + /** Whether a section the token cannot access fails the run or is skipped with a warning. */ onMissingPermission: "fail" | "warn"; + + /** The sections allowlist and the required set. */ sections: SectionSelection; + /** Whether to hide private/internal targets from the public view. */ privateRepos: PrivateReposPolicy; + /** The `undeclared` input: the fallback policy below a list's wrapper and the file's top-level `_undeclared`; unset by default. */ undeclared?: UndeclaredPolicy | undefined; } diff --git a/src/flows/execute.ts b/src/flows/execute.ts index d3be56583..4e41319ac 100644 --- a/src/flows/execute.ts +++ b/src/flows/execute.ts @@ -16,9 +16,12 @@ import { concludeSnapshot, runSnapshot } from "./snapshot.js"; /** What a face hands the executor; the config carries everything else. */ export interface RunDeps { + /** The face's sink for every line, output, and summary block. */ readonly io: Io; + /** Opens the client a config's token authorizes; a merge never asks for one. */ readonly createClient: (token: string, io: Io, apiVersion: string) => GitHubClient; + /** The artifact channel's uploader; parseConfig refuses that channel for a face that declares no upload capability. */ readonly uploader?: ArtifactUploader; } diff --git a/src/flows/input-readers.ts b/src/flows/input-readers.ts index 0720ea53f..41277ded4 100644 --- a/src/flows/input-readers.ts +++ b/src/flows/input-readers.ts @@ -33,8 +33,12 @@ import { import { PRIVATE_REPOS_POLICIES, type PrivateReposPolicy } from "./redact.js"; export interface Inputs { + /** An input trimmed, empty when unset. */ readonly value: InputReader; + + /** An input trimmed, its declared default when unset. */ readonly orDefault: (name: InputName) => string; + /** A declared list input, split; the default when unset. */ readonly list: (name: ListInput) => string[]; } diff --git a/src/flows/inputs.ts b/src/flows/inputs.ts index 708bc57fb..cc6f43dd8 100644 --- a/src/flows/inputs.ts +++ b/src/flows/inputs.ts @@ -26,26 +26,17 @@ export const DEFAULT_LAYERING = "deep" satisfies Layering; * runner. */ export interface InputDecl { - /** - * The action.yml description; the generator folds it to width. Plain prose: the inputs table renders it inside - * an HTML cell, so a "|" would split the row and a "<" would open a tag; gen-inputs-table.ts refuses both. - */ + /** The action.yml description; the generator folds it to width. Plain prose: the inputs table renders it inside an HTML cell, so a "|" would split the row and a "<" would open a tag; gen-inputs-table.ts refuses both. */ readonly description: string; + /** The action.yml default, verbatim (an empty string means "unset"). */ readonly default: string; - /** - * A comma- or newline-separated list. parseConfig reads such an input only - * through its list() port (repos is split by the target resolver instead), - * and the CLI lets the flag repeat; a single-value input has no `list`. - */ + + /** A comma- or newline-separated list: parseConfig reads such an input only through its list() port (repos is split by the target resolver instead), and the CLI lets the flag repeat; a single-value input has no `list`. */ readonly list?: true; } -/** - * The single source action.yml (bun run build:action-docs) and the inputs reference page's table (bun run - * build:inputs-table) are generated from, in its listing order. Adding an input here is the whole declaration. A - * new mode's inputs go beside their mode's. - */ +/** The one source action.yml (build:action-docs) and the inputs reference table (build:inputs-table) are generated from, in listing order; adding an input here is the whole declaration, and a new mode's inputs go beside their mode's. */ export const INPUT_DECLS = { token: { description: @@ -223,6 +214,7 @@ export type InputReader = (name: InputName) => string; * GITHUB_SERVER_URL, GITHUB_RUN_ID -> the run URL */ export type ConfigEnv = Readonly>; + export const FILTER_INPUTS = [ "visibility", "archived", @@ -235,27 +227,24 @@ export const FILTER_INPUTS = [ type FilterInput = (typeof FILTER_INPUTS)[number]; type _UnlistedFilter = MustBeNever>; + export const MODES = ["apply", "check", "render", "snapshot"] as const; export type Mode = (typeof MODES)[number]; -/** - * Their declared defaults are empty so "explicitly set" is detectable, as with the discovery filters; apply and check - * reject a set one instead of silently ignoring it. - */ +/** Their declared defaults are empty so "explicitly set" is detectable, as with the discovery filters; apply and check reject a set one instead of silently ignoring it. */ export const RENDER_ONLY_INPUTS = [ "rendered-file", "layering", ] as const satisfies readonly InputName[]; + +/** Their declared defaults are empty so "explicitly set" is detectable; apply and check reject a set one instead of silently ignoring it. */ export const SNAPSHOT_ONLY_INPUTS = [ "snapshot-file", "snapshot-dir", ] as const satisfies readonly InputName[]; -/** - * `token` is tolerated unread (a workflow commonly sets it on every step). Every declared input NOT listed here is an - * apply/check control, so the merge rejects it unless it holds its declared default, which the runner supplies whether - * or not the workflow set the input. - */ + +/** `token` is tolerated unread (a workflow commonly sets it on every step); every declared input NOT listed here is an apply/check control, which the merge rejects unless it holds its declared default, which the runner supplies either way. */ export const RENDER_INPUTS = [ "mode", "settings-file", @@ -265,17 +254,12 @@ export const RENDER_INPUTS = [ "token", ] as const satisfies readonly InputName[]; -/** - * Derived from the declarations, so a future input is rejected by the merge until listed in RENDER_INPUTS; exported so - * the layering guide's table is pinned to the whole set. - */ +/** Derived from the declarations, so a future input is rejected by the merge until listed in RENDER_INPUTS; exported so the layering guide's table is pinned to the whole set. */ export const RENDER_REJECTED_INPUTS: readonly InputName[] = ( Object.keys(INPUT_DECLS) as InputName[] ).filter((name) => !(RENDER_INPUTS as readonly string[]).includes(name)); -/** - * Every declared input NOT listed here is an apply, check, or merge control, so the snapshot rejects it unless it - * holds its declared default, which the runner supplies whether or not the workflow set the input. - */ + +/** Every declared input NOT listed here is an apply, check, or merge control, which the snapshot rejects unless it holds its declared default, supplied by the runner whether or not the workflow set it. */ export const SNAPSHOT_INPUTS = [ "token", "repository", @@ -291,10 +275,7 @@ export const SNAPSHOT_INPUTS = [ ...FILTER_INPUTS, ] as const satisfies readonly InputName[]; -/** - * Derived from the declarations, so a future input is rejected by the snapshot until listed in SNAPSHOT_INPUTS; - * exported so the snapshot guide's table is pinned to the whole set. - */ +/** Derived from the declarations, so a future input is rejected by the snapshot until listed in SNAPSHOT_INPUTS; exported so the snapshot guide's table is pinned to the whole set. */ export const SNAPSHOT_REJECTED_INPUTS: readonly InputName[] = ( Object.keys(INPUT_DECLS) as InputName[] ).filter((name) => !(SNAPSHOT_INPUTS as readonly string[]).includes(name)); diff --git a/src/flows/layers.ts b/src/flows/layers.ts index 1c2929d85..647f12b49 100644 --- a/src/flows/layers.ts +++ b/src/flows/layers.ts @@ -27,16 +27,16 @@ export function readLayerFiles( ); } -/** - * A merge has no `sections` allowlist: the merged document is applied later by a step whose allowlist this run cannot - * know, so an unknown top-level section is an error naming the layer, as in an apply. - */ +/** A merge has no `sections` allowlist: the merged document is applied later by a step whose allowlist this run cannot know, so an unknown top-level section is an error naming the layer, as in an apply. */ const EVERY_SECTION = SectionSelection.ALL; export interface FoldedLayers { /** The fold as validation parsed it: the branded document every other verb takes. */ settings: ValidatedSettings; + + /** Each lower entry a higher layer's `_remove` dropped, for the run's notices. */ notices: RemovalNotice[]; + /** The fold rendered in the canonical order (src/engine/canonical.ts), exactly as mode: render writes it to rendered-file. */ yaml: string; } diff --git a/src/flows/library.ts b/src/flows/library.ts index 82f6872d8..295369bb8 100644 --- a/src/flows/library.ts +++ b/src/flows/library.ts @@ -46,12 +46,16 @@ function sink(io: Io | undefined): { io: Io; log: () => CollectedLine[] } { export interface ValidateOptions { /** How the document is named in problems and warnings; a file path, usually. */ source?: string; + /** The `sections` allowlist: an unknown top-level key outside a non-empty one is a warning, not an error. */ sections?: SectionSelection; + /** Where the warnings print; without one they come back as the report's `log`. */ io?: Io; + /** The action's `undeclared` input: the fallback policy below a list's wrapper and the file's own; unset by default. */ undeclared?: UndeclaredPolicy; + /** Who authored the document: "operator" (the default) honors `$NAME` secret references, "target" refuses them. */ secretSource?: SettingsSource; } @@ -79,10 +83,14 @@ export function validateSettings( export interface MergeOptions { /** How the merged document is named in problems and warnings. */ source?: string; + /** How the list sections fold across layers; the action's `layering` input, "deep" unless set. */ layering?: FoldOptions["layering"]; + /** The action's `undeclared` input: the fallback policy below a list's wrapper and the file's own; unset by default. */ undeclared?: UndeclaredPolicy; + + /** Where the warnings and notices print; without one they come back as the report's `log`. */ io?: Io; } diff --git a/src/flows/multi.ts b/src/flows/multi.ts index a761f82be..42db6dfb7 100644 --- a/src/flows/multi.ts +++ b/src/flows/multi.ts @@ -12,7 +12,12 @@ import { type Err, err, ok, type ResultAsync, safeTry } from "neverthrow"; import { resolveCentralTargets } from "../discovery/central.js"; -import { type DiscoveryFilters, discoverRepos, formatSkipNotice } from "../discovery/discover.js"; +import { + type DiscoveryFilters, + type DiscoveryResult, + discoverRepos, + formatSkipNotice, +} from "../discovery/discover.js"; import { parseReposInput } from "../discovery/repos-input.js"; import { type CentralTarget, @@ -61,11 +66,18 @@ export const DEFAULT_SETTINGS_FILE = ".github/settings.yml"; * the three modes name the same fleet from the same inputs. */ export interface TargetsConfig extends Pick { + /** The `repos-dir` input: a directory of central files, empty when unset. */ reposDir: string; + + /** The `repos` input as written, split and judged by parseReposInput; empty when unset. */ reposInput: string; + /** The owner a bare `.yml` file under repos-dir belongs to. */ adminOwner: string; + + /** The discovery filters, every input defaulted. */ discoveryFilters: DiscoveryFilters; + /** Filter inputs the user explicitly set, for the misuse rejections. */ discoveryFiltersSet: string[]; } @@ -74,11 +86,13 @@ export interface MultiConfig extends RunFlowConfig, TargetsConfig { defaultsFile: string; } -/** The channel is the only sink in scope, so a redacted target's text lands only in its report. */ +/** + * The channel is the only sink in scope, so a redacted target's text lands only in its report. `defaults` was + * validated once before any target ran; null when no `defaults-file` was given. + */ async function processTarget(ctx: { api: GitHubClient; target: Target; - /** Validated once before any target ran; null when no `defaults-file` was given. */ defaults: ValidatedSettings | null; cfg: MultiConfig; injectMarker: boolean; @@ -235,7 +249,10 @@ const slugOf = (entry: FleetEntry): string => (isRejected(entry) ? entry.slug : export interface ResolvedTargets { /** Deduped, central first, in the order the run processes them. */ targets: FleetEntry[]; + + /** The redaction decision: which slugs hide, and the label each shows. */ plan: RedactionPlan; + /** A slug's resolved visibility; "unknown" for a slug never resolved. */ visibilityOf: (slug: string) => RepoVisibility; } @@ -291,9 +308,8 @@ export function resolveTargets( }); } -/** The fleet with every hidden slug registered; what resolveTargets lists and reports from. */ +/** The fleet with every hidden slug registered; what resolveTargets lists and reports from. `remote` is in listing order: the repos input's, or discovery's. */ interface MaskedFleet { - /** In listing order: the repos input's, or discovery's. */ remote: Array; plan: RedactionPlan; visibilityOf: (slug: string) => RepoVisibility; @@ -303,68 +319,141 @@ interface MaskedFleet { } /** - * Emits no notice, warning, or log line of this flow's own before the mask (the client's debug traces are the - * client's); resolveTargets relies on that to order the central warnings after it. + * The remote half of the fleet before visibility is resolved: the repos input's targets or discovery's, with what + * discovery already knows (`knownVisibility`, authoritative for those repos, so their per-target probe is skipped) and + * the private slugs it filtered out (`filteredPrivateSlugs`: masked, never placeholdered). */ -function maskFleet( - api: GitHubClient, - cfg: TargetsConfig, - io: Io, - central: CentralTarget[], -): ResultAsync { +interface RemoteFleet { + readonly remote: Array; + readonly skipGroups: MaskedFleet["skipGroups"]; + readonly filteredOutCount: number; + readonly knownVisibility: Map; + readonly filteredPrivateSlugs: Private[]; +} + +/** The discovered fleet: each found slug a target (or a rejected name), each filtered group a skip notice. */ +function discoveredFleet(discovered: DiscoveryResult): RemoteFleet { + const origin = 'repos: "*" discovery'; + const filteredPrivateSlugs: Private[] = []; + let filteredOutCount = 0; + for (const group of discovered.filtered) { + filteredOutCount += group.repos.length; + for (const repo of group.repos) { + if (repo.visibility !== "public") { + filteredPrivateSlugs.push(repo.slug); + } + } + } + + const knownVisibility = new Map(); + const remote: Array = []; + for (const found of discovered.repos) { + knownVisibility.set(slugKey(found.slug), found.visibility); + remote.push( + parseRepoSlug(found.slug).match( + (repo): RemoteTarget | RejectedTarget => ({ repo, source: "remote", origin }), + () => ({ source: "remote", origin, slug: found.slug }), + ), + ); + } + return { + remote, + skipGroups: [...discovered.filtered], + filteredOutCount, + knownVisibility, + filteredPrivateSlugs, + }; +} + +/** + * The remote targets: the repos input's, or discovery's under "*". parseConfig refuses a bad repos list and misplaced + * filters before any file is read; a library caller that builds the config by hand meets the same refusals here. + */ +function remoteFleet(api: GitHubClient, cfg: TargetsConfig): ResultAsync { return safeTry(async function* () { - const remote: Array = []; - let filteredOutCount = 0; - const skipGroups: MaskedFleet["skipGroups"] = []; - // Visibility learned from discovery is authoritative for those repos, so their per-target probe is skipped. - const knownVisibility = new Map(); - // Private slugs discovery filtered out are masked but never placeholdered. - const filteredPrivateSlugs: Private[] = []; - // parseConfig refuses a bad repos list and misplaced filters before any file is read; a library caller that - // builds the config by hand meets the same refusals here. - if (cfg.reposInput) { - const parsed = yield* parseReposInput(cfg.reposInput); - if (parsed.discover) { - const origin = 'repos: "*" discovery'; - const discovered = yield* discoverRepos(api, cfg.discoveryFilters); - for (const group of discovered.filtered) { - skipGroups.push(group); - filteredOutCount += group.repos.length; - for (const repo of group.repos) { - if (repo.visibility !== "public") { - filteredPrivateSlugs.push(repo.slug); - } - } - } - for (const found of discovered.repos) { - knownVisibility.set(slugKey(found.slug), found.visibility); - remote.push( - parseRepoSlug(found.slug).match( - (repo): RemoteTarget | RejectedTarget => ({ repo, source: "remote", origin }), - () => ({ source: "remote", origin, slug: found.slug }), - ), - ); - } - } else if (cfg.discoveryFiltersSet.length > 0) { + const none: RemoteFleet = { + remote: [], + skipGroups: [], + filteredOutCount: 0, + knownVisibility: new Map(), + filteredPrivateSlugs: [], + }; + if (!cfg.reposInput) { + if (cfg.discoveryFiltersSet.length > 0) { return fail({ code: "discovery-filters-without-wildcard", filters: cfg.discoveryFiltersSet, - targets: "explicit-repos", + targets: "repos-dir", }); - } else { - const origin = 'the "repos" input'; - remote.push(...parsed.repos.map((repo) => ({ repo, source: "remote" as const, origin }))); } - } else if (cfg.discoveryFiltersSet.length > 0) { + return ok(none); + } + + const parsed = yield* parseReposInput(cfg.reposInput); + if (parsed.discover) { + const discovered = yield* discoverRepos(api, cfg.discoveryFilters); + return ok(discoveredFleet(discovered)); + } + + if (cfg.discoveryFiltersSet.length > 0) { return fail({ code: "discovery-filters-without-wildcard", filters: cfg.discoveryFiltersSet, - targets: "repos-dir", + targets: "explicit-repos", }); } + const origin = 'the "repos" input'; + const remote = parsed.repos.map((repo) => ({ repo, source: "remote" as const, origin })); + return ok({ ...none, remote }); + }); +} + +/** + * Every distinct target slug's visibility under `redact`: discovery's value where it knew one and "public" for the + * self slug, so neither is probed. Empty under `show`, where visibility is never consulted. + */ +async function resolveVisibilities( + api: GitHubClient, + cfg: TargetsConfig, + orderedSlugs: readonly string[], + knownVisibility: ReadonlyMap, +): Promise> { + const visibilityBySlug = new Map(); + if (cfg.privateRepos !== "redact") { + return visibilityBySlug; + } + + const resolveVisibility = createVisibilityResolver(api); + const self = slugKey(cfg.selfSlug); + for (const slug of orderedSlugs) { + const key = slugKey(slug); + if (visibilityBySlug.has(key)) { + continue; + } + if (key === self) { + visibilityBySlug.set(key, "public"); + continue; + } + const known = knownVisibility.get(key); + visibilityBySlug.set(key, known ?? (await resolveVisibility(slug))); + } + return visibilityBySlug; +} + +/** + * Emits no notice, warning, or log line of this flow's own before the mask (the client's debug traces are the + * client's); resolveTargets relies on that to order the central warnings after it. + */ +function maskFleet( + api: GitHubClient, + cfg: TargetsConfig, + io: Io, + central: CentralTarget[], +): ResultAsync { + return safeTry(async function* () { + const fleet = yield* remoteFleet(api, cfg); const redact = cfg.privateRepos === "redact"; - const self = slugKey(cfg.selfSlug); // Visibility is resolved for every distinct target slug before the plan, and the resolved value (not a boolean) // drives two decisions that fail closed in opposite directions, so an unknown never posts a private report to a @@ -373,23 +462,13 @@ function maskFleet( // `show`, or the self slug -> no probe // redaction -> hide unless proven public // report delivery -> deliver only when proven private or internal - const resolveVisibility = createVisibilityResolver(api); - const orderedSlugs = [...central.map((t) => t.repo.slug), ...remote.map(slugOf)]; - const visibilityBySlug = new Map(); - if (redact) { - for (const slug of orderedSlugs) { - const key = slugKey(slug); - if (visibilityBySlug.has(key)) { - continue; - } - if (key === self) { - visibilityBySlug.set(key, "public"); - continue; - } - const known = knownVisibility.get(key); - visibilityBySlug.set(key, known ?? (await resolveVisibility(slug))); - } - } + const orderedSlugs = [...central.map((t) => t.repo.slug), ...fleet.remote.map(slugOf)]; + const visibilityBySlug = await resolveVisibilities( + api, + cfg, + orderedSlugs, + fleet.knownVisibility, + ); // Under `redact` the map holds every target slug, so the fallback only fires under `show`, where visibility is // never consulted; it still fails CLOSED, since "unknown" redacts as private and delivers as unproven. const visibilityOf = (slug: string): RepoVisibility => @@ -398,7 +477,7 @@ function maskFleet( const plan = planRedaction( cfg.privateRepos, orderedSlugs, - filteredPrivateSlugs, + fleet.filteredPrivateSlugs, (slug) => visibilityOf(slug) !== "public", cfg.selfSlug, ); @@ -408,7 +487,14 @@ function maskFleet( for (const slug of plan.maskedSlugs) { io.mask(slug); } - return ok({ remote, plan, visibilityOf, skipGroups, filteredOutCount, redact }); + return ok({ + remote: fleet.remote, + plan, + visibilityOf, + skipGroups: fleet.skipGroups, + filteredOutCount: fleet.filteredOutCount, + redact, + }); }); } diff --git a/src/flows/redact.ts b/src/flows/redact.ts index 4a10f5c9b..d2c50c886 100644 --- a/src/flows/redact.ts +++ b/src/flows/redact.ts @@ -29,21 +29,17 @@ export function privatePlaceholder(n: number): string { return `private repository #${n}`; } -/** - * For a redacted target whose visibility could not be PROVEN private or internal, so the report was withheld (delivery - * fails closed the opposite way from redaction). Shared verbatim by both run flows: the cause and the fix are slug-free. - */ +/** For a redacted target whose visibility could not be PROVEN private or internal, so the report was withheld (delivery fails closed the opposite way from redaction); shared verbatim by both run flows, since the cause and the fix are slug-free. */ export const WITHHELD_REPORT_NOTICE = "visibility could not be verified (the repository-metadata probe failed or was inconclusive " + "- typically the token cannot read the target repository), so the private report was " + "withheld rather than risk delivering it to a public repository. Grant the token metadata " + "read access and re-run; a transient API failure also leaves visibility unverified"; -/** One target's end state: safe closed values plus the detail the public view projects from. */ +/** One target's end state: safe closed values plus the detail the public view projects from; `display` is the public label, the slug or its "private repository #N" placeholder. */ export interface TargetOutcome { source: Target["source"]; result: RunOutcome; - /** The public label: the slug, or its "private repository #N" placeholder. */ display: string; detail: TargetDetail | Private; } @@ -62,16 +58,15 @@ type RedactedOutcome = { function redactOutcomes(outcomes: ClosedOutcome[]): RedactedOutcome[] { return outcomes.map((o) => { const withCode = - o.httpStatus !== undefined ? `${REDACTED_DETAIL}, HTTP ${o.httpStatus}` : REDACTED_DETAIL; + o.httpStatus === undefined ? REDACTED_DETAIL : `${REDACTED_DETAIL}, HTTP ${o.httpStatus}`; return { key: o.key, status: o.status, detail: [withCode] }; }); } -/** The public rendering of one target's detail: the section rows, the note under its heading, and the file it wrote. */ +/** The public rendering of one target's detail: the section rows, the note under its heading, and the file a snapshot target wrote, as the public view may show it. */ export interface PublicDetail { outcomes: RedactedOutcome[]; note?: string; - /** Where a snapshot target's file went, as the public view may show it. */ file?: string; } @@ -125,7 +120,7 @@ export function emitRedactedResult( const keysWith = (status: ClosedOutcome["status"]): string => { const keys = outcomes .filter((o) => o.status === status) - .map((o) => (o.httpStatus !== undefined ? `${o.key} (${o.httpStatus})` : o.key)); + .map((o) => (o.httpStatus === undefined ? o.key : `${o.key} (${o.httpStatus})`)); return keys.length > 0 ? ` - ${keys.join(", ")}` : ""; }; switch (result) { @@ -158,7 +153,9 @@ function unreachable(result: never): never { export interface RedactionPlan { isRedacted(slug: string): boolean; + display(slug: string): string; + /** Every slug that must be masked: redacted targets plus discovery-filtered privates. */ maskedSlugs: string[]; } @@ -239,10 +236,13 @@ export function capturingIo(io: Io): { io: Io; drain(): CollectedLine[] } { export interface TargetChannel { /** The public label: the slug, or its placeholder. */ display: string; + /** Sink for the target's own lines, attributed to it (prefixed in the clear). */ io: Io; + /** Sink for lines that already name their source (validation warnings): unprefixed, or the same capture. */ unprefixed: Io; + /** Seals or opens the target's end state: everything in its detail but the slug, which the channel holds. */ close(end: Omit): TargetOutcome["detail"]; } diff --git a/src/flows/run-config.ts b/src/flows/run-config.ts index 746da9b9f..abcf989b0 100644 --- a/src/flows/run-config.ts +++ b/src/flows/run-config.ts @@ -5,6 +5,7 @@ */ import { err, ok, type Result, safeTry } from "neverthrow"; +import type { DiscoveryFilters } from "../discovery/discover.js"; import { LIST_SEPARATOR, parseReposInput } from "../discovery/repos-input.js"; import { LAYERINGS } from "../engine/layers.js"; import type { Problem } from "../problem.js"; @@ -25,6 +26,7 @@ import { type ConfigEnv, DEFAULT_LAYERING, INPUT_DECLS, + type InputName, type InputReader, MODES, RENDER_ONLY_INPUTS, @@ -47,12 +49,37 @@ export type RunConfig = | (CommonConfig & (({ kind: "single" } & SingleConfig) | ({ kind: "multi" } & MultiConfig))) | ({ kind: "render" } & RenderConfig) | ({ kind: "snapshot" } & Pick & SnapshotConfig); + +/** The validated discovery filters, and the names the workflow set explicitly, which the misuse rejections name. */ +interface DiscoveryInputs { + readonly discoveryFilters: DiscoveryFilters; + readonly discoveryFiltersSet: string[]; +} + +/** The target inputs apply and check read once, since the arm they take depends on them. */ +interface TargetInputs { + readonly reposInput: string; + readonly reposDir: string; + readonly defaultsFile: string; + readonly settingsFile: string; +} + +/** The inputs among `names` set to something other than their declared default. */ +function setOffDefault(input: Inputs, names: readonly N[]): N[] { + return names.filter((name) => { + const value = input.value(name); + return value !== "" && value !== INPUT_DECLS[name].default; + }); +} + +/** The inputs among `names` the workflow set at all. */ +function setInputs(input: Inputs, names: readonly N[]): N[] { + return names.filter((name) => input.value(name) !== ""); +} + function parseRenderConfig(input: Inputs): Result, Problem> { return safeTry(function* () { - const rejected = RENDER_REJECTED_INPUTS.filter((name) => { - const value = input.value(name); - return value !== "" && value !== INPUT_DECLS[name].default; - }); + const rejected = setOffDefault(input, RENDER_REJECTED_INPUTS); if (rejected.length > 0) { return err({ code: "input-rejected-in-render", inputs: rejected }); } @@ -72,9 +99,13 @@ function parseRenderConfig(input: Inputs): Result; +/** The dir form of a mode: snapshot run: the multi-repo targets written under snapshotDir. */ +type SnapshotDirConfig = Extract; + /** What both snapshot forms read before the destination picks the arm. */ function readSnapshotBase(input: Inputs, env: ConfigEnv) { return safeTry(function* () { @@ -164,39 +195,24 @@ export function parseSnapshotFileConfig( return parseSnapshotFileArm(inputs(read), env, path); } -/** Read and validate the mode: snapshot inputs; the first problem wins. */ -function parseSnapshotConfig( +/** The dir arm: the fleet inputs, the single-repo one refused. */ +function parseSnapshotDirArm( input: Inputs, env: ConfigEnv, -): Result, Problem> { + snapshotDir: string, +): Result { return safeTry(function* () { - const rejected = SNAPSHOT_REJECTED_INPUTS.filter((name) => { - const value = input.value(name); - return value !== "" && value !== INPUT_DECLS[name].default; - }); - if (rejected.length > 0) { - return err({ code: "input-rejected-in-snapshot", inputs: rejected }); - } - const snapshotFile = input.value("snapshot-file"); - const snapshotDir = input.value("snapshot-dir"); - if (snapshotFile && snapshotDir) { - return err({ code: "input-snapshot-destinations-both" }); - } - if (!snapshotFile && !snapshotDir) { - return err({ code: "input-snapshot-destination-missing" }); - } - if (snapshotFile) { - return parseSnapshotFileArm(input, env, snapshotFile); - } const { base, filters } = yield* readSnapshotBase(input, env); if (input.value("repository")) { return err({ code: "input-repository-with-snapshot-dir" }); } + const reposInput = input.value("repos"); const reposDir = input.value("repos-dir"); if (!reposInput && !reposDir) { return err({ code: "input-snapshot-dir-without-targets" }); } + yield* refuseTargetInputs(reposInput, filters.discoveryFiltersSet); return ok({ ...base, @@ -211,6 +227,31 @@ function parseSnapshotConfig( }); } +/** Read and validate the mode: snapshot inputs; the first problem wins. */ +function parseSnapshotConfig( + input: Inputs, + env: ConfigEnv, +): Result, Problem> { + const rejected = setOffDefault(input, SNAPSHOT_REJECTED_INPUTS); + if (rejected.length > 0) { + return err({ code: "input-rejected-in-snapshot", inputs: rejected }); + } + + const snapshotFile = input.value("snapshot-file"); + const snapshotDir = input.value("snapshot-dir"); + if (snapshotFile && snapshotDir) { + return err({ code: "input-snapshot-destinations-both" }); + } + if (!snapshotFile && !snapshotDir) { + return err({ code: "input-snapshot-destination-missing" }); + } + if (snapshotFile) { + return parseSnapshotFileArm(input, env, snapshotFile); + } + + return parseSnapshotDirArm(input, env, snapshotDir); +} + /** * What the face running the config can do; parseConfig refuses an input that needs a capability the face lacks, so * the refusal has one owner and the flows never re-check it. @@ -220,30 +261,26 @@ export interface RunCapabilities { readonly artifactUpload: boolean; } -/** Read and validate every input through `read`; the first problem wins. */ -export function parseConfig( - read: InputReader, +/** The workflow run's URL; empty outside a run, where the runner's three variables are not all set. */ +function runUrlOf(env: ConfigEnv, githubRepository: string): string { + const serverUrl = env.GITHUB_SERVER_URL ?? ""; + const runId = env.GITHUB_RUN_ID ?? ""; + return serverUrl && githubRepository && runId + ? `${serverUrl}/${githubRepository}/actions/runs/${runId}` + : ""; +} + +/** + * What apply and check share, read in input order. The report channel is refused for a face without the upload it + * needs, and beside `private-repos: show`, where it would silently deliver nothing. + */ +function readCommon( + input: Inputs, env: ConfigEnv, + mode: "apply" | "check", capabilities: RunCapabilities, -): Result { - const input = inputs(read); +): Result { return safeTry(function* () { - // The mode decides which inputs exist at all, so it is read first: a merge never needs the token. - const mode = yield* readEnum(input, "mode", MODES, INPUT_DECLS.mode.default, "mode"); - if (mode === "render") { - return parseRenderConfig(input); - } - if (mode === "snapshot") { - return parseSnapshotConfig(input, env); - } - const renderOnly = RENDER_ONLY_INPUTS.filter((name) => input.value(name) !== ""); - if (renderOnly.length > 0) { - return err({ code: "input-render-only", inputs: renderOnly, mode }); - } - const snapshotOnly = SNAPSHOT_ONLY_INPUTS.filter((name) => input.value(name) !== ""); - if (snapshotOnly.length > 0) { - return err({ code: "input-snapshot-only", inputs: snapshotOnly, mode }); - } const token = yield* readToken(input, env); const githubRepository = env.GITHUB_REPOSITORY ?? ""; const { onMissingPermission, sections, privateRepos } = yield* readPolicies(input); @@ -265,13 +302,7 @@ export function parseConfig( return err({ code: "input-report-without-redaction" }); } const reportPublicKey = yield* resolveReportPublicKey(input, privateReport); - const serverUrl = env.GITHUB_SERVER_URL ?? ""; - const runId = env.GITHUB_RUN_ID ?? ""; - const runUrl = - serverUrl && githubRepository && runId - ? `${serverUrl}/${githubRepository}/actions/runs/${runId}` - : ""; - const common: CommonConfig = { + return ok({ token, mode, onMissingPermission, @@ -281,53 +312,110 @@ export function parseConfig( privateReport, reportPublicKey, selfSlug: githubRepository, - runUrl, + runUrl: runUrlOf(env, githubRepository), undeclared, - }; + }); + }); +} - const { discoveryFilters, discoveryFiltersSet } = yield* readDiscoveryFilters(input); +/** The multi-repo arm: the single-repo inputs refused, then the target problems the inputs alone decide. */ +function parseMultiArm( + input: Inputs, + common: CommonConfig, + filters: DiscoveryInputs, + targets: TargetInputs, +): Result, Problem> { + if (input.value("repository")) { + return err({ code: "input-repository-with-multi" }); + } + if (targets.settingsFile !== DEFAULT_SETTINGS_FILE) { + return err({ code: "input-settings-file-with-multi" }); + } - const reposInput = input.value("repos"); - const reposDir = input.value("repos-dir"); - const defaultsFile = input.value("defaults-file"); - const settingsFile = input.orDefault("settings-file"); - - if (reposInput || reposDir) { - if (input.value("repository")) { - return err({ code: "input-repository-with-multi" }); - } - if (settingsFile !== DEFAULT_SETTINGS_FILE) { - return err({ code: "input-settings-file-with-multi" }); - } - yield* refuseTargetInputs(reposInput, discoveryFiltersSet); - const adminOwner = githubRepository.split("/")[0] ?? ""; - return ok({ - ...common, - kind: "multi", - reposDir, - reposInput, - defaultsFile, - adminOwner, - discoveryFilters, - discoveryFiltersSet, - }); + return refuseTargetInputs(targets.reposInput, filters.discoveryFiltersSet).map(() => ({ + ...common, + kind: "multi" as const, + reposDir: targets.reposDir, + reposInput: targets.reposInput, + defaultsFile: targets.defaultsFile, + adminOwner: common.selfSlug.split("/")[0] ?? "", + discoveryFilters: filters.discoveryFilters, + discoveryFiltersSet: filters.discoveryFiltersSet, + })); +} + +/** The single-repo arm: the fleet inputs refused, the one settings file read as written. */ +function parseSingleArm( + input: Inputs, + common: CommonConfig, + filters: DiscoveryInputs, + targets: TargetInputs, +): Result, Problem> { + if (filters.discoveryFiltersSet.length > 0) { + return err({ + code: "discovery-filters-without-wildcard", + filters: filters.discoveryFiltersSet, + targets: "single-repo", + }); + } + if (targets.defaultsFile) { + return err({ code: "input-defaults-file-without-multi" }); + } + // The engine modes read exactly one file, so even a stray separator ("only.yml,") is rejected rather than repaired. + if (LIST_SEPARATOR.test(targets.settingsFile)) { + return err({ + code: "input-settings-file-is-list", + value: targets.settingsFile, + mode: common.mode, + }); + } + + return readSingleTarget(input, common.selfSlug).map((repo) => ({ + ...common, + kind: "single" as const, + repo, + settingsFile: targets.settingsFile, + })); +} + +/** Read and validate every input through `read`; the first problem wins. */ +export function parseConfig( + read: InputReader, + env: ConfigEnv, + capabilities: RunCapabilities, +): Result { + const input = inputs(read); + return safeTry(function* () { + // The mode decides which inputs exist at all, so it is read first: a merge never needs the token. + const mode = yield* readEnum(input, "mode", MODES, INPUT_DECLS.mode.default, "mode"); + if (mode === "render") { + return parseRenderConfig(input); + } + if (mode === "snapshot") { + return parseSnapshotConfig(input, env); } - if (discoveryFiltersSet.length > 0) { - return err({ - code: "discovery-filters-without-wildcard", - filters: discoveryFiltersSet, - targets: "single-repo", - }); + const renderOnly = setInputs(input, RENDER_ONLY_INPUTS); + if (renderOnly.length > 0) { + return err({ code: "input-render-only", inputs: renderOnly, mode }); } - if (defaultsFile) { - return err({ code: "input-defaults-file-without-multi" }); + const snapshotOnly = setInputs(input, SNAPSHOT_ONLY_INPUTS); + if (snapshotOnly.length > 0) { + return err({ code: "input-snapshot-only", inputs: snapshotOnly, mode }); } - // The engine modes read exactly one file, so even a stray separator ("only.yml,") is rejected rather than repaired. - if (LIST_SEPARATOR.test(settingsFile)) { - return err({ code: "input-settings-file-is-list", value: settingsFile, mode }); + + const common = yield* readCommon(input, env, mode, capabilities); + const filters = yield* readDiscoveryFilters(input); + const targets: TargetInputs = { + reposInput: input.value("repos"), + reposDir: input.value("repos-dir"), + defaultsFile: input.value("defaults-file"), + settingsFile: input.orDefault("settings-file"), + }; + if (targets.reposInput || targets.reposDir) { + return parseMultiArm(input, common, filters, targets); } - const repo = yield* readSingleTarget(input, githubRepository); - return ok({ ...common, kind: "single", repo, settingsFile }); + + return parseSingleArm(input, common, filters, targets); }); } diff --git a/src/flows/snapshot.ts b/src/flows/snapshot.ts index 5e9f44516..a904ee7e1 100644 --- a/src/flows/snapshot.ts +++ b/src/flows/snapshot.ts @@ -44,21 +44,24 @@ import { canonicalPath, landingNames, renameTarget, writeReplacing } from "./set import { openSingleRepoChannel } from "./single.js"; import { snapshotTakenLine, writeSnapshotDirSummary, writeSummary } from "./summary.js"; -/** - * The schema the written file's editor hint points at: the schema of this - * release line, spelled as the README's quick start spells it. The marker - * lets a major release rewrite the tag here (release-please-config.json lists - * this file); test/docs/readme.test.ts pins it to the README's hint. - */ +/** The schema the written file's editor hint points at, spelled as the README's quick start does; the release-please block markers let a major release rewrite the tag, and test/docs/readme.test.ts pins it to the README's hint. */ + +// x-release-please-start-major export const SNAPSHOT_SCHEMA_URL = - "https://raw.githubusercontent.com/Vivswan/github-settings-as-code/v2/lib/settings.schema.json"; // x-release-please-major + "https://raw.githubusercontent.com/Vivswan/github-settings-as-code/v2/lib/settings.schema.json"; + +// x-release-please-end interface SnapshotConfigBase { + /** Whether a denied section skips the document (warn) or fails the run. */ onMissingPermission: "fail" | "warn"; + /** The allowlist; its required set is unused, since a snapshot never writes. */ sections: SectionSelection; + /** Whether to hide private/internal targets from the public view. */ privateRepos: PrivateReposPolicy; + /** The repository the run acts for; a target equal to it is never redacted. */ selfSlug: string; } @@ -138,14 +141,14 @@ function destinationCollision(cfg: SnapshotConfig): Result { * through the target's channel. The engine has already annotated every * skipped and failed section; the unsupported ones get one notice here, since * they are the sections the file will not carry. The written path names the - * slug, so it travels in the result for the channel to seal. + * slug, so it travels in the result for the channel to seal. `pathInput` is + * the input the path came from, named when the write fails. */ async function snapshotTarget(ctx: { api: GitHubClient; repo: RepoRef; cfg: SnapshotConfigBase; path: string; - /** The input the path came from, named when the write fails. */ pathInput: "snapshot-file" | "snapshot-dir"; channel: TargetChannel; }): Promise { diff --git a/src/flows/summary.ts b/src/flows/summary.ts index c1f0ebe15..471d03803 100644 --- a/src/flows/summary.ts +++ b/src/flows/summary.ts @@ -118,6 +118,17 @@ export function writeMultiSummary(io: SummaryIo, views: PublicTargetView[], mode io.summary(lines.join("\n")); } +/** The rollup's first line: how many of the targets' snapshots were written under the directory. */ +function writtenLine(written: number, total: number, snapshotDir: string): string { + if (written === 0) { + return `No snapshot was written under ${markdownCell(snapshotDir)}.`; + } + if (written === total) { + return `Snapshots written under ${markdownCell(snapshotDir)}.`; + } + return `${written} of ${total} snapshots written under ${markdownCell(snapshotDir)}.`; +} + /** The snapshot-dir summary: the fleet rollup with each target's file and the run's moment, then one section table per target. */ export function writeSnapshotDirSummary( io: SummaryIo, @@ -129,11 +140,7 @@ export function writeSnapshotDirSummary( const lines = [ `## github-settings-as-code (snapshot, ${countNoun(views.length, "repository", "repositories")})`, "", - written === 0 - ? `No snapshot was written under ${markdownCell(snapshotDir)}.` - : written === views.length - ? `Snapshots written under ${markdownCell(snapshotDir)}.` - : `${written} of ${views.length} snapshots written under ${markdownCell(snapshotDir)}.`, + writtenLine(written, views.length, snapshotDir), "", snapshotTakenLine(takenAt), "", diff --git a/src/github/api-error.ts b/src/github/api-error.ts index 5512ce50d..f5468aa2a 100644 --- a/src/github/api-error.ts +++ b/src/github/api-error.ts @@ -6,47 +6,50 @@ * sections/contract/requests.ts withhold the same way. */ +/** + * The one shape GitHub's answer to a failed request takes. `rateLimited` is the client's own proof that a 403 is a + * rate limit, from signals the value cannot carry (retry-after, errors[].type RATE_LIMITED, the secondary-rate + * phrase; the ambiguous zero-quota header only on a withheld body), recorded because the body it was read from may + * be withheld; classifyApiError is its one reader, and withheld() copies it through. `graphqlTypes` is what + * tolerance decisions read instead of the status, a lossy fold (FORBIDDEN and a mixed [FORBIDDEN, UNPROCESSABLE] + * both land on 403/422); the values are structural enums, never echoes, so the field survives a withheld response. + * + * every errors[] entry carries a string type -> the types, deduped and sorted + * any entry untyped -> omitted, and the response is never tolerable + */ export interface ApiError { + /** The HTTP status, or the one a GraphQL errors[] folds to. */ status: number; + + /** GitHub's message with its errors[] appended; a withholding constant for a secret-carrying or redacted request. */ message: string; + + /** The response body as text; the same constant when withheld. */ body: string; + /** GitHub's documentation_url for the failing endpoint, when the body carries one. */ documentationUrl?: string; - /** - * The client's own proof that a 403 is a rate limit, from signals the value cannot carry (retry-after, errors[].type - * RATE_LIMITED, the secondary-rate phrase; the ambiguous zero-quota header only on a withheld body), recorded because - * the body it was read from may be withheld. classifyApiError is its one reader; withheld() copies it through. - */ + + /** The client's proof that a 403 is a rate limit; the doc above says where it comes from. */ rateLimited?: true; - /** - * Tolerance decisions read this instead of the status, which is a lossy fold (FORBIDDEN and a mixed - * [FORBIDDEN, UNPROCESSABLE] both land on 403/422). The values are structural enums, never echoes, so the field - * survives a withheld response. - * - * every errors[] entry carries a string type -> the types, deduped and sorted - * any entry untyped -> omitted, and the response is never tolerable - */ + + /** GitHub's structured error types, for tolerance decisions; the doc above says when they are present. */ graphqlTypes?: readonly string[]; } -/** - * A 4xx body can ECHO the rejected value inside its free-text message/errors, where no field name finds it and JSON - * escaping defeats exact-literal masking, so nothing of the body survives. - */ +/** A 4xx body can ECHO the rejected value inside its free-text message/errors, where no field name finds it and JSON escaping defeats exact-literal masking, so nothing of the body survives. */ export const SECRET_RESPONSE_WITHHELD = "response body withheld: the request carried a secret field and an error body may echo its value"; -/** - * GraphQL error messages quote the slug and live state verbatim ("Could not resolve to a Repository with the name - * 'o/private'") where a REST denial says only "Not Found", and the output mask is exact-literal, so a re-cased mention - * would slip it. - */ +/** GraphQL error messages quote the slug and live state verbatim ("Could not resolve to a Repository with the name 'o/private'") where a REST denial says only "Not Found", and the output mask is exact-literal, so a re-cased mention would slip it. */ export const REDACTED_RESPONSE_WITHHELD = "response body withheld: the repository is redacted and a GraphQL error message may carry its name or live state"; +/** Replaces a secret-carrying request's transport message: some transport failures quote request details in free text. */ export const SECRET_TRANSPORT_WITHHELD = "the transport failed before an HTTP response arrived (details withheld: the request carried a secret field)"; +/** Replaces a redacted repository's transport message, for the same reason. */ export const REDACTED_TRANSPORT_WITHHELD = "the transport failed before an HTTP response arrived (details withheld: the repository is redacted)"; @@ -84,61 +87,85 @@ export function isHttpError(error: unknown): error is OctokitHttpError { ); } -/** - * Shared by tryRequest and tryGraphql. For a secret-carrying request the response is replaced wholesale (a 4xx body may - * echo the rejected value), so only the status and the content-free rate-limit flag survive; the classification runs FIRST. - */ -export function apiErrorFromHttp(error: OctokitHttpError, carriesSecret: boolean): ApiError { - const body = error.response?.data; - const headers = error.response?.headers ?? {}; - const classificationText = - typeof body === "object" && body !== null && "message" in body - ? String((body as { message: unknown }).message) - : typeof body === "string" && body - ? body - : error.message; - // A rate-limit 403's message need not say "rate limit", and one misread as a missing grant becomes permission - // advice, so the headers and errors[] decide first. - // retry-after, errors[].type RATE_LIMITED, "secondary rate" -> definitive: no documented permission 403 carries any of them - // a secret echoing "secondary rate" -> cannot spoof: a permission 403 never echoes the payload - // x-ratelimit-remaining: 0 -> rides a permission 403 on the token's last quota unit too; - // only a withheld body accepts it - const errorsRateLimited = - typeof body === "object" && - body !== null && - Array.isArray((body as { errors?: unknown }).errors) && - ((body as { errors: unknown[] }).errors ?? []).some( +/** GitHub's JSON error body, or null for a body without a `message`. */ +function jsonErrorBody( + body: unknown, +): { message: unknown; errors?: unknown; documentation_url?: unknown } | null { + return typeof body === "object" && body !== null && "message" in body + ? (body as { message: unknown; errors?: unknown; documentation_url?: unknown }) + : null; +} + +/** The JSON body's `message`, else a non-empty text body, else the transport's own message. */ +function messageOf(body: unknown, fallback: string): string { + const json = jsonErrorBody(body); + if (json !== null) { + return String(json.message); + } + if (typeof body === "string" && body) { + return body; + } + return fallback; +} + +/** Whether any errors[] entry of the JSON body is typed RATE_LIMITED. */ +function errorsRateLimited(body: unknown): boolean { + if (typeof body !== "object" || body === null) { + return false; + } + const errors = (body as { errors?: unknown }).errors; + return ( + Array.isArray(errors) && + errors.some( (entry) => typeof entry === "object" && entry !== null && (entry as { type?: unknown }).type === "RATE_LIMITED", - ); - const definitiveRateLimit = + ) + ); +} + +/** + * A rate-limit 403's message need not say "rate limit", and one misread as a missing grant becomes permission + * advice, so the headers and errors[] decide first. + * retry-after, errors[].type RATE_LIMITED, "secondary rate" -> definitive: no documented permission 403 carries any of them + * a secret echoing "secondary rate" -> cannot spoof: a permission 403 never echoes the payload + * x-ratelimit-remaining: 0 -> rides a permission 403 on the token's last quota unit too; + * only a withheld body accepts it + */ +function isRateLimited( + error: OctokitHttpError, + body: unknown, + text: string, + carriesSecret: boolean, +): boolean { + const headers = error.response?.headers ?? {}; + const definitive = error.status === 429 || (error.status === 403 && (headers["retry-after"] !== undefined || - errorsRateLimited || - /\bsecondary rate\b/i.test(classificationText))); - const rateLimited = - definitiveRateLimit || - (carriesSecret && error.status === 403 && String(headers["x-ratelimit-remaining"]) === "0"); - let message: string; - let documentationUrl: string | undefined; - if (typeof body === "object" && body !== null && "message" in body) { - message = String((body as { message: unknown }).message); - const errors = (body as { errors?: unknown }).errors; - if (errors) { - message += ` (${JSON.stringify(errors)})`; - } - const docUrl = (body as { documentation_url?: unknown }).documentation_url; - if (typeof docUrl === "string" && docUrl) { - documentationUrl = docUrl; - } - } else if (typeof body === "string" && body) { - message = body; - } else { - message = error.message; - } + errorsRateLimited(body) || + /\bsecondary rate\b/i.test(text))); + return ( + definitive || + (carriesSecret && error.status === 403 && String(headers["x-ratelimit-remaining"]) === "0") + ); +} + +/** + * Shared by tryRequest and tryGraphql. For a secret-carrying request the response is replaced wholesale (a 4xx body may + * echo the rejected value), so only the status and the content-free rate-limit flag survive; the classification runs FIRST. + */ +export function apiErrorFromHttp(error: OctokitHttpError, carriesSecret: boolean): ApiError { + const body = error.response?.data; + const json = jsonErrorBody(body); + const classificationText = messageOf(body, error.message); + const rateLimited = isRateLimited(error, body, classificationText, carriesSecret); + const message = json?.errors + ? `${classificationText} (${JSON.stringify(json.errors)})` + : classificationText; + const docUrl = json?.documentation_url; + const documentationUrl = typeof docUrl === "string" && docUrl ? docUrl : undefined; const readable: ApiError = { status: error.status, message, @@ -149,14 +176,14 @@ export function apiErrorFromHttp(error: OctokitHttpError, carriesSecret: boolean return carriesSecret ? withheld(readable, SECRET_RESPONSE_WITHHELD) : readable; } -/** - * Keyed on GitHub's structured error `type`. A secret-carrying request withholds message and body (a `type` enum - * cannot echo). - * - * mixed types -> the earlier in the ladder wins: a rate limit never reads as a permission failure, nor that as a bad payload - * NOT_FOUND -> 404, how fine-grained tokens conceal denied resources, like REST - */ -export function apiErrorFromGraphqlErrors(errors: unknown[], carriesSecret: boolean): ApiError { +/** What the errors[] entries carry: their types, their messages, and whether every entry was typed. */ +interface GraphqlErrorParts { + readonly types: ReadonlySet; + readonly messages: readonly string[]; + readonly everyEntryTyped: boolean; +} + +function graphqlErrorParts(errors: unknown[]): GraphqlErrorParts { const types = new Set(); const messages: string[] = []; let everyEntryTyped = true; @@ -165,37 +192,64 @@ export function apiErrorFromGraphqlErrors(errors: unknown[], carriesSecret: bool everyEntryTyped = false; continue; } + const type = (entry as { type?: unknown }).type; if (typeof type === "string") { types.add(type); } else { everyEntryTyped = false; } + const message = (entry as { message?: unknown }).message; if (typeof message === "string" && message) { messages.push(message); } } - const rateLimited = types.has("RATE_LIMITED"); - const status = - rateLimited || types.has("FORBIDDEN") || types.has("INSUFFICIENT_SCOPES") - ? 403 - : types.has("NOT_FOUND") - ? 404 - : 422; + return { types, messages, everyEntryTyped }; +} + +/** The status the structured types fold to; the earlier in the ladder wins, so a rate limit never reads as a permission failure, nor that as a bad payload. */ +function graphqlStatus(types: ReadonlySet): 403 | 404 | 422 { + if (types.has("RATE_LIMITED") || types.has("FORBIDDEN") || types.has("INSUFFICIENT_SCOPES")) { + return 403; + } + if (types.has("NOT_FOUND")) { + return 404; + } + return 422; +} + +/** `message` is required on every errors[] entry by GitHub's contract, so the fallback fires only off-contract; it names the structural types (safe enums, never echoes) so the reader is not left with a bare status. */ +function graphqlMessage(parts: GraphqlErrorParts): string { + const joined = parts.messages.join("; "); + if (joined) { + return joined; + } + if (parts.types.size > 0) { + return `GraphQL request failed with no error message (error types: ${[...parts.types].sort().join(", ")})`; + } + return "GraphQL request failed with no error message or error type in the errors[] response"; +} + +/** + * Keyed on GitHub's structured error `type`. A secret-carrying request withholds message and body (a `type` enum + * cannot echo). + * + * mixed types -> the earlier in the ladder wins: a rate limit never reads as a permission failure, nor that as a bad payload + * NOT_FOUND -> 404, how fine-grained tokens conceal denied resources, like REST + */ +export function apiErrorFromGraphqlErrors(errors: unknown[], carriesSecret: boolean): ApiError { + const parts = graphqlErrorParts(errors); + const rateLimited = parts.types.has("RATE_LIMITED"); // graphqlTypes only when EVERY entry carried a string type: an untyped entry must make the whole response untolerable // rather than hide behind its typed siblings. const graphqlTypes = - everyEntryTyped && types.size > 0 ? { graphqlTypes: Object.freeze([...types].sort()) } : {}; + parts.everyEntryTyped && parts.types.size > 0 + ? { graphqlTypes: Object.freeze([...parts.types].sort()) } + : {}; const readable: ApiError = { - status, - // `message` is required on every errors[] entry by GitHub's contract, so the fallback fires only off-contract; it - // names the structural types (safe enums, never echoes) so the reader is not left with a bare status. - message: - messages.join("; ") || - (types.size > 0 - ? `GraphQL request failed with no error message (error types: ${[...types].sort().join(", ")})` - : "GraphQL request failed with no error message or error type in the errors[] response"), + status: graphqlStatus(parts.types), + message: graphqlMessage(parts), body: JSON.stringify(errors), ...(rateLimited ? { rateLimited: true } : {}), ...graphqlTypes, @@ -223,11 +277,7 @@ export function transportReason(error: unknown, withholdReason: string | undefin */ export type ApiErrorKind = "rate-limit" | "permission" | "other"; -/** - * GitHub's primary limit answers 403 with "API rate limit exceeded ..." and neither retry-after nor errors[].type, so a - * readable message is the one signal left for it, and the one channel a GitHubClient without headers has. A withheld - * message is a constant that never matches. - */ +/** GitHub's primary limit answers 403 with "API rate limit exceeded" and neither retry-after nor errors[].type, so the message is the one signal (and the one channel a headerless GitHubClient has); a withheld message is a constant that never matches. */ const RATE_LIMIT_PHRASE = /rate limit/i; export function classifyApiError(error: ApiError): ApiErrorKind { diff --git a/src/github/api.ts b/src/github/api.ts index e577f3eee..dc8f97506 100644 --- a/src/github/api.ts +++ b/src/github/api.ts @@ -42,10 +42,7 @@ import { TraceRedaction, } from "./trace-redaction.js"; -/** - * The single source for the header default here, the action.yml `api-version` default, and the inputs fallback; the - * action-yml contract test asserts the three stay equal. - */ +/** The single source for the header default here, the action.yml `api-version` default, and the inputs fallback; the action-yml contract test asserts the three stay equal. */ export const DEFAULT_API_VERSION = "2022-11-28"; /** @@ -82,6 +79,7 @@ export const PAGE_SIZE = 100; export interface ListOptions { /** Items per page; PAGE_SIZE when omitted. An endpoint with a documented cap below it passes the cap. */ perPage?: number | undefined; + /** Ends the walk after the first page this accepts, for a scan that needs only the newest page carrying a match. */ until?: (page: unknown) => boolean; } @@ -97,12 +95,14 @@ export interface GitHubClient { payload?: unknown, options?: RequestMark & { accept?: string; raw?: boolean; redactTrace?: boolean }, ): Promise>; + /** * One GET list walk: `data` carries every page's body in order, each as GitHub sent it (a bare list, or the * `{total_count, : []}` envelope), so the caller selects the list by its key. The walk ends where GitHub's * pagination ends, or after the page `until` accepts; the first page it cannot fetch is the whole answer. */ tryList(path: string, options?: ListOptions): Promise>; + /** * Failures, including the errors[] GitHub delivers inside an HTTP 200, come back as the same ApiError the REST * classifiers read. `slug` names the owner/repo: GraphQL carries the target in the request BODY, invisible to the @@ -134,11 +134,10 @@ function throttleCallback( }; } -// Failing loudly with the API message beats stalling a workflow for an hour. Exported so the docs contradiction test -// pins the semantics guide's number to this value. +/** Failing loudly with the API message beats stalling a workflow for an hour; exported so the docs contradiction test pins the semantics guide's number to this value. */ export const MAX_RETRY_WAIT_S = 60; -// Exported so the test harness builds its retry budgets (1 + MAX_RETRIES) from the one real value, and the docs -// contradiction test pins the guide's retry count to it. + +/** Exported so the test harness builds its retry budgets (1 + MAX_RETRIES) from the one real value, and the docs contradiction test pins the guide's retry count to it. */ export const MAX_RETRIES = 2; // The request-log plugin stays: its per-attempt trace line carries GitHub's request id, which support asks for. @@ -153,36 +152,86 @@ function envRetryBaseMs(): number | undefined { /** Among the 4xx only 408 is retried here; 429 and the rate-limit 403s belong to the throttling plugin, which honors Retry-After. */ const DO_NOT_RETRY = Array.from({ length: 100 }, (_, i) => 400 + i).filter((s) => s !== 408); -/** - * A marked payload is traced as this token, never field by field: the mark says a resolved secret is somewhere in it - * under a name the field scan may not know, and a JSON-escaped value slips the runner's exact-literal mask. - */ +/** A marked payload is traced as this token, never field by field: the mark says a resolved secret is somewhere in it under a name the field scan may not know, and a JSON-escaped value slips the runner's exact-literal mask. */ const MARKED_PAYLOAD_TRACE = ""; export interface GitHubApiOptions { + /** The token every request authenticates with. */ token: string; + /** Trace sink for redacted request lines; defaults to a silent trace with nothing masked. */ io?: TraceIo; + + /** The API origin; GITHUB_API_URL, else api.github.com, when omitted. */ baseUrl?: string; + + /** The X-GitHub-Api-Version header; DEFAULT_API_VERSION when omitted. */ apiVersion?: string; - /** - * Real milliseconds in one plugin second: Retry-After units, the retry backoff step, and the write limiter's gap. - * Undefined reads GSAC_RETRY_BASE_MS once; the plugin topology is the same at every value. - */ + + /** Real milliseconds in one plugin second (Retry-After units, the retry backoff step, the write limiter's gap); undefined reads GSAC_RETRY_BASE_MS once, and the plugin topology is the same at every value. */ retryBaseMs?: number; + /** The limiter the throttling plugin paces through; TIMERS_SCHEDULER unless GSAC_RETRY_BASE_MS selects the immediate one. */ scheduler?: Scheduler; + /** Passed to octokit verbatim; octokit-core's own agent string when omitted. */ userAgent?: string; } +/** What one GraphQL call's answers are judged with: the operation, whether its variables carry a secret, whether its repository is redacted (read live at every use), and its trace line. */ +interface GraphqlCall { + readonly op: GraphqlOp; + readonly carriesSecret: boolean; + readonly redacted: () => boolean; + readonly trace: (status: number, suffix?: string) => void; +} + const SILENT_TRACE: TraceIo = { debug() {}, masked: maskRegistry(() => {}).masked }; +/** The reason replacing a GraphQL transport error's message, when the request's secret or the repository's redaction demands one. */ +function graphqlWithholdReason(carriesSecret: boolean, redacted: boolean): string | undefined { + if (carriesSecret) { + return SECRET_TRANSPORT_WITHHELD; + } + if (redacted) { + return REDACTED_TRANSPORT_WITHHELD; + } + return undefined; +} + +/** The trace suffix for a body's `extensions.warnings`: free text that can echo input values like error messages, so a secret-carrying request keeps only the count. */ +function warningsSuffix(warnings: unknown, carriesSecret: boolean): string { + if (!Array.isArray(warnings) || warnings.length === 0) { + return ""; + } + if (carriesSecret) { + return ` warnings: ${warnings.length} (details withheld: the request carried a secret field)`; + } + return ` warnings: ${JSON.stringify(warnings)}`; +} + +/** A redacted repository's GraphQL error is rebuilt from the allowlist: its messages quote the slug and live state verbatim, which the exact-literal output mask cannot catch. */ +function forRedacted(call: GraphqlCall, error: ApiError): ApiError { + return call.redacted() ? withheld(error, REDACTED_RESPONSE_WITHHELD) : error; +} + +/** A response body as the contract hands it over: the text of a raw media type (octokit returns it as text), else the JSON, null for a 204 or empty body. */ +function answerData(data: unknown, raw: boolean): unknown { + if (raw) { + return typeof data === "string" ? data : ""; + } + // Octokit surfaces 204/empty bodies as ""; the contract is null. + return data === undefined || data === "" ? null : data; +} + /** The Octokit instance is built here and never injected: a consumer needing control over transport or plugins implements GitHubClient directly. */ export class GitHubApi implements GitHubClient { private readonly octokit: InstanceType; + private readonly trace: TraceRedaction; + private readonly baseUrl: string; + constructor(options: GitHubApiOptions) { this.baseUrl = options.baseUrl ?? process.env.GITHUB_API_URL ?? "https://api.github.com"; this.trace = new TraceRedaction(options.io ?? SILENT_TRACE); @@ -344,16 +393,11 @@ export class GitHubApi implements GitHubClient { // Either signal withholds: the caller's mark knows the value's origin, the scan knows the wire's field names. const marked = options?.carriesSecret === true; const carriesSecret = marked || secretScan.value.carriesSecret; + const tracedPayload = marked ? MARKED_PAYLOAD_TRACE : JSON.stringify(secretScan.value.traced); const trace = (status: number): void => { const safe = this.trace.path(path); - this.trace.debug( - `${method} ${safe.path} -> ${status} (${Date.now() - started}ms)` + - (safe.redacted || payload === undefined - ? "" - : marked - ? ` payload: ${MARKED_PAYLOAD_TRACE}` - : ` payload: ${JSON.stringify(secretScan.value.traced)}`), - ); + const suffix = safe.redacted || payload === undefined ? "" : ` payload: ${tracedPayload}`; + this.trace.debug(`${method} ${safe.path} -> ${status} (${Date.now() - started}ms)${suffix}`); }; try { const response = await this.octokit.request({ @@ -364,13 +408,7 @@ export class GitHubApi implements GitHubClient { ...(payload === undefined ? {} : { data: secretScan.value.payload }), } as unknown as Parameters["request"]>[0]); trace(response.status); - const data = response.data as unknown; - if (options?.raw) { - // Non-JSON media type: octokit hands the body back as text. - return { data: typeof data === "string" ? data : "" }; - } - // Octokit surfaces 204/empty bodies as ""; the contract is null. - return { data: data === undefined || data === "" ? null : data }; + return { data: answerData(response.data as unknown, options?.raw === true) }; } catch (error) { if (isHttpError(error)) { trace(error.status); @@ -387,6 +425,70 @@ export class GitHubApi implements GitHubClient { } } + /** A request that threw, as the answer: GitHub's HTTP error, the errors[] the throttling plugin rethrew, or the transport's failure. */ + private graphqlThrown(call: GraphqlCall, error: unknown): ClientAnswer> { + const withholdContent = (): boolean => call.carriesSecret || call.redacted(); + if (isHttpError(error)) { + call.trace(error.status); + return { error: forRedacted(call, apiErrorFromHttp(error, withholdContent())) }; + } + + // The throttling plugin inspects GraphQL bodies itself: it retries a RATE_LIMITED errors[] response and, once the + // retries are spent, rethrows a plain Error carrying the response with no HTTP status (the wire status was 200). + const rethrownErrors = (error as { response?: { data?: { errors?: unknown } } } | null) + ?.response?.data?.errors; + if (Array.isArray(rethrownErrors) && rethrownErrors.length > 0) { + call.trace(200); + return { + error: forRedacted(call, apiErrorFromGraphqlErrors(rethrownErrors, withholdContent())), + }; + } + + return { + failed: transportFailure( + `GRAPHQL ${call.op.name}`, + transportReason(error, graphqlWithholdReason(call.carriesSecret, call.redacted())), + this.baseUrl, + ), + }; + } + + /** A 200 body as the answer: its errors[] (non-empty by contract) as the error, else its data map; a body outside the wire contract fails, never quoted. */ + private graphqlAnswer( + call: GraphqlCall, + response: { status: number; data: unknown }, + ): ClientAnswer> { + const body = (response.data ?? {}) as { + data?: unknown; + errors?: unknown; + extensions?: { warnings?: unknown }; + }; + call.trace(response.status, warningsSuffix(body.extensions?.warnings, call.carriesSecret)); + if (body.errors !== undefined && (!Array.isArray(body.errors) || body.errors.length === 0)) { + // The GraphQL contract makes errors, when present, a NON-EMPTY list; a malformed value must not read as "no errors" + // and turn a partial response into a success. The body is never quoted. + return { + failed: `GRAPHQL ${call.op.name} returned a malformed errors value (not a non-empty list); the GraphQL endpoint at ${this.baseUrl} is not answering the GraphQL wire contract. Re-run, and retry later if it persists`, + }; + } + + const errors = Array.isArray(body.errors) ? body.errors : []; + if (errors.length > 0) { + const withholdContent = call.carriesSecret || call.redacted(); + return { error: forRedacted(call, apiErrorFromGraphqlErrors(errors, withholdContent)) }; + } + + const data = body.data; + if (!isPlainObject(data)) { + // A 200 with neither errors nor a data map is outside the GraphQL contract; the body is not quoted, since it could carry private live state. + return { + failed: `GRAPHQL ${call.op.name} returned a response carrying neither errors nor a data object; the GraphQL endpoint at ${this.baseUrl} is not answering the GraphQL wire contract. Re-run, and retry later if it persists`, + }; + } + + return { data }; + } + /** * The load-bearing difference from REST: GraphQL failures arrive as an HTTP 200 carrying a non-empty errors[]. * @@ -410,27 +512,27 @@ export class GitHubApi implements GitHubClient { failed: `GRAPHQL ${op.name} was not sent: ${reason}, so they could not be safely inspected for secret fields. Replace that value with a plain string in the settings file`, }; } + const marked = options?.carriesSecret === true; - const carriesSecret = marked || scan.value.carriesSecret; // Read live at every emission, never snapshotted at request start: a mask registered mid-flight must redact what follows. const redacted = (): boolean => this.trace.isRedacted(slug); // The operation addresses its repository in the BODY, which the path redactor never sees: a redacted slug collapses // the ENTIRE line, since the variables carry the repository's live state. const tracedVariables = marked ? MARKED_PAYLOAD_TRACE : JSON.stringify(scan.value.traced); - const trace = (status: number, suffix = ""): void => { - this.trace.debug( - redacted() - ? "" - : this.trace.message( - `GRAPHQL ${op.name} -> ${status} (${Date.now() - started}ms) variables: ${tracedVariables}${suffix}`, - ), - ); + const call: GraphqlCall = { + op, + carriesSecret: marked || scan.value.carriesSecret, + redacted, + trace: (status, suffix = "") => { + this.trace.debug( + redacted() + ? "" + : this.trace.message( + `GRAPHQL ${op.name} -> ${status} (${Date.now() - started}ms) variables: ${tracedVariables}${suffix}`, + ), + ); + }, }; - // A redacted repository's GraphQL error is rebuilt from the allowlist: its messages quote the slug and live state - // verbatim, which the exact-literal output mask cannot catch. - const withholdContent = (): boolean => carriesSecret || redacted(); - const forRedacted = (error: ApiError): ApiError => - redacted() ? withheld(error, REDACTED_RESPONSE_WITHHELD) : error; let response: { status: number; data: unknown }; try { response = (await this.octokit.request({ @@ -443,68 +545,9 @@ export class GitHubApi implements GitHubClient { data: unknown; }; } catch (error) { - if (isHttpError(error)) { - trace(error.status); - return { error: forRedacted(apiErrorFromHttp(error, withholdContent())) }; - } - // The throttling plugin inspects GraphQL bodies itself: it retries a RATE_LIMITED errors[] response and, once the - // retries are spent, rethrows a plain Error carrying the response with no HTTP status (the wire status was 200). - const rethrownErrors = (error as { response?: { data?: { errors?: unknown } } } | null) - ?.response?.data?.errors; - if (Array.isArray(rethrownErrors) && rethrownErrors.length > 0) { - trace(200); - return { - error: forRedacted(apiErrorFromGraphqlErrors(rethrownErrors, withholdContent())), - }; - } - return { - failed: transportFailure( - `GRAPHQL ${op.name}`, - transportReason( - error, - carriesSecret - ? SECRET_TRANSPORT_WITHHELD - : redacted() - ? REDACTED_TRANSPORT_WITHHELD - : undefined, - ), - this.baseUrl, - ), - }; - } - const body = (response.data ?? {}) as { - data?: unknown; - errors?: unknown; - extensions?: { warnings?: unknown }; - }; - const warnings = body.extensions?.warnings; - trace( - response.status, - Array.isArray(warnings) && warnings.length > 0 - ? // Warning entries are free text that can echo input values like error messages, so a secret-carrying request keeps only the count. - carriesSecret - ? ` warnings: ${warnings.length} (details withheld: the request carried a secret field)` - : ` warnings: ${JSON.stringify(warnings)}` - : "", - ); - if (body.errors !== undefined && (!Array.isArray(body.errors) || body.errors.length === 0)) { - // The GraphQL contract makes errors, when present, a NON-EMPTY list; a malformed value must not read as "no errors" - // and turn a partial response into a success. The body is never quoted. - return { - failed: `GRAPHQL ${op.name} returned a malformed errors value (not a non-empty list); the GraphQL endpoint at ${this.baseUrl} is not answering the GraphQL wire contract. Re-run, and retry later if it persists`, - }; + return this.graphqlThrown(call, error); } - const errors = Array.isArray(body.errors) ? body.errors : []; - if (errors.length > 0) { - return { error: forRedacted(apiErrorFromGraphqlErrors(errors, withholdContent())) }; - } - const data = body.data; - if (!isPlainObject(data)) { - // A 200 with neither errors nor a data map is outside the GraphQL contract; the body is not quoted, since it could carry private live state. - return { - failed: `GRAPHQL ${op.name} returned a response carrying neither errors nor a data object; the GraphQL endpoint at ${this.baseUrl} is not answering the GraphQL wire contract. Re-run, and retry later if it persists`, - }; - } - return { data }; + + return this.graphqlAnswer(call, response); } } diff --git a/src/github/scheduler.ts b/src/github/scheduler.ts index a12a86f16..83db17462 100644 --- a/src/github/scheduler.ts +++ b/src/github/scheduler.ts @@ -22,6 +22,7 @@ type FailedHandler = ( interface SchedulerLimiter { on(name: string, handler: FailedHandler): unknown; + /** Both Bottleneck call shapes: `schedule(fn, ...args)` and `schedule(options, fn, ...args)`. */ schedule(...call: unknown[]): Promise; } @@ -36,12 +37,15 @@ interface SchedulerGroup { */ export interface Scheduler { new (): SchedulerLimiter; + + /** The plugin's limiter groups (global, auth, search, notifications, write), one limiter per key. */ Group: new (options: { id: string; maxConcurrent?: number; minTime?: number; timeout?: number; }) => SchedulerGroup; + /** The plugin attaches its rate-limit listeners to a plain object through this emitter. */ Events: new ( target: object, diff --git a/src/github/secret-scan.ts b/src/github/secret-scan.ts index 6e00136d4..4d4fa41ca 100644 --- a/src/github/secret-scan.ts +++ b/src/github/secret-scan.ts @@ -36,9 +36,12 @@ class NotPlainData { function renderKeyPath(path: readonly string[]): string { return path - .map((segment, index) => - /^\d+$/.test(segment) ? `[${segment}]` : index === 0 ? segment : `.${segment}`, - ) + .map((segment, index) => { + if (/^\d+$/.test(segment)) { + return `[${segment}]`; + } + return index === 0 ? segment : `.${segment}`; + }) .join(""); } diff --git a/src/github/slug.ts b/src/github/slug.ts index 6cc5c0f35..0822028c9 100644 --- a/src/github/slug.ts +++ b/src/github/slug.ts @@ -4,13 +4,12 @@ * identities (collaborator logins, environment names) fold through that section's branded key. */ -/** - * One owner or name, as regex source. Permissive on purpose: parseRepoSlug adds the "." and ".." refusal on top, while - * trace redaction must still recognize the path a buggy caller built from such a slug. - */ +/** One owner or name, as regex source. Permissive on purpose: parseRepoSlug adds the "." and ".." refusal on top, while trace redaction must still recognize the path a buggy caller built from such a slug. */ export const SLUG_SEGMENT = String.raw`[\w.-]+`; +/** The brand's phantom key: declared only, so the fold below is the one way to mint a SlugKey. */ declare const repoSlugKey: unique symbol; + export type SlugKey = string & { readonly [repoSlugKey]: true }; export function slugKey(slug: string): SlugKey { diff --git a/src/github/trace-redaction.ts b/src/github/trace-redaction.ts index ad2c7242d..2fa05d5c9 100644 --- a/src/github/trace-redaction.ts +++ b/src/github/trace-redaction.ts @@ -9,8 +9,7 @@ import { SLUG_SEGMENT, type SlugKey, slugKey } from "./slug.js"; export type TraceIo = Pick; -// The segment charset stops at the segment boundary so an octokit line's trailing " - 204 with id ..." is never -// folded into the name; the `i` flag keeps a mixed-case path from slipping the redaction. +// The segment charset stops at the segment boundary so an octokit line's trailing " - 204 with id ..." is never folded into the name, and the `i` flag keeps a mixed-case path from slipping the redaction. const REPO_SLUG = new RegExp(`/repos/(${SLUG_SEGMENT}/${SLUG_SEGMENT})`, "i"); export function repoSlugOf(path: string): string | undefined { diff --git a/src/io.ts b/src/io.ts index 3372dcc43..97be90ad5 100644 --- a/src/io.ts +++ b/src/io.ts @@ -20,21 +20,23 @@ type Minted = F & { readonly [MASK_PAIR]: true }; * from two calls still typechecks; closing that would take one opaque registry value on Io. */ export interface MaskPair { - /** - * Redaction registers every private slug here as defense in depth. Required, not optional, so a missing - * implementation cannot silently no-op in production. - */ + /** Redaction registers every private slug here as defense in depth; required, not optional, so a missing implementation cannot silently no-op in production. */ readonly mask: Minted<(value: string) => void>; + /** The API trace reads it to redact structurally (whole path, dropped payload) where the runner's literal `***` cannot. */ readonly masked: Minted<() => ReadonlySet>; } export interface Io extends MaskPair { annotate(level: AnnotationLevel, message: string): void; + log(line: string): void; + /** A trace line, shown only when the run has step debug logging enabled. */ debug(line: string): void; + summary(markdown: string): void; + output(name: OutputName, value: string): void; } diff --git a/src/private-open.ts b/src/private-open.ts index e30aedb4e..d437e5e19 100644 --- a/src/private-open.ts +++ b/src/private-open.ts @@ -1,7 +1,7 @@ /** * The seal's symbol and the one capability that opens it, kept apart from the * brand so biome's noRestrictedImports can confine this module's importers to - * the brand itself and the projections (biome.json overrides, test/private.test.ts). + * the brand itself and the projections (biome.local.jsonc overrides, test/private.test.ts). */ import type { Private } from "./private.js"; diff --git a/src/problem.ts b/src/problem.ts index 6ddb3d821..d77ef7365 100644 --- a/src/problem.ts +++ b/src/problem.ts @@ -12,13 +12,7 @@ import { describeKind } from "./plain-data.js"; import type { SectionKey } from "./schema.js"; import { agree, countNoun, quote } from "./text.js"; -/** - * The advice appended to a transient (non-permission) API failure: a network - * blip or a 5xx that survived the retries. One source for the discovery - * problems rendered here and multi.ts's remote-file read failure, so the "not - * a permission problem" wording cannot drift between them. Face-neutral: the - * action and the command line print the same line. - */ +/** Appended to a transient (non-permission) API failure, a network blip or a 5xx that survived the retries; one source for the discovery problems here and multi.ts's remote-file read failure, so the wording cannot drift between the two faces. */ export const RERUN_ADVICE = "This is not a permission problem; re-run, and retry later if it persists"; @@ -81,7 +75,18 @@ export type CentralFileProblem = * their prose needs: the prose fragments are literal unions, the positions are * numbers, and a document value enters only as `actual`, which the renderer * describes by SHAPE. `keyField` is the module's declared key field, never - * read from a document. + * read from a document; `keyKind` is its kind in prose ("string" when the + * module says nothing else), `alongside` the other paths of a composite + * identity (a reviewer's `type` beside its `id`), `keyPaths` the dotted paths + * a removal names its entry by (the key field's own, or a composite), and + * `extra` the author's dotted paths riding beside the key and the marker + * (`color`, `config.secret`), in the entry's order. + * + * Three fields carry a convention their names do not: `fallback` is the value + * an unset input means, null where unset means "no value" (the `undeclared` + * input); `index` is the colliding layer's position in the settings-file list, + * from 0; `denied` is true for a denial or an invalid token, where a user PAT + * is the fix. */ export type Problem = // Action inputs @@ -91,7 +96,6 @@ export type Problem = readonly value: string; readonly noun: string; readonly allowed: readonly string[]; - /** The value an unset input means; null when unset means "no value" (the `undeclared` input). */ readonly fallback: string | null; } | { @@ -141,14 +145,12 @@ export type Problem = | { readonly code: "discovery-filters-without-wildcard"; readonly filters: readonly string[]; - /** Where the run's targets come from instead of `repos: "*"`. */ readonly targets: "single-repo" | "explicit-repos" | "repos-dir" | "snapshot-file"; } | { readonly code: "input-defaults-file-without-multi" } | { readonly code: "input-settings-file-is-list"; readonly value: string; - /** An engine mode reading its one file, or the command line's init, which writes one. */ readonly mode: EngineMode | "init"; } | { readonly code: "input-repository-not-slug"; readonly value: string } @@ -198,9 +200,7 @@ export type Problem = readonly layer: string; readonly site: string; readonly keyField: string; - /** The field's kind in prose; "string" when the module says nothing else. */ readonly keyKind?: string; - /** The other paths of a composite identity (a reviewer's `type` beside its `id`), when the module names one. */ readonly alongside?: readonly string[]; } | { @@ -221,16 +221,13 @@ export type Problem = readonly code: "layer-remove-with-fields"; readonly layer: string; readonly site: string; - /** The dotted paths a removal names its entry by: the key field's own, or a composite (`type` and `id`). */ readonly keyPaths: readonly string[]; - /** The dotted paths riding beside the key and the marker (`color`, `config.secret`), in the entry's order. */ readonly extra: readonly string[]; } | { readonly code: "layer-remove-nothing"; readonly layer: string; readonly site: string; - /** replace: the higher list already wins; swapped: the entry is copied whole; unmatched: no lower entry claims the key. */ readonly reason: "replace" | "swapped" | "unmatched"; } // The section selection @@ -239,7 +236,6 @@ export type Problem = | { readonly code: "rendered-file-is-layer"; readonly renderedFile: string; - /** The colliding layer's position in the settings-file list, from 0. */ readonly index: number; readonly layer: string; } @@ -275,7 +271,6 @@ export type Problem = readonly path: string; readonly status: number; readonly message: string; - /** True for a denial or an invalid token, where a user PAT is the fix. */ readonly denied: boolean; } | { readonly code: "discovery-transport-failed"; readonly reason: string } @@ -294,14 +289,24 @@ export type SettingsProblem = ProblemOf< "settings-not-mapping" | "settings-not-plain-mapping" | "settings-malformed-sections" >; +/** The action-input members, every code under `input-`. */ +type InputProblem = ProblemOf>; + +/** The snapshot destination's input members, every code under `input-snapshot-`. */ +type SnapshotInputProblem = ProblemOf>; + +/** The target resolution's members: the repos input, the repos-dir, and discovery. */ +type TargetProblem = ProblemOf>; + +/** What is left once the inputs, the layers, and the targets are worded: the document, the sources, the flows. */ +type RunProblem = Exclude; + +/** Why discovery fails under the workflow token: only a user PAT can enumerate a user's repositories. */ const PAT_ADVICE = "Discovery needs a user PAT; the workflow GITHUB_TOKEN and GitHub App installation tokens " + 'cannot enumerate a user\'s repositories. List the target repositories explicitly in the "repos" input'; -/** - * The underscore rule at the document level; the wrapper's line (src/sections/shared/schema-helpers.ts) says the - * same in the wrapper's terms. The two directives are all the underscore ever means. - */ +/** The underscore rule at the document level; the wrapper's line (src/sections/shared/schema-helpers.ts) says the same in the wrapper's terms, and the two directives are all the underscore ever means. */ const DIRECTIVES_ADVICE = "The underscore marks this action's directives, \"_layering\" (a file's top level or a list section's {entries} " + 'wrapper) and "_undeclared" (a file\'s top level or a wrapper), and nothing else; there are no private-note keys. ' + @@ -417,48 +422,68 @@ function describeInvalidReposEntries(problem: ProblemOf<"repos-input-invalid-ent return `the "repos" input has ${countNoun(count, "invalid entry", "invalid entries")}: ${parts.join("; ")}. Or use "*" alone to discover repositories`; } -/** - * The ONE place a problem is worded. INVARIANT for the layer members: a - * message names the layer as the layer list names it, the site's key path, and - * the kind of problem - never a value from the document. mode: render has no - * private-repos redaction context, so a value echoed there (a label name, a - * rule type, a mis-shaped section body) could land a private repository's - * settings in a public log. `actual` reaches the prose only through - * describeKind; the marker test in test/engine/layers.test.ts pins this. - */ -export function describeProblem(problem: Problem): string { +/** The `layer "": ` prefix every layer refusal opens with. */ +function layerSite(problem: LayerProblem): string { + return `layer ${quote(problem.layer)}: ${problem.site}`; +} + +function describeNothingToRemove(reason: "replace" | "swapped" | "unmatched"): string { + switch (reason) { + case "replace": + return "under _layering: replace the higher list already wins, so there is nothing to remove"; + case "swapped": + return "its entry is copied whole (a new key, or a same-key swap under shallow), so its nested lists meet nothing to remove"; + case "unmatched": + return "no lower layer declares an entry under its key"; + } +} + +/** Whether the problem's code starts with `prefix`, narrowing to the members it names. */ +function codeStartsWith

( + problem: Problem, + prefix: P, +): problem is ProblemOf> { + return problem.code.startsWith(prefix); +} + +/** The snapshot destination's inputs: one of the two forms, with the targets that form takes. */ +function describeSnapshotInputProblem(problem: SnapshotInputProblem): string { switch (problem.code) { - case "input-unsupported-value": { - const values = problem.allowed.map((v) => - v === problem.fallback ? `"${v}" (default)` : `"${v}"`, - ); - const unset = problem.fallback === null ? ", or leave it unset" : ""; - return `the "${problem.input}" input is "${problem.value}", which is not a supported ${problem.noun}. Set it to ${values.join(", ")}${unset}`; - } - case "input-unknown-sections": - return problem.unknown - .map((unknown) => describeUnknownSectionInput(unknown, problem.known)) - .join("; "); - case "required-sections-excluded": { - const count = problem.excluded.length; - const pronoun = agree(count, "it", "them"); + case "input-snapshot-only": { + const { subject, applies, inputs, they } = inputsWording(problem.inputs); return ( - `the "required-sections" ${agree(count, "entry", "entries")} ${quoteList(problem.excluded)} ${agree(count, "is", "are")} ` + - `excluded by the "sections" allowlist, so the run would pass without ever attempting ` + - `${pronoun}. Add ${pronoun} to the "sections" input, or remove ${pronoun} from ` + - `"required-sections"` + `${subject} only ${applies} to mode: snapshot, but this run is in ` + + `${problem.mode} mode, so ${they} would never be ` + + `used. Remove the ${inputs}, or set mode: snapshot to write the live settings to a file` ); } - case "input-report-key-unused": - return `the "report-public-key" input only applies to private-report: artifact, but the channel is "${problem.channel}", so the key would never be used. Remove report-public-key, or set private-report: artifact`; - case "input-report-key-missing": - return ( - 'private-report: artifact needs a "report-public-key" input: the age recipient every ' + - 'report is encrypted to. Generate a keypair with "age-keygen -o key.txt", keep key.txt ' + - 'secret, and set report-public-key to the printed "age1..." recipient (safe to commit)' - ); - case "input-report-key-invalid": - return `the "report-public-key" input is not a valid age recipient: ${problem.reason}. It must be an "age1..." public key from "age-keygen" (the recipient line, not the AGE-SECRET-KEY identity)`; + case "input-snapshot-destination-missing": + return 'mode: snapshot needs exactly one of the "snapshot-file" input (one repository\'s settings written to that file) or the "snapshot-dir" input (one /.yml per repos or repos-dir target under that directory). Set one of them'; + case "input-snapshot-destinations-both": + return 'the "snapshot-file" and "snapshot-dir" inputs are both set, but a snapshot run writes one form: a single repository to snapshot-file, or one /.yml per multi-repo target under snapshot-dir. Remove one of them'; + case "input-snapshot-file-with-multi": + return 'the "snapshot-file" input writes one repository\'s snapshot, but "repos" or "repos-dir" names multi-repo targets. Set "snapshot-dir" to write one file per target, or remove the multi-repo inputs and name the repository with "repository"'; + case "input-snapshot-dir-without-targets": + return 'the "snapshot-dir" input needs multi-repo targets: set "repos" (an owner/name list, or "*" to discover) or "repos-dir". To snapshot one repository, set "snapshot-file" instead'; + } +} + +/** An input of another mode or target form, refused with the step it belongs to. */ +function describeModeInputProblem( + problem: ProblemOf< + | "input-rejected-in-render" + | "input-rendered-file-missing" + | "input-settings-file-empty" + | "input-render-only" + | "input-rejected-in-snapshot" + | "input-repository-with-snapshot-dir" + | "input-repository-with-multi" + | "input-settings-file-with-multi" + | "input-defaults-file-without-multi" + | "input-settings-file-is-list" + >, +): string { + switch (problem.code) { case "input-rejected-in-render": { const { subject, verb, inputs, them } = inputsWording(problem.inputs); return ( @@ -480,14 +505,6 @@ export function describeProblem(problem: Problem): string { `used. Remove the ${inputs}, or set mode: render to fold settings files` ); } - case "input-snapshot-only": { - const { subject, applies, inputs, they } = inputsWording(problem.inputs); - return ( - `${subject} only ${applies} to mode: snapshot, but this run is in ` + - `${problem.mode} mode, so ${they} would never be ` + - `used. Remove the ${inputs}, or set mode: snapshot to write the live settings to a file` - ); - } case "input-rejected-in-snapshot": { const { subject, verb, inputs, them, they } = inputsWording(problem.inputs); return ( @@ -497,16 +514,52 @@ export function describeProblem(problem: Problem): string { `the apply, check, or render step ${they} ${agree(problem.inputs.length, "belongs", "belong")} to` ); } - case "input-snapshot-destination-missing": - return 'mode: snapshot needs exactly one of the "snapshot-file" input (one repository\'s settings written to that file) or the "snapshot-dir" input (one /.yml per repos or repos-dir target under that directory). Set one of them'; - case "input-snapshot-destinations-both": - return 'the "snapshot-file" and "snapshot-dir" inputs are both set, but a snapshot run writes one form: a single repository to snapshot-file, or one /.yml per multi-repo target under snapshot-dir. Remove one of them'; - case "input-snapshot-file-with-multi": - return 'the "snapshot-file" input writes one repository\'s snapshot, but "repos" or "repos-dir" names multi-repo targets. Set "snapshot-dir" to write one file per target, or remove the multi-repo inputs and name the repository with "repository"'; case "input-repository-with-snapshot-dir": return 'the "repository" input cannot be combined with "snapshot-dir", which writes one file per "repos" or "repos-dir" target. Remove "repository", or set "snapshot-file" to snapshot one repository'; - case "input-snapshot-dir-without-targets": - return 'the "snapshot-dir" input needs multi-repo targets: set "repos" (an owner/name list, or "*" to discover) or "repos-dir". To snapshot one repository, set "snapshot-file" instead'; + case "input-repository-with-multi": + return 'the "repository" input cannot be combined with "repos" or "repos-dir"; multi-repo targets come from those inputs. Remove "repository", or remove the multi-repo inputs to stay in single-repo mode'; + case "input-settings-file-with-multi": + return 'the "settings-file" input cannot be combined with "repos" or "repos-dir": central targets are read from repos-dir files and remote targets from each repository\'s own .github/settings.yml. Remove the settings-file override'; + case "input-defaults-file-without-multi": + return 'the "defaults-file" input only applies to multi-repo mode, but this run is in single-repo mode, so the defaults would never apply. Remove the input, or add "repos" or "repos-dir" to switch to multi-repo mode'; + case "input-settings-file-is-list": + return problem.mode === "init" + ? `the "settings-file" input is "${problem.value}", which contains a list separator: init writes exactly one settings file, and only mode: render takes a newline- or comma-separated list. Name one file` + : `the "settings-file" input is "${problem.value}", which contains a list separator: ` + + `${problem.mode} mode reads exactly one settings file, and only mode: render takes a ` + + "newline- or comma-separated list. Name one file, or set mode: render to fold the list into " + + "one document"; + } +} + +/** An action input's value, the sections it names, the report channel it asks for, or the target it cannot name. */ +function describeInputProblem(problem: InputProblem): string { + if (codeStartsWith(problem, "input-snapshot-")) { + return describeSnapshotInputProblem(problem); + } + + switch (problem.code) { + case "input-unsupported-value": { + const values = problem.allowed.map((v) => + v === problem.fallback ? `"${v}" (default)` : `"${v}"`, + ); + const unset = problem.fallback === null ? ", or leave it unset" : ""; + return `the "${problem.input}" input is "${problem.value}", which is not a supported ${problem.noun}. Set it to ${values.join(", ")}${unset}`; + } + case "input-unknown-sections": + return problem.unknown + .map((unknown) => describeUnknownSectionInput(unknown, problem.known)) + .join("; "); + case "input-report-key-unused": + return `the "report-public-key" input only applies to private-report: artifact, but the channel is "${problem.channel}", so the key would never be used. Remove report-public-key, or set private-report: artifact`; + case "input-report-key-missing": + return ( + 'private-report: artifact needs a "report-public-key" input: the age recipient every ' + + 'report is encrypted to. Generate a keypair with "age-keygen -o key.txt", keep key.txt ' + + 'secret, and set report-public-key to the printed "age1..." recipient (safe to commit)' + ); + case "input-report-key-invalid": + return `the "report-public-key" input is not a valid age recipient: ${problem.reason}. It must be an "age1..." public key from "age-keygen" (the recipient line, not the AGE-SECRET-KEY identity)`; case "input-token-missing": return 'cannot call the GitHub API: no token was provided. Set the "token" input (--token on the command line), or export GITHUB_TOKEN'; case "input-report-without-redaction": @@ -527,21 +580,6 @@ export function describeProblem(problem: Problem): string { `negating the whole pattern` ); } - case "input-repository-with-multi": - return 'the "repository" input cannot be combined with "repos" or "repos-dir"; multi-repo targets come from those inputs. Remove "repository", or remove the multi-repo inputs to stay in single-repo mode'; - case "input-settings-file-with-multi": - return 'the "settings-file" input cannot be combined with "repos" or "repos-dir": central targets are read from repos-dir files and remote targets from each repository\'s own .github/settings.yml. Remove the settings-file override'; - case "discovery-filters-without-wildcard": - return describeFiltersWithoutWildcard(problem); - case "input-defaults-file-without-multi": - return 'the "defaults-file" input only applies to multi-repo mode, but this run is in single-repo mode, so the defaults would never apply. Remove the input, or add "repos" or "repos-dir" to switch to multi-repo mode'; - case "input-settings-file-is-list": - return problem.mode === "init" - ? `the "settings-file" input is "${problem.value}", which contains a list separator: init writes exactly one settings file, and only mode: render takes a newline- or comma-separated list. Name one file` - : `the "settings-file" input is "${problem.value}", which contains a list separator: ` + - `${problem.mode} mode reads exactly one settings file, and only mode: render takes a ` + - "newline- or comma-separated list. Name one file, or set mode: render to fold the list into " + - "one document"; case "input-repository-not-slug": return `cannot target a repository: "${problem.value}" is not an owner/name slug. Set the "repository" input (--repository on the command line) to a value like "octocat/hello-world"; inside GitHub Actions, GITHUB_REPOSITORY supplies it`; case "input-artifact-unsupported": @@ -550,16 +588,20 @@ export function describeProblem(problem: Problem): string { "Actions runner can do, and this run has no artifact upload (the command line, or a library " + 'caller without an uploader). Set private-report to "issue", "issue-on-failure", or "none"' ); - case "settings-not-mapping": - return `${problem.source} must be a YAML mapping of section names to settings, but its top level parsed as a ${problem.shape}. Rewrite the top level as "section: ..." keys`; - case "settings-not-plain-mapping": - return `${problem.source} must be a plain YAML mapping of section names to settings, but its top level parsed as another type (a YAML-tagged value like !!timestamp parses to a Date). Rewrite the top level as "section: ..." keys`; - case "settings-malformed-sections": - return `${problem.source} has malformed section entries: ${problem.issues.join("; ")}. ${PASSTHROUGH_ADVICE}`; - case "yaml-invalid": - return problem.reason; - case "settings-file-unreadable": - return describeUnreadable(problem); + default: + return describeModeInputProblem(problem); + } +} + +/** + * The layer refusals. INVARIANT: a message names the layer as the layer list names it, the site's key path, and the + * kind of problem - never a value from the document. mode: render has no private-repos redaction context, so a value + * echoed there (a label name, a rule type, a mis-shaped section body) could land a private repository's settings in + * a public log. `actual` reaches the prose only through describeKind; the marker test in test/engine/layers.test.ts + * pins this. + */ +function describeLayerProblem(problem: LayerProblem): string { + switch (problem.code) { case "layer-cycle": return `${layerSite(problem)} contains a reference cycle (a YAML anchor that includes itself); layers must be trees`; case "layer-wrong-shape": @@ -576,6 +618,56 @@ export function describeProblem(problem: Problem): string { return `${layerSite(problem)} carries _remove: true beside ${quoteList(problem.extra)}; a removal names its ${andList(problem.keyPaths)} and nothing else. Drop the ${agree(problem.extra.length, "field", "fields")}, or the marker`; case "layer-remove-nothing": return `${layerSite(problem)} carries _remove: true, but ${describeNothingToRemove(problem.reason)}. Remove the entry, or fix its key`; + } +} + +/** The target resolution: the repos input's entries, the repos-dir's files, and "*" discovery. */ +function describeTargetProblem(problem: TargetProblem): string { + switch (problem.code) { + case "repos-input-wildcard-mixed": + return 'the "repos" input mixes "*" with explicit repositories. Use "*" alone to discover every repository the token owns, or list the repositories without it'; + case "repos-input-invalid-entries": + return describeInvalidReposEntries(problem); + case "repos-dir-missing": + return `repos-dir "${problem.reposDir}" does not exist in the workspace, so there are no central settings files to read. Add an actions/checkout step before this action, or fix the repos-dir path`; + case "repos-dir-unreadable": + return `cannot read repos-dir "${problem.reposDir}": ${problem.reason}. Check that it is a readable directory of settings files`; + case "repos-dir-invalid-files": + return `repos-dir "${problem.reposDir}" has ${countNoun(invalidFileCount(problem.files), "invalid settings file", "invalid settings files")}:\n- ${problem.files.map(describeCentralFile).join("\n- ")}`; + case "discovery-filters-without-wildcard": + return describeFiltersWithoutWildcard(problem); + case "discovery-request-failed": + return `cannot discover repositories for repos: "*": GET ${problem.path} failed: ${problem.status} ${problem.message}. ${problem.denied ? PAT_ADVICE : RERUN_ADVICE}`; + case "discovery-transport-failed": + return `cannot discover repositories for repos: "*": ${problem.reason}. ${RERUN_ADVICE}`; + case "discovery-response-not-a-list": + return `cannot discover repositories for repos: "*": GET ${problem.path} returned a JSON value that is not a list, so the response cannot be paginated. ${RERUN_ADVICE}`; + } +} + +/** The section selection, the settings document and its sources, the flows' fatal paths, and the private report. */ +function describeRunProblem(problem: RunProblem): string { + switch (problem.code) { + case "required-sections-excluded": { + const count = problem.excluded.length; + const pronoun = agree(count, "it", "them"); + return ( + `the "required-sections" ${agree(count, "entry", "entries")} ${quoteList(problem.excluded)} ${agree(count, "is", "are")} ` + + `excluded by the "sections" allowlist, so the run would pass without ever attempting ` + + `${pronoun}. Add ${pronoun} to the "sections" input, or remove ${pronoun} from ` + + `"required-sections"` + ); + } + case "settings-not-mapping": + return `${problem.source} must be a YAML mapping of section names to settings, but its top level parsed as a ${problem.shape}. Rewrite the top level as "section: ..." keys`; + case "settings-not-plain-mapping": + return `${problem.source} must be a plain YAML mapping of section names to settings, but its top level parsed as another type (a YAML-tagged value like !!timestamp parses to a Date). Rewrite the top level as "section: ..." keys`; + case "settings-malformed-sections": + return `${problem.source} has malformed section entries: ${problem.issues.join("; ")}. ${PASSTHROUGH_ADVICE}`; + case "yaml-invalid": + return problem.reason; + case "settings-file-unreadable": + return describeUnreadable(problem); case "rendered-file-is-layer": return ( `the "rendered-file" input "${problem.renderedFile}" is layer ${problem.index + 1} of the ` + @@ -603,39 +695,21 @@ export function describeProblem(problem: Problem): string { : `multi-repo mode found no targets: repos-dir yielded no settings files and the "repos" input resolved to no repositories. Add per-repo files to the repos-dir, or list repositories in the "repos" input`; case "repo-slug-invalid": return `"${problem.value}" is not an owner/name repository slug (use a value like "octocat/hello-world")`; - case "repos-input-wildcard-mixed": - return 'the "repos" input mixes "*" with explicit repositories. Use "*" alone to discover every repository the token owns, or list the repositories without it'; - case "repos-input-invalid-entries": - return describeInvalidReposEntries(problem); - case "repos-dir-missing": - return `repos-dir "${problem.reposDir}" does not exist in the workspace, so there are no central settings files to read. Add an actions/checkout step before this action, or fix the repos-dir path`; - case "repos-dir-unreadable": - return `cannot read repos-dir "${problem.reposDir}": ${problem.reason}. Check that it is a readable directory of settings files`; - case "repos-dir-invalid-files": - return `repos-dir "${problem.reposDir}" has ${countNoun(invalidFileCount(problem.files), "invalid settings file", "invalid settings files")}:\n- ${problem.files.map(describeCentralFile).join("\n- ")}`; - case "discovery-request-failed": - return `cannot discover repositories for repos: "*": GET ${problem.path} failed: ${problem.status} ${problem.message}. ${problem.denied ? PAT_ADVICE : RERUN_ADVICE}`; - case "discovery-transport-failed": - return `cannot discover repositories for repos: "*": ${problem.reason}. ${RERUN_ADVICE}`; - case "discovery-response-not-a-list": - return `cannot discover repositories for repos: "*": GET ${problem.path} returned a JSON value that is not a list, so the response cannot be paginated. ${RERUN_ADVICE}`; case "age-recipient-invalid": return `not a valid age recipient: ${problem.reason}`; } } -/** The `layer "": ` prefix every layer refusal opens with. */ -function layerSite(problem: LayerProblem): string { - return `layer ${quote(problem.layer)}: ${problem.site}`; -} - -function describeNothingToRemove(reason: "replace" | "swapped" | "unmatched"): string { - switch (reason) { - case "replace": - return "under _layering: replace the higher list already wins, so there is nothing to remove"; - case "swapped": - return "its entry is copied whole (a new key, or a same-key swap under shallow), so its nested lists meet nothing to remove"; - case "unmatched": - return "no lower layer declares an entry under its key"; +/** The ONE place a problem is worded, by the code's family; the layer group's doc states the value-free invariant. */ +export function describeProblem(problem: Problem): string { + if (codeStartsWith(problem, "input-")) { + return describeInputProblem(problem); + } + if (codeStartsWith(problem, "layer-")) { + return describeLayerProblem(problem); + } + if (codeStartsWith(problem, "repos-") || codeStartsWith(problem, "discovery-")) { + return describeTargetProblem(problem); } + return describeRunProblem(problem); } diff --git a/src/report/composer.ts b/src/report/composer.ts index 7abe92373..60c742100 100644 --- a/src/report/composer.ts +++ b/src/report/composer.ts @@ -17,8 +17,8 @@ interface OutcomeRow { detail: string[]; } +/** What one report renders; `target` is the owner/name slug, unredacted: this document is private. */ export interface ReportInput { - /** The target's owner/name slug, unredacted: this document is private. */ target: string; adminRepo: string; runUrl: string; diff --git a/src/report/delivery.ts b/src/report/delivery.ts index fb143a318..4a545495d 100644 --- a/src/report/delivery.ts +++ b/src/report/delivery.ts @@ -29,6 +29,9 @@ import { MARKER_LABEL, } from "./issue-report.js"; +/** The channels, in the words of the `private-report` input; the type below says what each delivers. */ +export const PRIVATE_REPORT_CHANNELS = ["none", "issue", "issue-on-failure", "artifact"] as const; + /** * A report reaches only a redacted target proven private or internal (flows/deliver.ts decides). * @@ -37,8 +40,6 @@ import { * `issue-on-failure` -> the same issue, created only when the run needs attention; recovery closes it with the healthy report * `artifact` -> the delivered reports as one age-encrypted artifact, for readers with the key but no GitHub access */ -export const PRIVATE_REPORT_CHANNELS = ["none", "issue", "issue-on-failure", "artifact"] as const; - export type PrivateReportChannel = (typeof PRIVATE_REPORT_CHANNELS)[number]; export type IssueChannel = Extract; @@ -77,9 +78,13 @@ export interface RedactedDetail extends TargetDetail { export interface ReportRunMeta { /** The admin repository the workflow ran in (GITHUB_REPOSITORY / selfSlug). */ adminRepo: string; + /** Link to the workflow run (may be empty on local runs). */ runUrl: string; + + /** The run's mode, as the report's header states it. */ mode: string; + /** ISO timestamp captured once at the run's start, passed in (never Date.now here). */ timestamp: string; } @@ -147,10 +152,14 @@ export interface RunConclusion { interface ReportTarget { /** The owner/name pair the issue channel posts into; null when the slug did not parse. */ repo: RepoRef | null; + /** The public placeholder, the only name a delivery warning may carry. */ display: string; + /** The target's own conclusion; an exit of 1 opens the report issue, 0 closes it. */ conclusion: RunConclusion; + + /** The sealed end state the report opens in full. */ detail: Private; } diff --git a/src/report/issue-report.ts b/src/report/issue-report.ts index f6515e472..c605dde7b 100644 --- a/src/report/issue-report.ts +++ b/src/report/issue-report.ts @@ -14,6 +14,7 @@ import type { RepoRef } from "../discovery/targets.js"; import type { GitHubClient } from "../github/api.js"; import { type ApiError, classifyApiError } from "../github/api-error.js"; import type { SettingsFile } from "../schema.js"; +import { listEntries } from "../sections/contract/declared.js"; import { type EndpointDecl, expand } from "../sections/contract/endpoints.js"; import { grantFor, type SectionPermission } from "../sections/contract/permissions.js"; import { nameKey } from "../sections/labels/index.js"; @@ -27,14 +28,17 @@ export const ISSUE_TITLE = "[automated] settings-as-code: private settings repor /** Makes the lookup one indexed request; the search API is eventually consistent and separately throttled, so it is never used. */ export const MARKER_LABEL = "settings-as-code-report"; +/** The marker label as the injection declares it and the ensure-create posts it. */ export const MARKER_LABEL_CONFIG = { name: MARKER_LABEL, color: "0e2a47", description: "managed by settings-as-code private reporting - do not remove", } as const satisfies LabelConfig; +/** The grant the issue channel needs on the target, for the PAT prose and the mock's permission gate. */ export const ISSUE_REPORT_PERMISSION: SectionPermission = { repo: ["issues"] }; +/** The channel's routes, shaped like a section's so the mock routes and USED_PATHS pick them up. */ export const ISSUE_REPORT_ENDPOINTS = { list: { route: "GET /repos/{owner}/{repo}/issues", @@ -63,6 +67,7 @@ export type IssueReportMode = "always" | "on-failure"; export interface LandedWrites { /** The marker label was created on the target this run (the ensure-create answered 2xx rather than 422). */ labelCreated: boolean; + /** The report issue this run created before a later step failed; a delivery that succeeds names its issue itself. */ createdIssue: number | null; } @@ -91,10 +96,7 @@ function deliveryWarning(error: ApiError, landed: LandedWrites): Failure { }; } -/** - * A request with no HTTP answer: the client's line embeds the request path (the private slug), so nothing from it may - * escape; the warning is the same one a client that throws instead of answering earns (deliverIssueReport). - */ +/** A request with no HTTP answer: the client's line embeds the request path (the private slug), so nothing from it may escape; the same warning a client that throws instead of answering earns (deliverIssueReport). */ const TRANSPORT_WARNING = "could not deliver the private report: the request failed before an HTTP response arrived. Re-run, or set private-report: none if it persists"; @@ -257,20 +259,12 @@ async function closeIfOpen( return { delivered: "updated", number: found.number, labelCreated: landed.labelCreated }; } -/** `landed` is owned by the caller and records each write as it lands, so a failure of any kind can still report them. */ -async function deliver( +/** The marker label's ensure-create; a 422 means it already exists. */ +async function ensureMarkerLabel( api: GitHubClient, - repo: RepoRef, - body: string, - needsAttention: boolean, - mode: IssueReportMode, + ref: { repo: RepoRef }, landed: LandedWrites, -): Promise { - const ref = { repo }; - if (mode === "on-failure" && !needsAttention) { - return closeIfOpen(api, ref, body, landed); - } - // A 422 means the marker label already exists. +): Promise<{ created: boolean } | Failure> { const label = await api.tryRequest("POST", expand(ISSUE_REPORT_ENDPOINTS.createLabel, ref), { name: MARKER_LABEL_CONFIG.name, color: MARKER_LABEL_CONFIG.color, @@ -282,7 +276,19 @@ async function deliver( if ("error" in label && label.error.status !== 422) { return deliveryWarning(label.error, landed); } - landed.labelCreated = !("error" in label); + return { created: !("error" in label) }; +} + +/** + * The report issue to reuse: the label lookup, then the title scan for a human-stripped marker. The PATCH leaves + * labels alone (a human may have added their own) unless the title scan found the issue with the marker stripped: + * without reattaching it, every label-filtered lookup, the on-failure close included, misses forever. + */ +async function locateReportIssue( + api: GitHubClient, + ref: { repo: RepoRef }, + landed: LandedWrites, +): Promise<{ found: ReportIssue | null; relabel: string[] | undefined } | Failure> { const listed = await findReportIssue( api, ref, @@ -293,38 +299,53 @@ async function deliver( if ("warning" in listed) { return listed; } - let found = listed.found; - // The PATCH leaves labels alone (a human may have added their own) unless the title scan found the issue with the - // marker stripped: without reattaching it, every label-filtered lookup, the on-failure close included, misses forever. - let relabel: string[] | undefined; - if (!found) { - const scanned = await fallbackScan(api, ref, landed); - if ("warning" in scanned) { - return scanned; - } - found = scanned.found; - if (found && !found.labels.includes(MARKER_LABEL)) { - relabel = [...found.labels, MARKER_LABEL]; - } + if (listed.found) { + return { found: listed.found, relabel: undefined }; } - const state = needsAttention ? "open" : "closed"; - if (found) { - const updated = await api.tryRequest( - "PATCH", - expand(ISSUE_REPORT_ENDPOINTS.update, ref, { issue_number: String(found.number) }), - relabel ? { body, state, labels: relabel } : { body, state }, - ); - if ("failed" in updated) { - return transportWarning(landed); - } - if ("error" in updated) { - return deliveryWarning(updated.error, landed); - } - return { delivered: "updated", number: found.number, labelCreated: landed.labelCreated }; + + const scanned = await fallbackScan(api, ref, landed); + if ("warning" in scanned) { + return scanned; + } + const found = scanned.found; + const relabel = + found && !found.labels.includes(MARKER_LABEL) ? [...found.labels, MARKER_LABEL] : undefined; + return { found, relabel }; +} + +/** A found issue updated in place: the body replaced, the state mirroring the run, the marker reattached when the scan found it stripped. */ +async function updateReportIssue( + api: GitHubClient, + ref: { repo: RepoRef }, + found: ReportIssue, + patch: { body: string; state: "open" | "closed"; relabel: string[] | undefined }, + landed: LandedWrites, +): Promise { + const { body, state, relabel } = patch; + const updated = await api.tryRequest( + "PATCH", + expand(ISSUE_REPORT_ENDPOINTS.update, ref, { issue_number: String(found.number) }), + relabel ? { body, state, labels: relabel } : { body, state }, + ); + if ("failed" in updated) { + return transportWarning(landed); } + if ("error" in updated) { + return deliveryWarning(updated.error, landed); + } + return { delivered: "updated", number: found.number, labelCreated: landed.labelCreated }; +} + +/** A first delivery: the issue created with the marker, then closed when the run is healthy, since creation cannot set the state. */ +async function createReportIssue( + api: GitHubClient, + ref: { repo: RepoRef }, + report: { body: string; state: "open" | "closed" }, + landed: LandedWrites, +): Promise { const created = await api.tryRequest("POST", expand(ISSUE_REPORT_ENDPOINTS.create, ref), { title: ISSUE_TITLE, - body, + body: report.body, labels: [MARKER_LABEL], }); if ("failed" in created) { @@ -333,6 +354,7 @@ async function deliver( if ("error" in created) { return deliveryWarning(created.error, landed); } + const issue = created.data as { number?: unknown } | null; if (typeof issue?.number !== "number") { return malformedWarning( @@ -341,12 +363,11 @@ async function deliver( ); } landed.createdIssue = issue.number; - if (state === "closed") { - // Creation cannot set the state, so a healthy first run closes right after. + if (report.state === "closed") { const closed = await api.tryRequest( "PATCH", expand(ISSUE_REPORT_ENDPOINTS.update, ref, { issue_number: String(issue.number) }), - { state }, + { state: report.state }, ); if ("failed" in closed) { return transportWarning(landed); @@ -358,6 +379,39 @@ async function deliver( return { delivered: "created", number: issue.number, labelCreated: landed.labelCreated }; } +/** `landed` is owned by the caller and records each write as it lands, so a failure of any kind can still report them. */ +async function deliver( + api: GitHubClient, + repo: RepoRef, + body: string, + needsAttention: boolean, + mode: IssueReportMode, + landed: LandedWrites, +): Promise { + const ref = { repo }; + if (mode === "on-failure" && !needsAttention) { + return closeIfOpen(api, ref, body, landed); + } + + const label = await ensureMarkerLabel(api, ref, landed); + if ("warning" in label) { + return label; + } + landed.labelCreated = label.created; + + const located = await locateReportIssue(api, ref, landed); + if ("warning" in located) { + return located; + } + + const state: "open" | "closed" = needsAttention ? "open" : "closed"; + if (located.found) { + const patch = { body, state, relabel: located.relabel }; + return updateReportIssue(api, ref, located.found, patch, landed); + } + return createReportIssue(api, ref, { body, state }, landed); +} + /** * Never throws: report delivery is auxiliary, so every failure comes back as a public-safe warning and the run's result * stays untouched. `needsAttention` (failed, or check-mode drift: exactly what fails the run) opens the issue; a healthy @@ -390,15 +444,11 @@ export function injectMarkerLabel(settings: SettingsFile): { } { // Unwrapped here and rebuilt in the SAME form below, so the injection never rewrites the operator's chosen shape or policy. const declaration = settings.labels; - const wrapped = !Array.isArray(declaration); - const labels = wrapped - ? declaration !== undefined && Array.isArray(declaration?.entries) - ? declaration.entries - : null - : declaration; - if (labels === null || !Array.isArray(labels)) { + if (declaration === undefined) { return { settings, outcome: "unchanged" }; } + const wrapped = !Array.isArray(declaration); + const labels = listEntries(declaration); const rebuild = (entries: LabelConfig[]): SettingsFile["labels"] => wrapped ? { ...(declaration as UndeclaredPolicyList), entries } : entries; const marker = nameKey(MARKER_LABEL); diff --git a/src/schema.ts b/src/schema.ts index c06619c16..e3c358e79 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -63,10 +63,7 @@ type SectionSlice = | { readonly key: string; readonly kind: "slice" | "knob"; readonly slice: z.ZodType } | { readonly key: string; readonly kind: "layered"; readonly slice: z.ZodArray }; -/** - * The ONE table of sections and their slices: SettingsFile's shape is built from it, so a property is its section's - * slice by construction. Its order is the published schema's property order; execution order is SECTION_KEYS. - */ +/** The ONE table of sections and their slices: SettingsFile's shape is built from it, so a property is its section's slice by construction; its order is the published schema's property order, and execution order is SECTION_KEYS. */ const SECTION_SLICES = [ { key: "repository", kind: "slice", slice: RepositoryConfig }, { key: "labels", kind: "knob", slice: LabelConfig }, @@ -127,6 +124,7 @@ const sectionShape = Object.fromEntries( SECTION_SLICES.map((section) => [section.key, composed(section)]), ) as SectionShape; +/** The whole document: every section's composed slice, optional, and the two directives. */ export const SettingsFile = z .object({ ...sectionShape, @@ -136,6 +134,7 @@ export const SettingsFile = z _undeclared: UndeclaredPolicySchema.optional(), }) .meta({ id: "SettingsFile" }); + export type SettingsFile = z.infer; /** Every recognized top-level section, in execution order. */ @@ -197,11 +196,7 @@ export const UNDECLARED_POLICY_SECTIONS = [ export type UndeclaredPolicySection = (typeof UNDECLARED_POLICY_SECTIONS)[number]; -/** - * Every list section, in execution order: the knobbed ones and the plain lists whose wrapper takes `_layering` alone. - * The fold (engine/layers.ts) unions each by the key its module declares; ./sections/registry.ts requires that - * declaration of every member, so a section added here without one fails to compile. - */ +/** Every list section in execution order, the knobbed ones and the plain lists whose wrapper takes `_layering` alone; the fold unions each by the key its module declares (registry.ts requires it), so a member added here without one fails to compile. */ export const LIST_SECTIONS = [ "labels", "rulesets", @@ -263,10 +258,7 @@ type _KnobListSound = MustBeNever< Exclude<(typeof UNDECLARED_POLICY_SECTIONS)[number], KnobbedByType> >; -/** - * Sections whose plain form (no wrapper) matches the Probot Settings app schema; docs/start/migrating-from-probot.md - * is pinned against this list. - */ +/** Sections whose plain form (no wrapper) matches the Probot Settings app schema; docs/start/migrating-from-probot.md is pinned against this list. */ export const PROBOT_PARITY_KEYS = [ "repository", "labels", @@ -276,14 +268,12 @@ export const PROBOT_PARITY_KEYS = [ "milestones", ] as const satisfies readonly SectionKey[]; -/** - * Directives to the merge, not sections: declared on the document so the published schema types them. - * validateSectionShapes copies only SECTION_KEYS, so none of them reaches the apply path. - */ +/** Directives to the merge, not sections: declared on the document so the published schema types them; validateSectionShapes copies only SECTION_KEYS, so none of them reaches the apply path. */ export const DOCUMENT_DIRECTIVE_KEYS = [ "_layering", "_undeclared", ] as const satisfies readonly (keyof SettingsFile)[]; + type DocumentDirectiveKey = (typeof DOCUMENT_DIRECTIVE_KEYS)[number]; type _UnlistedSection = MustBeNever>; diff --git a/src/sections/actions/index.ts b/src/sections/actions/index.ts index 9d773d677..ace82d60c 100644 --- a/src/sections/actions/index.ts +++ b/src/sections/actions/index.ts @@ -1,3 +1,4 @@ +import { isDeepStrictEqual } from "node:util"; import { ok, type Result, safeTry } from "neverthrow"; import { z } from "zod"; import { phantomKeys, phantomNote, subsetDiff } from "../../engine/diff.js"; @@ -19,17 +20,18 @@ import { import { projectOntoSchema, readOrNote } from "../shared/snapshot-helpers.js"; import { ActionsConfig } from "./schema.js"; +// The section grant; the OIDC subject pair declares its own Actions grant in the table below. const permission: SectionPermission = { repo: ["administration"] }; // The contract documents both 400 and 422 for a rejected template, so the same advice keys both. const OIDC_TEMPLATE_HINT = "include_claim_keys entries must be unique claim keys of the OIDC token (alphanumeric and underscores only); see the OIDC subject claim customization endpoint documentation"; -// GitHub documents this pair for private repositories and a bare 403 on the GET with no prose -// about why, so a denial here is ambiguous. +// GitHub documents this pair for private repositories and a bare 403 on the GET with no prose about why, so a denial here is ambiguous. const FORK_PR_PRIVATE_DENIAL = "the fork PR workflow settings are documented for private repositories, so a denial here can also mean the repository is public"; +// Roles pair as get/put; endpointRouted and cacheLimit type each PUT off its GET's name. const ENDPOINTS = { getPermissions: { route: "GET /repos/{owner}/{repo}/actions/permissions", @@ -184,16 +186,7 @@ type CacheKey = keyof NonNullable; type _CacheEndpointsComplete = MustBeNever>; type _CacheEndpointsSound = MustBeNever>; -/** - * Claim-key ORDER defines the OIDC subject format ("repo:...:context:..."), so unlike subsetDiff's - * set comparison of scalar lists this one matches element by element: a reordered live value is drift. - */ -function sameClaimKeyOrder(declared: readonly string[], live: readonly string[]): boolean { - return declared.length === live.length && declared.every((key, index) => live[index] === key); -} - -// GitHub answers an unset list as null or omits it; both read as absent, so the snapshot's projection -// onto the template variants never sees a null. The rest of the body rides into subsetDiff as passthrough. +// An unset list answers null or is omitted; both read as absent, so the snapshot's projection onto the template variants never sees a null. const LiveOidcSub = z.looseObject({ include_claim_keys: z .array(z.string()) @@ -229,19 +222,19 @@ type BasePermissions = Pick; /** * The routing table holds the handlers themselves, so a routed key without one cannot exist. * Function-valued properties, not method shorthand, so the per-key value types check strictly. + * A snapshot over several GETs reads each through readOrNote itself, so under warn its siblings + * survive a denied one. */ interface RoutedDestination { + /** Reads the key's own GET and pushes its drift-gated PUT and its notes onto the plan. */ plan: ( ctx: ActionsContext, section: SectionMeta, declared: NonNullable, plan: ActionsPlan, ) => Promise>; - /** - * Read the live state back as the settings file would declare it; undefined when none applies. - * A destination over several GETs reads each through readOrNote itself, so under warn its - * siblings survive a denied one. - */ + + /** The live state as the settings file would declare it; undefined when none applies. */ snapshot: ( ctx: ActionsSnapshotContext, section: SectionMeta, @@ -250,32 +243,44 @@ interface RoutedDestination { ) => Promise>; } -/** `N` is inferred from the GET alone, so a PUT of another name does not compile. */ +/** + * `N` is inferred from the GET alone, so a PUT of another name does not compile. + * + * With a `mapping`, a passthrough key outside its shape that the GET never echoes is noted as never converging, + * while a key inside the shape the GET omits is drift the PUT resolves. + */ export function endpointRouted< K extends keyof ActionsConfig, N extends string, Live extends object, >( wiring: { + /** The GET whose body `live` parses; `N` is inferred from it alone. */ get: `get${N}` & ReadRole; + + /** The PUT, which must share the GET's name. */ put: NoInfer<`put${N}`> & WriteRole; + /** The drift-line prefix ("actions.access"). */ label: string; + /** The change line apply reports after the PUT lands. */ applied: string; + + /** The operation in settings-file terms ("setting the cache retention limit"), for the failure prose. */ describe?: string; + /** The GET body, every field `read` consumes declared, so an off-shape answer fails the read. */ live: z.ZodType; + /** The GET body as the settings file declares the key (the inverse of `body`). */ read: (live: Live) => ActionsConfig[K]; } & (NonNullable extends Record ? { + /** The PUT body from the declared mapping; the mapping itself when absent. */ body?: (declared: NonNullable) => Record; - /** - * The declared mapping's shape and what the note calls the live object: a passthrough key - * outside the shape that the GET never echoes is noted as never converging, while a key - * inside it the GET omits is drift the PUT resolves. - */ + + /** The declared mapping's shape and what the note calls the live object. */ mapping: { shape: z.ZodObject; noun: string }; } : { @@ -323,9 +328,7 @@ function sliceOf(key: K): (live: unknown) => Acti return (live) => projectOntoSchema(slice, live) as ActionsConfig[K]; } -// Every DECLARED key names its destination, so the mapped `satisfies` makes a schema field with no -// entry a compile error; a routed entry IS its handler, so routed-but-unhandled cannot exist either. -// Undeclared (future) keys fall through to the base permissions PUT verbatim, never silently dropped. +// A schema field with no entry here fails to compile; an undeclared (future) key rides in the base permissions PUT verbatim, never silently dropped. const KEY_DESTINATION = { enabled: "base", allowed_actions: "base", @@ -452,7 +455,9 @@ const KEY_DESTINATION = { const claimKeys = declared.use_default ? undefined : declared.include_claim_keys; if (claimKeys !== undefined) { const liveKeys = live.include_claim_keys ?? []; - if (!sameClaimKeyOrder(claimKeys, liveKeys)) { + // Claim-key ORDER defines the OIDC subject format ("repo:...:context:..."), so unlike subsetDiff's + // set comparison of scalar lists this compares element by element: a reordered live value is drift. + if (!isDeepStrictEqual(claimKeys, liveKeys)) { drift.push( valueDrift( "actions.oidc_customization_sub.include_claim_keys", @@ -517,10 +522,12 @@ type RoutedKey = { // Per-key handler types, so planRouted's generic dispatch stays correlated to one literal key. const ROUTED_DESTINATIONS: { [K in RoutedKey]: RoutedDestination } = KEY_DESTINATION; +// The keys with a handler of their own, in table order, which is the order plan and snapshot visit them. const ROUTED_KEYS = (Object.keys(KEY_DESTINATION) as (keyof ActionsConfig)[]).filter( (key): key is RoutedKey => typeof KEY_DESTINATION[key] !== "string", ); +// Keyed by string: the partition walks Object.entries(desired), whose keys are not narrowed. const ROUTED_KEY_SET: ReadonlySet = new Set(ROUTED_KEYS); async function planRouted( @@ -563,6 +570,63 @@ const WORKFLOW_KEYS = keysTo("workflow"); const KNOWN_PERMISSION_KEYS = keysTo("base"); +/** The base permissions and workflow bodies the declared keys fill, with the two defaults the base PUT needs. */ +function baseBodies(desired: ActionsConfig): { + permissions: Record; + workflow: Record; +} { + const permissions: Record = {}; + const workflow: Record = {}; + for (const [key, value] of Object.entries(desired)) { + if (ROUTED_KEY_SET.has(key)) { + continue; + } + if (WORKFLOW_KEYS.has(key)) { + workflow[key] = value; + } else { + permissions[key] = value; + } + } + + if (desired.selected_actions !== undefined && permissions.allowed_actions === undefined) { + // The allowlist endpoint answers 409 unless the policy is "selected", so an undeclared policy + // is inferred; a contradicting declared one is rejected upfront by the shape's superRefine. + permissions.allowed_actions = "selected"; + } + + if (Object.keys(permissions).length > 0) { + // The PUT body requires `enabled`; declaring any base-permissions key implies actions are on + // unless the file says otherwise. + permissions.enabled = permissions.enabled ?? true; + } + + return { permissions, workflow }; +} + +/** The note for the keys riding verbatim in the base PUT, or undefined when every key is a known one. */ +function unrecognizedKeysNote( + permissions: Record, +): string | undefined { + const routed = Object.keys(permissions).filter((k) => !KNOWN_PERMISSION_KEYS.has(k)); + if (routed.length === 0) { + return undefined; + } + + // The base PUT body always carries an enabled value (baseBodies defaults it), so a mis-routed key + // can flip Actions on as a side effect; the note says so. JSON.stringify keeps a malformed quoted + // "false" distinguishable from the boolean. + const enabledValue = JSON.stringify(permissions.enabled); + const count = routed.length; + return ( + `${agree(count, "key", "keys")} [${routed.join(", ")}] ${agree(count, "is", "are")} not recognized by this action; ` + + `${agree(count, "it rides", "they ride")} verbatim ` + + `in PUT /actions/permissions (a body that also sets enabled: ${enabledValue}), where ` + + `GitHub may ignore ${agree(count, "it", "them")} - a "no such field" drift line for a key means GitHub does not ` + + `return it, so it can never be proven to have taken and apply would re-send the body ` + + `on every run; remove it from the actions section of the settings file` + ); +} + export const actionsSection = { key: "actions", undeclaredDefault: "untouched", @@ -574,43 +638,10 @@ export const actionsSection = { const section = this; return safeTry(async function* () { const plan: ActionsPlan = { ops: [], notes: [], drift: [] }; - const permissions: Record = {}; - const workflow: Record = {}; - for (const [key, value] of Object.entries(desired)) { - if (ROUTED_KEY_SET.has(key)) { - continue; - } - if (WORKFLOW_KEYS.has(key)) { - workflow[key] = value; - } else { - permissions[key] = value; - } - } - if (desired.selected_actions !== undefined && permissions.allowed_actions === undefined) { - // The allowlist endpoint answers 409 unless the policy is "selected", so an undeclared policy - // is inferred; a contradicting declared one is rejected upfront by the shape's superRefine. - permissions.allowed_actions = "selected"; - } - if (Object.keys(permissions).length > 0) { - // The PUT body requires `enabled`; declaring any base-permissions key implies actions are on - // unless the file says otherwise. - permissions.enabled = permissions.enabled ?? true; - } - const routed = Object.keys(permissions).filter((k) => !KNOWN_PERMISSION_KEYS.has(k)); - if (routed.length > 0) { - // The base PUT body always carries an enabled value (defaulted above), so a mis-routed key can - // flip Actions on as a side effect; the note says so. JSON.stringify keeps a malformed quoted - // "false" distinguishable from the boolean. - const enabledValue = JSON.stringify(permissions.enabled); - const count = routed.length; - plan.notes.push( - `${agree(count, "key", "keys")} [${routed.join(", ")}] ${agree(count, "is", "are")} not recognized by this action; ` + - `${agree(count, "it rides", "they ride")} verbatim ` + - `in PUT /actions/permissions (a body that also sets enabled: ${enabledValue}), where ` + - `GitHub may ignore ${agree(count, "it", "them")} - a "no such field" drift line for a key means GitHub does not ` + - `return it, so it can never be proven to have taken and apply would re-send the body ` + - `on every run; remove it from the actions section of the settings file`, - ); + const { permissions, workflow } = baseBodies(desired); + const note = unrecognizedKeysNote(permissions); + if (note !== undefined) { + plan.notes.push(note); } if (Object.keys(permissions).length > 0) { diff --git a/src/sections/actions/schema.ts b/src/sections/actions/schema.ts index 4d6de5881..90bc0ebad 100644 --- a/src/sections/actions/schema.ts +++ b/src/sections/actions/schema.ts @@ -15,18 +15,14 @@ import { open, rule } from "../shared/schema-helpers.js"; /** GitHub's rule for an OIDC claim key; the PUT 422s on anything else. */ const CLAIM_KEY = /^[A-Za-z0-9_]+$/; -/** - * The fields the GET reports and the PUT does not take, as [path to the holder, field]: a declared value - * can only diff against live and re-PUT forever, so the document is refused with the key named. The - * holder is read off the open() parse output, which is how the refinement sees a key the shape does - * not declare. - */ +/** [path to the holder, field] pairs the GET reports and the PUT does not take; declared, one could only diff against live and re-PUT forever, so the parse refuses it by name. */ const REPORTED_ONLY: readonly (readonly [readonly string[], string])[] = [ [[], "selected_actions_url"], [["artifact_and_log_retention"], "maximum_allowed_days"], [["oidc_customization_sub"], "sub_claim_prefix"], ]; +/** The holder is read off the open() parse output, which is how the refinement sees a key the shape does not declare. */ function refuseReportedOnly(ctx: z.core.$RefinementCtx, declared: object): void { for (const [path, field] of REPORTED_ONLY) { const holder = path.reduce( @@ -75,12 +71,7 @@ const ClaimKeys = z }), ); -/** - * The two templates are two variants, discriminated on use_default, so the claim-key list has no - * home on the default one and the plan narrows on the flag instead of re-checking it. The default - * variant is open(), so a list declared beside use_default: true is refused by name here rather - * than riding through. - */ +/** Two variants discriminated on use_default, so the plan narrows on the flag instead of re-checking it; the default variant is open(), so a list declared beside use_default: true is refused by name here rather than riding through. */ export const OidcTemplate = z .discriminatedUnion("use_default", [ open({ use_default: z.literal(true), use_immutable_subject: z.boolean().optional() }), @@ -105,6 +96,7 @@ export const OidcTemplate = z }), ); +/** The declared actions: mapping, open so an undeclared key rides through to the base permissions PUT. */ export const ActionsConfig = open({ enabled: z.boolean().optional(), allowed_actions: z.enum(ALLOWED_ACTIONS).optional(), @@ -149,4 +141,5 @@ export const ActionsConfig = open({ }), ) .meta({ id: "ActionsConfig" }); + export type ActionsConfig = z.infer; diff --git a/src/sections/autolinks/schema.ts b/src/sections/autolinks/schema.ts index d5f5b49e1..0f29c0f04 100644 --- a/src/sections/autolinks/schema.ts +++ b/src/sections/autolinks/schema.ts @@ -3,7 +3,6 @@ import { z } from "zod"; import { open } from "../shared/schema-helpers.js"; -// GitHub's own charset for a prefix; the create 422s on anything else, so the document refuses it first. const KEY_PREFIX = /^[A-Za-z0-9.\-_+=:/#]+$/; export const AutolinkConfig = open({ diff --git a/src/sections/branches/endpoints.ts b/src/sections/branches/endpoints.ts index 42e178fbc..488bd2ade 100644 --- a/src/sections/branches/endpoints.ts +++ b/src/sections/branches/endpoints.ts @@ -1,12 +1,6 @@ -/** The REST endpoint dictionary both index.ts and graphql-ops.ts derive their plan context type from. */ - import type { DefinitiveRejection, EndpointDecl } from "../contract/endpoints.js"; -/** - * GitHub's one body for a protection endpoint on a branch that does not exist. A denied request never - * spells it (a fine-grained read is "Not Found", a write "Resource not accessible ..."), so the match is - * definitive wherever it lands: the PUT (this declaration) and the advisory probe (index.ts). - */ +/** GitHub's one body for a protection call on a missing branch; no denial spells it (a fine-grained read says "Not Found", a write "Resource not accessible"), so the match is definitive at the PUT here and at index.ts's advisory probe. */ export const MISSING_BRANCH: DefinitiveRejection = { status: 404, message: "Branch not found", @@ -14,6 +8,7 @@ export const MISSING_BRANCH: DefinitiveRejection = { "the declared branch does not exist on the repo, so its protection cannot be applied; create the branch, or remove it from the settings file", }; +/** The REST endpoint dictionary both index.ts and graphql-ops.ts derive their plan context type from. */ export const ENDPOINTS = { // A fine-grained 404 reads as "unprotected", so a denied token surfaces on the first write, not here. getProtection: { diff --git a/src/sections/branches/graphql-ops.ts b/src/sections/branches/graphql-ops.ts index edb7410f7..d339a0a4d 100644 --- a/src/sections/branches/graphql-ops.ts +++ b/src/sections/branches/graphql-ops.ts @@ -8,10 +8,7 @@ import { type GraphqlOpDecl, graphqlOp } from "../contract/graphql.js"; import type { PlanContext, PlannedOp, SectionPlan } from "../contract/plan.js"; import type { ENDPOINTS } from "./endpoints.js"; -/** - * The rule selection both rules reads share, so the snapshot's read cannot lag the planner's - * translation tables (test/sections/graphql-queries.test.ts asserts every twin is selected). - */ +// The selection both rules reads share, so the snapshot's read cannot lag the planner's translation tables (test/sections/graphql-queries.test.ts asserts every twin is selected). const RULES_SELECTION = `($owner: String!, $repo: String!, $cursor: String) { repository(owner: $owner, name: $repo) { branchProtectionRules(first: 100, after: $cursor) { @@ -54,11 +51,7 @@ const RULES_SELECTION = `($owner: String!, $repo: String!, $cursor: String) { } }`; -/** - * Classic protection IS a BranchProtectionRule upstream, so this lists literal and wildcard rules - * alike. NOT_FOUND is tolerated so a fine-grained denial reads as "no rules visible" and surfaces - * at the first write, the section's posture everywhere. - */ +// Classic protection IS a BranchProtectionRule upstream, so literal and wildcard rules list alike; NOT_FOUND is tolerated so a fine-grained denial reads as "no rules visible" and surfaces at the first write, the section's posture everywhere. const RULES_QUERY = graphqlOp<{ owner: string; repo: string }>()({ name: "BranchProtectionRules", kind: "read", @@ -70,10 +63,7 @@ const RULES_QUERY = graphqlOp<{ owner: string; repo: string }>()({ query: `query BranchProtectionRules${RULES_SELECTION}`, }); -/** - * The snapshot's read of the same rules. No write follows a snapshot to surface a denial, so - * NOT_FOUND is not tolerated here: a concealed denial fails the read with the grant advice. - */ +// No write follows a snapshot to surface a denial, so NOT_FOUND is not tolerated here: a concealed denial fails the read with the grant advice. const RULES_SNAPSHOT = graphqlOp<{ owner: string; repo: string }>()({ name: "BranchProtectionRulesSnapshot", kind: "read", @@ -82,10 +72,7 @@ const RULES_SNAPSHOT = graphqlOp<{ owner: string; repo: string }>()({ query: `query BranchProtectionRulesSnapshot${RULES_SELECTION}`, }); -/** - * Execution-phase, like the two actor lookups: a fine-grained denial answers NOT_FOUND, which none - * of the three tolerates, so they may only run where the posture puts the denial, at the first write. - */ +// Execution-phase, like the two actor lookups: a fine-grained denial answers NOT_FOUND, which none of the three tolerates, so they may only run where the posture puts the denial, at the first write. const REPO_LOOKUP = graphqlOp<{ owner: string; repo: string }>()({ name: "BranchProtectionRepository", kind: "read", @@ -96,11 +83,7 @@ const REPO_LOOKUP = graphqlOp<{ owner: string; repo: string }>()({ }`, }); -/** - * REST /users/{username} can still carry a legacy node_id for old accounts (the mutation would - * answer a deprecation warning), so users resolve through GraphQL. The repository selection routes - * the read: every repo-addressed read takes $owner/$repo. - */ +// REST /users/{username} can still carry a legacy node_id for old accounts (the mutation would answer a deprecation warning), so users resolve through GraphQL; the repository selection routes the read, as every repo-addressed read takes $owner/$repo. const ACTOR_USER = graphqlOp<{ owner: string; repo: string; login: string }>()({ name: "BranchProtectionActorUser", kind: "read", @@ -117,6 +100,7 @@ const ACTOR_USER = graphqlOp<{ owner: string; repo: string; login: string }>()({ }`, }); +// Routed like ACTOR_USER; a team resolves under its organization, so the slug's org half is a variable of its own. const ACTOR_TEAM = graphqlOp<{ owner: string; repo: string; org: string; team: string }>()({ name: "BranchProtectionActorTeam", kind: "read", @@ -133,11 +117,7 @@ const ACTOR_TEAM = graphqlOp<{ owner: string; repo: string; org: string; team: s }`, }); -/** - * The create and update payloads re-read the persisted rule, so selecting - * requiredDeploymentEnvironments IS the post-mutation read-back the silent-drop check needs (GitHub - * drops names of environments that do not exist without failing the mutation). - */ +// The payload re-reads the persisted rule, so selecting requiredDeploymentEnvironments IS the post-mutation read-back the silent-drop check needs (GitHub drops names of environments that do not exist without failing the mutation). const CREATE_RULE = graphqlOp<{ input: Record }>()({ name: "CreateBranchProtectionRule", kind: "write", @@ -152,6 +132,7 @@ const CREATE_RULE = graphqlOp<{ input: Record }>()({ }`, }); +// The same read-back selection as the create, for the same silent-drop check. const UPDATE_RULE = graphqlOp<{ input: Record }>()({ name: "UpdateBranchProtectionRule", kind: "write", @@ -167,6 +148,7 @@ const UPDATE_RULE = graphqlOp<{ input: Record }>()({ }`, }); +// A mutation must select a field and nothing of a deleted rule reads back, so clientMutationId stands in. const DELETE_RULE = graphqlOp<{ input: Record }>()({ name: "DeleteBranchProtectionRule", kind: "write", @@ -176,6 +158,7 @@ const DELETE_RULE = graphqlOp<{ input: Record }>()({ }`, }); +/** Keyed by the role a planned op names, so a role and its declaration cannot drift apart. */ export const GRAPHQL = { rulesQuery: RULES_QUERY, rulesSnapshot: RULES_SNAPSHOT, @@ -187,10 +170,7 @@ export const GRAPHQL = { deleteRule: DELETE_RULE, } as const satisfies Record; -/** - * A rule node as the selection returns it, every twin the translators read declared with the SDL's - * nullability, so a value off the vocabulary fails the read instead of vanishing from the classic view. - */ +/** A rule node as the selection returns it, every twin the translators read declared with the SDL's nullability, so a value off the vocabulary fails the read instead of vanishing from the classic view. */ export const RuleNode = z.looseObject({ id: z.string(), pattern: z.string(), @@ -234,6 +214,7 @@ export const RuleNode = z.looseObject({ pageInfo: z.looseObject({ hasNextPage: z.boolean() }), }), }); + export type RuleNode = z.infer; /** @@ -243,13 +224,16 @@ export type RuleNode = z.infer; export type LiveRules = Map | null; export interface GraphqlRun { + /** The plan-time read; a routed update of a literal whose rule it lacks reads the rule again at execution (graphql-writes.ts lateRuleId). */ rules: LiveRules; + + /** Adopted from the first user or team lookup that selects it, or looked up when a create needs it. */ repoId: string | null; + + /** Keyed by the case-folded actor string, since GitHub canonicalizes actor names. */ actorIds: Map; - /** - * Every bypass actor a planned mutation resolves at execution, appended by ruleVariables() as it - * seals one; plan() resolves them all ahead of the FIRST write, whichever entry they belong to. - */ + + /** Every bypass actor a planned mutation resolves at execution, appended as ruleVariables() seals one; plan() resolves them all ahead of the FIRST write, whichever entry they belong to. */ lateActors: string[]; } diff --git a/src/sections/branches/graphql-vocabulary.ts b/src/sections/branches/graphql-vocabulary.ts index 43c191e94..d238a67e3 100644 --- a/src/sections/branches/graphql-vocabulary.ts +++ b/src/sections/branches/graphql-vocabulary.ts @@ -7,6 +7,7 @@ import type { MustBeNever } from "../../types.js"; import type { BooleanControl } from "./keys.js"; import type { BranchProtectionConfig } from "./schema.js"; +/** Named after the REST keys; the one unobvious twin is allow_fork_syncing, which GitHub spells lockAllowsFetchAndMerge (it only matters on a locked branch). */ export const GRAPHQL_BOOLEAN_TWINS = { enforce_admins: "isAdminEnforced", required_linear_history: "requiresLinearHistory", @@ -19,6 +20,7 @@ export const GRAPHQL_BOOLEAN_TWINS = { required_signatures: "requiresCommitSignatures", } as const satisfies Record; +/** The scalar review sub-keys; a wildcard entry may declare no other under required_pull_request_reviews (index.ts refuses them). */ export const GRAPHQL_REVIEW_TWINS = { required_approving_review_count: "requiredApprovingReviewCount", require_code_owner_reviews: "requiresCodeOwnerReviews", @@ -26,15 +28,13 @@ export const GRAPHQL_REVIEW_TWINS = { require_last_push_approval: "requireLastPushApproval", } as const; +/** contexts only: a wildcard entry declaring the structured checks list is refused (index.ts). */ export const GRAPHQL_STATUS_CHECK_TWINS = { strict: "requiresStrictStatusChecks", contexts: "requiredStatusCheckContexts", } as const; -/** - * The keys no REST protection endpoint carries: they ride the updateBranchProtectionRule mutation - * alone. The ONE spelling; the routed types, the guard, the drift, and the snapshot derive from it. - */ +/** The keys no REST protection endpoint carries, so they ride the rule mutation alone; the ONE spelling, which the routed types, the guard, the drift, and the snapshot derive from. */ export const ROUTED_KEYS = [ "force_push_bypassers", "required_deployments", diff --git a/src/sections/branches/graphql-writes.ts b/src/sections/branches/graphql-writes.ts index 4c9726d3b..b9d4671a4 100644 --- a/src/sections/branches/graphql-writes.ts +++ b/src/sections/branches/graphql-writes.ts @@ -22,22 +22,60 @@ import { ROUTED_KEYS, type RoutedProtection, } from "./graphql-vocabulary.js"; -import { type BranchConfig, type BranchProtectionConfig, parseBypassActor } from "./schema.js"; +import { + type BranchConfig, + type BranchProtectionConfig, + type BypassActor, + parseBypassActor, +} from "./schema.js"; /** The node id a lookup selects; null when the token cannot see the object. */ const NodeId = z.looseObject({ id: z.string() }).nullable().optional(); +/** REPO_LOOKUP's answer; the actor lookups select the same field, which adoptRepoId reads off them. */ const RepositoryLookup = z.looseObject({ repository: NodeId }); +/** ACTOR_USER's answer; the repository selection beside the user is what routes the read. */ const UserLookup = z.looseObject({ repository: NodeId, user: NodeId }); +/** ACTOR_TEAM's answer; the team hangs under its organization, each null when invisible. */ const TeamLookup = z.looseObject({ repository: NodeId, organization: z.looseObject({ team: NodeId }).nullable().optional(), }); +/** The REST App body; only node_id is read, which an old App may still carry in the legacy format (endpoints.ts appLookup). */ const AppLookup = z.looseObject({ node_id: z.string().optional() }); +/** The two protection mappings a wildcard rule carries as an on/off flag plus the named sub-keys' twins. */ +const STRUCTURED_PAIRS: Readonly> = { + required_status_checks: { flag: "requiresStatusChecks", twins: GRAPHQL_STATUS_CHECK_TWINS }, + required_pull_request_reviews: { flag: "requiresApprovingReviews", twins: GRAPHQL_REVIEW_TWINS }, +}; + +interface StructuredPair { + flag: string; + twins: Readonly>; +} + +function translateStructuredPair( + input: Record, + pair: StructuredPair, + value: unknown, +): void { + if (value === null) { + input[pair.flag] = false; + return; + } + input[pair.flag] = true; + const mapping = value as Record; + for (const [classic, twin] of Object.entries(pair.twins)) { + if (classic in mapping) { + input[twin] = mapping[classic]; + } + } +} + /** Shape validation already restricted a wildcard entry's keys, so an unknown key here is a bug. */ function translateWildcardProtection(protection: BranchProtectionConfig): Record { const input: Record = {}; @@ -50,32 +88,9 @@ function translateWildcardProtection(protection: BranchProtectionConfig): Record input[booleanTwin] = value; continue; } - if (key === "required_status_checks") { - if (value === null) { - input.requiresStatusChecks = false; - } else { - input.requiresStatusChecks = true; - const checks = value as Record; - for (const [classic, twin] of Object.entries(GRAPHQL_STATUS_CHECK_TWINS)) { - if (classic in checks) { - input[twin] = checks[classic]; - } - } - } - continue; - } - if (key === "required_pull_request_reviews") { - if (value === null) { - input.requiresApprovingReviews = false; - } else { - input.requiresApprovingReviews = true; - const reviews = value as Record; - for (const [classic, twin] of Object.entries(GRAPHQL_REVIEW_TWINS)) { - if (classic in reviews) { - input[twin] = reviews[classic]; - } - } - } + const pair = STRUCTURED_PAIRS[key]; + if (pair !== undefined) { + translateStructuredPair(input, pair, value); continue; } throw new Error(`BUG: wildcard protection key "${key}" escaped shape validation`); @@ -162,108 +177,127 @@ function verifyDeploymentReadback( return ok(undefined); } +/** The force_push_bypassers line when the declared actors and the live rule's allowances differ. */ +function bypassersDrift( + prefix: string, + declared: readonly string[], + node: RuleNode | undefined, +): string[] { + const live = node ? [...bypassActorStrings(node)].sort() : []; + if (sameNamesFold(declared, live)) { + return []; + } + return [ + `${prefix}.force_push_bypassers: the settings file declares [${[...declared].sort().join(", ")}] but the live rule allows [${live.join( + ", ", + )}]; apply will replace the allowance list`, + ]; +} + +/** The required_deployments line when the declaration and the live rule differ. */ +function deploymentsDrift( + prefix: string, + declared: NonNullable | null, + node: RuleNode | undefined, +): string[] { + const liveOn = node?.requiresDeployments === true; + const liveEnvs = ( + Array.isArray(node?.requiredDeploymentEnvironments) + ? (node.requiredDeploymentEnvironments as unknown[]).map(String) + : [] + ).sort(); + if (declared === null) { + if (!liveOn) { + return []; + } + return [ + `${prefix}.required_deployments: declared null (not required) but the live rule requires deployments to [${liveEnvs.join( + ", ", + )}]; apply will turn the requirement off`, + ]; + } + if (liveOn && sameNamesFold(declared.environments, liveEnvs)) { + return []; + } + return [ + `${prefix}.required_deployments: the settings file requires deployments to [${[ + ...declared.environments, + ] + .sort() + .join( + ", ", + )}] but the live rule ${liveOn ? `requires [${liveEnvs.join(", ")}]` : "does not require deployments"}; apply will set the declared list`, + ]; +} + function routedKeyDrift( prefix: string, protection: BranchProtectionConfig, rules: LiveRules, pattern: string, ): string[] { - const drift: string[] = []; if (rules === null) { // An unreadable view can never read as clean, so the declared value is written regardless. - for (const key of ROUTED_KEYS) { - if (protection[key] !== undefined) { - drift.push( - `${prefix}.${key}: the live rule cannot be read (the rules query answered not found); apply will set the declared value`, - ); - } - } - return drift; + return ROUTED_KEYS.filter((key) => protection[key] !== undefined).map( + (key) => + `${prefix}.${key}: the live rule cannot be read (the rules query answered not found); apply will set the declared value`, + ); } const node = rules.get(pattern); - const declaredActors = protection.force_push_bypassers; - if (declaredActors !== undefined) { - const live = node ? [...bypassActorStrings(node)].sort() : []; - if (!sameNamesFold(declaredActors, live)) { - drift.push( - `${prefix}.force_push_bypassers: the settings file declares [${[...declaredActors].sort().join(", ")}] but the live rule allows [${live.join( - ", ", - )}]; apply will replace the allowance list`, - ); - } + const drift: string[] = []; + if (protection.force_push_bypassers !== undefined) { + drift.push(...bypassersDrift(prefix, protection.force_push_bypassers, node)); } - const declaredDeployments = protection.required_deployments; - if (declaredDeployments !== undefined) { - const liveOn = node?.requiresDeployments === true; - const liveEnvs = ( - Array.isArray(node?.requiredDeploymentEnvironments) - ? (node.requiredDeploymentEnvironments as unknown[]).map(String) - : [] - ).sort(); - if (declaredDeployments === null) { - if (liveOn) { - drift.push( - `${prefix}.required_deployments: declared null (not required) but the live rule requires deployments to [${liveEnvs.join( - ", ", - )}]; apply will turn the requirement off`, - ); - } - } else if (!liveOn || !sameNamesFold(declaredDeployments.environments, liveEnvs)) { - drift.push( - `${prefix}.required_deployments: the settings file requires deployments to [${[ - ...declaredDeployments.environments, - ] - .sort() - .join( - ", ", - )}] but the live rule ${liveOn ? `requires [${liveEnvs.join(", ")}]` : "does not require deployments"}; apply will set the declared list`, - ); - } + if (protection.required_deployments !== undefined) { + drift.push(...deploymentsDrift(prefix, protection.required_deployments, node)); } return drift; } -/** - * Cached per run under the case-folded string: GitHub canonicalizes actor names. A user or team read - * also selects the repository's node id, which a later rule CREATE reuses instead of a dedicated lookup. - * - * user -> GraphQL (REST /users can still carry a legacy node_id; see ACTOR_USER) - * team -> GraphQL - * app -> the public REST lookup (legacy-id caveat on appLookup in endpoints.ts); no repository id - */ -async function resolveActorId( +function adoptRepoId(graphqlRun: GraphqlRun, data: z.infer): void { + const id = data.repository?.id; + if (graphqlRun.repoId === null && id !== undefined && id.length > 0) { + graphqlRun.repoId = id; + } +} + +/** The user's node id, undefined when the token cannot see the user; the repository id rides along. */ +function lookupUserId( ctx: BranchesContext, exec: ExecTools, graphqlRun: GraphqlRun, + login: string, raw: string, -): Promise> { - const cacheKey = raw.toLowerCase(); - const cached = graphqlRun.actorIds.get(cacheKey); - if (cached !== undefined) { - return ok(cached); - } - const actor = parseBypassActor(raw); - if (actor === null) { - throw new Error(`BUG: force_push_bypassers actor "${raw}" escaped shape validation`); - } - return safeTry(async function* () { - let id: string | undefined; - if (actor.kind === "user") { - const data = yield* ctx.read.actorUser.call( - exec, - UserLookup, - { ...repoVariables(ctx), login: actor.login }, - { describe: `resolving force-push bypass user "${raw}"` }, - ); +): Read { + return ctx.read.actorUser + .call( + exec, + UserLookup, + { ...repoVariables(ctx), login }, + { describe: `resolving force-push bypass user "${raw}"` }, + ) + .map((data) => { adoptRepoId(graphqlRun, data); - id = data.user?.id; - } else if (actor.kind === "team") { - const data = yield* ctx.read.actorTeam.call( - exec, - TeamLookup, - { ...repoVariables(ctx), org: actor.org, team: actor.team }, - { describe: `resolving force-push bypass team "${raw}"` }, - ); + return data.user?.id; + }); +} + +/** The team's node id; a missing team under the organization is refused by name. */ +function lookupTeamId( + ctx: BranchesContext, + exec: ExecTools, + graphqlRun: GraphqlRun, + actor: { org: string; team: string }, + raw: string, +): Read { + return ctx.read.actorTeam + .call( + exec, + TeamLookup, + { ...repoVariables(ctx), org: actor.org, team: actor.team }, + { describe: `resolving force-push bypass team "${raw}"` }, + ) + .andThen((data) => { adoptRepoId(graphqlRun, data); const team = data.organization?.team; if (team === null || team === undefined) { @@ -274,22 +308,76 @@ async function resolveActorId( ), ); } - id = team.id; - } else { - const result = yield* ctx.read.appLookup.tryCall(exec, AppLookup, { - params: { app_slug: actor.slug }, - describe: `resolving force-push bypass App "${raw}"`, - }); + return ok(team.id); + }); +} + +/** The App's node id from the public REST lookup; no App with the slug is refused by name. */ +function lookupAppId( + ctx: BranchesContext, + exec: ExecTools, + slug: string, + raw: string, +): Read { + return ctx.read.appLookup + .tryCall(exec, AppLookup, { + params: { app_slug: slug }, + describe: `resolving force-push bypass App "${raw}"`, + }) + .andThen((result) => { if ("error" in result) { return err( sectionFailure( "refused", - `branches: force_push_bypassers actor "${raw}": no GitHub App with slug "${actor.slug}" exists; check the actor spelling in the settings file`, + `branches: force_push_bypassers actor "${raw}": no GitHub App with slug "${slug}" exists; check the actor spelling in the settings file`, ), ); } - id = result.data.node_id; - } + return ok(result.data.node_id); + }); +} + +/** + * A user or team read also selects the repository's node id, which a later rule CREATE reuses + * instead of a dedicated lookup. + * + * user -> GraphQL (REST /users can still carry a legacy node_id; see ACTOR_USER) + * team -> GraphQL + * app -> the public REST lookup (legacy-id caveat on appLookup in endpoints.ts); no repository id + */ +function lookupActorId( + ctx: BranchesContext, + exec: ExecTools, + graphqlRun: GraphqlRun, + actor: BypassActor, + raw: string, +): Read { + if (actor.kind === "user") { + return lookupUserId(ctx, exec, graphqlRun, actor.login, raw); + } + if (actor.kind === "team") { + return lookupTeamId(ctx, exec, graphqlRun, actor, raw); + } + return lookupAppId(ctx, exec, actor.slug, raw); +} + +/** Cached per run under the case-folded string: GitHub canonicalizes actor names. */ +async function resolveActorId( + ctx: BranchesContext, + exec: ExecTools, + graphqlRun: GraphqlRun, + raw: string, +): Promise> { + const cacheKey = raw.toLowerCase(); + const cached = graphqlRun.actorIds.get(cacheKey); + if (cached !== undefined) { + return ok(cached); + } + const actor = parseBypassActor(raw); + if (actor === null) { + throw new Error(`BUG: force_push_bypassers actor "${raw}" escaped shape validation`); + } + return lookupActorId(ctx, exec, graphqlRun, actor, raw).andThen((id) => { if (id === undefined || id.length === 0) { return err( sectionFailure( @@ -303,13 +391,6 @@ async function resolveActorId( }); } -function adoptRepoId(graphqlRun: GraphqlRun, data: z.infer): void { - const id = data.repository?.id; - if (graphqlRun.repoId === null && id !== undefined && id.length > 0) { - graphqlRun.repoId = id; - } -} - /** * Read at EXECUTION time when the plan-time fetch did not carry the rule: a PUT planned earlier may * have created it, or the rules query answered its tolerated NOT_FOUND. @@ -466,22 +547,23 @@ export function planRoutedUpdate( } const deploymentFields = requiredDeployments === undefined ? {} : deploymentInputFields(requiredDeployments); + // A rule the plan-time fetch did not carry is looked up at execution: the PUT planned above may + // create it, or the rules query answered NOT_FOUND. + const variables = + node === undefined + ? ruleVariables(ctx, graphqlRun, deploymentFields, forcePushBypassers, async () => + lateRuleId(ctx, name).map((id) => ({ branchProtectionRuleId: id })), + ) + : ruleVariables( + ctx, + graphqlRun, + { branchProtectionRuleId: node.id, ...deploymentFields }, + forcePushBypassers, + ); plan.ops.push({ role: "updateRule", describe: `setting the GraphQL-only protection fields of branch "${name}"`, - // A rule the plan-time fetch did not carry is looked up at execution: the PUT planned above may - // create it, or the rules query answered NOT_FOUND. - variables: - node !== undefined - ? ruleVariables( - ctx, - graphqlRun, - { branchProtectionRuleId: node.id, ...deploymentFields }, - forcePushBypassers, - ) - : ruleVariables(ctx, graphqlRun, deploymentFields, forcePushBypassers, async () => - lateRuleId(ctx, name).map((id) => ({ branchProtectionRuleId: id })), - ), + variables, drift, change: verifiedChange( `set ${routedKeys} on "${name}"`, diff --git a/src/sections/branches/index.ts b/src/sections/branches/index.ts index 10f6ffb33..d0c6237d1 100644 --- a/src/sections/branches/index.ts +++ b/src/sections/branches/index.ts @@ -19,7 +19,7 @@ import { identifiedBy } from "../contract/keyed-list.js"; import { liveByIdentity, liveIdentity } from "../contract/live.js"; import type { SectionMeta, SectionModule } from "../contract/module.js"; import type { SectionPermission } from "../contract/permissions.js"; -import type { Read } from "../contract/plan.js"; +import type { PlainData, Read } from "../contract/plan.js"; import { layeredList, routed, rule } from "../shared/schema-helpers.js"; import { ENDPOINTS, MISSING_BRANCH } from "./endpoints.js"; import { @@ -27,6 +27,7 @@ import { type BranchesPlan, GRAPHQL, type GraphqlRun, + type RuleNode, } from "./graphql-ops.js"; import { fetchRules, @@ -55,9 +56,11 @@ import { type BranchConfig, BranchesConfig, BranchProtectionConfig, + copiedActorName, PROTECTION_MAPPING_KEYS, } from "./schema.js"; +/** The classic PUT rejects a payload missing any of these; null is a valid value for each. */ export const REQUIRED_PROTECTION_KEYS = [ "required_status_checks", "enforce_admins", @@ -65,12 +68,7 @@ export const REQUIRED_PROTECTION_KEYS = [ "restrictions", ] as const; -/** - * The protection PUT's vocabulary by holder path: the schema's keys and the boolean controls at - * the top, the declared keys and the review booleans under each open mapping. A declared key - * outside it that the GET does not echo is noted as never converging; a documented key the GET - * omits (an off control, an optional review setting) is drift the PUT resolves. - */ +/** The PUT's vocabulary by holder path; a declared key outside it that the GET does not echo is noted as never converging, while a documented key the GET omits (an off control, an optional review setting) is drift the PUT resolves. */ const PROTECTION_VOCABULARY: ReadonlyMap> = new Map( Object.entries({ ...PROTECTION_MAPPING_KEYS, @@ -150,8 +148,7 @@ export function isWildcardPattern(name: string): boolean { return /[*?[]/.test(name); } -// GitHub spells this one list two ways inside required_status_checks and the GET returns both, -// so a declaration carrying either covers the other. +// GitHub spells this one list two ways inside required_status_checks and the GET returns both, so a declaration carrying either covers the other. const STATUS_CHECK_ALIASES: Readonly> = { "required_status_checks.checks": "required_status_checks.contexts", "required_status_checks.contexts": "required_status_checks.checks", @@ -217,21 +214,8 @@ const LiveProtection = z.looseObject({ required_signatures: z.looseObject({ enabled: z.boolean() }).optional(), }); -/** One item of the protected-branch listing; only the name is read (the protection is probed). */ const LiveBranchSummary = z.looseObject({ name: z.string() }); -/** - * A literal-pattern rule the REST reads did not surface as a protected branch: no branch of that - * name exists (a pattern needs none), or its protection probe was denied. A literal entry applies - * through the REST PUT, which needs the branch, so the rule is noted instead of written. The - * pattern appears once and the text stays short, so the rendered header line ("# " prefixed) - * stays under 256 chars for patterns up to 54 chars. - */ -const unreachableLiteralRuleNote = (pattern: string): string => - `branches[${pattern}]: rule kept out of the snapshot: REST read no protected branch by this ` + - "name (the branch is missing, or its protection is unreadable); create the branch or fix the " + - "grant, then snapshot again"; - /** * Built in the ONE place that decides whether the GraphQL run state exists, so no other site re-spells the predicate. * The run rides exactly the entries that need it: a literal entry's protection carries no routed key, so no @@ -249,191 +233,84 @@ export type ClassifiedEntry = branch: { name: string; protection: PlainTyped | null }; }; -const WILDCARD_KEY_ERROR = (name: string, key: string): string => - `the wildcard entry "${name}" declares protection.${key}, which this section does not manage on wildcard rules; ` + - `only the keys it can round-trip through the GraphQL rule mutations apply here: [${WILDCARD_KEYS.join(", ")}]. ` + - "For actor lists and richer controls, prefer the rulesets section (the modern successor of classic protection)"; +/** A literal or routed entry's declared protection, once known not to be null. */ +type LiteralProtection = NonNullable< + Exclude["branch"]["protection"] +>; -export const branchesSection = { - // Branch names and patterns are verbatim keys; two entries for one would overwrite each other's write on every run. - ...identifiedBy("branches", "name", "branch"), - undeclaredDefault: "untouched", - permission, - endpoints: ENDPOINTS, - graphql: GRAPHQL, - // The wildcard key sweep composes HERE, not in schema.ts: it reads the GraphQL translation tables, - // which are this section's own machinery, and nothing outside them can reach a wildcard rule. - shape: routed(layeredList(BranchesConfig)).check( - rule((declared, refineCtx) => { - // The routed shape parsed one of the two forms; under the wrapper an issue's path starts at `entries`. - const wrapped = !Array.isArray(declared); - const entries = listEntries(declared as BranchConfig[] | { entries: BranchConfig[] }); - const at = (index: number, ...rest: string[]): (string | number)[] => [ - ...(wrapped ? ["entries"] : []), - index, - ...rest, - ]; - entries.forEach((entry: BranchConfig, index) => { - // The entry, its name, or its protection may be raw or missing beside its own shape issue; a regex would - // coerce the name (`[object Object]` wildcards), and a non-mapping protection holds no keys to sweep. - if ( - !isPlainObject(entry) || - typeof entry.name !== "string" || - !isWildcardPattern(entry.name) - ) { - return; - } - const protection = entry.protection; - if (!isPlainObject(protection)) { - return; - } - for (const key of Object.keys(protection)) { - if (!WILDCARD_KEY_SET.has(key)) { - refineCtx.addIssue({ - code: "custom", - path: at(index, "protection", key), - message: WILDCARD_KEY_ERROR(entry.name, key), - }); - } - } - // The structured pairs translate NAMED sub-keys only, so an unknown sub-key would be silently - // lost; the schema already holds each pair to a mapping or null. - const nested: Array<[string, Record]> = [ - ["required_status_checks", GRAPHQL_STATUS_CHECK_TWINS], - ["required_pull_request_reviews", GRAPHQL_REVIEW_TWINS], - ]; - for (const [key, twins] of nested) { - const value = protection[key]; - if (!isPlainObject(value)) { - continue; - } - for (const subKey of Object.keys(value)) { - if (!(subKey in twins)) { - refineCtx.addIssue({ - code: "custom", - path: at(index, "protection", key, subKey), - message: WILDCARD_KEY_ERROR(entry.name, `${key}.${subKey}`), - }); - } - } - } - }); - }), - ), - async plan(ctx, desired): Promise> { - const section = this; - return safeTry(async function* () { - const branches = listEntries(desired); - const plan: BranchesPlan = { ops: [], notes: [], drift: [] }; - // The first entry that needs the GraphQL surface starts the one rules read, ahead of every REST - // probe; a pure-REST declaration never starts it, so no separate predicate gates the fetch. - let graphqlRun: GraphqlRun | null = null; - const entries: ClassifiedEntry[] = []; - for (const branch of branches) { - const protection: PlainTyped | null = branch.protection; - if (isWildcardPattern(branch.name)) { - graphqlRun ??= yield* startGraphqlRun(ctx); - entries.push({ kind: "wildcard", branch, graphqlRun }); - } else if (hasRoutedGraphqlKeys(protection)) { - graphqlRun ??= yield* startGraphqlRun(ctx); - entries.push({ kind: "routed", branch: { name: branch.name, protection }, graphqlRun }); - } else { - entries.push({ kind: "literal", branch: { name: branch.name, protection } }); - } - } - if (graphqlRun !== null) { - const declaredPatterns = new Set(branches.map((branch) => branch.name)); - for (const pattern of [...(graphqlRun.rules?.keys() ?? [])].sort()) { - if (isWildcardPattern(pattern) && !declaredPatterns.has(pattern)) { - plan.notes.push( - `undeclared classic protection rule "${pattern}" exists on the repo - declare it to manage it (this action never deletes undeclared rules)`, - ); - } - } - } - for (const entry of entries) { - if (entry.kind === "wildcard") { - await planWildcardEntry(ctx, entry.graphqlRun, entry.branch, plan); - continue; - } - yield* await planLiteralEntry(ctx, section, entry, plan); - } - // Every actor a planned mutation resolves at execution resolves ahead of the plan's FIRST write, - // whichever entry it belongs to: a misspelled actor fails while every branch's live protection - // is still untouched, and the mutations' thunks then find the ids cached. - const [lead, ...rest] = plan.ops; - if (graphqlRun !== null && graphqlRun.lateActors.length > 0 && lead !== undefined) { - plan.ops = [ - { - ...lead, - before: async (exec) => - (await resolveActorIds(ctx, exec, graphqlRun, graphqlRun.lateActors)).map( - () => undefined, - ), - }, - ...rest, - ]; - } - return ok(plan); - }); - }, - // The listing also names branches only a ruleset protects, whose classic protection probe answers - // 404: nothing to declare here. The engine notes the denial that 404 could also be when every - // listed branch answers it. The rules read is unconditional, unlike plan()'s: it is the only view - // of the wildcard rules and of the two routed keys, and it names which rule protects a branch. - async snapshot(ctx) { - const section = this; - return safeTry(async function* () { - const listed = yield* ctx.read.listProtected.listAll(LiveBranchSummary, { - query: { protected: "true" }, - }); - // Git refnames are exact, so the fold is the name itself. - yield* liveByIdentity( - section, - "protected branch", - listed, - (branch) => branch.name, - (branch) => liveIdentity(branch.name), - ); - const rules = yield* fetchRulesForSnapshot(ctx); - const entries: BranchConfig[] = []; - const notes: string[] = []; - for (const { name } of listed) { - const probe = yield* ctx.read.getProtection.probeAbsent(LiveProtection, { - params: { branch: name }, - describe: `branch "${name}"`, - }); - if ("missing" in probe) { - continue; - } - const rule = rules.get(name); - if (rule === undefined) { - // The effective protection of a branch only a wildcard rule matches: that rule is written - // below as the wildcard entry, and a literal entry here would create a second rule on apply. - continue; - } - entries.push({ - name, - protection: { ...protectionSnapshot(probe.data), ...routedKeysSnapshot(rule) }, - }); - } - const written = new Set(entries.map((entry) => entry.name)); - // Connection order, never sorted: GitHub applies overlapping wildcard rules in creation order, - // which the connection lists, and apply creates the entries in file order. - for (const [pattern, rule] of rules) { - if (isWildcardPattern(pattern)) { - entries.push({ name: pattern, protection: wildcardSnapshot(rule) }); - } else if (!written.has(pattern)) { - notes.push(unreachableLiteralRuleNote(pattern)); - } - } - if (entries.length === 0) { - return ok({ value: undefined, notes }); +type ProtectionProbe = { data: z.infer } | { missing: true }; + +/** One literal entry's PUT as planned against its probe. */ +interface ProtectionPut { + name: string; + prefix: string; + params: { branch: string }; + payload: Record; + declared: LiteralProtection; +} + +/** The declared signatures toggle beside what the PUT planning learned of the branch. */ +interface SignatureToggle { + name: string; + prefix: string; + params: { branch: string }; + required: boolean; + live: Record | null; + putPlanned: boolean; +} + +/** + * A literal-pattern rule the REST reads did not surface as a protected branch: no branch of that + * name exists (a pattern needs none), or its protection probe was denied. A literal entry applies + * through the REST PUT, which needs the branch, so the rule is noted instead of written. The + * pattern appears once and the text stays short, so the rendered header line ("# " prefixed) + * stays under 256 chars for patterns up to 54 chars. + */ +function unreachableLiteralRuleNote(pattern: string): string { + return ( + `branches[${pattern}]: rule kept out of the snapshot: REST read no protected branch by this ` + + "name (the branch is missing, or its protection is unreadable); create the branch or fix the " + + "grant, then snapshot again" + ); +} + +function wildcardKeyError(name: string, key: string): string { + return ( + `the wildcard entry "${name}" declares protection.${key}, which this section does not manage on wildcard rules; ` + + `only the keys it can round-trip through the GraphQL rule mutations apply here: [${WILDCARD_KEYS.join(", ")}]. ` + + "For actor lists and richer controls, prefer the rulesets section (the modern successor of classic protection)" + ); +} + +/** + * The keys a wildcard entry declares that the rule mutations cannot round-trip, each as its path + * steps under protection. The structured pairs translate NAMED sub-keys only, so an unknown sub-key + * would be silently lost; the schema already holds each pair to a mapping or null. + */ +function wildcardKeysOutsideVocabulary(protection: Record): string[][] { + const outside: string[][] = []; + for (const key of Object.keys(protection)) { + if (!WILDCARD_KEY_SET.has(key)) { + outside.push([key]); + } + } + const nested: Array<[string, Record]> = [ + ["required_status_checks", GRAPHQL_STATUS_CHECK_TWINS], + ["required_pull_request_reviews", GRAPHQL_REVIEW_TWINS], + ]; + for (const [key, twins] of nested) { + const value = protection[key]; + if (!isPlainObject(value)) { + continue; + } + for (const subKey of Object.keys(value)) { + if (!(subKey in twins)) { + outside.push([key, subKey]); } - return ok({ value: entries, notes }); - }); - }, -} satisfies SectionModule<"branches", typeof ENDPOINTS, typeof GRAPHQL>; + } + } + return outside; +} /** * The declared protection a live GET body reads back as: flattenProtection's PUT vocabulary with @@ -460,164 +337,6 @@ function startGraphqlRun(ctx: BranchesContext): Read { })); } -async function planLiteralEntry( - ctx: BranchesContext, - section: SectionMeta, - entry: Exclude, - plan: BranchesPlan, -): Promise> { - return safeTry(async function* () { - const { branch } = entry; - const params = { branch: branch.name }; - const prefix = `branches[${branch.name}].protection`; - const probe = yield* ctx.read.getProtection.probeAbsent(LiveProtection, { - params, - describe: `branch "${branch.name}"`, - }); - if (branch.protection === null) { - if ("missing" in probe) { - return ok(undefined); - } - plan.ops.push({ - role: "removeProtection", - params, - drift: [ - `branches[${branch.name}]: protected live but the settings file declares protection: null; apply will remove the protection`, - ], - change: `removed protection from "${branch.name}"`, - }); - return ok(undefined); - } - // GitHub's protection PUT silently DROPS required_signatures, and the other two routed keys have - // no REST field at all, so none of them may ride the REST payload. - const { - required_signatures: requiredSignatures, - force_push_bypassers: forcePushBypassers, - required_deployments: requiredDeployments, - ...payload - } = branch.protection; - // The classic API rejects payloads missing the core keys; null is a valid value for each. - for (const key of REQUIRED_PROTECTION_KEYS) { - if (!(key in payload)) { - payload[key] = null; - } - } - if (isPlainObject(payload.required_status_checks)) { - payload.required_status_checks = putStatusChecks(payload.required_status_checks); - } - let live: Record | null = null; - // GitHub does not document whether the PUT preserves the sub-resource and the GraphQL-only - // fields, so a planned PUT re-applies every declared one. - let putPlanned = false; - if ("missing" in probe) { - // Protection 404s for a missing BRANCH too; the advisory probe tells the two apart. A denied probe - // is a 404 "Not Found" as well (fine-grained tokens conceal denied reads), so only GitHub's own - // body counts and a denial keeps the plain unprotected reading. - const branchProbe = yield* ctx.read.branchProbe.tryCall(z.unknown(), { params }); - if ("error" in branchProbe && matchesRejection(MISSING_BRANCH, branchProbe.error)) { - // The same failure the PUT raises without the Contents grant, so the outcome does not depend on it. - return err( - sectionFailure( - "refused", - `${section.key}: branches[${branch.name}]: ${MISSING_BRANCH.advice}`, - ), - ); - } - noteUndocumentedKeys(plan, prefix, payload, {}); - plan.ops.push({ - role: "putProtection", - params, - payload, - describe: `replacing protection for branch "${branch.name}"`, - drift: [ - `branches[${branch.name}]: unprotected live but the settings file declares protection; apply will protect it`, - ], - change: `applied protection to "${branch.name}"`, - }); - putPlanned = true; - } else { - live = flattenProtection(probe.data); - // The protection GET OMITS required_signatures entirely when signed commits are not required, - // so an absent live field means false; normalized so declared false does not read as drift. - if (!("required_signatures" in live)) { - live.required_signatures = false; - } - const declaredRest: Record = { ...payload }; - for (const key of REQUIRED_PROTECTION_KEYS) { - if (!(key in branch.protection)) { - delete declaredRest[key]; - } - } - // Both sides compare in GitHub's spelling; the PUT payload keeps the file's. - const declaredView = foldActorNames(declaredRest); - const liveView = withEmptyReviewHolders(foldActorNames(live)); - noteUndocumentedKeys(plan, prefix, declaredView, liveView); - // The PUT replaces the whole protection, so live settings the declaration omits are REMOVED by - // it: drift, not silence. The signature toggle is the one live field the PUT never touches. - const { required_signatures: _liveSignatures, ...liveRest } = liveView; - const restDrift = [ - ...subsetDiff(declaredView, liveView, prefix), - ...omittedLiveDrift(declaredView, liveRest, prefix), - ]; - const drift = justified(restDrift); - if (drift !== null) { - plan.ops.push({ - role: "putProtection", - params, - payload, - describe: `replacing protection for branch "${branch.name}"`, - drift, - change: `applied protection to "${branch.name}"`, - }); - putPlanned = true; - } - } - // The toggle applies through its sub-endpoint once the PUT has ensured the protection (and with it - // the sub-resource) exists; an undeclared toggle leaves the live requirement alone. - if (requiredSignatures !== undefined) { - const sigDrift = subsetDiff( - { required_signatures: requiredSignatures }, - { required_signatures: live?.required_signatures ?? false }, - prefix, - ); - if (sigDrift.length === 0 && putPlanned) { - sigDrift.push( - `${prefix}.required_signatures: re-applied after the protection PUT (GitHub does not document whether the PUT preserves it)`, - ); - } - const drift = justified(sigDrift); - if (drift !== null) { - plan.ops.push( - requiredSignatures - ? { - role: "sigPost", - params, - describe: `requiring signed commits on branch "${branch.name}"`, - drift, - change: `required signed commits on "${branch.name}"`, - } - : { - role: "sigDelete", - params, - describe: `removing the signed-commit requirement from branch "${branch.name}"`, - drift, - change: `removed the signed-commit requirement from "${branch.name}"`, - }, - ); - } - } - if (entry.kind === "routed") { - planRoutedUpdate(ctx, entry.graphqlRun, plan, { - name: branch.name, - protection: entry.branch.protection, - prefix, - putPlanned, - }); - } - return ok(undefined); - }); -} - /** * GET /protection wraps booleans as {url, enabled} and expands actor lists into user/team/app * OBJECTS, while the PUT shape uses login/slug strings; both unwrap so check compares like with @@ -654,7 +373,6 @@ function putStatusChecks>(status: T): T { return { ...status, checks, contexts }; } -const ACTOR_NAME_KEYS = ["login", "slug"] as const; const ACTOR_LIST_KEYS = new Set(["users", "teams", "apps"]); /** @@ -674,9 +392,10 @@ function foldActorNames(protection: Record): Record = new Set(REVIEW_ACTOR_HOLDERS); /** @@ -716,22 +435,444 @@ function flattenValue(value: unknown): unknown { const out: Record = {}; for (const [key, inner] of Object.entries(record)) { if (ACTOR_LIST_KEYS.has(key) && Array.isArray(inner)) { - out[key] = inner.map((actor) => { - if (typeof actor === "object" && actor !== null) { - for (const nameKey of ACTOR_NAME_KEYS) { - const name = (actor as Record)[nameKey]; - if (typeof name === "string") { - return name; - } - } - } - return actor; - }); - } else if (isGetOnlyKey(key)) { + out[key] = inner.map((actor) => copiedActorName(actor) ?? actor); + continue; + } + if (isGetOnlyKey(key)) { // The GET-only vocabulary at any depth (keys.ts); the schema refuses the same keys in a declaration. - } else { - out[key] = flattenValue(inner); + continue; } + out[key] = flattenValue(inner); } return out; } + +/** + * The REST PUT's body: GitHub's PUT silently DROPS required_signatures and the other two routed + * keys have no REST field at all, so none of them rides it; the core keys are present (null when + * the file omits one) and the status checks are in the PUT's spelling. + */ +function restPayload(protection: LiteralProtection): Record { + const { + required_signatures: _signatures, + force_push_bypassers: _bypassers, + required_deployments: _deployments, + ...payload + } = protection; + for (const key of REQUIRED_PROTECTION_KEYS) { + if (!(key in payload)) { + payload[key] = null; + } + } + if (isPlainObject(payload.required_status_checks)) { + payload.required_status_checks = putStatusChecks(payload.required_status_checks); + } + return payload; +} + +/** + * The drift a replacing PUT resolves: the declared values that differ from the live ones, and the + * live settings the declaration omits, which the PUT would remove. The undocumented-key note lands + * here too. + */ +function replacingPutDrift( + plan: BranchesPlan, + put: ProtectionPut, + live: Record, +): readonly [string, ...string[]] | null { + const declaredRest: Record = { ...put.payload }; + for (const key of REQUIRED_PROTECTION_KEYS) { + if (!(key in put.declared)) { + delete declaredRest[key]; + } + } + // Both sides compare in GitHub's spelling; the PUT payload keeps the file's. + const declaredView = foldActorNames(declaredRest); + const liveView = withEmptyReviewHolders(foldActorNames(live)); + noteUndocumentedKeys(plan, put.prefix, declaredView, liveView); + // The PUT replaces the whole protection, so live settings the declaration omits are REMOVED by + // it: drift, not silence. The signature toggle is the one live field the PUT never touches. + const { required_signatures: _liveSignatures, ...liveRest } = liveView; + return justified([ + ...subsetDiff(declaredView, liveView, put.prefix), + ...omittedLiveDrift(declaredView, liveRest, put.prefix), + ]); +} + +/** + * The protection PUT the probe calls for, if any: the first PUT on an unprotected branch (a missing + * branch is refused first), or the replacing PUT when the live protection drifts. Answers the live + * view the signature toggle compares against, null when unprotected, and whether a PUT was planned. + */ +async function planProtectionPut( + ctx: BranchesContext, + section: SectionMeta, + plan: BranchesPlan, + put: ProtectionPut, + probe: ProtectionProbe, +): Promise | null; putPlanned: boolean }, SectionFailure>> { + return safeTry(async function* () { + const { name, prefix, params, payload } = put; + if ("missing" in probe) { + // Protection 404s for a missing BRANCH too; the advisory probe tells the two apart. A denied probe + // is a 404 "Not Found" as well (fine-grained tokens conceal denied reads), so only GitHub's own + // body counts and a denial keeps the plain unprotected reading. + const branchProbe = yield* ctx.read.branchProbe.tryCall(z.unknown(), { params }); + if ("error" in branchProbe && matchesRejection(MISSING_BRANCH, branchProbe.error)) { + // The same failure the PUT raises without the Contents grant, so the outcome does not depend on it. + return err( + sectionFailure("refused", `${section.key}: branches[${name}]: ${MISSING_BRANCH.advice}`), + ); + } + noteUndocumentedKeys(plan, prefix, payload, {}); + plan.ops.push({ + role: "putProtection", + params, + payload, + describe: `replacing protection for branch "${name}"`, + drift: [ + `branches[${name}]: unprotected live but the settings file declares protection; apply will protect it`, + ], + change: `applied protection to "${name}"`, + }); + return ok({ live: null, putPlanned: true }); + } + const live = flattenProtection(probe.data); + // The protection GET OMITS required_signatures entirely when signed commits are not required, + // so an absent live field means false; normalized so declared false does not read as drift. + if (!("required_signatures" in live)) { + live.required_signatures = false; + } + const drift = replacingPutDrift(plan, put, live); + if (drift === null) { + return ok({ live, putPlanned: false }); + } + plan.ops.push({ + role: "putProtection", + params, + payload, + describe: `replacing protection for branch "${name}"`, + drift, + change: `applied protection to "${name}"`, + }); + return ok({ live, putPlanned: true }); + }); +} + +/** + * The toggle applies through its sub-endpoint once the PUT has ensured the protection (and with it + * the sub-resource) exists, and again after a planned PUT, since GitHub does not document whether + * the PUT preserves it. + */ +function planSignatureToggle(plan: BranchesPlan, toggle: SignatureToggle): void { + const { name, prefix, params, required, live, putPlanned } = toggle; + const sigDrift = subsetDiff( + { required_signatures: required }, + { required_signatures: live?.required_signatures ?? false }, + prefix, + ); + if (sigDrift.length === 0 && putPlanned) { + sigDrift.push( + `${prefix}.required_signatures: re-applied after the protection PUT (GitHub does not document whether the PUT preserves it)`, + ); + } + const drift = justified(sigDrift); + if (drift === null) { + return; + } + if (required) { + plan.ops.push({ + role: "sigPost", + params, + describe: `requiring signed commits on branch "${name}"`, + drift, + change: `required signed commits on "${name}"`, + }); + return; + } + plan.ops.push({ + role: "sigDelete", + params, + describe: `removing the signed-commit requirement from branch "${name}"`, + drift, + change: `removed the signed-commit requirement from "${name}"`, + }); +} + +/** A literal entry's operations in order: the PUT (or the removal), the signatures toggle, the routed update. */ +async function planLiteralEntry( + ctx: BranchesContext, + section: SectionMeta, + entry: Exclude, + plan: BranchesPlan, +): Promise> { + return safeTry(async function* () { + const { branch } = entry; + const params = { branch: branch.name }; + const prefix = `branches[${branch.name}].protection`; + const probe = yield* ctx.read.getProtection.probeAbsent(LiveProtection, { + params, + describe: `branch "${branch.name}"`, + }); + if (branch.protection === null) { + if ("missing" in probe) { + return ok(undefined); + } + plan.ops.push({ + role: "removeProtection", + params, + drift: [ + `branches[${branch.name}]: protected live but the settings file declares protection: null; apply will remove the protection`, + ], + change: `removed protection from "${branch.name}"`, + }); + return ok(undefined); + } + const put: ProtectionPut = { + name: branch.name, + prefix, + params, + payload: restPayload(branch.protection), + declared: branch.protection, + }; + const { live, putPlanned } = yield* await planProtectionPut(ctx, section, plan, put, probe); + const requiredSignatures = branch.protection.required_signatures; + // An undeclared toggle leaves the live requirement alone. + if (requiredSignatures !== undefined) { + planSignatureToggle(plan, { + name: branch.name, + prefix, + params, + required: requiredSignatures, + live, + putPlanned, + }); + } + if (entry.kind === "routed") { + planRoutedUpdate(ctx, entry.graphqlRun, plan, { + name: branch.name, + protection: entry.branch.protection, + prefix, + putPlanned, + }); + } + return ok(undefined); + }); +} + +/** + * The entries by the surface they apply through. The first entry that needs the GraphQL surface + * starts the one rules read, ahead of every REST probe; a pure-REST declaration never starts it, + * so no separate predicate gates the fetch. + */ +async function classifyEntries( + ctx: BranchesContext, + branches: readonly PlainTyped[], +): Promise> { + return safeTry(async function* () { + let graphqlRun: GraphqlRun | null = null; + const entries: ClassifiedEntry[] = []; + for (const branch of branches) { + const protection: PlainTyped | null = branch.protection; + if (isWildcardPattern(branch.name)) { + graphqlRun ??= yield* startGraphqlRun(ctx); + entries.push({ kind: "wildcard", branch, graphqlRun }); + } else if (hasRoutedGraphqlKeys(protection)) { + graphqlRun ??= yield* startGraphqlRun(ctx); + entries.push({ kind: "routed", branch: { name: branch.name, protection }, graphqlRun }); + } else { + entries.push({ kind: "literal", branch: { name: branch.name, protection } }); + } + } + return ok({ entries, graphqlRun }); + }); +} + +/** A note per live wildcard rule the file does not declare: this action never deletes undeclared rules. */ +function undeclaredRuleNotes( + graphqlRun: GraphqlRun, + branches: readonly { name: string }[], +): string[] { + const declaredPatterns = new Set(branches.map((branch) => branch.name)); + return [...(graphqlRun.rules?.keys() ?? [])] + .sort() + .filter((pattern) => isWildcardPattern(pattern) && !declaredPatterns.has(pattern)) + .map( + (pattern) => + `undeclared classic protection rule "${pattern}" exists on the repo - declare it to manage it (this action never deletes undeclared rules)`, + ); +} + +/** + * Every actor a planned mutation resolves at execution resolves ahead of the plan's FIRST write, + * whichever entry it belongs to: a misspelled actor fails while every branch's live protection + * is still untouched, and the mutations' thunks then find the ids cached. + */ +function withActorsResolvedFirst( + ctx: BranchesContext, + graphqlRun: GraphqlRun, + ops: BranchesPlan["ops"], +): BranchesPlan["ops"] { + const [lead, ...rest] = ops; + if (graphqlRun.lateActors.length === 0 || lead === undefined) { + return ops; + } + return [ + { + ...lead, + before: async (exec) => + (await resolveActorIds(ctx, exec, graphqlRun, graphqlRun.lateActors)).map(() => undefined), + }, + ...rest, + ]; +} + +/** + * A literal entry per listed branch whose protection probe answers and whose own rule the rules + * read carries. A branch only a wildcard rule matches is left to that rule's entry, since a + * literal entry here would create a second rule on apply. + */ +async function literalSnapshotEntries( + ctx: BranchesContext, + listed: readonly { name: string }[], + rules: ReadonlyMap, +): Promise> { + return safeTry(async function* () { + const entries: BranchConfig[] = []; + for (const { name } of listed) { + const probe = yield* ctx.read.getProtection.probeAbsent(LiveProtection, { + params: { branch: name }, + describe: `branch "${name}"`, + }); + if ("missing" in probe) { + continue; + } + const rule = rules.get(name); + if (rule === undefined) { + continue; + } + entries.push({ + name, + protection: { ...protectionSnapshot(probe.data), ...routedKeysSnapshot(rule) }, + }); + } + return ok(entries); + }); +} + +/** + * The wildcard rules as entries in connection order, never sorted (GitHub applies overlapping + * wildcard rules in creation order, which the connection lists, and apply creates the entries in + * file order), and a note per literal rule no written entry covers. + */ +function wildcardSnapshotEntries( + rules: ReadonlyMap, + written: ReadonlySet, +): { entries: BranchConfig[]; notes: string[] } { + const entries: BranchConfig[] = []; + const notes: string[] = []; + for (const [pattern, rule] of rules) { + if (isWildcardPattern(pattern)) { + entries.push({ name: pattern, protection: wildcardSnapshot(rule) }); + } else if (!written.has(pattern)) { + notes.push(unreachableLiteralRuleNote(pattern)); + } + } + return { entries, notes }; +} + +export const branchesSection = { + // Branch names and patterns are verbatim keys; two entries for one would overwrite each other's write on every run. + ...identifiedBy("branches", "name", "branch"), + undeclaredDefault: "untouched", + permission, + endpoints: ENDPOINTS, + graphql: GRAPHQL, + // The wildcard key sweep composes HERE, not in schema.ts: it reads the GraphQL translation tables, + // which are this section's own machinery, and nothing outside them can reach a wildcard rule. + shape: routed(layeredList(BranchesConfig)).check( + rule((declared, refineCtx) => { + // The routed shape parsed one of the two forms; under the wrapper an issue's path starts at `entries`. + const wrapped = !Array.isArray(declared); + const entries = listEntries(declared as BranchConfig[] | { entries: BranchConfig[] }); + const at = (index: number, ...rest: string[]): (string | number)[] => [ + ...(wrapped ? ["entries"] : []), + index, + ...rest, + ]; + entries.forEach((entry: BranchConfig, index) => { + // The entry, its name, or its protection may be raw or missing beside its own shape issue; a regex would + // coerce the name (`[object Object]` wildcards), and a non-mapping protection holds no keys to sweep. + if ( + !isPlainObject(entry) || + typeof entry.name !== "string" || + !isWildcardPattern(entry.name) + ) { + return; + } + const protection = entry.protection; + if (!isPlainObject(protection)) { + return; + } + for (const steps of wildcardKeysOutsideVocabulary(protection)) { + refineCtx.addIssue({ + code: "custom", + path: at(index, "protection", ...steps), + message: wildcardKeyError(entry.name, steps.join(".")), + }); + } + }); + }), + ), + async plan(ctx, desired): Promise> { + const section = this; + return safeTry(async function* () { + const branches = listEntries(desired); + const plan: BranchesPlan = { ops: [], notes: [], drift: [] }; + const { entries, graphqlRun } = yield* await classifyEntries(ctx, branches); + if (graphqlRun !== null) { + plan.notes.push(...undeclaredRuleNotes(graphqlRun, branches)); + } + for (const entry of entries) { + if (entry.kind === "wildcard") { + await planWildcardEntry(ctx, entry.graphqlRun, entry.branch, plan); + continue; + } + yield* await planLiteralEntry(ctx, section, entry, plan); + } + if (graphqlRun !== null) { + plan.ops = withActorsResolvedFirst(ctx, graphqlRun, plan.ops); + } + return ok(plan); + }); + }, + // The listing also names branches only a ruleset protects, whose classic protection probe answers + // 404: nothing to declare here. The engine notes the denial that 404 could also be when every + // listed branch answers it. The rules read is unconditional, unlike plan()'s: it is the only view + // of the wildcard rules and of the two routed keys, and it names which rule protects a branch. + async snapshot(ctx) { + const section = this; + return safeTry(async function* () { + const listed = yield* ctx.read.listProtected.listAll(LiveBranchSummary, { + query: { protected: "true" }, + }); + // Git refnames are exact, so the fold is the name itself. + yield* liveByIdentity( + section, + "protected branch", + listed, + (branch) => branch.name, + (branch) => liveIdentity(branch.name), + ); + const rules = yield* fetchRulesForSnapshot(ctx); + const literal = yield* await literalSnapshotEntries(ctx, listed, rules); + const written = new Set(literal.map((entry) => entry.name)); + const wildcard = wildcardSnapshotEntries(rules, written); + const entries = [...literal, ...wildcard.entries]; + if (entries.length === 0) { + return ok({ value: undefined, notes: wildcard.notes }); + } + return ok({ value: entries, notes: wildcard.notes }); + }); + }, +} satisfies SectionModule<"branches", typeof ENDPOINTS, typeof GRAPHQL>; diff --git a/src/sections/branches/keys.ts b/src/sections/branches/keys.ts index 87c006a48..f5fc40346 100644 --- a/src/sections/branches/keys.ts +++ b/src/sections/branches/keys.ts @@ -4,16 +4,10 @@ * would otherwise never read back equal and drift forever, or 422 where the PUT expects a boolean. */ +/** The echoes by name; the links (url, *_url) match by suffix in isUrlKey. */ export const GET_ONLY_KEYS: ReadonlySet = new Set(["name", "enabled", "enforcement_level"]); -export const isUrlKey = (key: string): boolean => key === "url" || key.endsWith("_url"); - -export const isGetOnlyKey = (key: string): boolean => GET_ONLY_KEYS.has(key) || isUrlKey(key); - -/** - * The controls the PUT takes as a bare boolean and the GET wraps as {url, enabled}; - * graphql-vocabulary.ts pins its twin table to this list, so a control added there is added here first. - */ +// The controls the PUT takes as a bare boolean and the GET wraps as {url, enabled}; graphql-vocabulary.ts pins its twin table to this list, so a control added there is added here first. const BOOLEAN_CONTROLS = [ "enforce_admins", "required_linear_history", @@ -25,14 +19,22 @@ const BOOLEAN_CONTROLS = [ "allow_fork_syncing", "required_signatures", ] as const; -export type BooleanControl = (typeof BOOLEAN_CONTROLS)[number]; + +/** The string-keyed view for the walks whose keys are plain strings. */ export const BOOLEAN_CONTROL_SET: ReadonlySet = new Set(BOOLEAN_CONTROLS); -/** - * The controls whose PUT schema marks the boolean nullable, null being the "off" spelling; the PUT - * 422s null under any other control. - */ +/** The controls whose PUT schema marks the boolean nullable, null being the "off" spelling; the PUT 422s null under any other control. */ export const NULLABLE_CONTROLS: ReadonlySet = new Set([ "enforce_admins", "allow_force_pushes", ]); + +export type BooleanControl = (typeof BOOLEAN_CONTROLS)[number]; + +export function isUrlKey(key: string): boolean { + return key === "url" || key.endsWith("_url"); +} + +export function isGetOnlyKey(key: string): boolean { + return GET_ONLY_KEYS.has(key) || isUrlKey(key); +} diff --git a/src/sections/branches/schema.ts b/src/sections/branches/schema.ts index 1c2b8697b..1b0590433 100644 --- a/src/sections/branches/schema.ts +++ b/src/sections/branches/schema.ts @@ -6,8 +6,6 @@ import { stringItems } from "../shared/raw-values.js"; import { open, rule } from "../shared/schema-helpers.js"; import { BOOLEAN_CONTROL_SET, isGetOnlyKey, isUrlKey } from "./keys.js"; -// --- Actor vocabulary (branches force_push_bypassers) ------------------------ - export type BypassActor = | { kind: "user"; login: string } | { kind: "team"; org: string; team: string } @@ -48,8 +46,6 @@ function duplicateIn(list: unknown): string | null { return null; } -// --- Actor holders: restrictions, dismissal_restrictions, bypass_pull_request_allowances -------- - const ACTOR_LIST_EXAMPLE = { users: { nameKey: "login", example: "octocat" }, teams: { nameKey: "slug", example: "platform-team" }, @@ -57,7 +53,8 @@ const ACTOR_LIST_EXAMPLE = { } as const; type ActorList = keyof typeof ACTOR_LIST_EXAMPLE; -function copiedActorName(item: unknown): string | null { +/** The login or slug of a GET actor object, or null for anything else. */ +export function copiedActorName(item: unknown): string | null { if (!isPlainObject(item)) { return null; } @@ -117,12 +114,10 @@ function reviewActorHolder(holder: string) { ); } -/** - * The PUT requires users and teams under restrictions (apps stays optional), so a mapping missing - * either would 422 at apply and is refused at parse instead. - */ const Restrictions = z.looseObject( { + // The PUT requires users and teams here (apps stays optional), so a mapping missing either + // would 422 at apply and is refused at parse instead. users: actorList("restrictions", "users"), teams: actorList("restrictions", "teams"), apps: actorList("restrictions", "apps").optional(), @@ -130,8 +125,6 @@ const Restrictions = z.looseObject( { error: holderError("restrictions") }, ); -// --- Controls whose wrong shapes the settings file alone reveals --------------- - const STRICT_ERROR = "required_status_checks.strict must be an unquoted true or false (GitHub's protection PUT rejects the requirement without it): true also requires the branch to be up to date with its base before merging, false only requires the checks to pass"; @@ -159,13 +152,10 @@ function checkItemKeyError(issue: z.core.$ZodRawIssue): string | undefined { return `a required_status_checks.checks item takes only context and app_id (GitHub's protection PUT has no other field there); remove ${keys}`; } -/** - * One required check, GitHub's PUT vocabulary exactly: the check's name and the App that must report - * it (`null` reads back from GitHub as "any App" and is sent as -1, see index.ts putStatusChecks). - */ const RequiredStatusCheck = z.strictObject( { context: z.string({ error: CHECK_CONTEXT_ERROR }), + // null reads back from GitHub as "any App" and is sent as -1 (index.ts putStatusChecks). app_id: z.int({ error: CHECK_APP_ID_ERROR }).nullable().optional(), }, { error: checkItemKeyError }, @@ -190,9 +180,11 @@ const RequiredStatusChecks = z } }), ) - // The refinement's published-schema twin: zod refinements do not reach z.toJSONSchema, and - // test/published-schema.test.ts holds the two sides to the same verdicts. - .meta({ anyOf: [{ required: ["contexts"] }, { required: ["checks"] }] }); + .meta({ + // The refinement's published-schema twin: zod refinements do not reach z.toJSONSchema, and + // test/published-schema.test.ts holds the two sides to the same verdicts. + anyOf: [{ required: ["contexts"] }, { required: ["checks"] }], + }); const RequiredPullRequestReviews = z.looseObject( { @@ -214,7 +206,6 @@ const RequiredPullRequestReviews = z.looseObject( }, ); -/** What carries the fact a GET-only echo repeats, so the message can say why removing it loses nothing. */ const ECHO_CARRIER: Readonly> = { name: "the entry's name already names the branch", enabled: "the control's own key carries the toggle", @@ -281,11 +272,7 @@ function refuseGetOnlyKeys( ancestors.delete(value); } -/** - * The keys the schema declares under each open protection mapping, by the mapping's dotted path. - * index.ts completes the PUT vocabulary with the controls that pass through (the boolean controls, - * the review booleans) and notes a declared key outside it that the GET never echoes. - */ +/** The schema's keys under each open protection mapping, by dotted path; index.ts adds the pass-through controls to complete the PUT vocabulary and notes a declared key outside it that the GET never echoes. */ export const PROTECTION_MAPPING_KEYS = { required_status_checks: Object.keys(RequiredStatusChecks.shape), required_pull_request_reviews: Object.keys(RequiredPullRequestReviews.shape), @@ -294,6 +281,7 @@ export const PROTECTION_MAPPING_KEYS = { restrictions: Object.keys(Restrictions.shape), } as const satisfies Readonly>; +/** The protection mapping; the GET-only sweep runs at every depth, since the wrappers sit under each control and the links under the actor holders too. */ export const BranchProtectionConfig = z .looseObject({ required_status_checks: RequiredStatusChecks.nullable().optional(), @@ -321,13 +309,13 @@ export const BranchProtectionConfig = z .nullable() .optional(), }) - // Every depth: the wrappers sit under each control, and the links under the actor holders too. .check( rule((protection, refineCtx) => { refuseGetOnlyKeys(protection, [], refineCtx); }), ) .meta({ id: "BranchProtectionConfig" }); + export type BranchProtectionConfig = z.infer; export const BranchConfig = open({ diff --git a/src/sections/check_suite_preferences/schema.ts b/src/sections/check_suite_preferences/schema.ts index 4b4e6fbfc..df1a7971f 100644 --- a/src/sections/check_suite_preferences/schema.ts +++ b/src/sections/check_suite_preferences/schema.ts @@ -4,8 +4,6 @@ import { z } from "zod"; import { isPlainObject } from "../../plain-data.js"; import { open, rule } from "../shared/schema-helpers.js"; -// No GitHub App has id 0, and GitHub rejects fractions. Parse refuses the id here; otherwise the PATCH reports whatever -// GitHub answers, late and on every run. The duplicate below is the case nothing would ever report. const APP_ID_RULE = "a GitHub App id is a positive integer (the App's settings page shows it); GitHub has no app 0 and rejects fractions"; const AppId = z.int(APP_ID_RULE).positive(APP_ID_RULE); diff --git a/src/sections/code_quality_setup/schema.ts b/src/sections/code_quality_setup/schema.ts index 221032d5b..cf6ec4372 100644 --- a/src/sections/code_quality_setup/schema.ts +++ b/src/sections/code_quality_setup/schema.ts @@ -21,6 +21,7 @@ export const CODE_QUALITY_LANGUAGES = { declarable: ["csharp", "go", "java-kotlin", "javascript-typescript", "python", "ruby"], getOnly: { rust: null }, } as const satisfies SetupLanguages; + type _VocabularyIsTheVendoredSpec = MustBeNever< VocabularyDrift< typeof CODE_QUALITY_LANGUAGES, diff --git a/src/sections/code_scanning_default_setup/schema.ts b/src/sections/code_scanning_default_setup/schema.ts index 1bd562072..b64743b87 100644 --- a/src/sections/code_scanning_default_setup/schema.ts +++ b/src/sections/code_scanning_default_setup/schema.ts @@ -32,6 +32,7 @@ export const CODE_SCANNING_LANGUAGES = { ], getOnly: { javascript: "javascript-typescript", typescript: "javascript-typescript" }, } as const satisfies SetupLanguages; + type _VocabularyIsTheVendoredSpec = MustBeNever< VocabularyDrift< typeof CODE_SCANNING_LANGUAGES, diff --git a/src/sections/collaborators/index.ts b/src/sections/collaborators/index.ts index a13b53f5c..f8384fab5 100644 --- a/src/sections/collaborators/index.ts +++ b/src/sections/collaborators/index.ts @@ -4,9 +4,10 @@ * collaborator list and the pending invitations), each with its own writes. */ -import { err, ok, safeTry } from "neverthrow"; +import { err, ok, type Result, safeTry } from "neverthrow"; import { z } from "zod"; import { INVITATION_ROLES } from "../../generated/spec-roles.js"; +import type { UndeclaredPolicy } from "../../types.js"; import { undeclaredDrift, undeclaredNote, @@ -14,7 +15,7 @@ import { valueDrift, } from "../contract/drift.js"; import type { EndpointDecl } from "../contract/endpoints.js"; -import { sectionFailure } from "../contract/errors.js"; +import { type SectionFailure, sectionFailure } from "../contract/errors.js"; import { identifiedBy } from "../contract/keyed-list.js"; import { liveByIdentity, liveIdentity } from "../contract/live.js"; import { @@ -36,11 +37,11 @@ const LiveCollaborator = z.looseObject({ }); type LiveCollaborator = z.infer; -// `permissions` speaks the READ vocabulary (read/write/...) that roleForPermission maps declared -// permissions into; `invitee` is null on email invitations. const LiveInvitation = z.looseObject({ id: z.number(), + // null on an email invitation invitee: z.looseObject({ login: z.string().optional() }).nullable().optional(), + // The READ vocabulary (read/write/...), which roleForPermission maps declared permissions into. permissions: z.string().optional(), expired: z.boolean().optional(), }); @@ -52,8 +53,9 @@ function isNamedInvitation(invitation: LiveInvitation): invitation is NamedInvit return typeof invitation.invitee?.login === "string" && invitation.invitee.login !== ""; } -/** GitHub matches logins case-insensitively; the brand marks a login already folded to the key every lookup reads. */ declare const collaboratorLoginKey: unique symbol; + +/** GitHub matches logins case-insensitively; the brand marks a login already folded to the key every lookup reads. */ type LoginKey = string & { readonly [collaboratorLoginKey]: true }; function loginKey(login: string): LoginKey { @@ -91,18 +93,33 @@ const ENDPOINTS = { } as const satisfies Record; type CollaboratorsContext = PlanContext; +type CollaboratorsPlan = SectionPlan>; -/** Both pools in one read, each indexed under the guard, so plan() and snapshot() see the same live access. */ -function readLiveAccess( - ctx: CollaboratorsContext, - section: SectionMeta, -): Read<{ +/** Both live pools, indexed; the email invitations (a null invitee, which no username can declare) are a pool of their own. */ +interface LiveAccess { collaborators: LiveCollaborator[]; liveByLogin: Map; invitations: NamedInvitation[]; inviteByLogin: Map; emailInvitations: LiveInvitation[]; -}> { +} + +/** A declared entry as the writes spell it: the permission the PUT takes, and the role GitHub reads it back as. */ +interface Wanted { + readonly username: string; + readonly permission: string; + readonly role: string; +} + +/** What the undeclared walks share: who is declared, and what the policy says to do with everyone else. */ +interface Undeclared { + readonly declaredKeys: ReadonlySet; + readonly policy: UndeclaredPolicy; + readonly listDefault: UndeclaredPolicy; +} + +/** Both pools in one read, each indexed under the guard, so plan() and snapshot() see the same live access. */ +function readLiveAccess(ctx: CollaboratorsContext, section: SectionMeta): Read { return safeTry(async function* () { const collaborators = yield* ctx.read.list.listAll(LiveCollaborator, { query: { affiliation: "direct" }, @@ -137,6 +154,296 @@ function isOwner(ctx: CollaboratorsContext, login: LoginKey): boolean { return login === loginKey(ctx.repo.owner); } +/** The update for a declared entry already a collaborator, when its live role differs. */ +function settleCollaborator( + wanted: Wanted, + existing: LiveCollaborator, + plan: CollaboratorsPlan, +): void { + if ((existing.role_name ?? "") === wanted.role) { + return; + } + + plan.ops.push({ + role: "update", + params: { username: wanted.username }, + payload: { permission: wanted.permission }, + describe: `updating collaborator "${wanted.username}"`, + drift: [ + valueDrift( + `collaborators[${wanted.username}]`, + JSON.stringify(wanted.role), + JSON.stringify(existing.role_name), + { remedy: "apply will set the declared permission" }, + ), + ], + change: `updated collaborator "${wanted.username}" (${wanted.permission})`, + }); +} + +/** The op or note for a declared entry whose live state is an unexpired pending invitation. */ +function settlePendingInvitation( + wanted: Wanted, + invitation: NamedInvitation, + plan: CollaboratorsPlan, +): void { + const { username } = wanted; + if ((invitation.permissions ?? "") === wanted.role) { + return; + } + + if (!INVITATION_ROLES.has(wanted.role)) { + // A custom role never appears on an invitation, and the PATCH's enum (src/generated/spec-roles.ts) can lag + // the GET's, so neither can be set on a pending one; the PUT applies it once the invitation is accepted. + const settable = [...INVITATION_ROLES].join(", "); + plan.notes.push( + `invitation for "${username}" is pending; the invitation PATCH accepts only ${settable}, ` + + `so "${wanted.permission}" cannot be set on it - left untouched, the next apply grants it once the invitation is accepted`, + ); + return; + } + + // The invitation PATCH speaks the READ vocabulary, so it takes the mapped role, not the declared permission. + plan.ops.push({ + role: "updateInvitation", + params: { invitation_id: String(invitation.id) }, + payload: { permissions: wanted.role }, + describe: `updating the pending invitation for "${username}"`, + drift: [ + valueDrift( + `collaborators[${username}] (pending invitation)`, + JSON.stringify(wanted.role), + JSON.stringify(invitation.permissions), + { remedy: "apply will update the invitation" }, + ), + ], + change: `updated pending invitation for "${username}" (${wanted.permission})`, + }); +} + +/** The invitation for a declared entry with no live access, an expired invitation cancelled first. */ +function inviteDeclared( + wanted: Wanted, + expired: NamedInvitation | undefined, + plan: CollaboratorsPlan, +): void { + const { username } = wanted; + const label = `collaborators[${username}]`; + if (expired) { + // An expired invitation cannot be revived by a PATCH: cancel it, and the PUT below mints a fresh one. + plan.ops.push({ + role: "cancelInvitation", + params: { invitation_id: String(expired.id) }, + describe: `cancelling the expired invitation for "${username}"`, + drift: [ + `${label}: pending invitation expired; apply will cancel it and send a fresh invitation with "${wanted.permission}"`, + ], + change: `cancelled the expired invitation for "${username}"`, + }); + } + + plan.ops.push({ + role: "update", + params: { username }, + payload: { permission: wanted.permission }, + describe: `inviting collaborator "${username}"`, + drift: [ + `${label}: missing - not a collaborator on the repo; apply will send an invitation with "${wanted.permission}"`, + ], + change: expired + ? `re-invited collaborator "${username}" (${wanted.permission}) - the pending invitation had expired` + : `invited collaborator "${username}" (${wanted.permission})`, + }); +} + +/** One declared entry settled against the collaborator it already is, else its pending invitation, else invited. */ +function settleDeclared( + collaborator: CollaboratorConfig, + access: LiveAccess, + plan: CollaboratorsPlan, +): void { + const permission = collaborator.permission ?? DEFAULT_ROLE; + const wanted: Wanted = { + username: collaborator.username, + permission, + role: roleForPermission(permission), + }; + const login = loginKey(collaborator.username); + + const existing = access.liveByLogin.get(login); + if (existing) { + // On GitHub a user is never a collaborator AND an invitee at once, so this branch settles the entry. + settleCollaborator(wanted, existing, plan); + return; + } + + const invitation = access.inviteByLogin.get(login); + if (invitation && invitation.expired !== true) { + settlePendingInvitation(wanted, invitation, plan); + return; + } + + inviteDeclared(wanted, invitation, plan); +} + +/** A removal per live collaborator neither the owner nor declared, or a note each under the keep policy. */ +function planUndeclaredCollaborators( + ctx: CollaboratorsContext, + live: readonly LiveCollaborator[], + undeclared: Undeclared, + plan: CollaboratorsPlan, +): void { + for (const collaborator of live) { + const login = loginKey(collaborator.login); + if (isOwner(ctx, login) || undeclared.declaredKeys.has(login)) { + continue; + } + + if (undeclared.policy === "keep") { + plan.notes.push( + undeclaredNote({ + subject: `collaborator "${collaborator.login}"`, + state: "has access but is not declared", + add: "them", + manage: "their access", + action: "REMOVE them", + }), + ); + continue; + } + + plan.ops.push({ + role: "remove", + params: { username: collaborator.login }, + drift: [ + undeclaredDrift(undeclared.listDefault, { + label: `collaborators[${collaborator.login}]`, + action: "REMOVE them", + add: "them", + keep: "their access", + }), + ], + change: `REMOVED undeclared collaborator "${collaborator.login}"`, + }); + } +} + +/** A cancellation per pending invitation nobody declared, or a note each under the keep policy. */ +function planUndeclaredInvitations( + invitations: readonly NamedInvitation[], + undeclared: Undeclared, + plan: CollaboratorsPlan, +): void { + for (const invitation of invitations) { + const invitee = invitation.invitee.login; + if (undeclared.declaredKeys.has(loginKey(invitee))) { + continue; + } + + if (undeclared.policy === "keep") { + plan.notes.push( + undeclaredNote({ + subject: `invitation for "${invitee}"`, + state: "is pending but not declared", + add: "them", + manage: "their access", + action: "CANCEL the invitation", + }), + ); + continue; + } + + plan.ops.push({ + role: "cancelInvitation", + params: { invitation_id: String(invitation.id) }, + drift: [ + undeclaredDrift(undeclared.listDefault, { + label: `collaborators[${invitee}]`, + state: "a pending invitation not in the settings file", + action: "CANCEL it", + add: "them", + keep: "the invitation", + }), + ], + change: `CANCELLED undeclared invitation for "${invitee}"`, + }); + } +} + +/** The snapshot entries of the direct collaborators; the owner is left out with a note, and a role GitHub does not report fails. */ +function collaboratorEntries( + ctx: CollaboratorsContext, + section: SectionMeta, + collaborators: readonly LiveCollaborator[], + notes: string[], +): Result { + return safeTry(function* () { + const entries: CollaboratorConfig[] = []; + for (const collaborator of collaborators) { + const label = `collaborators[${collaborator.login}]`; + if (isOwner(ctx, loginKey(collaborator.login))) { + notes.push( + leftOutOfSnapshot(label, "the repository owner's access is implicit and never managed"), + ); + continue; + } + + if (collaborator.role_name === undefined) { + return err( + sectionFailure( + "live-shape", + `${label}: GitHub reported no role_name for this collaborator, so their permission cannot be read back`, + ), + ); + } + + // The section deletes undeclared access, so readBackPermission fails rather than notes here. + const permission = yield* readBackPermission(section, label, collaborator.role_name, notes); + if (permission !== undefined) { + entries.push({ username: collaborator.login, permission }); + } + } + + return ok(entries); + }); +} + +/** The snapshot entries of the pending invitations, the expired ones set aside by label; a missing permission fails. */ +function invitationEntries( + section: SectionMeta, + invitations: readonly NamedInvitation[], + notes: string[], +): Result<{ entries: CollaboratorConfig[]; expired: string[] }, SectionFailure> { + return safeTry(function* () { + const entries: CollaboratorConfig[] = []; + const expired: string[] = []; + for (const invitation of invitations) { + const login = invitation.invitee.login; + const label = `collaborators[${login}]`; + if (invitation.expired === true) { + expired.push(label); + continue; + } + + if (invitation.permissions === undefined) { + return err( + sectionFailure( + "live-shape", + `${label}: GitHub reported no permissions on the pending invitation, so it cannot be read back`, + ), + ); + } + + const permission = yield* readBackPermission(section, label, invitation.permissions, notes); + if (permission !== undefined) { + entries.push({ username: login, permission }); + } + } + + return ok({ entries, expired }); + }); +} + export const collaboratorsSection = { ...identifiedBy("collaborators", "username", "collaborator", { fold: loginKey }), undeclaredDefault: "delete", @@ -149,173 +456,29 @@ export const collaboratorsSection = { consequence: `a misspelled "permission" key would silently grant the default "${DEFAULT_ROLE}" role instead of the intended one`, }, async plan(ctx, declared) { - const { policy, entries: desired } = undeclaredPolicy(declared, defaultUndeclaredPolicy(this)); + const listDefault = defaultUndeclaredPolicy(this); + const { policy, entries: desired } = undeclaredPolicy(declared, listDefault); // Both pools are resolved BEFORE the declared walk, so a declared user is never mistaken for - // undeclared in the other pool; email invitations (null invitee, which no username can declare) - // split into their own pool. - return readLiveAccess(ctx, this).map( - ({ collaborators: live, liveByLogin, invitations, inviteByLogin, emailInvitations }) => { - const declaredKeys = new Set(); - const plan: SectionPlan> = { ops: [], notes: [], drift: [] }; - - for (const collaborator of desired) { - const { username } = collaborator; - const login = loginKey(username); - declaredKeys.add(login); - const wantPermission = collaborator.permission ?? DEFAULT_ROLE; - const wantRole = roleForPermission(wantPermission); - const label = `collaborators[${username}]`; - const existing = liveByLogin.get(login); - if (existing) { - // On GitHub a user is never a collaborator AND an invitee at once, so this branch settles the entry. - if ((existing.role_name ?? "") !== wantRole) { - plan.ops.push({ - role: "update", - params: { username }, - payload: { permission: wantPermission }, - describe: `updating collaborator "${username}"`, - drift: [ - valueDrift(label, JSON.stringify(wantRole), JSON.stringify(existing.role_name), { - remedy: "apply will set the declared permission", - }), - ], - change: `updated collaborator "${username}" (${wantPermission})`, - }); - } - continue; - } - const invitation = inviteByLogin.get(login); - if (invitation && invitation.expired !== true) { - if ((invitation.permissions ?? "") === wantRole) { - continue; - } - if (!INVITATION_ROLES.has(wantRole)) { - // A custom role never appears on an invitation, and the PATCH's enum (src/generated/spec-roles.ts) can lag - // the GET's, so neither can be set on a pending one; the PUT applies it once the invitation is accepted. - const settable = [...INVITATION_ROLES].join(", "); - plan.notes.push( - `invitation for "${username}" is pending; the invitation PATCH accepts only ${settable}, ` + - `so "${wantPermission}" cannot be set on it - left untouched, the next apply grants it once the invitation is accepted`, - ); - continue; - } - // The invitation PATCH speaks the READ vocabulary, so it takes the mapped role, not the declared permission. - plan.ops.push({ - role: "updateInvitation", - params: { invitation_id: String(invitation.id) }, - payload: { permissions: wantRole }, - describe: `updating the pending invitation for "${username}"`, - drift: [ - valueDrift( - `${label} (pending invitation)`, - JSON.stringify(wantRole), - JSON.stringify(invitation.permissions), - { remedy: "apply will update the invitation" }, - ), - ], - change: `updated pending invitation for "${username}" (${wantPermission})`, - }); - continue; - } - if (invitation) { - // An expired invitation cannot be revived by a PATCH: cancel it, and the PUT below mints a fresh one. - plan.ops.push({ - role: "cancelInvitation", - params: { invitation_id: String(invitation.id) }, - describe: `cancelling the expired invitation for "${username}"`, - drift: [ - `${label}: pending invitation expired; apply will cancel it and send a fresh invitation with "${wantPermission}"`, - ], - change: `cancelled the expired invitation for "${username}"`, - }); - } - plan.ops.push({ - role: "update", - params: { username }, - payload: { permission: wantPermission }, - describe: `inviting collaborator "${username}"`, - drift: [ - `${label}: missing - not a collaborator on the repo; apply will send an invitation with "${wantPermission}"`, - ], - change: invitation - ? `re-invited collaborator "${username}" (${wantPermission}) - the pending invitation had expired` - : `invited collaborator "${username}" (${wantPermission})`, - }); - } - - for (const collaborator of live) { - const login = loginKey(collaborator.login); - if (isOwner(ctx, login) || declaredKeys.has(login)) { - continue; - } - if (policy === "keep") { - plan.notes.push( - undeclaredNote({ - subject: `collaborator "${collaborator.login}"`, - state: "has access but is not declared", - add: "them", - manage: "their access", - action: "REMOVE them", - }), - ); - continue; - } - plan.ops.push({ - role: "remove", - params: { username: collaborator.login }, - drift: [ - undeclaredDrift(defaultUndeclaredPolicy(this), { - label: `collaborators[${collaborator.login}]`, - action: "REMOVE them", - add: "them", - keep: "their access", - }), - ], - change: `REMOVED undeclared collaborator "${collaborator.login}"`, - }); - } - - for (const invitation of invitations) { - const invitee = invitation.invitee.login; - if (declaredKeys.has(loginKey(invitee))) { - continue; - } - if (policy === "keep") { - plan.notes.push( - undeclaredNote({ - subject: `invitation for "${invitee}"`, - state: "is pending but not declared", - add: "them", - manage: "their access", - action: "CANCEL the invitation", - }), - ); - continue; - } - plan.ops.push({ - role: "cancelInvitation", - params: { invitation_id: String(invitation.id) }, - drift: [ - undeclaredDrift(defaultUndeclaredPolicy(this), { - label: `collaborators[${invitee}]`, - state: "a pending invitation not in the settings file", - action: "CANCEL it", - add: "them", - keep: "the invitation", - }), - ], - change: `CANCELLED undeclared invitation for "${invitee}"`, - }); - } - - for (const invitation of emailInvitations) { - plan.notes.push( - `invitation ${invitation.id} was sent by email, so no username can declare it; left untouched - cancel it from the repository's Access settings if it is unwanted`, - ); - } - return plan; - }, - ); + // undeclared in the other pool. + return readLiveAccess(ctx, this).map((access) => { + const plan: CollaboratorsPlan = { ops: [], notes: [], drift: [] }; + const declaredKeys = new Set(); + for (const collaborator of desired) { + declaredKeys.add(loginKey(collaborator.username)); + settleDeclared(collaborator, access, plan); + } + + const undeclared: Undeclared = { declaredKeys, policy, listDefault }; + planUndeclaredCollaborators(ctx, access.collaborators, undeclared, plan); + planUndeclaredInvitations(access.invitations, undeclared, plan); + for (const invitation of access.emailInvitations) { + plan.notes.push( + `invitation ${invitation.id} was sent by email, so no username can declare it; left untouched - cancel it from the repository's Access settings if it is unwanted`, + ); + } + + return plan; + }); }, /** * Omitted with a note: the owner and email invitations (no-ops for plan()), and expired @@ -329,69 +492,16 @@ export const collaboratorsSection = { ({ collaborators, invitations, emailInvitations }) => safeTry(function* () { const notes: string[] = []; - const entries: CollaboratorConfig[] = []; - const expired: string[] = []; - for (const collaborator of collaborators) { - const label = `collaborators[${collaborator.login}]`; - if (isOwner(ctx, loginKey(collaborator.login))) { - notes.push( - leftOutOfSnapshot( - label, - "the repository owner's access is implicit and never managed", - ), - ); - continue; - } - if (collaborator.role_name === undefined) { - return err( - sectionFailure( - "live-shape", - `${label}: GitHub reported no role_name for this collaborator, so their permission cannot be read back`, - ), - ); - } - // The section deletes undeclared access, so readBackPermission fails rather than notes here. - const permission = yield* readBackPermission( - section, - label, - collaborator.role_name, - notes, - ); - if (permission !== undefined) { - entries.push({ username: collaborator.login, permission }); - } - } - for (const invitation of invitations) { - const login = invitation.invitee.login; - const label = `collaborators[${login}]`; - if (invitation.expired === true) { - expired.push(label); - continue; - } - if (invitation.permissions === undefined) { - return err( - sectionFailure( - "live-shape", - `${label}: GitHub reported no permissions on the pending invitation, so it cannot be read back`, - ), - ); - } - const permission = yield* readBackPermission( - section, - label, - invitation.permissions, - notes, - ); - if (permission !== undefined) { - entries.push({ username: login, permission }); - } - } + const entries = yield* collaboratorEntries(ctx, section, collaborators, notes); + const pending = yield* invitationEntries(section, invitations, notes); + entries.push(...pending.entries); + // With nothing to declare the section is omitted, so apply never reaches the expired ones. const outcome = entries.length > 0 ? "apply cancels it - add the entry to re-invite them" : "nothing else is declared, so the section is omitted and apply leaves it - declare the entry to re-invite them"; - for (const label of expired) { + for (const label of pending.expired) { notes.push(leftOutOfSnapshot(label, `the pending invitation has expired; ${outcome}`)); } for (const invitation of emailInvitations) { @@ -402,6 +512,7 @@ export const collaboratorsSection = { ), ); } + if (entries.length === 0) { return ok({ value: undefined, notes }); } diff --git a/src/sections/contract/declared.ts b/src/sections/contract/declared.ts index 4e27d069d..fc98f9452 100644 --- a/src/sections/contract/declared.ts +++ b/src/sections/contract/declared.ts @@ -135,7 +135,6 @@ export function duplicateFieldIssues, identity: { readonly field: F; - /** Folds the field to the key GitHub matches it by; omitted, GitHub matches exactly. */ readonly fold?: (name: string) => string; }, what: string, diff --git a/src/sections/contract/docs.ts b/src/sections/contract/docs.ts index 3052e77a3..71735a870 100644 --- a/src/sections/contract/docs.ts +++ b/src/sections/contract/docs.ts @@ -14,10 +14,7 @@ const Role = z.string().regex(/^[A-Za-z][A-Za-z0-9]*$/, "a role is the declarati /** One fact, rendered as one bullet on the coverage page; a line break would end the bullet early. */ const Fact = z.string().regex(/^\S(?:[^\r\n]*\S)?$/, "a fact is one non-blank line"); -/** - * One row of the coverage page's Supported table: the GitHub surface it covers, the calls that serve it, and - * the facts a reader needs about how the section handles it. - */ +/** One row of the coverage page's Supported table: the GitHub surface, the calls serving it, and the facts about how the section handles it. */ const CoverageRow = z .strictObject({ /** The Area cell: the GitHub feature as a docs link, and nothing else (the fields it spans are facts). */ @@ -33,14 +30,13 @@ const CoverageRow = z notes: z.tuple([Fact], Fact).readonly(), }) .readonly(); + export type CoverageRow = z.infer; -/** - * Keyed as .github/scripts/lib/schema-descriptions.ts spells a site (`LabelConfig.color`, - * `SettingsFile.labels`, `UndeclaredPolicyList<*>.entries`). - */ +/** Keyed as .github/scripts/lib/schema-descriptions.ts spells a site (`LabelConfig.color`, `SettingsFile.labels`, `UndeclaredPolicyList<*>.entries`). */ const SchemaDescriptions = z.record(z.string().min(1), z.string().min(1)).readonly(); +/** The shape of one section's docs/sections/.docs.yml. */ export const SectionDocs = z .strictObject({ /** The section's two authored cells in the Sections table on docs/reference/sections.md. */ @@ -58,6 +54,7 @@ export const SectionDocs = z schema: SchemaDescriptions, }) .readonly(); + export type SectionDocs = z.infer; /** A docs file carrying schema descriptions only: the shared factories' and the document root's. */ diff --git a/src/sections/contract/drift.ts b/src/sections/contract/drift.ts index b9ee84cf7..e9353c9eb 100644 --- a/src/sections/contract/drift.ts +++ b/src/sections/contract/drift.ts @@ -20,8 +20,10 @@ export function cannotVerifyNote( opts: { /** Why GitHub cannot show the value ("GitHub never reveals a webhook secret"). */ why: string; + /** What stays unverified ("the declared value", "them"). */ what: string; + /** Apply's every-run verb phrase ("re-sends it", "re-asserts the declared preferences"). */ reasserts: string; }, @@ -54,12 +56,16 @@ export function undeclaredPolicy( export function undeclaredNote(opts: { /** The subject naming the live resource: `label "stale"`, `autolink JIRA-`. */ subject: string; + /** How the resource presents; the common case is the default. */ state?: string; + /** The pronoun for "add ... to the settings file" ("it" unless plural). */ add?: string; + /** What adding it would manage ("it", or "their access" for people). */ manage?: string; + /** What `_undeclared: delete` would make apply do, with any consequence. */ action: string; }): string { @@ -80,6 +86,7 @@ export function valueDrift( opts: { /** Qualifies the live value, in parentheses: a raw state behind the compared one, why order counts. */ qualifier?: string; + /** The apply clause; null when a generic line beside it already names the remedy (a recreate's field lines). */ remedy?: string | null; } = {}, @@ -100,12 +107,16 @@ export function undeclaredDrift( opts: { /** The drift-line prefix with the natural key: `labels[stale]`. */ label: string; + /** What apply will do, with any consequence worth naming. */ action: string; + /** When "not in the settings file" understates it (a PENDING INVITATION rather than a collaborator); the knob clause follows it. */ state?: string; + /** The pronoun for "add ... to the settings file" ("it" unless plural). */ add?: string; + /** What adding it would keep ("it", or "their access" for people). */ keep?: string; }, diff --git a/src/sections/contract/endpoints.ts b/src/sections/contract/endpoints.ts index 303f3a066..e88446dcb 100644 --- a/src/sections/contract/endpoints.ts +++ b/src/sections/contract/endpoints.ts @@ -24,41 +24,47 @@ export type HintableStatus = 400 | 412 | 422; * test pins every declaration against the e2e mock's denial responses. */ export interface DefinitiveRejection { + /** A denial's status (403 or 404); the message is what tells the rejection apart. */ readonly status: DenialStatus; + /** Compared whole, never as a substring: "Not Found" is a fine-grained denial. */ readonly message: string; + /** What to fix, as a lowercase clause without a trailing period; failureFor starts a sentence with it. */ readonly advice: string; } type GetRoute = Extract; +/** Overrides the section's permission; "none" means public. Resolved by endpointPermission(). */ +type PermissionOverride = SectionPermission | "none"; + /** - * `statuses` keys are the outcomes the handler treats as normal (the e2e mock reads the keys); its 4xx keys - * other than 401 and 429 are the tolerated errors (toleratedStatuses). The request helpers build paths from - * these declarations via expand(), so a section can never call a path it has not declared. + * A read's phase. Omitted, the read is available to plan(), so check mode and preflight may meet it. "execution" + * gates it behind the ExecTools token only a thunk receives (ReadPort in ./plan.ts), so check mode never issues it. + * + * e2e mock -> treats an execution read in check mode as a violation + * denialPosture() -> rejects a primaryRead on it: no denied first read can be classified from it + */ +export type ReadPhase = "execution"; + +/** + * The request helpers build paths from these declarations via expand(), so a section can never call a path it has + * not declared. */ export type EndpointDecl = | (EndpointDeclFields & { readonly route: GetRoute; - readonly permission?: SectionPermission | "none"; + readonly permission?: PermissionOverride; readonly accessGrade?: never; readonly alwaysRewrite?: never; readonly unverifiable?: never; - /** - * Omitted, the read is available to plan(), so check mode and preflight may meet it. "execution" gates it - * behind the ExecTools token only a thunk receives (ReadPort in ./plan.ts), so check mode never issues it. - * - * e2e mock -> treats an execution read in check mode as a violation - * denialPosture() -> rejects a primaryRead on it: no denied first read can be classified from it - */ - readonly phase?: "execution"; + readonly phase?: ReadPhase; }) | (EndpointDeclFields & Recurrence & { readonly route: Exclude; - /** Overrides the section's permission; "none" means public. Resolved by endpointPermission(). */ - readonly permission?: SectionPermission | "none"; + readonly permission?: PermissionOverride; readonly accessGrade?: never; readonly phase?: never; }) @@ -84,43 +90,36 @@ export interface GatedReadDecl extends EndpointDeclFields { readonly accessGrade: "write"; readonly alwaysRewrite?: never; readonly unverifiable?: never; - /** As on a plain GET: "execution" gates the read behind a thunk's token (the Codespaces sealing key). */ - readonly phase?: "execution"; + readonly phase?: ReadPhase; } +/** + * The fields every arm of EndpointDecl shares. `primaryRead` marks the section's one primary read and what a + * fine-grained 404 on it means; the read port (ReadPort in ./plan.ts) and denialPosture() read it. + * + * notFound: "denied" -> a permission-denied failure that stops the section (an advisory read exposes only tryCall, which returns it as { error }) + * notFound: "absent" -> a missing resource; the section proceeds + */ interface EndpointDeclFields { + /** Keyed by the outcomes the handler treats as normal (the e2e mock reads the keys); the 4xx keys other than 401 and 429 are the tolerated errors (toleratedStatuses). */ readonly statuses: Readonly>; - /** - * By default an advisory READ's failures come back as { error } instead of aborting the section; a rate - * limit still throws, and an explicit `tolerate` narrows the set (declaredTolerance). The e2e mock - * derives its advisory-read exemption from this flag via allEndpoints(). - */ + + /** A read whose failures come back as { error } instead of ending the section (a rate limit still throws; declaredTolerance narrows); the e2e mock's denial barrier exempts it. */ readonly advisory?: boolean; - /** - * Payloads pass through verbatim, so a hint names the failure CLASS and points at the docs, never valid - * values that could go stale; failureFor appends it to the status's rejection message. Style: one or two sentences, no trailing period. - */ + + /** Appended by failureFor to a rejection's message; since payloads pass through verbatim, a hint names the failure CLASS and points at the docs, never values that could go stale (no trailing period). */ readonly hints?: Readonly>>; - /** - * Appended to the permission-denied failure's message (which never reads `hints`) when a 403/404 can mean - * something other than a missing grant (Git LFS disabled account-wide) and the body does not tell - * the readings apart; a body that does is a `rejections` entry. One sentence, no trailing period. - */ + + /** Appended to a denial's message when its 403/404 can mean more than a missing grant (Git LFS disabled account-wide); a body that tells the readings apart is a `rejections` entry instead (no trailing period). */ readonly denialHint?: string; + /** The endpoint's definitive rejections (see DefinitiveRejection); the e2e mock serves the same declarations. */ readonly rejections?: readonly DefinitiveRejection[]; - /** - * When GitHub caps per_page below the standard 100 (the Actions variables list: 30). The page loop - * requests exactly this many and treats a shorter page as the last, so a request GitHub would silently - * clamp cannot truncate the walk after page one. - */ + + /** GitHub's per_page cap where it is below 100 (the Actions variables list: 30); the page loop asks for exactly this many, so a silently clamped request cannot truncate the walk after page one. */ readonly pageSize?: number; - /** - * The section's PRIMARY READ and what a fine-grained 404 on it means: "denied" classifies as - * a permission-denied failure and stops the section (an advisory read only exposes tryCall, which returns it as - * { error }), "absent" reads as a missing resource and proceeds. At most one per section; the read port - * (ReadPort in ./plan.ts) and denialPosture() read it. - */ + + /** The section's one primary read, with what its fine-grained 404 means (the rows above). */ readonly primaryRead?: { readonly notFound: "denied" | "absent" }; } diff --git a/src/sections/contract/errors.ts b/src/sections/contract/errors.ts index ebea0bf39..d0817623c 100644 --- a/src/sections/contract/errors.ts +++ b/src/sections/contract/errors.ts @@ -12,7 +12,8 @@ import { grantFor, type SectionPermission, samePermission } from "./permissions. /** * What ends a section's work, as a value. `message` is the whole line the loops report; `kind` is read for policy - * alone (a denial's partial-success handling in engine/orchestrate.ts), never to rebuild prose. + * alone (a denial's partial-success handling in engine/orchestrate.ts), never to rebuild prose. The denial alone + * carries more: its `status` is the safe code the redacted view shows. * * live-shape -> GitHub's answer parsed but cannot be reconciled (an item without an id, a repeated rule type) * refused -> the section declines to proceed: the settings file conflicts with live state, an actor cannot be @@ -29,7 +30,6 @@ export type SectionFailure = readonly kind: "permission-denied"; readonly section: string; readonly detail: string; - /** The HTTP status that raised the denial, for the redacted view's safe code. */ readonly status: number; readonly message: string; }; @@ -85,28 +85,79 @@ function sentence(clause: string): string { return clause.charAt(0).toUpperCase() + clause.slice(1); } +/** + * `operation` says WHAT was being done in settings-file terms. `op` supplies the hints and denial hint and resolves + * the EFFECTIVE permission: an override renders its own grant advice, and a public operation ("none") cannot be a + * missing-grant failure, so its 403/404 takes the generic branch; a GraphQL failure renders `GRAPHQL ` in + * the method/path slot. + */ +interface FailureContext { + readonly operation?: string; + readonly op?: FailingOp; +} + +/** + * The two spellings of the failed request: the operation label says what was being done, and the raw method/path + * keeps the request identifiable. + * + * cause -> creating ruleset "x" failed - POST /repos/...: 422 ... + * denied -> POST /repos/... (creating ruleset "x"): 403 ... + */ +function requestLines( + method: string, + path: string, + error: ApiError, + operation: string | undefined, +): { cause: string; denied: string } { + const request = `${method} ${path}`; + const outcome = `${error.status} ${error.message}`; + return { + cause: `${operation ? `${operation} failed - ` : ""}${request}: ${outcome}`, + denied: `${request}${operation ? ` (${operation})` : ""}: ${outcome}`, + }; +} + +/** The denial's advice: the grant of the EFFECTIVE permission at the level the section needs, or the section's own, then the endpoint's hint. */ +function denialAdvice( + section: SectionMeta, + effective: SectionPermission | undefined, + op: FailingOp | undefined, +): string { + const grant = + effective !== undefined && !samePermission(effective, section.permission) + ? grantFor(effective, undefined, overrideAdviceLevel(section, effective)) + : sectionGrant(section); + const denialHint = op?.denialHint ? `. Note: ${op.denialHint}` : ""; + return `To fix, ${grant}${denialHint}`; +} + +/** + * The advice a rejection's declaration carries: a GraphQL op's declared outcome prose for each observed error type + * (a GraphQL rejection carries types, not a status), a REST endpoint's hint for the status. + */ +function rejectionAdvice(op: FailingOp | undefined, error: ApiError): string | undefined { + if (op === undefined) { + return undefined; + } + if ("outcomes" in op) { + return toleratedGraphqlErrors(op) + .filter((type) => error.graphqlTypes?.includes(type)) + .map((type) => op.outcomes[type]) + .filter((outcome): outcome is string => outcome !== undefined) + .map(sentence) + .join(". "); + } + return op.hints?.[error.status as HintableStatus]; +} + export function failureFor( section: SectionMeta, method: string, path: string, error: ApiError, - context?: { - operation?: string; - /** - * Supplies the hints and denial hint and resolves the EFFECTIVE permission: an override renders its - * own grant advice, and a public operation ("none") cannot be a missing-grant failure, so its 403/404 - * takes the generic branch; a GraphQL failure renders `GRAPHQL ` in the method/path slot. - */ - op?: FailingOp; - }, + context?: FailureContext, ): SectionFailure { - // The operation label says WHAT was being done in settings-file terms; the raw method/path keeps the request identifiable. - // creating ruleset "x" failed - POST /repos/...: 422 ... - // the token was denied POST /repos/... (creating ruleset "x"): 403 ... - const request = `${method} ${path}`; - const outcome = `${error.status} ${error.message}`; - const cause = `${context?.operation ? `${context.operation} failed - ` : ""}${request}: ${outcome}`; - const denied = `${request}${context?.operation ? ` (${context.operation})` : ""}: ${outcome}`; + const { cause, denied } = requestLines(method, path, error, context?.operation); const kind = classifyApiError(error); if (kind === "rate-limit") { // Secondary rate limits arrive as 403 and must not read as missing permissions. @@ -125,14 +176,9 @@ export function failureFor( if (kind === "permission" && effective !== "none") { const alsoMissing = error.status === 404 ? " (a 404 here can also mean the resource does not exist)" : ""; - const denialHint = context?.op?.denialHint ? `. Note: ${context.op.denialHint}` : ""; - const grant = - effective !== undefined && !samePermission(effective, section.permission) - ? grantFor(effective, undefined, overrideAdviceLevel(section, effective)) - : sectionGrant(section); return permissionDenied( section.key, - `the token was denied ${denied}${alsoMissing}. To fix, ${grant}${denialHint}`, + `the token was denied ${denied}${alsoMissing}. ${denialAdvice(section, effective, op)}`, error.status, ); } @@ -148,18 +194,7 @@ export function failureFor( message: `${section.key}: ${cause}. The token was rejected as invalid or expired; update the token input (or the secret it reads) with a valid, unexpired PAT`, }; } - // A GraphQL rejection carries error types, not a status; its declared outcomes stand in for status-keyed hints. - const advice = - op === undefined - ? undefined - : "outcomes" in op - ? toleratedGraphqlErrors(op) - .filter((type) => error.graphqlTypes?.includes(type)) - .map((type) => op.outcomes[type]) - .filter((outcome): outcome is string => outcome !== undefined) - .map(sentence) - .join(". ") - : op.hints?.[error.status as HintableStatus]; + const advice = rejectionAdvice(op, error); const hint = advice ? `. ${advice}` : ""; const docs = error.documentationUrl ? `. The fields and values this endpoint accepts are documented at ${error.documentationUrl}` diff --git a/src/sections/contract/graphql.ts b/src/sections/contract/graphql.ts index 98e40ea0f..4ba4d96e5 100644 --- a/src/sections/contract/graphql.ts +++ b/src/sections/contract/graphql.ts @@ -1,3 +1,4 @@ +import type { ReadPhase } from "./endpoints.js"; import type { SectionPermission } from "./permissions.js"; /** GitHub's errors[] entry `type` values, as far as this system models them; the e2e validator's known-type check reads the array. */ @@ -9,10 +10,7 @@ export const GRAPHQL_ERROR_TYPES = [ "UNPROCESSABLE", ] as const; -/** - * RATE_LIMITED is absent on purpose (throttling is a transport concern every operation handles alike), - * as is INSUFFICIENT_SCOPES (a wrong token; the transport folds it into the 403 class). - */ +/** RATE_LIMITED is absent on purpose (throttling is a transport concern every operation handles alike), as is INSUFFICIENT_SCOPES (a wrong token; the transport folds it into the 403 class). */ const GRAPHQL_TOLERABLE_ERRORS = ["FORBIDDEN", "NOT_FOUND", "UNPROCESSABLE"] as const; export type GraphqlTolerableError = (typeof GRAPHQL_TOLERABLE_ERRORS)[number]; @@ -31,29 +29,24 @@ interface GraphqlConnectionDecl { * erases to the metadata consumers' default) and lets the request helpers type-check call-site variables. */ interface GraphqlOpCommon> { - /** - * The wire dispatch key (operationName on every call), globally unique across sections - * (allGraphqlOps asserts it): the mock and the coverage tripwire address the operation by it without parsing the query. - */ + /** The operationName of every call, unique across sections (allGraphqlOps asserts it), so the mock and the coverage tripwire address the operation without parsing the query. */ readonly name: string; - /** - * As EndpointDecl.statuses: "ok" documents the success meaning, and each - * declared error type is a TOLERATED outcome (tryCallGraphql returns it as - * { error } instead of throwing). - */ + + /** As EndpointDecl.statuses: "ok" documents the success meaning, and each declared error type is a TOLERATED outcome (tryCallGraphql returns it as { error } instead of throwing). */ readonly outcomes: Readonly<{ ok: string } & Partial>>; + /** As EndpointDecl.permission: "none" means public, omitted means the section's own. */ readonly permission?: SectionPermission | "none"; + /** Read only by the e2e mock, which exempts advisory reads from its denial barrier; the GraphQL helpers tolerate by declared outcomes alone. */ readonly advisory?: boolean; - /** - * Appended to the permission-denied failure's message for an operation whose - * FORBIDDEN/NOT_FOUND can mean something other than a missing token grant. - * One sentence, no trailing period. - */ + + /** Appended to a denial's message when the operation's FORBIDDEN/NOT_FOUND can mean more than a missing token grant; no trailing period. */ readonly denialHint?: string; + /** GraphQL rejections carry no HTTP status for a hint to key on; the `never` makes declaring one a compile error (see FailingOp). */ readonly hints?: never; + /** Type-only marker for `V`; never set at runtime. */ readonly _variables?: V; } @@ -69,14 +62,12 @@ export type GraphqlOpDecl = Record | (GraphqlOpCommon & { readonly kind: "write"; readonly query: `mutation ${string}`; - /** Pagination is a read concern. */ readonly connection?: never; readonly phase?: never; }); @@ -91,7 +82,7 @@ export type GraphqlPaginatedReadDecl = Record< readonly kind: "read"; readonly query: `query ${string}$cursor${string}`; readonly connection: GraphqlConnectionDecl; - readonly phase?: "execution"; + readonly phase?: ReadPhase; }; /** diff --git a/src/sections/contract/keyed-list.ts b/src/sections/contract/keyed-list.ts index 70798d480..60a53f80d 100644 --- a/src/sections/contract/keyed-list.ts +++ b/src/sections/contract/keyed-list.ts @@ -19,31 +19,22 @@ import { * layer's to choose, never the module's. */ export interface KeyedListLayering { - /** - * Folded as the planner folds them (a label claims its name plus its pre-rename name); null when the - * entry carries none, which the layer boundary refuses. - */ + /** Folded as the planner folds them (a label claims its name plus its pre-rename name); null when the entry carries none, which the layer boundary refuses. */ readonly keys: (entry: Readonly>) => readonly string[] | null; + /** The entry field the keys come from, for refusal prose ("name", "type"). */ readonly keyField: string; + /** The field's kind in the same prose ("string" unless said otherwise; a reviewer's `id` is "numeric"). */ readonly keyKind?: string; - /** - * Fields of a merged entry that are themselves keyed lists (rulesets' `rules`, an environment's `variables`). A - * nested list arrives as a bare list or a nested `{_undeclared, entries}` wrapper and unions by its own key under - * the directive its entry inherits; its wrapper takes no `_layering` (nestedKnobbed in ../shared/schema-helpers.ts). - */ + + /** Entry fields that are themselves keyed lists (rulesets' `rules`, an environment's `variables`), each unioning by its own key under the directive its entry inherits. */ readonly nested?: Readonly>; - /** - * The dotted paths a `_remove: true` entry may carry beside the marker: the key field's own unless the key spans - * several (a reviewer is its `type` and `id`). Any other path on a removal is refused at the layer boundary by name. - */ + + /** The dotted paths a `_remove: true` entry may carry beside the marker, the key field's own when omitted (a reviewer's are `type` and `id`); the layer boundary refuses any other by name. */ readonly removalPaths?: readonly string[]; - /** - * A NESTED list's `_undeclared` default (an environment's variables), the last fallback engine/undeclared.ts resolves a - * nested wrapper without a policy to; absent on a nested list that takes no knob (a ruleset's rules, reviewers). - * test/sections/registry.test.ts pins it to the nested wrappers the schema declares. - */ + + /** A nested list's `_undeclared` default (an environment's variables), engine/undeclared.ts's last fallback for a policy-less nested wrapper; absent where the nested list takes no knob (a ruleset's rules), as test/sections/registry.test.ts pins. */ readonly undeclaredDefault?: UndeclaredPolicy; } diff --git a/src/sections/contract/live.ts b/src/sections/contract/live.ts index a1e9e15da..57ec2c814 100644 --- a/src/sections/contract/live.ts +++ b/src/sections/contract/live.ts @@ -21,14 +21,16 @@ export function plural(noun: string): string { return /(s|x|ch|sh)$/.test(noun) ? `${noun}es` : `${noun}s`; } +declare const LIVE_IDENTITY: unique symbol; + +/** The brand liveIdentity() alone mints, so every producer of a duplicate-identity message renders alike. */ +export type LiveIdentity = string & { readonly [LIVE_IDENTITY]: true }; + /** * The ONE rendering of a live item in a duplicate-identity message: the human key, then the server-side * addressing values that tell two apart (`v1 (milestone number 1)`; a value equal to the key adds nothing, - * so a label's address IS its name). The brand is minted here alone, so every producer renders alike. + * so a label's address IS its name). */ -declare const LIVE_IDENTITY: unique symbol; -export type LiveIdentity = string & { readonly [LIVE_IDENTITY]: true }; - export function liveIdentity( name: string, address: Readonly> = {}, diff --git a/src/sections/contract/module.ts b/src/sections/contract/module.ts index 876857262..216eb831f 100644 --- a/src/sections/contract/module.ts +++ b/src/sections/contract/module.ts @@ -30,7 +30,6 @@ import type { PlanContext, PlannedOp, SectionPlan, SnapshotContext } from "./pla interface SectionContextBase { api: GitHubClient; - /** The target repository, parsed once at the boundary (see RepoRef). */ repo: RepoRef; } @@ -46,10 +45,7 @@ export type EndpointDict = Readonly>; export type GraphqlDict = Readonly>; -/** - * GET /orgs/{org} is public, so no token permission; its 404 is the personal-account signal. A section whose - * other reads can never be denied marks it the primary read (`primaryRead: { notFound: "absent" }`). - */ +/** GET /orgs/{org} is public, so no token permission, and its 404 is the personal-account signal; a section with no deniable read marks it the primary read (`primaryRead: { notFound: "absent" }`). */ export const ORG_PROBE = { route: "GET /orgs/{org}", statuses: { 200: "the organization", 404: "not an organization (a personal account)" }, @@ -59,71 +55,63 @@ export const ORG_PROBE = { /** * `E` and `G` must be the module's LITERAL (`as const`) dictionaries: ../registry.ts derives the * `${key}.${role}` unions from them, and the e2e mock's handler tables are typed by those unions. + * + * `undeclaredDefault` is what the section does with the live resources the file omits; the wrapped + * `{_undeclared, entries}` form overrides it per run, and the conditional type pins "untouched" to a section + * outside UNDECLARED_POLICY_SECTIONS: + * + * "delete" -> lists live resources and DELETES undeclared ones; `_undeclared: keep` softens to notes + * "keep" -> lists live resources and KEEPS undeclared ones as notes; `_undeclared: delete` hardens + * "untouched" -> takes no `_undeclared` knob; the section applies no undeclared policy */ export interface SectionMeta< K extends SectionKey = SectionKey, E extends EndpointDict = EndpointDict, G extends GraphqlDict = GraphqlDict, > { + /** The document key the section owns; PlanContext brands a plan() call by it (./plan.ts). */ readonly key: K; + /** Drives the grant prose (sectionGrant), the e2e mock's permission gate, and the fuzz oracle. */ readonly permission: SectionPermission; + /** Appended to the derived grant advice when a denial can mean more than a missing grant (an ambiguous 403). */ readonly grantCaveat?: string; - /** - * "org": the resources exist only under an ORGANIZATION owner. The registry wraps the module's plan() and - * snapshot() in the owner gate (./owner.ts), which probes the `org` role (ORG_PROBE, 404 tolerated) and - * no-ops with a note on a personal account, so no section body spells the probe; the registry's lockstep - * type admits the flag only beside that role. The single source of owner-kind modeling: the fuzz oracle's - * personal-account fold reads it, and test/sections/registry.test.ts pins it to the probe endpoint. - */ + + /** "org": the resources exist only under an organization owner, so the registry wraps plan() and snapshot() in the owner gate (./owner.ts); the fuzz oracle's personal-account fold reads it too. */ readonly ownerSensitivity?: "org"; + /** Every REST endpoint the section may call, by role; the e2e mock's routes and USED_PATHS derive from it. */ readonly endpoints: E; - /** - * Every GraphQL operation the section may issue, by role; the e2e mock, the coverage tripwire, and the - * fuzz generators iterate allGraphqlOps(). - */ + + /** Every GraphQL operation the section may issue, by role; the e2e mock, the coverage tripwire, and the fuzz generators iterate allGraphqlOps(). */ readonly graphql?: G; - /** - * The generated Sections table's Undeclared-default column derives from it, and test/sections/docs-registry.test.ts - * fails a coverage note that contradicts it; the wrapped `{_undeclared, entries}` form overrides it per run. - * - * "delete" -> lists live resources and DELETES undeclared ones; `_undeclared: keep` softens to notes - * "keep" -> lists live resources and KEEPS undeclared ones as notes; `_undeclared: delete` hardens - * "untouched" -> takes no `_undeclared` knob; the section applies no undeclared policy - * - * The conditional type pins the pairing: a section in UNDECLARED_POLICY_SECTIONS says "delete" or "keep", one outside it "untouched". - */ + + /** The undeclared policy (the rows above); the generated Sections table's Undeclared-default column derives from it, and test/sections/docs-registry.test.ts fails a coverage note that contradicts it. */ readonly undeclaredDefault: K extends UndeclaredPolicySection ? UndeclaredPolicy : "untouched"; - /** - * Read by engine/layers.ts for the layered merge. Optional on the interface for the sections that take no - * list; ../registry.ts requires it of every list section, so a list module omitting it fails to compile. - */ + + /** Read by engine/layers.ts for the layered merge; optional here for the sections that take no list, while ../registry.ts requires it of every list section. */ readonly layering?: K extends ListSection ? KeyedListLayering : never; } +/** + * The handlers beside the declarations. `closedSurface` is declared only by CLOSED sections, whose API calls never + * forward extra entry keys (collaborators, teams, workflows), so an unrecognized key would apply "successfully" and + * never converge; an open passthrough section must NOT declare it, since its extra keys reach GitHub. + * + * `known` mapped over EVERY entry key -> a new schema field forces a decision here; a phantom key is an excess property + * EntryOf sees through the wrapped form -> a closed section that also takes the knob (collaborators) stays closed in both forms + * validateSectionShapes (engine/validate.ts) -> rejects before any section writes + */ interface SectionModuleBase< K extends SectionKey = SectionKey, E extends EndpointDict = EndpointDict, G extends GraphqlDict = GraphqlDict, > extends SectionMeta { - /** - * Declared fields are checked and unknown fields pass through, so validation does not fight - * passthrough-first forward compatibility. STRICT nested sub-shapes are sanctioned only where the - * endpoint offers no passthrough destination (actions.cache, the environment secrets and - * deployment_protection_rules entries), where an extra key can only be a typo. - */ + /** Checks the declared fields and passes unknown ones through (forward compatibility); a STRICT nested shape is sanctioned only where the endpoint has no passthrough destination (actions.cache), where an extra key can only be a typo. */ shape: z.ZodType; - /** - * Declared only by CLOSED sections, whose API calls never forward extra entry keys (collaborators, teams, - * workflows), so an unrecognized key would apply "successfully" and never converge; open passthrough - * sections must NOT declare it, since their extra keys reach GitHub. - * - * `known` mapped over EVERY entry key -> a new schema field forces a decision here; a phantom key is an excess property - * EntryOf sees through the wrapped form -> a closed section that also takes the knob (collaborators) stays closed in both forms - * validateSectionShapes (engine/validate.ts) -> rejects before any section writes - */ + + /** The closed section's known entry keys and the consequence of a stray one (the rows above). */ closedSurface?: [EntryOf>] extends [never] ? never : { @@ -131,9 +119,11 @@ interface SectionModuleBase< known: { readonly [P in Extract>, string>]: true; }; + /** What the unrecognized key would silently do, as message prose. */ consequence: string; }; + /** * Declared only by sections with designated secret fields (every webhooks entry's config.secret); the * values are returned raw, and nothing here reads the environment. @@ -196,6 +186,7 @@ export type SectionModule< ctx: PlanContext, desired: ValidatedInput, ): Promise>, SectionFailure>>; + /** Pinned so a non-literal object carrying a run() handler is not assignable either. */ run?: never; }; diff --git a/src/sections/contract/owner.ts b/src/sections/contract/owner.ts index f86dc2ffd..e3b087d15 100644 --- a/src/sections/contract/owner.ts +++ b/src/sections/contract/owner.ts @@ -35,11 +35,7 @@ interface OrgProbePort { }; } -/** - * Whether each owner is a personal account, per client (one client is one run's token): the first - * gated section probes, the rest read the answer. A failed probe (a denial, a server error) is not - * kept, so every section reports it through its own port. - */ +/** Per client (one client is one run's token), whether each owner is a personal account: the first gated section probes, the rest read the answer, and a failed probe (a denial, a server error) is not kept. */ const personalByClient = new WeakMap< GitHubClient, Map>> diff --git a/src/sections/contract/permissions.ts b/src/sections/contract/permissions.ts index 32e1789de..f2f95aad6 100644 --- a/src/sections/contract/permissions.ts +++ b/src/sections/contract/permissions.ts @@ -21,6 +21,7 @@ export type PatResource = export interface SectionPermission { /** Fine-grained PAT repository permissions; ANY one of these grants access. */ readonly repo: readonly [PatResource, ...PatResource[]]; + /** Additional organization permission required (teams only). */ readonly org?: "members"; } @@ -59,12 +60,12 @@ export const RESOURCE_LABEL: Record = { checks: "Checks", }; +/** The organization permission's label, as the token UI shows it. */ export const RESOURCE_LABEL_ORG: Record, string> = { members: "Members", }; -// Each PAT resource's query parameter on GitHub's pre-filled token form (the generated token-form -// link, in this order); total over PatResource, so a new resource names its parameter or records a null exemption. +/** Each PAT resource's query parameter on GitHub's pre-filled token form (the generated token-form link, in this order); total over PatResource, so a new resource names its parameter or records a null exemption. */ export const RESOURCE_SLUGS: Record = { // The form drops unknown parameters silently (the old variables= spelling failed that way), so every // non-null slug was verified against the live token form on 2026-07-28. diff --git a/src/sections/contract/plan.ts b/src/sections/contract/plan.ts index 6c66a9f7a..05a49020c 100644 --- a/src/sections/contract/plan.ts +++ b/src/sections/contract/plan.ts @@ -51,6 +51,73 @@ export type PlainData = | readonly PlainData[] | { readonly [key: string]: PlainData | undefined }; +/** The path of a non-plain value as the BUG line spells it: `(root)`, `rules[0].check`, `["a.b"].c`. */ +function renderPath(path: readonly (string | number)[]): string { + if (path.length === 0) { + return "(root)"; + } + return path + .map((segment, index) => { + if (typeof segment === "number") { + return `[${segment}]`; + } + const bare = /^[A-Za-z_$][\w$]*$/.test(segment); + return bare ? `${index === 0 ? "" : "."}${segment}` : `[${JSON.stringify(segment)}]`; + }) + .join(""); +} + +function rejectPayload(path: readonly (string | number)[], reason: string): never { + throw new Error( + `BUG: a planned payload carries a value JSON cannot carry at ${renderPath(path)}: ${reason}; request data must be plain`, + ); +} + +/** Why JSON cannot carry a node, beyond what nonPlainReason names: a non-finite number, which JSON would turn into null. */ +function nonPlainAt(node: unknown, at: "item" | "field"): string | null { + const reason = nonPlainReason(node, at); + if (reason !== null) { + return reason; + } + if (typeof node === "number" && !Number.isFinite(node)) { + return "a non-finite number, which JSON would turn into null"; + } + return null; +} + +/** + * The walk below one node. A YAML alias to an ancestor parses to a cycle, which JSON cannot carry (a shared alias to + * a sibling is fine and is visited twice), so `ancestors` holds the containers on the path down to the node. + */ +function requirePlain( + node: unknown, + path: readonly (string | number)[], + at: "item" | "field", + ancestors: Set, +): void { + const reason = nonPlainAt(node, at); + if (reason !== null) { + rejectPayload(path, reason); + } + if (node === null || typeof node !== "object") { + return; + } + if (ancestors.has(node)) { + rejectPayload(path, "a reference back to one of its own containers (a cycle)"); + } + ancestors.add(node); + if (Array.isArray(node)) { + for (const [index, item] of node.entries()) { + requirePlain(item, [...path, index], "item", ancestors); + } + } else { + for (const [key, item] of Object.entries(node)) { + requirePlain(item, [...path, key], "field", ancestors); + } + } + ancestors.delete(node); +} + /** * The runtime proof for a body the types cannot vouch for. A body built from declared values and literals is * PlainData by type alone (PlainTyped in ./declared.ts reads the validated document's `unknown` leaves as the walk @@ -62,56 +129,10 @@ export type PlainData = * a library caller's getter answering differently than under the walk -> the same, after the proof */ export function plainData(value: unknown): PlainData { - const render = (path: readonly (string | number)[]): string => - path.length === 0 - ? "(root)" - : path - .map((segment, index) => { - if (typeof segment === "number") { - return `[${segment}]`; - } - const bare = /^[A-Za-z_$][\w$]*$/.test(segment); - return bare ? `${index === 0 ? "" : "."}${segment}` : `[${JSON.stringify(segment)}]`; - }) - .join(""); - const reject = (path: readonly (string | number)[], reason: string): never => { - throw new Error( - `BUG: a planned payload carries a value JSON cannot carry at ${render(path)}: ${reason}; request data must be plain`, - ); - }; - // A YAML alias to an ancestor parses to a cycle, which JSON cannot carry (a shared alias to a sibling is fine and is visited twice). - const ancestors = new Set(); - const plain = (node: unknown, path: readonly (string | number)[], at: "item" | "field"): void => { - const reason = - nonPlainReason(node, at) ?? - (typeof node === "number" && !Number.isFinite(node) - ? "a non-finite number, which JSON would turn into null" - : null); - if (reason !== null) { - reject(path, reason); - } - if (node === null || typeof node !== "object") { - return; - } - if (ancestors.has(node)) { - reject(path, "a reference back to one of its own containers (a cycle)"); - } - ancestors.add(node); - if (Array.isArray(node)) { - for (const [index, item] of node.entries()) { - plain(item, [...path, index], "item"); - } - } else { - for (const [key, item] of Object.entries(node)) { - plain(item, [...path, key], "field"); - } - } - ancestors.delete(node); - }; if (value === undefined) { - reject([], "undefined, which has no JSON form"); + rejectPayload([], "undefined, which has no JSON form"); } - plain(value, [], "field"); + requirePlain(value, [], "field", new Set()); return value as PlainData; } @@ -325,7 +346,6 @@ export interface PlanContext< K extends SectionKey = SectionKey, > { readonly section: K; - /** The target repository, parsed once at the boundary (see RepoRef). */ readonly repo: RepoRef; readonly read: BoundReads; } @@ -422,28 +442,19 @@ export interface SnapshotContext< * drift line (DriftFor), so "check reported clean while apply mutated" is unrepresentable. */ export interface PlannedOpBase { - /** - * Check mode renders these; apply renders `change`. - * labels[bug]: color d73a4a != live ffffff; apply will update it - */ + /** Rendered by check mode (`labels[bug]: color d73a4a != live ffffff; apply will update it`); apply renders `change`. */ readonly drift: D; - /** - * A thunk when the line depends on what the server echoed (one line or several, never none); its failure - * is the verification failure, reported beside the requests that landed. - */ + + /** A thunk when the line depends on what the server echoed (one line or several, never none); its failure is the verification failure, reported beside the requests that landed. */ readonly change: string | ((response: unknown) => Result); + /** The operation in settings-file terms ("arming the interaction limit"), for the failure prose; the `describe` the request helpers take. */ readonly describe?: string; - /** - * For a server-assigned value (a created environment's node id) a later operation's thunk reads from - * where the hook stores it. It must not render; its failure fails the operation. - */ + + /** Stores a server-assigned value (a created environment's node id) where a later operation's thunk reads it; it renders nothing, and its failure fails the operation. */ readonly capture?: (response: unknown) => Result; - /** - * Execution-time reads before the request is sealed and issued (bypass actors' node ids, pinned ahead - * of the first write so a bad input fails while live state is untouched). Its failure fails the - * operation with its request never sent. - */ + + /** Execution-time reads ahead of the request (bypass actors' node ids, pinned before the first write so a bad input fails with live state untouched); its failure fails the operation with the request never sent. */ readonly before?: Late; } @@ -537,7 +548,8 @@ type PlannedRestOp> = PlannedOpBa /** * A planned GraphQL mutation under one specific role of a literal dictionary. Always * drift-bearing: alwaysRewrite is a REST endpoint declaration and no GraphQL mutation writes - * a value it cannot read back, so none is unconditional by contract. + * a value it cannot read back, so none is unconditional by contract. Tolerance is by HTTP status, + * which a GraphQL rejection has none of, so `tolerate` is never. */ type PlannedGraphqlOp> = PlannedOpBase< readonly [string, ...string[]] @@ -547,7 +559,6 @@ type PlannedGraphqlOp> = Pl readonly params?: never; readonly query?: never; readonly payload?: never; - /** Tolerance is by HTTP status, which a GraphQL rejection has none of. */ readonly tolerate?: never; }; @@ -709,11 +720,7 @@ function boundReads( return Object.freeze(port) as BoundReads; } -/** - * The client each context was minted over. A gate composed at the registry door (owner.ts) reads it - * to share one probe across the sections of a run; the registry is private, so the port itself - * stays the only thing a section body can reach. - */ +/** The client each context was minted over; the owner gate (owner.ts) reads it to share one probe across a run's sections, while the port stays the only thing a section body can reach. */ const clients = new WeakMap(); /** The client a context reads through; a context not minted by planContext() or snapshotContext() is a BUG. */ diff --git a/src/sections/contract/requests.ts b/src/sections/contract/requests.ts index e948c74e3..585c7f9b7 100644 --- a/src/sections/contract/requests.ts +++ b/src/sections/contract/requests.ts @@ -35,6 +35,24 @@ export type OptsArg = [PathParams] ex ? [opts?: { params?: undefined } & Extra] : [opts: { params: Readonly, string>> } & Extra]; +/** + * One list read's pages: `extract` selects the list from a page's body (a bare array, or a {total_count, : []} + * envelope) and answers null for a page without one, which `shape` names in the malformed line. + */ +interface PageWalk { + readonly path: string; + readonly extract: (data: unknown) => unknown[] | null; + readonly shape: string; + readonly describe?: string; +} + +/** One page of a GraphQL connection as the loop reads it: the nodes, and whether and where the walk continues. */ +interface ConnectionPage { + readonly nodes: unknown[]; + readonly hasNextPage: boolean; + readonly endCursor: unknown; +} + /** The client's `failed` line as the section's failure: no status classifies it, so the line stands as given. */ function unanswered(failed: string): SectionFailure { return { kind: "transport", message: failed }; @@ -241,16 +259,13 @@ export async function probeAbsent( return ok({ data: result.data }); } -/** `extract` selects the list from one page's body (bare array, or a {total_count, : []} envelope). */ async function listPages( ctx: SectionContext, section: SectionMeta, endpoint: EndpointDecl, - path: string, - extract: (data: unknown) => unknown[] | null, - shape: string, - describe?: string, + walk: PageWalk, ): Promise> { + const { path, extract, shape, describe } = walk; // A page without the list ends the walk there, so its malformed diagnosis stands ahead of whatever a later page // would answer. const result = await ctx.api.tryList(path, { @@ -287,15 +302,12 @@ export async function listAll( ): Promise> { const opts = args[0]; const path = expand(endpoint, ctx, opts?.params, opts?.query); - return listPages( - ctx, - section, - endpoint, + return listPages(ctx, section, endpoint, { path, - (data) => (Array.isArray(data) ? data : null), - "a list", - opts?.describe, - ); + extract: (data) => (Array.isArray(data) ? data : null), + shape: "a list", + describe: opts?.describe, + }); } /** For endpoints wrapping the list in an envelope (GET /actions/workflows returns {total_count, workflows: []}). */ @@ -308,18 +320,15 @@ export async function listAllEnveloped( ): Promise> { const opts = args[0]; const path = expand(endpoint, ctx, opts?.params, opts?.query); - return listPages( - ctx, - section, - endpoint, + return listPages(ctx, section, endpoint, { path, - (data) => { + extract: (data) => { const chunk = (data as Record | null)?.[envelopeKey]; return Array.isArray(chunk) ? chunk : null; }, - `a "${envelopeKey}" list`, - opts?.describe, - ); + shape: `a "${envelopeKey}" list`, + describe: opts?.describe, + }); } /** The GraphQL sibling of call(); the failing request renders as `GRAPHQL ` where a REST error shows method and path. */ @@ -361,8 +370,9 @@ function graphqlErrorTolerated( /** * Tolerated error types come back as { error }; the set defaults to the declared outcomes, and an explicit - * `tolerate` only tolerates FEWER. Tolerance reads the OBSERVED GraphQL types (graphqlErrorTolerated), - * never the folded HTTP status. + * `tolerate` only tolerates FEWER (graphqlOp keeps the literal `outcomes` keys, so a tolerate naming an undeclared + * type does not compile). Tolerance reads the OBSERVED GraphQL types (graphqlErrorTolerated), never the folded + * HTTP status. */ export async function tryCallGraphql( ctx: SectionContext, @@ -370,7 +380,6 @@ export async function tryCallGraphql( op: O, variables: Readonly>, opts?: { - // graphqlOp preserves the literal `outcomes` keys, so a tolerate naming an undeclared type does not compile. tolerate?: readonly (keyof O["outcomes"] & GraphqlTolerableError)[]; describe?: string; }, @@ -388,11 +397,56 @@ export async function tryCallGraphql( return ok(result); } +/** The connection a page's data carries under `path`, or null when it lacks nodes or pageInfo.hasNextPage. */ +function connectionOf( + data: Record, + path: readonly string[], +): ConnectionPage | null { + const connection = path.reduce( + (node, key) => (node as Record | null)?.[key], + data, + ) as { nodes?: unknown; pageInfo?: { hasNextPage?: unknown; endCursor?: unknown } } | null; + const nodes = connection?.nodes; + const pageInfo = connection?.pageInfo; + if (!Array.isArray(nodes) || typeof pageInfo?.hasNextPage !== "boolean") { + return null; + } + return { nodes, hasNextPage: pageInfo.hasNextPage, endCursor: pageInfo.endCursor }; +} + /** - * The cursor loop lives here so paging cannot drift between sections. Declared error outcomes come back - * as { error } only on the FIRST page: absence describes the whole resource, and a tolerated type - * mid-walk means the connection vanished under the loop. + * One page of the walk. A declared error outcome comes back as { error } only on the FIRST page (a null cursor): + * absence describes the whole resource, and a tolerated type mid-walk means the connection vanished under the loop. */ +async function connectionPage( + ctx: SectionContext, + section: SectionMeta, + op: GraphqlPaginatedReadDecl, + variables: Readonly>, + cursor: string | null, +): Promise> { + const result = await ctx.api.tryGraphql(op, { ...variables, cursor }, ctx.repo.slug); + if ("failed" in result) { + return err(unanswered(result.failed)); + } + if ("error" in result) { + if (cursor === null && graphqlErrorTolerated(result.error, toleratedGraphqlErrors(op))) { + return ok(result); + } + return err(failureFor(section, "GRAPHQL", op.name, result.error, { op })); + } + const page = connectionOf(result.data, op.connection.path); + if (page === null) { + const path = op.connection.path.join("."); + return err({ + kind: "malformed", + message: `${section.key}: GRAPHQL ${op.name} returned a response without a "${path}" connection carrying nodes and pageInfo{hasNextPage, endCursor}, so the list cannot be paginated. The operation's query must select both under that path`, + }); + } + return ok({ page }); +} + +/** The cursor loop lives here so paging cannot drift between sections. */ export async function listGraphqlConnection( ctx: SectionContext, section: SectionMeta, @@ -400,42 +454,27 @@ export async function listGraphqlConnection( // The `cursor?: never` pin: the loop owns the variable, so a call site supplying its own does not compile. variables: Readonly> & { cursor?: never }, ): Promise> { - const path = op.connection.path; const items: unknown[] = []; let cursor: string | null = null; for (;;) { - const result = await ctx.api.tryGraphql(op, { ...variables, cursor }, ctx.repo.slug); - if ("failed" in result) { - return err(unanswered(result.failed)); + const answered = await connectionPage(ctx, section, op, variables, cursor); + if (answered.isErr()) { + return err(answered.error); } + const result = answered.value; if ("error" in result) { - if (cursor === null && graphqlErrorTolerated(result.error, toleratedGraphqlErrors(op))) { - return ok(result); - } - return err(failureFor(section, "GRAPHQL", op.name, result.error, { op })); - } - const connection = path.reduce( - (node, key) => (node as Record | null)?.[key], - result.data, - ) as { nodes?: unknown; pageInfo?: { hasNextPage?: unknown; endCursor?: unknown } } | null; - const nodes = connection?.nodes; - const pageInfo = connection?.pageInfo; - if (!Array.isArray(nodes) || typeof pageInfo?.hasNextPage !== "boolean") { - return err({ - kind: "malformed", - message: `${section.key}: GRAPHQL ${op.name} returned a response without a "${path.join(".")}" connection carrying nodes and pageInfo{hasNextPage, endCursor}, so the list cannot be paginated. The operation's query must select both under that path`, - }); + return ok(result); } + const { nodes, hasNextPage, endCursor } = result.page; items.push(...nodes); - if (!pageInfo.hasNextPage) { + if (!hasNextPage) { return ok({ items }); } - const endCursor = pageInfo.endCursor; if (typeof endCursor !== "string" || endCursor === cursor) { // hasNextPage without a fresh endCursor would loop forever. return err({ kind: "malformed", - message: `${section.key}: GRAPHQL ${op.name} reported hasNextPage without a new endCursor at "${path.join(".")}", so the pagination cannot advance. The operation's query must select pageInfo{hasNextPage, endCursor}`, + message: `${section.key}: GRAPHQL ${op.name} reported hasNextPage without a new endCursor at "${op.connection.path.join(".")}", so the pagination cannot advance. The operation's query must select pageInfo{hasNextPage, endCursor}`, }); } cursor = endCursor; diff --git a/src/sections/custom_properties/index.ts b/src/sections/custom_properties/index.ts index 8cb4d51c2..fdb5188a8 100644 --- a/src/sections/custom_properties/index.ts +++ b/src/sections/custom_properties/index.ts @@ -1,12 +1,14 @@ /** * `custom_properties:` section: values of organization-defined custom properties, set through ONE * bulk PATCH. Definitions are org-scoped, so only values are managed; a personal account no-ops - * with a note, and `value: null` unsets (reverting to the org default). Bespoke, not on listSection: - * every value rides one write, so there is no per-item create, update, or delete to declare. + * with a note, and `value: null` unsets (reverting to the org default), so a higher layer's null is + * the value, never a marker for the lower one. Bespoke, not on listSection: every value rides one + * write, so there is no per-item create, update, or delete to declare. */ import { ok, type Result } from "neverthrow"; import { z } from "zod"; +import type { UndeclaredPolicy } from "../../types.js"; import { type DeclaredIssue, declaredEntries } from "../contract/declared.js"; import { undeclaredDrift, @@ -119,11 +121,12 @@ const LiveProperty = z.looseObject({ property_name: z.string(), value: z.union([z.string(), z.array(z.string()), z.null()]), }); +type LiveProperty = z.infer; function propertiesByName( section: SectionMeta, - live: readonly z.infer[], -): Result>, SectionFailure> { + live: readonly LiveProperty[], +): Result, SectionFailure> { return liveByIdentity( section, "custom property", @@ -140,11 +143,110 @@ interface PendingUpdate { readonly change: string; } -// Verbatim: GitHub documents no case folding for property names, so entries are duplicates only when they match exactly. -// `value: null` unsets the property, so a higher layer's null is the value, never a marker for the lower one. +/** What the undeclared walk needs besides the live values: who is declared, and what the policy says to do with the rest. */ +interface Undeclared { + readonly declaredNames: ReadonlySet; + readonly policy: UndeclaredPolicy; + readonly listDefault: UndeclaredPolicy; +} + +/** The pending updates of the declared properties whose live value differs; a declared null unsets. */ +function declaredUpdates( + desired: readonly CustomPropertyConfig[], + liveByName: ReadonlyMap, +): PendingUpdate[] { + const updates: PendingUpdate[] = []; + for (const property of desired) { + const name = property.property_name; + const wanted = normalizeValue(property.value); + // A live null and an absent live entry both mean "unset". + const current = liveByName.get(name)?.value ?? null; + if (sameValue(wanted, current)) { + continue; + } + + const label = `custom_properties[${name}]`; + if (wanted === null) { + updates.push({ + property_name: name, + value: null, + drift: `${label}: declared null but the live value is ${show(current)}; apply will unset it (reverting to the org default, if any)`, + change: `unset custom property "${name}"`, + }); + continue; + } + + updates.push({ + property_name: name, + value: wanted, + drift: valueDrift(label, show(wanted), show(current)), + change: `set custom property "${name}" to ${show(wanted)}`, + }); + } + + return updates; +} + +/** The unsets of the live properties set but undeclared; under the keep policy each earns a note instead. */ +function undeclaredUpdates( + live: readonly LiveProperty[], + undeclared: Undeclared, + notes: string[], +): PendingUpdate[] { + const updates: PendingUpdate[] = []; + for (const property of live) { + const name = property.property_name; + if (undeclared.declaredNames.has(name) || property.value === null) { + continue; + } + + if (undeclared.policy === "keep") { + notes.push( + undeclaredNote({ + subject: `custom property "${name}"`, + state: "is set on the repo but not declared", + action: "UNSET it", + }), + ); + continue; + } + + updates.push({ + property_name: name, + value: null, + drift: undeclaredDrift(undeclared.listDefault, { + label: `custom_properties[${name}]`, + action: "unset it (reverting to the org default, if any)", + }), + change: `unset undeclared custom property "${name}"`, + }); + } + + return updates; +} + +/** The one PATCH carrying every pending update, its drift and change lines one per property; undefined with none. */ +function bulkUpdate(updates: readonly PendingUpdate[]): PlannedOp | undefined { + const [first, ...rest] = updates; + if (first === undefined) { + return undefined; + } + + return { + role: "update", + payload: { + properties: updates.map(({ property_name, value }) => ({ property_name, value })), + }, + describe: "updating custom property values", + drift: [first.drift, ...rest.map((update) => update.drift)], + change: () => ok([first.change, ...rest.map((update) => update.change)] as const), + }; +} + const IDENTITY = identifiedBy("custom_properties", "property_name", "custom property"); export const customPropertiesSection = { + // Verbatim: GitHub documents no case folding for property names, so entries are duplicates only when they match exactly. ...IDENTITY, undeclaredDefault: "keep", permission, @@ -170,77 +272,23 @@ export const customPropertiesSection = { ]; }, async plan(ctx, declared) { - const { policy, entries: desired } = undeclaredPolicy(declared, defaultUndeclaredPolicy(this)); + const listDefault = defaultUndeclaredPolicy(this); + const { policy, entries: desired } = undeclaredPolicy(declared, listDefault); const plan: SectionPlan> = { ops: [], notes: [], drift: [] }; // Not paginated upstream: one GET carries every value. return ctx.read.list.call(z.array(LiveProperty)).andThen((live) => propertiesByName(this, live).map((liveByName) => { const declaredNames = new Set(desired.map((p) => p.property_name)); + const undeclared: Undeclared = { declaredNames, policy, listDefault }; + const updates = [ + ...declaredUpdates(desired, liveByName), + ...undeclaredUpdates(live, undeclared, plan.notes), + ]; - // A live null and an absent live entry both mean "unset". - const updates: PendingUpdate[] = []; - for (const property of desired) { - const name = property.property_name; - const wanted = normalizeValue(property.value); - const current = liveByName.get(name)?.value ?? null; - if (sameValue(wanted, current)) { - continue; - } - const label = `custom_properties[${name}]`; - updates.push( - wanted === null - ? { - property_name: name, - value: null, - drift: `${label}: declared null but the live value is ${show(current)}; apply will unset it (reverting to the org default, if any)`, - change: `unset custom property "${name}"`, - } - : { - property_name: name, - value: wanted, - drift: valueDrift(label, show(wanted), show(current)), - change: `set custom property "${name}" to ${show(wanted)}`, - }, - ); + const update = bulkUpdate(updates); + if (update !== undefined) { + plan.ops.push(update); } - for (const property of live) { - const name = property.property_name; - if (declaredNames.has(name) || property.value === null) { - continue; - } - if (policy === "keep") { - plan.notes.push( - undeclaredNote({ - subject: `custom property "${name}"`, - state: "is set on the repo but not declared", - action: "UNSET it", - }), - ); - continue; - } - updates.push({ - property_name: name, - value: null, - drift: undeclaredDrift(defaultUndeclaredPolicy(this), { - label: `custom_properties[${name}]`, - action: "unset it (reverting to the org default, if any)", - }), - change: `unset undeclared custom property "${name}"`, - }); - } - const [first, ...rest] = updates; - if (first === undefined) { - return plan; - } - plan.ops.push({ - role: "update", - payload: { - properties: updates.map(({ property_name, value }) => ({ property_name, value })), - }, - describe: "updating custom property values", - drift: [first.drift, ...rest.map((update) => update.drift)], - change: () => ok([first.change, ...rest.map((update) => update.change)] as const), - }); return plan; }), ); diff --git a/src/sections/deploy_keys/index.ts b/src/sections/deploy_keys/index.ts index adca413a5..56a793a32 100644 --- a/src/sections/deploy_keys/index.ts +++ b/src/sections/deploy_keys/index.ts @@ -1,7 +1,4 @@ -/** - * `deploy_keys:` section: deploy keys matched by exact title; the declared material is a PUBLIC key. - * Immutable upstream (no update role), so a changed key or read_only flag is delete plus recreate. - */ +/** `deploy_keys:` section: deploy keys matched by exact title; the declared material is a PUBLIC key. */ import { ok } from "neverthrow"; import { z } from "zod"; @@ -16,11 +13,7 @@ import { parseStoredKey, } from "./schema.js"; -/** - * A live deploy key with its material parsed ONCE, at the response boundary: the two-field shape is the - * documented contract, the algorithm is GitHub's call. Material off that shape fails the read as a body - * outside the documented shape, so no hook downstream re-parses or re-checks it. - */ +// The material is parsed ONCE, at the response boundary: a key off the two-field shape fails the read as a body outside the documented shape, so no hook downstream re-parses it; the algorithm is GitHub's call. const LiveDeployKey = z .looseObject({ id: z.number(), @@ -41,6 +34,7 @@ const LiveDeployKey = z return { ...live, key: parsed.value.material, algorithm: parsed.value.algorithm }; }); +// No update route: a deploy key is immutable upstream, so a changed key or read_only flag is delete plus recreate. const ENDPOINTS = { list: { route: "GET /repos/{owner}/{repo}/keys", diff --git a/src/sections/deploy_keys/schema.ts b/src/sections/deploy_keys/schema.ts index 22e468501..a78c81177 100644 --- a/src/sections/deploy_keys/schema.ts +++ b/src/sections/deploy_keys/schema.ts @@ -1,14 +1,13 @@ -/** The `deploy_keys:` section's schema slice; root src/schema.ts composes the SettingsFile property from it. */ +/** + * The `deploy_keys:` section's schema slice; root src/schema.ts composes the SettingsFile property from it. + * PUBLIC_KEY_ALGORITHMS is listed by hand in the docs field note and leaves out DSA (ssh-dss): GitHub stopped + * accepting it on 2022-03-15, so a DSA key passing the parse would only fail at the create. + */ import { err, ok, type Result } from "neverthrow"; import { z } from "zod"; import { open, rule } from "../shared/schema-helpers.js"; -/** - * The key types GitHub accepts for a deploy key; the docs field note lists them for the reader. DSA (ssh-dss) is - * absent on purpose: GitHub stopped accepting DSA keys on 2022-03-15, so a DSA key passing here would only fail at the - * create request. - */ export const PUBLIC_KEY_ALGORITHMS = [ "ssh-ed25519", "ssh-rsa", @@ -18,11 +17,11 @@ export const PUBLIC_KEY_ALGORITHMS = [ "sk-ssh-ed25519@openssh.com", "sk-ecdsa-sha2-nistp256@openssh.com", ] as const; +const ALGORITHMS = new Set(PUBLIC_KEY_ALGORITHMS); const BASE64_QUARTET = "[A-Za-z0-9+/]{4}"; const BASE64_BLOB = `(?:${BASE64_QUARTET})*(?:${BASE64_QUARTET}|[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)`; -/** The one field separator; the rejection helpers split on it so they see the fields the pattern saw. */ const FIELD_SEPARATOR = String.raw`[ \t]`; /** A second line is refused rather than dropped, so nothing pasted after a valid key is silently normalized away. */ @@ -34,33 +33,29 @@ function materialPattern(algorithms: string): RegExp { const FIELDS = new RegExp(`${FIELD_SEPARATOR}+`); const LINE_BREAK = /[\r\n]/; -/** Whitespace the pattern never accepts as a separator, a non-breaking space for one; a comment may hold it. */ const ODD_WHITESPACE = /[^\S \t\r\n]/; +/** Splits on FIELD_SEPARATOR, so the rejection helpers see the fields the pattern saw. */ function fieldsOf(raw: string): string[] { return raw.split(FIELDS).filter((field) => field !== ""); } -/** - * The ONE acceptance rule for DECLARED deploy key material: the runtime parses through it and the published JSON - * schema carries its source as the field's `pattern`, so an editor and the run agree. - */ +// The ONE acceptance rule for DECLARED material: the runtime parses through it and the published JSON schema carries its source as the field's pattern, so an editor and the run agree. const PUBLIC_KEY_PATTERN = materialPattern( PUBLIC_KEY_ALGORITHMS.map((algorithm) => algorithm.replaceAll(".", "\\.")).join("|"), ); -/** - * The rule for material GitHub STORED: any algorithm token. GitHub validated the key when it accepted it and is - * the authority on what it takes, so a live key under an algorithm this list lacks must not abort the section. - */ +// The rule for material GitHub STORED, any algorithm token: GitHub validated the key when it accepted it, so a live key under an algorithm PUBLIC_KEY_ALGORITHMS lacks must not abort the section. const STORED_KEY_PATTERN = materialPattern(String.raw`\S+`); -const ALGORITHMS = new Set(PUBLIC_KEY_ALGORITHMS); +// The header and footer framing of every PEM and OpenSSH private key, assembled at runtime so no source line holds the string a secret scanner flags. +const PRIVATE_KEY_FRAMING = ["PRIVATE", "KEY-----"].join(" "); /** Whether the settings file can declare a key under this algorithm; a live key under any other is outside the section. */ export function declaresAlgorithm(algorithm: string): boolean { return ALGORITHMS.has(algorithm); } + const PUBLIC_KEY_FORM = `one line, " [comment]", with the algorithm one of ${PUBLIC_KEY_ALGORITHMS.join(", ")}`; const FEWER_THAN_TWO_FIELDS = "the key has fewer than two fields separated by a space or tab"; const HAS_LINE_BREAK = "the key contains a line break"; @@ -69,12 +64,6 @@ const HAS_ODD_WHITESPACE = const BLOB_NOT_BASE64 = "the key's second field is not base64 (the alphabet A-Z a-z 0-9 + / with = padding to a multiple of four)"; -/** - * The framing every PEM and OpenSSH private key carries in its header and footer, assembled at runtime so no - * source line holds the string a secret scanner flags as a private key. - */ -const PRIVATE_KEY_FRAMING = ["PRIVATE", "KEY-----"].join(" "); - /** `material` is the comparable form, algorithm + blob: GitHub may strip or rewrite the comment on storage. */ export interface PublicKeyMaterial { readonly algorithm: string; @@ -112,7 +101,8 @@ function declaredRejection(raw: string): string { /** * The shape rules both readings share, in the order a reader fixes them; undefined once the value is two or more - * fields on one line, so that only the algorithm or the blob can have failed the pattern. + * fields on one line, so that only the algorithm or the blob can have failed the pattern. Only those two fields are + * tested for odd whitespace (a non-breaking space, for one), since a comment may hold it. */ function formRejection( raw: string, @@ -153,12 +143,12 @@ export function parseStoredKey(raw: string): Result { return parseMaterial(STORED_KEY_PATTERN, raw, storedRejection); } +/** The check is entry-level so the refusal can name the entry by title; a field-level one would know only the index. */ export const DeployKeyConfig = open({ title: z.string(), key: z.string().meta({ pattern: PUBLIC_KEY_PATTERN.source }), read_only: z.boolean().optional(), }) - // Entry-level so the refusal can name the entry by title; the field-level form would know only the index. .check( rule((entry, refineCtx) => { // The key or the title may be raw beside its own shape issue (see ../shared/raw-values.ts): a non-string key is @@ -179,4 +169,5 @@ export const DeployKeyConfig = open({ }), ) .meta({ id: "DeployKeyConfig" }); + export type DeployKeyConfig = z.infer; diff --git a/src/sections/environments/branch-policies.ts b/src/sections/environments/branch-policies.ts index e97f481d9..2c4c4e39d 100644 --- a/src/sections/environments/branch-policies.ts +++ b/src/sections/environments/branch-policies.ts @@ -22,22 +22,16 @@ import type { LiveEnvironmentBody } from "./index.js"; import type { NestedPlan } from "./nested.js"; import type { DeploymentBranchPolicyConfig } from "./schema.js"; -// "delete" like the nested variables list: patterns are readable, recreatable configuration. +/** "delete" like the nested variables list: patterns are readable, recreatable configuration. */ export const BRANCH_POLICIES_DEFAULT_POLICY: UndeclaredPolicy = "delete"; -/** - * GitHub's spec marks every field optional. A policy without a name has no identity to reconcile by, - * and skipping it would let check report falsely clean while the delete policy neither removed nor noted it. - * - * missing type -> the server default "branch" - * missing name -> loud failure - * missing id -> loud failure when a delete addresses it - */ +// GitHub's spec marks every field optional; a policy without a name fails loudly rather than being skipped, or check would read falsely clean while the delete policy neither removed nor noted it. const LiveBranchPolicy = z.looseObject({ id: z.number().optional(), name: z.string().optional(), type: z.string().optional(), }); + type LiveBranchPolicy = z.infer; /** "branch" is GitHub's server-side default when the type is absent. */ @@ -130,77 +124,71 @@ export function duplicateBranchPolicyIssues( ); } -/** With custom_branch_policies off the pattern list 404s, so patterns already behind the flag reconcile on the next run. */ -export async function planBranchPolicies( - ctx: EnvironmentsRestContext, - section: SectionMeta, +/** + * The ops one declared pattern needs: a create when it is missing (unverified while the flag hides + * the list), a delete plus a recreate when its immutable type differs, none when it matches. + */ +function patternOps( envName: string, - policy: UndeclaredPolicy, - entries: readonly DeploymentBranchPolicyConfig[], - liveEnv: LiveEnvironmentBody | undefined, -): Promise> { - return safeTry(async function* () { - const params = { environment_name: envName }; - const planned: NestedPlan = { ops: [], notes: [] }; - const hidden = - liveEnv !== undefined && liveEnv.deployment_branch_policy?.custom_branch_policies !== true; - let live: LiveBranchPolicy[] = []; - if (hidden) { - // With no list read, preflight never probes listPolicies for this environment, so an - // Actions-read denial shows up on the next run's read instead of this one's preflight. - planned.notes.push( - `environments[${envName}].deployment_branch_policies: patterns are not verifiable until custom_branch_policies is true; apply will set the flag and create the declared patterns, and any pattern already behind the flag reconciles on the next run`, - ); - } else if (liveEnv !== undefined) { - live = yield* listBranchPolicies(ctx, envName); - } - const liveByName = yield* policiesByName(section, live, envName); - const declared = new Set(entries.map((pattern) => pattern.name)); + pattern: DeploymentBranchPolicyConfig, + existing: LiveBranchPolicy | undefined, + hidden: boolean, +): Result { + const label = `environments[${envName}].deployment_branch_policies[${pattern.name}]`; + if (!existing) { + return ok([ + { + ...createPolicyOp(envName, pattern), + drift: [ + hidden + ? `${label}: not verifiable until custom_branch_policies is true; apply will create it once the flag is set` + : missingDrift(label, { where: "on the environment" }), + ], + change: `created deployment branch policy "${pattern.name}" in environment "${envName}"`, + }, + ]); + } - for (const pattern of entries) { - const label = `environments[${envName}].deployment_branch_policies[${pattern.name}]`; - const existing = liveByName.get(pattern.name); - if (!existing) { - planned.ops.push({ - ...createPolicyOp(envName, pattern), - drift: [ - hidden - ? `${label}: not verifiable until custom_branch_policies is true; apply will create it once the flag is set` - : missingDrift(label, { where: "on the environment" }), - ], - change: `created deployment branch policy "${pattern.name}" in environment "${envName}"`, - }); - continue; - } - const desiredType = pattern.type ?? "branch"; - const liveType = livePolicyType(existing); - if (liveType === desiredType) { - continue; - } - const typeDrift = subsetDiff({ type: desiredType }, { type: liveType }, label); - if (!hasDrift(typeDrift)) { - throw new Error( - `BUG: environments: the pattern "${pattern.name}" of environment "${envName}" has a type mismatch (${liveType} vs ${desiredType}) that subsetDiff did not render`, - ); - } - planned.ops.push( - { - role: "removePolicy", - params: { ...params, branch_policy_id: yield* livePolicyId(existing, envName) }, - drift: [ - `${label}: the declared type differs from the live pattern's, and a policy's type is immutable; apply will delete and recreate it`, - ], - change: `deleted deployment branch policy "${pattern.name}" in environment "${envName}" to change its immutable type (${liveType} -> ${desiredType})`, - describe: `deleting deployment branch policy "${pattern.name}" in environment "${envName}" to change its immutable type`, - }, - { - ...createPolicyOp(envName, pattern), - drift: typeDrift, - change: `recreated deployment branch policy "${pattern.name}" in environment "${envName}" as type ${desiredType}`, - }, - ); - } + const desiredType = pattern.type ?? "branch"; + const liveType = livePolicyType(existing); + if (liveType === desiredType) { + return ok([]); + } + + const typeDrift = subsetDiff({ type: desiredType }, { type: liveType }, label); + if (!hasDrift(typeDrift)) { + throw new Error( + `BUG: environments: the pattern "${pattern.name}" of environment "${envName}" has a type mismatch (${liveType} vs ${desiredType}) that subsetDiff did not render`, + ); + } + + return livePolicyId(existing, envName).map((branchPolicyId): EnvironmentRestOp[] => [ + { + role: "removePolicy", + params: { environment_name: envName, branch_policy_id: branchPolicyId }, + drift: [ + `${label}: the declared type differs from the live pattern's, and a policy's type is immutable; apply will delete and recreate it`, + ], + change: `deleted deployment branch policy "${pattern.name}" in environment "${envName}" to change its immutable type (${liveType} -> ${desiredType})`, + describe: `deleting deployment branch policy "${pattern.name}" in environment "${envName}" to change its immutable type`, + }, + { + ...createPolicyOp(envName, pattern), + drift: typeDrift, + change: `recreated deployment branch policy "${pattern.name}" in environment "${envName}" as type ${desiredType}`, + }, + ]); +} +/** A keep-note or the delete for each live pattern the list does not name. */ +function undeclaredPolicyOps( + envName: string, + liveByName: ReadonlyMap, + declared: ReadonlySet, + policy: UndeclaredPolicy, +): Result { + return safeTry(function* () { + const planned: NestedPlan = { ops: [], notes: [] }; for (const [name, existing] of liveByName) { if (declared.has(name)) { continue; @@ -217,7 +205,10 @@ export async function planBranchPolicies( } planned.ops.push({ role: "removePolicy", - params: { ...params, branch_policy_id: yield* livePolicyId(existing, envName) }, + params: { + environment_name: envName, + branch_policy_id: yield* livePolicyId(existing, envName), + }, drift: [ undeclaredDrift(BRANCH_POLICIES_DEFAULT_POLICY, { label: `environments[${envName}].deployment_branch_policies[${name}]`, @@ -231,3 +222,41 @@ export async function planBranchPolicies( return ok(planned); }); } + +/** With custom_branch_policies off the pattern list 404s, so patterns already behind the flag reconcile on the next run. */ +export async function planBranchPolicies( + ctx: EnvironmentsRestContext, + section: SectionMeta, + envName: string, + policy: UndeclaredPolicy, + entries: readonly DeploymentBranchPolicyConfig[], + liveEnv: LiveEnvironmentBody | undefined, +): Promise> { + return safeTry(async function* () { + const planned: NestedPlan = { ops: [], notes: [] }; + const hidden = + liveEnv !== undefined && liveEnv.deployment_branch_policy?.custom_branch_policies !== true; + let live: LiveBranchPolicy[] = []; + if (hidden) { + // With no list read, preflight never probes listPolicies for this environment, so an + // Actions-read denial shows up on the next run's read instead of this one's preflight. + planned.notes.push( + `environments[${envName}].deployment_branch_policies: patterns are not verifiable until custom_branch_policies is true; apply will set the flag and create the declared patterns, and any pattern already behind the flag reconciles on the next run`, + ); + } else if (liveEnv !== undefined) { + live = yield* listBranchPolicies(ctx, envName); + } + const liveByName = yield* policiesByName(section, live, envName); + const declared = new Set(entries.map((pattern) => pattern.name)); + + for (const pattern of entries) { + const ops = yield* patternOps(envName, pattern, liveByName.get(pattern.name), hidden); + planned.ops.push(...ops); + } + + const undeclared = yield* undeclaredPolicyOps(envName, liveByName, declared, policy); + planned.ops.push(...undeclared.ops); + planned.notes.push(...undeclared.notes); + return ok(planned); + }); +} diff --git a/src/sections/environments/index.ts b/src/sections/environments/index.ts index feca11c62..5558cdb93 100644 --- a/src/sections/environments/index.ts +++ b/src/sections/environments/index.ts @@ -13,7 +13,7 @@ import { missingDrift } from "../contract/drift.js"; import type { SectionFailure } from "../contract/errors.js"; import { identifiedBy, type KeyedListLayering, keyedBy } from "../contract/keyed-list.js"; import { liveByIdentity, liveIdentity } from "../contract/live.js"; -import type { SectionModule } from "../contract/module.js"; +import type { SectionMeta, SectionModule } from "../contract/module.js"; import type { SectionPermission } from "../contract/permissions.js"; import { hasDrift } from "../contract/plan.js"; import { layeredList, routed } from "../shared/schema-helpers.js"; @@ -29,7 +29,9 @@ import { validateNested, } from "./nested.js"; import { + type EnvironmentsOp, type EnvironmentsPlan, + type EnvironmentsPlanContext, environmentNodeId, GRAPHQL_OPS, type PinDeclaration, @@ -39,11 +41,7 @@ import { import { EnvironmentConfig, EnvironmentsConfig, environmentKey } from "./schema.js"; import { sharedSecretNotes, snapshotNested, withPins } from "./snapshot.js"; -/** - * The environment body (the probe's, and each listing item's): the protection rules GET nests, which - * flattenEnvironment un-nests, and the branch-policy flags the nested planners read. Every field the - * section touches is declared, so an off-shape body fails the read instead of a translation. - */ +// Every field the section touches is declared here, so an off-shape body fails the read instead of flattenEnvironment's translation or a nested planner's flag read. const LiveEnvironmentBody = z.looseObject({ node_id: z.string().optional(), protection_rules: z @@ -68,23 +66,20 @@ const LiveEnvironmentBody = z.looseObject({ .nullable() .optional(), }); + export type LiveEnvironmentBody = z.infer; /** One item of the environment listing: the name plan() probes by is pinned; the id tells two same-fold names apart. */ const LiveEnvironment = LiveEnvironmentBody.extend({ id: z.number().optional(), name: z.string() }); +/** The section's default grant, which an endpoint's own permission in ENDPOINTS overrides (the nested lists' do; NESTED_OVERRIDES_CAVEAT names theirs). */ const permission: SectionPermission = { repo: ["environments"] }; -// GitHub gates the pattern and protection-rule endpoints outside the Environments permission (per -// the fine-grained permissions reference); appended to the grant so any denial in the section names them. +// GitHub gates the pattern and protection-rule endpoints outside the Environments permission; appended to the grant so any denial in the section names them. const NESTED_OVERRIDES_CAVEAT = 'declared "deployment_branch_policies" and "deployment_protection_rules" keys additionally need "Actions" (read) and "Administration" (read and write)'; -/** - * A reviewer is a `type` and a numeric `id`; users and teams number from separate spaces, so the pair is the key. A - * removal entry reaches here unvalidated (the standalone view drops it before the schema), so the type is read only - * as a string: a list or mapping there is a keyless entry, not a coerced name. - */ +// Users and teams number from separate id spaces, so the key is the type:id pair; a removal entry arrives unvalidated, so a non-string type is a keyless entry, never a coerced name. const REVIEWER_LAYERING: KeyedListLayering = { keyField: "id", keyKind: "numeric", @@ -95,11 +90,7 @@ const REVIEWER_LAYERING: KeyedListLayering = { removalPaths: ["type", "id"], }; -/** - * Environment names fold as plan() probes them (case-insensitive). The nested lists union by the key each - * planner reconciles by: variable and secret names uppercased as GitHub stores them, branch policies by their - * pattern, protection rules by App slug, reviewers by type and id. - */ +// Each nested list layers by the key its planner reconciles by; variable and secret names fold uppercase as GitHub stores them, environment names as plan() probes them. const IDENTITY = identifiedBy("environments", "name", "environment", { fold: environmentKey, nested: { @@ -121,6 +112,175 @@ const IDENTITY = identifiedBy("environments", "name", "environment", { }, }); +/** What one environment's pin mutations resolve their node id from; both closures share the captured id. */ +interface PinNodeId { + /** Fails when the body lacks a node_id; the plan calls it only from a mutation thunk. */ + nodeId: () => Result; + + /** The PUT's capture hook, present only for a created environment that declares a pin: the response is the id's only source. */ + capture: ((response: unknown) => Result) | undefined; +} + +/** An unknown rule type un-nests generically, or a declared setting of theirs would read as drift; the rule's own envelope keys stay out. */ +function unnestRuleSettings(out: Record, rule: Record): void { + for (const [key, value] of Object.entries(rule)) { + if (!["id", "node_id", "type", "url"].includes(key)) { + out[key] = value; + } + } +} + +/** + * GET nests wait_timer / prevent_self_review / reviewers inside protection_rules[]; translated back + * to the PUT shape so check compares like with like. Exported so the e2e state tests can assert + * their environmentFromPut inverts this exact function. + * + * An environment without protection answers protection_rules: [], so the disabled values are the + * baseline and a present rule overwrites its keys: a declared `wait_timer: 0` or `reviewers: []` + * is satisfied by the absence of the rule, as GitHub itself reads it. + */ +export function flattenEnvironment(live: LiveEnvironmentBody): Record { + const out: Record = { + ...live, + wait_timer: 0, + prevent_self_review: false, + reviewers: [], + }; + for (const rule of live.protection_rules ?? []) { + if (rule.type === "wait_timer") { + out.wait_timer = rule.wait_timer; + continue; + } + if (rule.type === "required_reviewers") { + const reviewers = rule.reviewers ?? []; + // The flag counts only with a reviewer to apply it to, the combination the schema accepts. + out.prevent_self_review = rule.prevent_self_review === true && reviewers.length > 0; + out.reviewers = reviewers.map((r) => ({ type: r.type, id: r.reviewer?.id })); + continue; + } + unnestRuleSettings(out, rule); + } + return out; +} + +/** + * The PUT replaces the environment's settings whole (an omitted `reviewers` clears the reviewers rule), so a + * non-empty live setting the entry omits is drift too, and the lines it makes (`omitted`) are what apply refuses + * the write over. The live body is split the way the entry was, so only the PUT's own keys take part in that sweep. + * The entry passes unknown keys through, so a key the GET never echoes would re-PUT on every apply without + * converging; `notes` names it. An entry key the GET omits (deployment_branch_policy on an environment that never + * set one) is drift the PUT resolves, so only a key outside the entry shape is noted. An environment that does + * not exist yet earns the missing line alone. + */ +function environmentDrift( + label: string, + settings: Record, + live: LiveEnvironmentBody | undefined, +): { drift: string[]; omitted: string[]; notes: string[] } { + if (live === undefined) { + return { drift: [missingDrift(label)], omitted: [], notes: [] }; + } + + const flattened = flattenEnvironment(live); + const liveSettings = splitEntry(projectOntoSchema(EnvironmentConfig, flattened)).settings; + const omitted = omittedDeltas(settings, liveSettings, { + sweep: replaceSweep(EnvironmentConfig), + }).map((delta) => renderDelta(label, delta)); + const phantom = phantomKeys(settings, flattened).filter( + (key) => !Object.hasOwn(EnvironmentConfig.shape, key), + ); + const notes = + phantom.length > 0 ? [phantomNote(label, phantom, "environment", "this PUT will re-run")] : []; + return { drift: [...subsetDiff(settings, flattened, label), ...omitted], omitted, notes }; +} + +/** + * The node id one environment's pin mutations address: validated off the probe body only when a + * mutation needs it, or captured from the PUT that creates the environment. + */ +function pinNodeId( + name: string, + live: LiveEnvironmentBody | undefined, + pinned: boolean | undefined, +): PinNodeId { + const probedNodeId = live === undefined ? undefined : { node_id: live.node_id }; + let createdNodeId: string | undefined; + const nodeId = (): Result => { + if (probedNodeId !== undefined) { + return environmentNodeId(name, probedNodeId); + } + if (createdNodeId === undefined) { + throw new Error( + `BUG: environments: the pin of "${name}" ran before the PUT that creates the environment`, + ); + } + return ok(createdNodeId); + }; + const capture = + live === undefined && pinned !== undefined + ? (response: unknown) => + environmentNodeId(name, response).andThen((id) => { + createdNodeId = id; + return ok(undefined); + }) + : undefined; + return { nodeId, capture }; +} + +/** + * One environment's share of the plan: its PUT when the settings drift, every nested list's ops after + * it, and the pin its `pinned` key declares. + */ +async function planEnvironment( + ctx: EnvironmentsPlanContext, + section: SectionMeta, + env: EnvironmentConfig, +): Promise< + Result< + { ops: EnvironmentsOp[]; notes: string[]; pin: PinDeclaration | undefined }, + SectionFailure + > +> { + return safeTry(async function* () { + const { settings, nested, routed } = splitEntry(env); + const name = env.name; + const params = { environment_name: name }; + const probe = yield* ctx.read.probe.probeAbsent(LiveEnvironmentBody, { + params, + describe: `environment "${name}"`, + }); + const live = "missing" in probe ? undefined : probe.data; + const label = `environments[${name}]`; + const { drift, omitted, notes: driftNotes } = environmentDrift(label, settings, live); + const ops: EnvironmentsOp[] = []; + const notes = [...driftNotes]; + const id = pinNodeId(name, live, routed.pinned); + if (hasDrift(drift)) { + ops.push({ + role: "update", + params, + payload: settings, + before: refuseOmitted(label, omitted), + drift, + change: `applied environment "${name}"`, + describe: `upserting environment "${name}"`, + capture: id.capture, + }); + } + + for (const key of NESTED_KEYS) { + const planned = yield* await planNested(ctx, section, key, name, nested, live); + ops.push(...planned.ops); + notes.push(...planned.notes); + } + + const pin = + routed.pinned === undefined ? undefined : { name, pinned: routed.pinned, nodeId: id.nodeId }; + return ok({ ops, notes, pin }); + }); +} + +/** The `environments:` section: each entry's PUT, its nested lists after it, and the pins after every environment. */ export const environmentsSection = { ...IDENTITY, undeclaredDefault: "untouched", @@ -153,66 +313,15 @@ export const environmentsSection = { async plan(ctx, desired) { const section = this; return safeTry(async function* () { - const environments = listEntries(desired); const plan: EnvironmentsPlan = { ops: [], notes: [], drift: [] }; /** Each entry's declared pin state, in file order (order IS the pin order). */ const pins: PinDeclaration[] = []; - for (const env of environments) { - const { settings, nested, routed } = splitEntry(env); - const name = env.name; - const params = { environment_name: name }; - const probe = yield* ctx.read.probe.probeAbsent(LiveEnvironmentBody, { - params, - describe: `environment "${name}"`, - }); - const live = "missing" in probe ? undefined : probe.data; - const label = `environments[${name}]`; - const { drift, omitted, notes } = - live === undefined - ? { drift: [missingDrift(label)], omitted: [], notes: [] } - : environmentDrift(label, settings, flattenEnvironment(live)); - plan.notes.push(...notes); - // The pin mutations' node id, off the probe or a created environment's PUT response. A probed - // body is validated only when a mutation needs it. - const probedNodeId = live === undefined ? undefined : { node_id: live.node_id }; - let createdNodeId: string | undefined; - const nodeId = (): Result => { - if (probedNodeId !== undefined) { - return environmentNodeId(name, probedNodeId); - } - if (createdNodeId === undefined) { - throw new Error( - `BUG: environments: the pin of "${name}" ran before the PUT that creates the environment`, - ); - } - return ok(createdNodeId); - }; - if (hasDrift(drift)) { - plan.ops.push({ - role: "update", - params, - payload: settings, - before: refuseOmitted(label, omitted), - drift, - change: `applied environment "${name}"`, - describe: `upserting environment "${name}"`, - capture: - live === undefined && routed.pinned !== undefined - ? (response) => - environmentNodeId(name, response).andThen((id) => { - createdNodeId = id; - return ok(undefined); - }) - : undefined, - }); - } - if (routed.pinned !== undefined) { - pins.push({ name, pinned: routed.pinned, nodeId }); - } - for (const key of NESTED_KEYS) { - const planned = yield* await planNested(ctx, section, key, name, nested, live); - plan.ops.push(...planned.ops); - plan.notes.push(...planned.notes); + for (const env of listEntries(desired)) { + const planned = yield* await planEnvironment(ctx, section, env); + plan.ops.push(...planned.ops); + plan.notes.push(...planned.notes); + if (planned.pin !== undefined) { + pins.push(planned.pin); } } // Pins plan after every environment op: a created environment's id comes from its PUT. @@ -258,64 +367,3 @@ export const environmentsSection = { }); }, } satisfies SectionModule<"environments", typeof ENDPOINTS, typeof GRAPHQL_OPS>; - -/** - * The PUT replaces the environment's settings whole (an omitted `reviewers` clears the reviewers rule), so a - * non-empty live setting the entry omits is drift too, and the lines it makes (`omitted`) are what apply refuses - * the write over. The live body is split the way the entry was, so only the PUT's own keys take part in that sweep. - * The entry passes unknown keys through, so a key the GET never echoes would re-PUT on every apply without - * converging; `notes` names it. An entry key the GET omits (deployment_branch_policy on an environment that never - * set one) is drift the PUT resolves, so only a key outside the entry shape is noted. - */ -function environmentDrift( - label: string, - settings: Record, - live: Record, -): { drift: string[]; omitted: string[]; notes: string[] } { - const liveSettings = splitEntry(projectOntoSchema(EnvironmentConfig, live)).settings; - const omitted = omittedDeltas(settings, liveSettings, { - sweep: replaceSweep(EnvironmentConfig), - }).map((delta) => renderDelta(label, delta)); - const phantom = phantomKeys(settings, live).filter( - (key) => !Object.hasOwn(EnvironmentConfig.shape, key), - ); - const notes = - phantom.length > 0 ? [phantomNote(label, phantom, "environment", "this PUT will re-run")] : []; - return { drift: [...subsetDiff(settings, live, label), ...omitted], omitted, notes }; -} - -/** - * GET nests wait_timer / prevent_self_review / reviewers inside protection_rules[]; translated back - * to the PUT shape so check compares like with like. Exported so the e2e state tests can assert - * their environmentFromPut inverts this exact function. - * - * An environment without protection answers protection_rules: [], so the disabled values are the - * baseline and a present rule overwrites its keys: a declared `wait_timer: 0` or `reviewers: []` - * is satisfied by the absence of the rule, as GitHub itself reads it. - */ -export function flattenEnvironment(live: LiveEnvironmentBody): Record { - const out: Record = { - ...live, - wait_timer: 0, - prevent_self_review: false, - reviewers: [], - }; - for (const rule of live.protection_rules ?? []) { - if (rule.type === "wait_timer") { - out.wait_timer = rule.wait_timer; - } else if (rule.type === "required_reviewers") { - const reviewers = rule.reviewers ?? []; - // The flag counts only with a reviewer to apply it to, the combination the schema accepts. - out.prevent_self_review = rule.prevent_self_review === true && reviewers.length > 0; - out.reviewers = reviewers.map((r) => ({ type: r.type, id: r.reviewer?.id })); - } else { - // Unknown rule types un-nest generically, or a declared setting of theirs would read as drift. - for (const [key, value] of Object.entries(rule)) { - if (!["id", "node_id", "type", "url"].includes(key)) { - out[key] = value; - } - } - } - } - return out; -} diff --git a/src/sections/environments/nested.ts b/src/sections/environments/nested.ts index 0437a0659..1bcc6a796 100644 --- a/src/sections/environments/nested.ts +++ b/src/sections/environments/nested.ts @@ -38,16 +38,14 @@ import type { EnvironmentVariableConfig, } from "./schema.js"; -/** - * Stripped from the environment PUT body by splitEntry; each plans as its own sub-resource after - * the PUT, so none can leak into the PUT payload or the environment diff. - */ +/** Stripped from the environment PUT body by splitEntry and planned as sub-resources after the PUT, so no nested list leaks into the PUT payload or the environment diff. */ export const NESTED_KEYS = [ "variables", "secrets", "deployment_branch_policies", "deployment_protection_rules", ] as const satisfies readonly (keyof EnvironmentConfig)[]; + export type NestedKey = (typeof NESTED_KEYS)[number]; type NestedDeclared = { [K in NestedKey]: NonNullable }; @@ -79,28 +77,22 @@ export interface NestedPlan { * properties strictly, so a planner paired with the wrong key's entry type is a compile error. */ interface NestedPlanner { - /** - * The policy for live sub-resources the declared list omits. The section-level "untouched" - * default describes sibling environments, not the resources inside one, so each key states its own. - */ + /** The section's "untouched" default describes sibling environments, not the resources inside one, so each key states its own policy for the live sub-resources its list omits. */ defaultPolicy: UndeclaredPolicy; - /** - * Beside a declared environment that does not exist yet: its sub-resources cannot be listed, - * so every entry plans as a create against an empty environment. - */ + + /** Beside a declared environment that does not exist yet, whose sub-resources cannot be listed, so every entry plans as a create. */ missingNote: (envName: string) => string; + /** The list's file-only checks (two entries naming one resource), run by the section's validate hook; plan() trusts them. */ validate: (entries: readonly NestedEntry[], envName: string) => readonly DeclaredIssue[]; + + /** liveEnv is undefined for an environment the plan creates: its sub-resources 404 until the PUT lands, so the planner reads nothing and plans every entry as a create. */ plan: ( ctx: EnvironmentsRestContext, section: SectionMeta, envName: string, policy: UndeclaredPolicy, entries: readonly NestedEntry[], - /** - * undefined for an environment the plan creates: its sub-resources 404 until the PUT lands, - * so the planner reads nothing and plans every entry as a create. - */ liveEnv: LiveEnvironmentBody | undefined, ) => Promise>; } @@ -202,15 +194,11 @@ export async function planNested( })); } -/** - * Scalars the environment PUT does not accept: splitEntry strips them beside NESTED_KEYS, and each - * applies through its own routed operation after every PUT (pinned rides the GraphQL pin mutations). - * The lockstep types pin this list to EnvironmentRoutedScalars, where schema.ts declares routed-ness; - * a routed scalar declared on EnvironmentConfig itself would ride the PUT body unnoticed. - */ +// Stripped from the PUT body beside NESTED_KEYS and applied through their own routed operations after every PUT; the lockstep types below hold the list equal to EnvironmentRoutedScalars, where schema.ts declares routed-ness. const ROUTED_SCALAR_KEYS = [ "pinned", ] as const satisfies readonly (keyof EnvironmentRoutedScalars)[]; + type RoutedScalarKey = (typeof ROUTED_SCALAR_KEYS)[number]; type _RoutedScalarsComplete = MustBeNever>; type _RoutedScalarsSound = MustBeNever>; diff --git a/src/sections/environments/pins.ts b/src/sections/environments/pins.ts index 37807d054..6455648e8 100644 --- a/src/sections/environments/pins.ts +++ b/src/sections/environments/pins.ts @@ -18,6 +18,7 @@ import { type EnvironmentKey, environmentKey, MAX_PINNED_ENVIRONMENTS } from "./ const PINS_SELECTION = "($owner: String!, $repo: String!, $cursor: String) { repository(owner: $owner, name: $repo) { pinnedEnvironments(first: 100, after: $cursor) { nodes { position environment { name } } pageInfo { hasNextPage endCursor } } } }"; +// The planner's read; its tolerated NOT_FOUND is what listLivePins reads as "no pins". const PINS_QUERY = graphqlOp<{ owner: string; repo: string }>()({ name: "EnvironmentPins", kind: "read", @@ -30,10 +31,7 @@ const PINS_QUERY = graphqlOp<{ owner: string; repo: string }>()({ }, }); -/** - * The snapshot's read of the same pins. No write follows a snapshot to surface a denial, so - * NOT_FOUND is not tolerated here: a concealed denial fails the read with the grant advice. - */ +// The snapshot's read of the same pins: no write follows a snapshot to surface a denial, so NOT_FOUND is not tolerated here and a concealed denial fails the read with the grant advice. const PINS_SNAPSHOT = graphqlOp<{ owner: string; repo: string }>()({ name: "EnvironmentPinsSnapshot", kind: "read", @@ -42,10 +40,7 @@ const PINS_SNAPSHOT = graphqlOp<{ owner: string; repo: string }>()({ outcomes: { ok: "the pinned environments with their 1-based positions, for the snapshot" }, }); -/** - * Verified live: a new pin lands at the TAIL (so appends are modelled locally), and UNPROCESSABLE - * is GitHub's cap rejection, the belt under the plan's own gate. - */ +// Verified live: a new pin lands at the TAIL (so appends are modelled locally), and UNPROCESSABLE is GitHub's cap rejection, the belt under the plan's own gate. const PIN_ENVIRONMENT = graphqlOp<{ environmentId: string; pinned: boolean }>()({ name: "PinEnvironment", kind: "write", @@ -61,11 +56,7 @@ const PIN_ENVIRONMENT = graphqlOp<{ environmentId: string; pinned: boolean }>()( }, }); -/** - * Verified live: the position is a 1-based RANK, and this is the only mutation that renumbers the - * list (it reads back contiguous afterwards). The reconciler only ever moves a pin LEFT, toward - * rank 1, where remove-and-insert semantics are unambiguous. - */ +// Verified live: the position is a 1-based RANK and this is the only mutation that renumbers the list, so the reconciler only ever moves a pin LEFT, toward rank 1, where remove-and-insert semantics are unambiguous. const REORDER_ENVIRONMENT = graphqlOp<{ environmentId: string; position: number }>()({ name: "ReorderEnvironment", kind: "write", @@ -74,6 +65,7 @@ const REORDER_ENVIRONMENT = graphqlOp<{ environmentId: string; position: number outcomes: { ok: "the pinned environment moved to its declared position" }, }); +/** The pin operations: plan() issues them only for a declared pinned key, so a pin-free file's plan never touches /graphql; snapshot reads pinsSnapshot whenever the listing is nonempty. */ export const GRAPHQL_OPS = { pins: PINS_QUERY, pinsSnapshot: PINS_SNAPSHOT, @@ -88,28 +80,49 @@ export type EnvironmentsOp = PlannedOp; export type EnvironmentsPlan = SectionPlan; export interface PinDeclaration { + /** The entry's name as declared; every lookup folds it through environmentKey. */ name: string; + + /** true leads the pinned list in declaration order; false unpins. */ pinned: boolean; + /** Fails when the body lacks a node_id; the plan calls it only from a mutation thunk. */ nodeId: () => Result; } interface LivePin { - /** - * Possibly NON-CONTIGUOUS on live GitHub (unpinning leaves a hole, a new pin appends via a - * monotonic counter, only a reorder renumbers), so only its RANK in the sorted list is compared. - */ + /** Possibly NON-CONTIGUOUS on live GitHub (unpinning leaves a hole, a new pin appends via a monotonic counter, only a reorder renumbers), so only its RANK in the sorted list is compared. */ position: number; + + /** As GitHub names the environment; compared through environmentKey. */ name: string; } /** The pinned environment names in rank order, as the snapshot orders its pinned entries. */ export type PinnedNames = readonly string[]; -/** - * A pin needs a numeric position and a name to reconcile by; silently skipping one would let check - * report falsely clean while apply reordered blind, so the node schema demands both. - */ +/** What planPins computes from the declarations and the live pins; check renders it and apply executes it. */ +interface PinPlan { + /** The live pins declared pinned: false. */ + unpins: string[]; + + /** The declared pins not yet live, in declaration order; each appends at the tail. */ + pins: string[]; + + /** Each a leftward move to a 1-based rank. */ + reorders: Array<{ name: string; rank: number }>; + + /** Live pins without a declaration that sit among the leading ranks; apply moves them after the declared block. */ + interleaved: string[]; + + /** The pinned count once the plan has run; the cap is never transiently exceeded. */ + finalCount: number; + + /** The live pin names in rank order, for the order drift line. */ + liveOrder: string[]; +} + +// Both fields are demanded: silently skipping a pin without a position or a name would let check report falsely clean while apply reordered blind. const LivePinNode = z.looseObject({ position: z.number(), environment: z.looseObject({ name: z.string() }), @@ -164,19 +177,7 @@ function rankPins( * pinned: false -> unpinned * no declaration -> never unpinned; moved after the declared block when it sits among the leading ranks */ -function planPins( - declarations: readonly PinDeclaration[], - live: readonly LivePin[], -): { - unpins: string[]; - pins: string[]; - /** Each a leftward move to a 1-based rank. */ - reorders: Array<{ name: string; rank: number }>; - interleaved: string[]; - /** The pinned count once the plan has run; the cap is never transiently exceeded. */ - finalCount: number; - liveOrder: string[]; -} { +function planPins(declarations: readonly PinDeclaration[], live: readonly LivePin[]): PinPlan { const desired = declarations.filter((entry) => entry.pinned).map((entry) => entry.name); const desiredKeys = new Set(desired.map(environmentKey)); const unpinKeys = new Set( diff --git a/src/sections/environments/protection-rules.ts b/src/sections/environments/protection-rules.ts index d9b0e90dd..51dd0fa27 100644 --- a/src/sections/environments/protection-rules.ts +++ b/src/sections/environments/protection-rules.ts @@ -11,20 +11,16 @@ import { type EnvironmentsRestContext, unreconcilable } from "./endpoints.js"; import type { NestedPlan } from "./nested.js"; import type { DeploymentProtectionRuleConfig } from "./schema.js"; -// "keep" for a security reason: Apps can enable themselves as deployment gates, and silently -// disabling a gate the file never named would weaken a protection nobody asked to weaken. +/** "keep" for a security reason: Apps can enable themselves as deployment gates, and silently disabling a gate the file never named would weaken a protection nobody asked to weaken. */ export const PROTECTION_RULES_DEFAULT_POLICY: UndeclaredPolicy = "keep"; -/** - * The endpoint documents enabled rules only, so presence is the enablement signal; `enabled` is - * read as a belt over that, since a rule the API ever reported disabled must not satisfy a declared - * gate. An enabled rule without an App slug fails loudly: it has no identity to reconcile by. - */ +// Presence is the enablement signal (the endpoint documents enabled rules only); `enabled` is read as a belt over it, since a rule the API ever reported disabled must not satisfy a declared gate. const LiveProtectionRule = z.looseObject({ id: z.number().optional(), enabled: z.boolean().optional(), app: z.looseObject({ id: z.number().optional(), slug: z.string().optional() }).optional(), }); + type LiveProtectionRule = z.infer; const RULE = { list: "deployment protection rule", entry: "rule" }; @@ -45,11 +41,7 @@ function liveRuleId(rule: LiveProtectionRule, envName: string): Result; export const DeploymentProtectionRuleConfig = z @@ -66,12 +69,7 @@ export const EnvironmentSecretConfig = z .meta({ id: "EnvironmentSecretConfig" }); export type EnvironmentSecretConfig = z.infer; -/** - * GitHub accepts exactly one flag on: both true is a 422, and both false is a 422 too because "any - * branch may deploy" is spelled `deployment_branch_policy: null`. open() over the generated body, which only the - * section can mint (shared/schema-marks.ts); the descriptor's prose sits on the generated nullable and comes back - * through the meta. - */ +// open() over the generated flags body, which only the section can mint (shared/schema-marks.ts); the descriptor's prose sits on the generated nullable and comes back through the meta. const DeploymentBranchPolicy = open(DeploymentBranchPolicyFlags.unwrap().shape) .check( rule((flags, refineCtx) => { @@ -101,6 +99,7 @@ const DeploymentBranchPolicy = open(DeploymentBranchPolicyFlags.unwrap().shape) ), }); +/** One environment entry: the PUT body's keys, plus the routed scalar and the nested lists nested.ts strips before the PUT. */ export const EnvironmentConfig = open({ name: z.string(), // Routed (see EnvironmentRoutedScalars): stripped from the PUT body and applied through the @@ -194,6 +193,7 @@ export const EnvironmentConfig = open({ ), ], }); + export type EnvironmentConfig = z.infer; export const EnvironmentsConfig = z.array(EnvironmentConfig).check( diff --git a/src/sections/environments/snapshot.ts b/src/sections/environments/snapshot.ts index 7a6e0dfb5..23b340a79 100644 --- a/src/sections/environments/snapshot.ts +++ b/src/sections/environments/snapshot.ts @@ -76,11 +76,61 @@ function wrapped(key: NestedKey, entries: E[]): UndeclaredPolicyList { } /** - * One environment's nested lists read back. The branch policies are listed only while the live - * flag enables them (the endpoint 404s otherwise); a disabled protection rule is not an active - * gate, so it is not declared. Each secret becomes a `$NAME` reference with a note asking for it. - * The policy and rule lists sit behind the Actions grant, not the section's, so a denial there - * is the key's own: a note under the warn policy, the section's failure under fail. + * The two lists behind the Actions grant, not the section's, so a denial there is the key's own: a note + * under the warn policy, the section's failure under fail. The branch policies are listed only while the + * live flag enables them (the endpoint 404s otherwise); a disabled protection rule is not an active + * gate, so it is not declared. + */ +async function snapshotGatedLists( + ctx: SnapshotContext, + section: SectionMeta, + envName: string, + liveEnv: LiveEnvironmentBody, +): Promise> { + return safeTry(async function* () { + const nested: NestedSnapshot = {}; + const notes: string[] = []; + if (liveEnv.deployment_branch_policy?.custom_branch_policies === true) { + const policies = yield* await readOrNote( + ctx, + notes, + `environments[${envName}].deployment_branch_policies`, + () => listBranchPolicies(ctx, envName), + ); + if ("value" in policies && policies.value.length > 0) { + const byName = yield* policiesByName(section, policies.value, envName); + nested.deployment_branch_policies = wrapped( + "deployment_branch_policies", + [...byName.values()].map((policy) => + projectOntoSchema(DeploymentBranchPolicyConfig, policy), + ), + ); + } + } + + const rules = yield* await readOrNote( + ctx, + notes, + `environments[${envName}].deployment_protection_rules`, + () => listProtectionRules(ctx, envName), + ); + if ("value" in rules) { + const enabled = [...(yield* enabledRulesBySlug(section, rules.value, envName)).keys()]; + if (enabled.length > 0) { + nested.deployment_protection_rules = wrapped( + "deployment_protection_rules", + enabled.map((app) => ({ app })), + ); + } + } + + return ok({ nested, notes }); + }); +} + +/** + * One environment's nested lists read back: the variables and secrets the engines index, then the two + * Actions-gated lists. Each secret becomes a `$NAME` reference with a note asking for it. */ export async function snapshotNested( ctx: SnapshotContext, @@ -124,39 +174,10 @@ export async function snapshotNested( ); } } - if (liveEnv.deployment_branch_policy?.custom_branch_policies === true) { - const policies = yield* await readOrNote( - ctx, - notes, - `environments[${envName}].deployment_branch_policies`, - () => listBranchPolicies(ctx, envName), - ); - if ("value" in policies && policies.value.length > 0) { - const byName = yield* policiesByName(section, policies.value, envName); - nested.deployment_branch_policies = wrapped( - "deployment_branch_policies", - [...byName.values()].map((policy) => - projectOntoSchema(DeploymentBranchPolicyConfig, policy), - ), - ); - } - } - const rules = yield* await readOrNote( - ctx, - notes, - `environments[${envName}].deployment_protection_rules`, - () => listProtectionRules(ctx, envName), - ); - if ("value" in rules) { - const enabled = [...(yield* enabledRulesBySlug(section, rules.value, envName)).keys()]; - if (enabled.length > 0) { - nested.deployment_protection_rules = wrapped( - "deployment_protection_rules", - enabled.map((app) => ({ app })), - ); - } - } - return ok({ nested, notes }); + + const gated = yield* await snapshotGatedLists(ctx, section, envName, liveEnv); + notes.push(...gated.notes); + return ok({ nested: { ...nested, ...gated.nested }, notes }); }); } diff --git a/src/sections/interaction_limits/index.ts b/src/sections/interaction_limits/index.ts index 3c3ac86f2..989db3c57 100644 --- a/src/sections/interaction_limits/index.ts +++ b/src/sections/interaction_limits/index.ts @@ -35,8 +35,6 @@ const ORG_OVERRIDE = "an organization- or user-level interaction limit overrides const CAP_UNAVAILABLE = "the pull request creation cap is not available on this repository"; -// The bypass endpoints document no 405 (only the cap pair does), so on a repository without the -// cap feature their denial is ambiguous. const BYPASS_DENIAL = "a 403 or 404 here can also mean the pull request creation cap is not available on this repository"; @@ -73,6 +71,8 @@ const ENDPOINTS = { 422: "enabled must be a boolean and max_open_pull_requests a whole number from 1 to 1000; see the pull request creation cap endpoint documentation", }, }, + // The bypass routes document no 405 (only the cap pair does), so on a repository without the cap + // feature their denial is ambiguous; the hint says so. bypassList: { route: "GET /repos/{owner}/{repo}/interaction-limits/pulls/bypass-list", statuses: { 200: "the pull request creation cap bypass list" }, @@ -110,12 +110,6 @@ const LiveInteractionLimit = z.looseObject({ origin: z.string().optional(), }); -/** - * An EMPTY plain object is GitHub's "no limit set"; anything else must be a limit, so a malformed - * body fails at the port instead of reading as absence. - */ -const LiveBaseLimit = z.union([z.strictObject({}), LiveInteractionLimit]); - const LiveBypassUser = z.looseObject({ login: z.string() }); type LiveLimitState = @@ -123,6 +117,9 @@ type LiveLimitState = | { kind: "repository"; limit: string; body: Record } | { kind: "inherited"; limit: string; origin: string; body: Record }; +/** An EMPTY plain object is GitHub's "no limit set"; anything else must be a limit, so a malformed body fails at the port instead of reading as absence. */ +const LiveBaseLimit = z.union([z.strictObject({}), LiveInteractionLimit]); + function liveBaseLimit(ctx: InteractionLimitsContext): Read { return ctx.read.get.call(LiveBaseLimit).map((parsed): LiveLimitState => { if (!("limit" in parsed)) { @@ -156,8 +153,8 @@ function splitDeclared(desired: DeclaredInteractionLimits): DeclaredLimits { } /** - * GitHub logins are case-insensitive. Removals go FIRST because the list holds at most 100 users, - * so adding before removing could transiently overflow it and 422. + * GitHub logins are case-insensitive. Removals go FIRST because the list holds at most BYPASS_MAX + * users, so adding before removing could transiently overflow it and 422. */ function bypassDelta( declared: readonly string[], @@ -173,7 +170,7 @@ function bypassDelta( /** * A single GET, not listAll(): the endpoint documents no pagination parameters (the list holds at - * most 100 users), so a page loop on a full 100-user list would re-request the same body forever. + * most BYPASS_MAX users), so a page loop on a full list would re-request the same body forever. */ function liveBypassLogins(ctx: InteractionLimitsContext): Read { return ctx.read.bypassList @@ -181,6 +178,185 @@ function liveBypassLogins(ctx: InteractionLimitsContext): Read { .map((live) => live.map((user) => user.login)); } +/** The plan for `interaction_limits: null`: the base limit's removal when one is live, else nothing. */ +function clearBaseLimit(ctx: InteractionLimitsContext): Read { + return liveBaseLimit(ctx).map((live): InteractionLimitsPlan => { + const plan: InteractionLimitsPlan = { ops: [], notes: [], drift: [] }; + if (live.kind === "none") { + return plan; + } + + plan.ops.push({ + role: "remove", + describe: "clearing the interaction limit", + drift: [ + live.kind === "inherited" + ? `interaction_limits: declared null but a live "${live.limit}" limit is set at the ${live.origin} level; ` + + "apply cannot remove it from the repository" + : `interaction_limits: declared null but a live "${live.limit}" limit is set; apply will remove it`, + ], + tolerate: { + statuses: [409], + outcome: (error) => ({ + note: `interaction_limits: ${ORG_OVERRIDE}, so the repository-level clear was not applied (${error.status})`, + }), + }, + change: "cleared the interaction limit", + }); + return plan; + }); +} + +/** The re-arm of the declared base limit, with the drift the live state earns and the notes an inherited limit or a declared expiry add. */ +function armBaseLimit( + ctx: InteractionLimitsContext, + base: NonNullable, + plan: InteractionLimitsPlan, +): Read { + return liveBaseLimit(ctx).andThen((live) => { + // Declared != effective is drift REGARDLESS of who set the live limit: an inherited limit adds + // the cannot-fix note, but check stays red rather than reporting a non-matching repository as clean. + const drift: string[] = []; + if (live.kind === "none") { + drift.push( + `interaction_limits: no live limit (never set, or it expired); apply will (re-)arm the declared "${base.limit}" limit`, + ); + } else { + // The live body carries limit/origin/expires_at but never the declared expiry duration, so + // only the limit is diffed. + drift.push(...subsetDiff({ limit: base.limit }, live.body, "interaction_limits")); + if (live.kind === "inherited") { + plan.notes.push( + `interaction_limits: ${ORG_OVERRIDE} (origin: ${live.origin}); apply cannot change it from the repository`, + ); + } + } + + if (base.expiry !== undefined) { + plan.notes.push( + cannotVerifyNote("interaction_limits.expiry", { + why: "GitHub reports only the computed expires_at", + what: "the declared duration", + reasserts: "re-arms it", + }), + ); + } + + // The PUT is alwaysRewrite: a matching live limit still re-arms (its expiry is ticking), so + // the drift may legitimately be empty here. + plan.ops.push({ + role: "put", + payload: base, + describe: `arming the "${base.limit}" interaction limit`, + drift, + tolerate: { + statuses: [409], + outcome: (error) => ({ + note: `interaction_limits: ${ORG_OVERRIDE}, so the repository-level limit was not applied (${error.status})`, + }), + }, + change: `armed the "${base.limit}" interaction limit (expiry: ${base.expiry ?? "one_day (GitHub default)"})`, + }); + return ok(); + }); +} + +/** The cap PATCH when the live cap diverges; a 405 is drift no op fixes, and a declared key GitHub drops is a note. */ +function planCreationCap( + ctx: InteractionLimitsContext, + cap: NonNullable, + plan: InteractionLimitsPlan, +): Read { + return ctx.read.capGet + .tryCall(LiveCreationCap, { describe: "reading the pull request creation cap" }) + .andThen((outcome) => { + if ("error" in outcome) { + // A tolerated 405: the declared cap cannot exist live and apply could not set it either, + // so this is honest drift no operation fixes. + plan.drift.push( + `interaction_limits.pull_request_creation_cap: declared but ${CAP_UNAVAILABLE} (405); apply cannot set it`, + ); + return ok(); + } + + // The cap object is loose passthrough and the PATCH is diff-gated, so a declared key GitHub + // ignores would re-PATCH on every apply without converging. + const phantom = phantomKeys(cap, outcome.data); + if (phantom.length > 0) { + plan.notes.push( + phantomNote( + "interaction_limits.pull_request_creation_cap", + phantom, + "creation cap", + "this PATCH will re-run", + ), + ); + } + + // Unlike the self-expiring base limit there is nothing to re-arm, so the cap PATCHes only on divergence. + const drift = subsetDiff(cap, outcome.data, "interaction_limits.pull_request_creation_cap"); + if (!hasDrift(drift)) { + return ok(); + } + + const capMax = + cap.max_open_pull_requests === undefined + ? "" + : `, max_open_pull_requests: ${cap.max_open_pull_requests}`; + plan.ops.push({ + role: "capPatch", + payload: cap, + describe: "setting the pull request creation cap", + drift, + tolerate: { + statuses: [405], + outcome: (error) => ({ + note: `interaction_limits.pull_request_creation_cap: ${CAP_UNAVAILABLE}, so the declared cap was not applied (${error.status})`, + }), + }, + change: `set the pull request creation cap (enabled: ${cap.enabled}${capMax})`, + }); + return ok(); + }); +} + +/** The bypass list's reconciliation, removals before additions. */ +function planBypassList( + ctx: InteractionLimitsContext, + bypass: readonly string[], + plan: InteractionLimitsPlan, +): Read { + return liveBypassLogins(ctx).andThen((liveLogins) => { + const { add, remove } = bypassDelta(bypass, liveLogins); + if (remove.length > 0) { + plan.ops.push({ + role: "bypassRemove", + payload: { users: remove }, + describe: "removing users from the pull request creation cap bypass list", + drift: [ + `interaction_limits.pull_request_creation_bypass: live ${agree(remove.length, "login", "logins")} [${remove.join(", ")}] ` + + `${agree(remove.length, "is", "are")} not declared; apply will remove ${agree(remove.length, "it", "them")}`, + ], + change: `removed [${remove.join(", ")}] from the pull request creation cap bypass list`, + }); + } + + if (add.length > 0) { + plan.ops.push({ + role: "bypassAdd", + payload: { users: add }, + describe: "adding users to the pull request creation cap bypass list", + drift: [ + `interaction_limits.pull_request_creation_bypass: declared ${agree(add.length, "login", "logins")} [${add.join(", ")}] ` + + `${agree(add.length, "is", "are")} not on the live bypass list; apply will add ${agree(add.length, "it", "them")}`, + ], + change: `added [${add.join(", ")}] to the pull request creation cap bypass list`, + }); + } + return ok(); + }); +} + export const interactionLimitsSection = { key: "interaction_limits", undeclaredDefault: "untouched", @@ -189,157 +365,24 @@ export const interactionLimitsSection = { shape: requirePlainMapping(InteractionLimitsConfig), async plan(ctx, desired) { return safeTry(async function* () { - const plan: InteractionLimitsPlan = { ops: [], notes: [], drift: [] }; - if (desired === null) { // null clears the BASE limit only; the cap and bypass list are separate resources. - const live = yield* liveBaseLimit(ctx); - if (live.kind === "none") { - return ok(plan); - } - plan.ops.push({ - role: "remove", - describe: "clearing the interaction limit", - drift: [ - live.kind === "inherited" - ? `interaction_limits: declared null but a live "${live.limit}" limit is set at the ${live.origin} level; ` + - "apply cannot remove it from the repository" - : `interaction_limits: declared null but a live "${live.limit}" limit is set; apply will remove it`, - ], - tolerate: { - statuses: [409], - outcome: (error) => ({ - note: `interaction_limits: ${ORG_OVERRIDE}, so the repository-level clear was not applied (${error.status})`, - }), - }, - change: "cleared the interaction limit", - }); - return ok(plan); + const cleared = yield* clearBaseLimit(ctx); + return ok(cleared); } + const plan: InteractionLimitsPlan = { ops: [], notes: [], drift: [] }; const { base, cap, bypass } = splitDeclared(desired); - if (base !== undefined) { - const live = yield* liveBaseLimit(ctx); - // Declared != effective is drift REGARDLESS of who set the live limit: an inherited limit adds - // the cannot-fix note, but check stays red rather than reporting a non-matching repository as clean. - const drift: string[] = []; - if (live.kind === "none") { - drift.push( - `interaction_limits: no live limit (never set, or it expired); apply will (re-)arm the declared "${base.limit}" limit`, - ); - } else { - // The live body carries limit/origin/expires_at but never the declared expiry duration, so - // only the limit is diffed. - drift.push(...subsetDiff({ limit: base.limit }, live.body, "interaction_limits")); - if (live.kind === "inherited") { - plan.notes.push( - `interaction_limits: ${ORG_OVERRIDE} (origin: ${live.origin}); apply cannot change it from the repository`, - ); - } - } - if (desired.expiry !== undefined) { - plan.notes.push( - cannotVerifyNote("interaction_limits.expiry", { - why: "GitHub reports only the computed expires_at", - what: "the declared duration", - reasserts: "re-arms it", - }), - ); - } - // The PUT is alwaysRewrite: a matching live limit still re-arms (its expiry is ticking), so - // the drift may legitimately be empty here. - plan.ops.push({ - role: "put", - payload: base, - describe: `arming the "${base.limit}" interaction limit`, - drift, - tolerate: { - statuses: [409], - outcome: (error) => ({ - note: `interaction_limits: ${ORG_OVERRIDE}, so the repository-level limit was not applied (${error.status})`, - }), - }, - change: `armed the "${base.limit}" interaction limit (expiry: ${desired.expiry ?? "one_day (GitHub default)"})`, - }); + yield* armBaseLimit(ctx, base, plan); } if (cap !== undefined) { - const outcome = yield* ctx.read.capGet.tryCall(LiveCreationCap, { - describe: "reading the pull request creation cap", - }); - if ("error" in outcome) { - // A tolerated 405: the declared cap cannot exist live and apply could not set it either, - // so this is honest drift no operation fixes. - plan.drift.push( - `interaction_limits.pull_request_creation_cap: declared but ${CAP_UNAVAILABLE} (405); apply cannot set it`, - ); - } else { - // The cap object is loose passthrough and the PATCH is diff-gated, so a declared key GitHub - // ignores would re-PATCH on every apply without converging. - const phantom = phantomKeys(cap, outcome.data); - if (phantom.length > 0) { - plan.notes.push( - phantomNote( - "interaction_limits.pull_request_creation_cap", - phantom, - "creation cap", - "this PATCH will re-run", - ), - ); - } - // Unlike the self-expiring base limit there is nothing to re-arm, so the cap PATCHes only on divergence. - const drift = subsetDiff( - cap, - outcome.data, - "interaction_limits.pull_request_creation_cap", - ); - if (hasDrift(drift)) { - plan.ops.push({ - role: "capPatch", - payload: cap, - describe: "setting the pull request creation cap", - drift, - tolerate: { - statuses: [405], - outcome: (error) => ({ - note: `interaction_limits.pull_request_creation_cap: ${CAP_UNAVAILABLE}, so the declared cap was not applied (${error.status})`, - }), - }, - change: - `set the pull request creation cap (enabled: ${cap.enabled}` + - `${cap.max_open_pull_requests !== undefined ? `, max_open_pull_requests: ${cap.max_open_pull_requests}` : ""})`, - }); - } - } + yield* planCreationCap(ctx, cap, plan); } if (bypass !== undefined) { - const liveLogins = yield* liveBypassLogins(ctx); - const { add, remove } = bypassDelta(bypass, liveLogins); - if (remove.length > 0) { - plan.ops.push({ - role: "bypassRemove", - payload: { users: remove }, - describe: "removing users from the pull request creation cap bypass list", - drift: [ - `interaction_limits.pull_request_creation_bypass: live ${agree(remove.length, "login", "logins")} [${remove.join(", ")}] ` + - `${agree(remove.length, "is", "are")} not declared; apply will remove ${agree(remove.length, "it", "them")}`, - ], - change: `removed [${remove.join(", ")}] from the pull request creation cap bypass list`, - }); - } - if (add.length > 0) { - plan.ops.push({ - role: "bypassAdd", - payload: { users: add }, - describe: "adding users to the pull request creation cap bypass list", - drift: [ - `interaction_limits.pull_request_creation_bypass: declared ${agree(add.length, "login", "logins")} [${add.join(", ")}] ` + - `${agree(add.length, "is", "are")} not on the live bypass list; apply will add ${agree(add.length, "it", "them")}`, - ], - change: `added [${add.join(", ")}] to the pull request creation cap bypass list`, - }); - } + yield* planBypassList(ctx, bypass, plan); } + return ok(plan); }); }, diff --git a/src/sections/interaction_limits/schema.ts b/src/sections/interaction_limits/schema.ts index 65d50e007..227f3ccc8 100644 --- a/src/sections/interaction_limits/schema.ts +++ b/src/sections/interaction_limits/schema.ts @@ -14,15 +14,16 @@ const LIMIT_RULE = `limit is one of ${INTERACTION_GROUPS.join(", ")} (GitHub's i const EXPIRY_RULE = `expiry is one of ${INTERACTION_EXPIRIES.join(", ")} (GitHub's interaction durations)`; const { minimum: CAP_MIN, maximum: CAP_MAX } = MAX_OPEN_PULL_REQUESTS; const CAP_RULE = `max_open_pull_requests is a whole number from ${CAP_MIN} to ${CAP_MAX} (GitHub's range)`; -/** GitHub caps the list itself, and the cap is what makes single-request reconciliation valid: one write takes it whole. */ export const BYPASS_MAX = 100; - -// GitHub reads the base limit back as limit, origin, and expires_at; the last two are never accepted, and an -// expires_at declared here would compare unequal on every run. const KNOWN_KEYS = "interaction_limits takes limit, expiry, pull_request_creation_cap, and pull_request_creation_bypass " + "(origin and expires_at are what GitHub reports, not what it accepts); remove the key, or fix its spelling"; +/** + * The strict mapping's unrecognized-key error, naming what GitHub accepts: it reads the base limit + * back as limit, origin, and expires_at, but takes only the first, and an expires_at declared here + * would compare unequal on every run. + */ function unknownKeyError(issue: z.core.$ZodRawIssue): string | undefined { if (issue.code !== "unrecognized_keys") { return undefined; @@ -31,8 +32,7 @@ function unknownKeyError(issue: z.core.$ZodRawIssue): string | undefined { return `${agree(issue.keys.length, "Unrecognized key", "Unrecognized keys")}: ${keys}; ${KNOWN_KEYS}`; } -// Not exported: consumers spell it NonNullable. The definition id stays -// "InteractionLimitsConfig"; moving it onto the nullable wrapper would change the published schema. +// Not exported (consumers spell NonNullable); the "InteractionLimitsConfig" id stays here, since moving it onto the nullable wrapper would change the published schema. const InteractionLimits = z .strictObject( { @@ -111,5 +111,7 @@ const InteractionLimits = z ) .meta({ id: "InteractionLimitsConfig" }); +/** The declared mapping, or null, which clears the base limit alone. */ export const InteractionLimitsConfig = InteractionLimits.nullable(); + export type InteractionLimitsConfig = z.infer; diff --git a/src/sections/labels/index.ts b/src/sections/labels/index.ts index fa19accc0..a5e19449c 100644 --- a/src/sections/labels/index.ts +++ b/src/sections/labels/index.ts @@ -5,19 +5,16 @@ import type { ListComparable, ListWrite } from "../shared/list/decl.js"; import { listSection } from "../shared/list/section.js"; import { LabelConfig } from "./schema.js"; -/** Case-insensitive matching is the section's whole contract; the brand marks a name as already folded. */ declare const labelNameKey: unique symbol; +/** Case-insensitive matching is the section's whole contract; the brand marks a name as already folded. */ export type NameKey = string & { readonly [labelNameKey]: true }; export function nameKey(name: string): NameKey { return name.toLowerCase() as NameKey; } -/** - * GitHub stores colors without the leading '#', lowercase; a color compared or written unfolded - * would drift forever against the stored form, so both lens sides may only spell a folded one. - */ declare const labelHexColor: unique symbol; +/** GitHub stores a color lowercase without the "#"; one compared or written unfolded would drift forever, so both lens sides may only spell a folded one. */ type HexColor = string & { readonly [labelHexColor]: true }; function normalizeColor(color: string): HexColor { diff --git a/src/sections/labels/schema.ts b/src/sections/labels/schema.ts index 99a64072f..a979b8fa9 100644 --- a/src/sections/labels/schema.ts +++ b/src/sections/labels/schema.ts @@ -1,10 +1,13 @@ -/** The `labels:` section's entry-config declaration (see src/schema.ts). */ +/** + * The `labels:` section's entry-config declaration (see src/schema.ts). A color is six hex digits because a name or + * CSS shorthand 422s on create and, on an existing label, drifts and re-PATCHes every run; the lens strips the + * optional "#" and folds the case. The description cap is counted in code points, GitHub's unit, as JSON Schema's + * maxLength does. + */ import { z } from "zod"; import { boundedString, open } from "../shared/schema-helpers.js"; -// GitHub stores a color as exactly six hex digits: a name or CSS shorthand 422s on create, and on an existing label -// drifts and re-PATCHes every run, so both are refused here. The lens strips the optional "#" and folds the case. const LabelColor = z .string() .regex( @@ -12,14 +15,13 @@ const LabelColor = z 'a label color is six hex digits, the leading "#" optional ("#d73a4a" or "d73a4a"); color names and three-digit shorthand are not accepted', ); -/** GitHub counts the cap in characters (code points), as JSON Schema's maxLength does. */ const DESCRIPTION_MAX = 100; -const DESCRIPTION_CAP = `a label description is at most ${DESCRIPTION_MAX} characters (GitHub's cap)`; const LabelDescription = boundedString( DESCRIPTION_MAX, "code points", - (count) => `${DESCRIPTION_CAP}; this one has ${count}`, + (count) => + `a label description is at most ${DESCRIPTION_MAX} characters (GitHub's cap); this one has ${count}`, ); export const LabelConfig = open({ @@ -28,4 +30,5 @@ export const LabelConfig = open({ description: LabelDescription.optional(), new_name: z.string().optional(), }).meta({ id: "LabelConfig" }); + export type LabelConfig = z.infer; diff --git a/src/sections/milestones/index.ts b/src/sections/milestones/index.ts index 8f599d576..4ec46cd86 100644 --- a/src/sections/milestones/index.ts +++ b/src/sections/milestones/index.ts @@ -10,6 +10,7 @@ import { exactName, type ListComparable, type ListWrite } from "../shared/list/d import { listSection } from "../shared/list/section.js"; import { MilestoneConfig } from "./schema.js"; +declare const milestoneDueDay: unique symbol; /** * GitHub keeps only the day of a due_on, read off the sent instant in US Pacific, and echoes it as that * day's Pacific midnight (07:00Z or 08:00Z): a UTC midnight lands on the previous day, and only a @@ -17,7 +18,6 @@ import { MilestoneConfig } from "./schema.js"; * file declares and the snapshot writes; the wire hook sends it as noon UTC, which GitHub stores on the * same day in either DST state, so the write converges on the compared day. */ -declare const milestoneDueDay: unique symbol; type DueDay = string & { readonly [milestoneDueDay]: true }; declare const milestoneDueOn: unique symbol; @@ -63,7 +63,6 @@ const ENDPOINTS = { }, } as const satisfies Record; -/** Deleting a milestone detaches it from its issues; every undeclared-delete line says so. */ const DETACH_ACTION = "DELETE it, detaching it from every issue that carries it"; export const milestonesSection = listSection({ diff --git a/src/sections/milestones/schema.ts b/src/sections/milestones/schema.ts index 8a6fbea73..802d5d952 100644 --- a/src/sections/milestones/schema.ts +++ b/src/sections/milestones/schema.ts @@ -4,19 +4,18 @@ import { z } from "zod"; import { MILESTONE_STATES } from "../../generated/spec-enums.js"; import { open } from "../shared/schema-helpers.js"; -/** - * A due date is a calendar day; the lens compares and snapshots the day, and the wire hook in index.ts sends it - * as the instant GitHub keeps on that day. A timestamp is taken for its date part, so a file spelling one parses. - */ +// A calendar day: the lens compares and snapshots the day and the wire hook in index.ts sends the instant GitHub keeps on it, so a timestamp is taken for its date part alone. const DueOn = z.union([z.iso.date(), z.iso.datetime()], { error: "due_on is a calendar day, YYYY-MM-DD (or an ISO 8601 UTC timestamp, YYYY-MM-DDTHH:MM:SSZ, whose time GitHub discards)", }); +/** title is the identity, matched exactly (index.ts): two titles differing in case are two milestones. */ export const MilestoneConfig = open({ title: z.string(), description: z.string().optional(), state: z.enum(MILESTONE_STATES).optional(), due_on: DueOn.optional(), }).meta({ id: "MilestoneConfig" }); + export type MilestoneConfig = z.infer; diff --git a/src/sections/pages/index.ts b/src/sections/pages/index.ts index eecb89682..13d7a152b 100644 --- a/src/sections/pages/index.ts +++ b/src/sections/pages/index.ts @@ -45,23 +45,112 @@ type PagesWirePayload = Omit & { source?: PagesSourceWire } */ type PagesCreateBody = Pick; -/** - * The site body must be a mapping: PagesConfig accepts null (the declared "Pages off"), so a null 200 - * would otherwise read back as a declaration that DISABLES the site. - */ +// A mapping only: PagesConfig accepts null as the declared "Pages off", so a null 200 would otherwise read back as a declaration that DISABLES the site. const LiveSite = z.looseObject({}); -/** - * A note, not a parse error: nothing in the file tells an Enterprise Cloud organization from github.com, - * where GitHub reports `public: true` and drops the field from the update. Only that signature (live - * true, declared false) earns it: a live non-public site proves the host supports visibility, so the - * PUT can make it public and the drift is ordinary. - */ +// A note, not a parse error, since nothing in the file tells an Enterprise Cloud organization from github.com; only live true beside declared false earns it, as a live non-public site proves the host supports visibility. const PUBLIC_VISIBILITY_NOTE = "pages.public: site visibility is settable only for organizations on GitHub Enterprise Cloud; " + "elsewhere GitHub reports public: true and ignores the field on the update, so this drift never " + "converges. Remove pages.public unless the repository belongs to an Enterprise Cloud organization"; +type PagesPlan = SectionPlan>; + +/** `pages: null` disables a live site; with no site the note says what else the 404 may mean. */ +function disablePlan(missing: boolean): PagesPlan { + if (missing) { + // A 404 is ambiguous: no Pages site, or a fine-grained token without the Pages permission. + // The non-null path stays loud either way (the POST would fail); this no-op path must say so. + return { + ops: [], + notes: [ + "pages: declared null and GitHub reports no Pages site, so there is nothing to disable. A fine-grained token missing the Pages permission gets the same answer; if this repo does have a Pages site, grant the token Pages read and write", + ], + drift: [], + }; + } + + return { + ops: [ + { + role: "remove", + drift: [ + "pages: enabled live but the settings file declares pages: null; apply will disable GitHub Pages", + ], + change: "disabled GitHub Pages", + }, + ], + notes: [], + drift: [], + }; +} + +/** + * A live site is updated in place where it diverges. The site mapping passes unknown keys through, so a + * key the GET never echoes would re-PUT on every apply without converging; the note names it beside the + * drift it causes. A site key the GET omits (https_enforced on a site created without it) is drift the + * PUT resolves, so only a key outside the site shape is noted. + */ +function updatePlan(payload: PagesWirePayload, live: Record): PagesPlan { + const plan: PagesPlan = { ops: [], notes: [], drift: [] }; + const phantom = phantomKeys(payload, live).filter( + (name) => !Object.hasOwn(PAGES_SITE_SHAPE, name), + ); + if (phantom.length > 0) { + plan.notes.push(phantomNote("pages", phantom, "Pages site", "this PUT will re-run")); + } + + const drift = subsetDiff(payload, live, "pages"); + if (hasDrift(drift)) { + plan.ops.push({ + role: "update", + payload, + drift, + change: "updated GitHub Pages configuration", + }); + if (payload.public === false && live.public === true) { + plan.notes.push(PUBLIC_VISIBILITY_NOTE); + } + } + + return plan; +} + +/** Enabling a site is the create POST, which takes build_type and source alone, then the update PUT for whatever else is declared. */ +function enablePlan(payload: PagesWirePayload): PagesPlan { + const plan: PagesPlan = { ops: [], notes: [], drift: [] }; + const create: PagesCreateBody = {}; + if (payload.build_type !== undefined) { + create.build_type = payload.build_type; + } + if (payload.source !== undefined) { + create.source = payload.source; + } + plan.ops.push({ + role: "create", + payload: create, + drift: [ + "pages: declared in the settings file but GitHub Pages is not enabled on the repo; apply will enable it", + ], + change: "enabled GitHub Pages", + }); + + const rest = Object.keys(payload).filter((k) => !Object.hasOwn(create, k)); + if (rest.length > 0) { + plan.ops.push({ + role: "update", + payload, + drift: [ + `pages: the create call takes only build_type and source, so apply will then set the remaining configuration (${rest.join(", ")})`, + ], + change: "applied remaining Pages configuration", + }); + } + + return plan; +} + +/** The `pages:` section module; a declared null disables the site. */ export const pagesSection = { key: "pages", undeclaredDefault: "untouched", @@ -71,84 +160,21 @@ export const pagesSection = { // source: null or a source without a branch. shape: PagesConfig, async plan(ctx, desired) { - const plan: SectionPlan> = { ops: [], notes: [], drift: [] }; return ctx.read.get.probeAbsent(LiveSite).map((probe) => { if (desired === null) { - if ("missing" in probe) { - // A 404 is ambiguous: no Pages site, or a fine-grained token without the Pages permission. - // The non-null path stays loud either way (the POST would fail); this no-op path must say so. - plan.notes.push( - "pages: declared null and GitHub reports no Pages site, so there is nothing to disable. A fine-grained token missing the Pages permission gets the same answer; if this repo does have a Pages site, grant the token Pages read and write", - ); - return plan; - } - plan.ops.push({ - role: "remove", - drift: [ - "pages: enabled live but the settings file declares pages: null; apply will disable GitHub Pages", - ], - change: "disabled GitHub Pages", - }); - return plan; + return disablePlan("missing" in probe); } + // The source is split off so the no-source form never carries a source key at all: an own - // `source: undefined` would count as a remainder below. + // `source: undefined` would count as a remainder in enablePlan. const { source, ...restConfig } = desired; const payload: PagesWirePayload = source === undefined ? restConfig : { ...restConfig, source: wireSource(source) }; - - if (!("missing" in probe)) { - // The site mapping passes unknown keys through, so a key the GET never echoes would re-PUT on - // every apply without converging; the note names it beside the drift it causes. A site key the - // GET omits (https_enforced on a site created without it) is drift the PUT resolves, so only a - // key outside the site shape is noted. - const phantom = phantomKeys(payload, probe.data).filter( - (name) => !Object.hasOwn(PAGES_SITE_SHAPE, name), - ); - if (phantom.length > 0) { - plan.notes.push(phantomNote("pages", phantom, "Pages site", "this PUT will re-run")); - } - const drift = subsetDiff(payload, probe.data, "pages"); - if (hasDrift(drift)) { - plan.ops.push({ - role: "update", - payload, - drift, - change: "updated GitHub Pages configuration", - }); - if (payload.public === false && probe.data.public === true) { - plan.notes.push(PUBLIC_VISIBILITY_NOTE); - } - } - return plan; - } - const create: PagesCreateBody = {}; - if (payload.build_type !== undefined) { - create.build_type = payload.build_type; + if ("missing" in probe) { + return enablePlan(payload); } - if (payload.source !== undefined) { - create.source = payload.source; - } - plan.ops.push({ - role: "create", - payload: create, - drift: [ - "pages: declared in the settings file but GitHub Pages is not enabled on the repo; apply will enable it", - ], - change: "enabled GitHub Pages", - }); - const rest = Object.keys(payload).filter((k) => !Object.hasOwn(create, k)); - if (rest.length > 0) { - plan.ops.push({ - role: "update", - payload, - drift: [ - `pages: the create call takes only build_type and source, so apply will then set the remaining configuration (${rest.join(", ")})`, - ], - change: "applied remaining Pages configuration", - }); - } - return plan; + + return updatePlan(payload, probe.data); }); }, // No site is nothing to declare (not `pages: null`, which would DISABLE Pages on apply); the diff --git a/src/sections/pages/schema.ts b/src/sections/pages/schema.ts index ede5b3d8d..701a991d3 100644 --- a/src/sections/pages/schema.ts +++ b/src/sections/pages/schema.ts @@ -37,8 +37,7 @@ const READ_ONLY_SITE_FIELDS = { const EMPTY_SITE_FIX = "an empty mapping configures nothing; declare at least one field, declare pages: null to disable the site, or remove the section"; -// Not exported: consumers spell it NonNullable. The definition id stays "PagesConfig"; -// moving it onto the nullable wrapper would change the published schema. +// Not exported: consumers spell it NonNullable; the definition id stays "PagesConfig", since moving it onto the nullable wrapper would change the published schema. const PagesSite = open({ build_type: z.enum(PAGES_BUILD_TYPES).optional(), source: open({ branch: z.string(), path: z.enum(PAGES_SOURCE_PATHS).optional() }).optional(), @@ -77,7 +76,9 @@ const PagesSite = open({ not: { anyOf: Object.keys(READ_ONLY_SITE_FIELDS).map((key) => ({ required: [key] })) }, }); +/** The section's shape; null is the declared "Pages off". */ export const PagesConfig = PagesSite.nullable(); + export type PagesConfig = z.infer; /** The site's own keys: a declared one the GET omits is drift the PUT resolves, so only a key outside this shape is a phantom. */ diff --git a/src/sections/registry.ts b/src/sections/registry.ts index 3844e7d64..625673c04 100644 --- a/src/sections/registry.ts +++ b/src/sections/registry.ts @@ -208,11 +208,7 @@ export type SectionGraphqlKey = { : never; }[K]; -/** - * Erasure must go through THIS mapped annotation: the compiler relates SectionModule to - * SectionModule by variance, while a literal module's closedSurface compared structurally - * against the union-collapsed wide form would be rejected. - */ +// Erasure goes through this mapped annotation: the compiler relates SectionModule to SectionModule by variance, while a literal module's closedSurface compared structurally against the union-collapsed wide form is rejected. const byKeyErased: { [K in SectionKey]: SectionModule } = byKey; /** @@ -256,15 +252,7 @@ function hasSnapshot( return module.snapshot !== undefined; } -/** - * The one door out of src/sections: the engine, the library, and the roster below all reach a module - * through it, so the owner gate (contract/owner.ts) and the foreign-context refusal are applied here once - * and not in 26 handlers, and so is the freeze (freezeDeclarations): the wrapper shares its declaration - * objects with the source module, so those are deep-frozen in place, while only the wrapper object is - * shallow-frozen (a test can still stub the source's handlers). The freeze lives here rather than in a - * definition helper because the modules arrive by several routes (literal objects, listSection, the - * secrets, variables, and setup factories). - */ +// The one door out of src/sections, so the owner gate, the foreign-context refusal, and the freeze are applied here once, not in 26 handlers: modules arrive by several routes (literals, listSection, the factories), so no definition helper sees them all. const guarded: { [K in SectionKey]: SectionModule } = Object.fromEntries( SECTION_KEYS.map((key) => [ key, @@ -272,6 +260,7 @@ const guarded: { [K in SectionKey]: SectionModule } = Object.fromEntries( ]), ) as { [K in SectionKey]: SectionModule }; +/** Every gated module, in SECTION_KEYS order: the execution order. */ export const SECTIONS: readonly SectionModule[] = SECTION_KEYS.map((key) => guarded[key]); export function sectionShape(key: SectionKey): z.ZodType { diff --git a/src/sections/repository/index.ts b/src/sections/repository/index.ts index c2dc2a387..efaf3b76b 100644 --- a/src/sections/repository/index.ts +++ b/src/sections/repository/index.ts @@ -18,6 +18,7 @@ import { type PlannedOp, type Read, type SectionPlan, + type SnapshotContext, } from "../contract/plan.js"; import { readOrNote } from "../shared/snapshot-helpers.js"; import { @@ -33,13 +34,16 @@ import { */ type DeclaredRepositoryKey = keyof typeof RepositoryConfig.shape & string; +// The grant the denial advice names for every route and GraphQL op below. const permission: SectionPermission = { repo: ["administration"] }; +// Shared by the LFS PUT and DELETE, the one pair whose 403 has causes a grant cannot fix. const LFS_DENIAL_HINT = "a 403 here can also mean Git LFS is disabled account-wide or for the root of this " + "repository network, or that the credential lacks billing access (organization repositories " + "need an organization owner or billing manager), rather than a missing token grant"; +// The 409 meaning of both immutable-releases writes; the snapshot's enforced note reuses it. const OWNER_ENFORCED = "the repository owner enforces immutable releases"; // FEATURE_TOGGLES names these entries by role, so declaration and use cannot drift. @@ -128,6 +132,8 @@ const ENDPOINTS = { // GitHub may return topics as null or omit them; the rest of the body rides into subsetDiff as passthrough. const LiveRepository = z.looseObject({ topics: z.array(z.string()).nullish() }); +type LiveRepository = z.infer; + /** The live security_and_analysis object narrowed to its PATCHable sub-keys; undefined when none. */ function snapshotSecurityAndAnalysis(live: unknown): Record | undefined { if (!isPlainObject(live)) { @@ -160,8 +166,15 @@ const ISSUE_CREATION_POLICIES = { type IssueCreationPolicy = keyof typeof ISSUE_CREATION_POLICIES; -// GraphQL treats an input field fed by an unprovided variable as not provided, so one mutation -// serves any declared subset: the input carries exactly the keys apply needs to move. +// The inverse of ISSUE_CREATION_POLICIES for the reads; a Map, so a wire value naming a prototype property cannot match. +const ISSUE_CREATION_POLICY_BY_WIRE: ReadonlyMap = new Map( + Object.entries(ISSUE_CREATION_POLICIES).map(([policy, wire]) => [ + wire, + policy as IssueCreationPolicy, + ]), +); + +// GraphQL treats an input field fed by an unprovided variable as not provided, so one mutation serves any declared subset. const UPDATE_FEATURES = graphqlOp<{ repositoryId: string; hasSponsorshipsEnabled?: boolean; @@ -187,12 +200,14 @@ const UPDATE_FEATURES = graphqlOp<{ outcomes: { ok: "the carried values set; the echoed state verifies each one took" }, }); +// Keyed by role as ENDPOINTS is; ctx.read and the plan ops address both tables by these names. const GRAPHQL_OPS = { featuresQuery: FEATURES_QUERY, updateFeatures: UPDATE_FEATURES, } as const satisfies Record; type RepositoryContext = PlanContext; +type RepositorySnapshotContext = SnapshotContext; type RepositoryOp = PlannedOp; type RepositoryPlan = SectionPlan; @@ -225,10 +240,15 @@ interface FeatureToggle { * (owner-enforced, already off) and are tolerated by declaration. */ interface ReadableToggle extends FeatureToggle { + /** The toggle's GET, a probe whose 404 is the off state. */ get: ProbeRole; + /** Parsed at the boundary, so an off-contract body fails loudly instead of driving a write. */ live: z.ZodType; + + /** Whether a 2xx answer means the feature is on. */ isEnabled: (live: LiveToggle) => boolean; + /** Enforced above the repository (immutable releases' enforced_by_owner), so the drift prose says apply cannot change it. */ isEnforced?: (live: LiveToggle) => boolean; } @@ -236,6 +256,13 @@ interface ReadableToggle extends FeatureToggle { /** null is the 204 no-content answer a toggle GET gives when the feature is on. */ type LiveToggle = null | { enabled: boolean; enforced_by_owner?: boolean }; +/** One toggle's snapshot answer; concealable marks the 404 that a fine-grained token missing the grant also answers. */ +interface ToggleProbe { + toggle: ReadableToggle; + live: LiveToggle | undefined; + concealable: boolean; +} + const LiveNoContent = z.null(); const LiveToggleState = z.looseObject({ @@ -307,18 +334,27 @@ const WRITE_ONLY_TOGGLES: readonly WriteOnlyToggle[] = [ * enable_sponsorships -> no REST field at all */ interface RoutedKey { + /** The settings key; SPECIAL_KEYS keeps it out of the PATCH body. */ readonly key: DeclaredRepositoryKey; + /** The change-line label ("sponsor button: enabled"). */ readonly label: string; + + /** The Repository field the query selects and the mutation echoes. */ readonly field: "hasSponsorshipsEnabled" | "issueCreationPolicy"; + variables(declared: unknown): Omit; + /** * undefined when the value is outside the vocabulary this section reads; the caller fails loudly, * since folding to a default could report a clean check against state the section does not understand. */ decode(live: unknown): unknown; + show(value: unknown): string; + changeText(value: unknown): string; + /** Appended to the unreadable-value error; one sentence, no trailing period. */ readonly unreadableHint?: string; } @@ -340,8 +376,7 @@ const GRAPHQL_ROUTED_KEYS = [ variables: (declared) => ({ issueCreationPolicy: ISSUE_CREATION_POLICIES[declared as IssueCreationPolicy], }), - decode: (live) => - live === "ALL" ? "all" : live === "COLLABORATORS_ONLY" ? "collaborators_only" : undefined, + decode: (live) => ISSUE_CREATION_POLICY_BY_WIRE.get(live), show: (value) => String(value), changeText: (value) => String(value), // The SDL marks Repository.issueCreationPolicy nullable, though a live probe never observed null @@ -387,10 +422,7 @@ interface LiveRoutedState { values: Record; } -/** - * The features read: the Repository object with the routed fields at their wire types (the vocabulary - * itself is decoded by each routed key), or null when the token cannot see the repository. - */ +/** The features read: the routed fields at their wire types (each routed key decodes its own), or a null repository when the token cannot see it. */ const LiveFeatures = z.looseObject({ repository: z .looseObject({ @@ -459,6 +491,307 @@ function toggleTolerated( : `repository.${toggle.key}: ${meaning}, so nothing changed (${status})`; } +/** The PATCH op when the declared fields differ from live; a declared key GitHub never echoes is noted first. */ +function planPatch( + plan: RepositoryPlan, + patch: Record, + live: LiveRepository, +): void { + if (Object.keys(patch).length === 0) { + return; + } + + // The PATCH is diff-gated and the fields pass through, so a declared key GitHub ignores would + // re-PATCH on every apply without converging. + const phantom = phantomKeys(patch, live); + if (phantom.length > 0) { + plan.notes.push(phantomNote("repository", phantom, "repository", "this PATCH will re-run")); + } + + const drift = subsetDiff(patch, live, "repository"); + if (hasDrift(drift)) { + plan.ops.push({ + role: "update", + payload: patch, + drift, + change: `patched repository fields: ${Object.keys(patch).join(", ")}`, + }); + } +} + +/** The topics PUT when the declared set differs from live, both compared as sorted sets after the wire fold. */ +function planTopics( + plan: RepositoryPlan, + topics: string | readonly string[], + live: LiveRepository, +): void { + const names = normalizeTopics(topics); + const drift = subsetDiff([...names].sort(), [...(live.topics ?? [])].sort(), "repository.topics"); + if (hasDrift(drift)) { + plan.ops.push({ + role: "topics", + payload: { names }, + drift, + change: `set topics: ${names.join(", ") || "(none)"}`, + }); + } +} + +/** + * One readable toggle's write after its probe, or nothing when the declared state is the live one. The + * write's declared tolerable statuses (409 owner-enforced, 404/422 already off on a remove) mean nothing + * changed: a note, never a change line. A write declaring none tolerates nothing. + */ +function planReadableToggle( + ctx: RepositoryContext, + section: SectionMeta, + toggle: ReadableToggle, + want: boolean, +): Read { + return ctx.read[toggle.get].probeAbsent(toggle.live).map((probe): RepositoryOp | undefined => { + const live = "missing" in probe ? undefined : probe.data; + const enabled = live === undefined ? false : toggle.isEnabled(live); + if (enabled === want) { + return undefined; + } + + const enforced = live !== undefined && toggle.isEnforced?.(live) === true; + const role = want ? toggle.put : toggle.remove; + return { + role, + drift: [ + valueDrift(`repository.${toggle.key}`, String(want), String(enabled), { + remedy: enforced + ? `the repository owner enforces ${toggle.label}, so apply cannot change it from the repository` + : undefined, + }), + ], + tolerate: { + outcome: (error) => ({ note: toggleTolerated(section, toggle, role, error.status) }), + }, + change: `${toggle.label}: ${want ? "enabled" : "disabled"}`, + }; + }); +} + +/** Each declared write-only toggle's unconditional write, with the note that nothing can read it back. */ +function planWriteOnlyToggles(plan: RepositoryPlan, desired: Record): void { + for (const toggle of WRITE_ONLY_TOGGLES) { + if (!(toggle.key in desired)) { + continue; + } + + const want = desired[toggle.key] === true; + plan.notes.push( + cannotVerifyNote(`repository.${toggle.key}`, { + why: "GitHub exposes no endpoint to read this state back", + what: "it", + reasserts: `re-asserts the declared value (${JSON.stringify(desired[toggle.key])})`, + }), + ); + plan.ops.push({ + role: want ? toggle.put : toggle.remove, + drift: [], + change: `${toggle.label}: ${want ? "enabled" : "disabled"}`, + }); + } +} + +/** One routed key's change line, or the verification failure when the echoed value is not the declared one. */ +function verifiedRoutedLine( + routed: RoutedKey, + echoed: Record, + want: unknown, +): Result { + if (echoed[routed.key] !== want) { + return err( + sectionFailure( + "unverified", + `repository: GRAPHQL ${UPDATE_FEATURES.name} was accepted, but GitHub ` + + `reports repository.${routed.key} ${routed.show(echoed[routed.key])} where ` + + `${routed.show(want)} was set, so the write did not take. ` + + `GitHub may restrict this setting on the repository`, + ), + ); + } + return ok(`${routed.label}: ${routed.changeText(echoed[routed.key])}`); +} + +/** The features mutation's change lines, each diverged value verified against the echoed post-state. */ +function verifiedRoutedChange( + response: unknown, + diverged: readonly [RoutedKey, ...RoutedKey[]], + desired: Record, +): Result { + const echoedRepo = (response as { updateRepository?: { repository?: Record } }) + .updateRepository?.repository; + if (!echoedRepo) { + return err( + sectionFailure( + "unverified", + `repository: GRAPHQL ${UPDATE_FEATURES.name} returned no repository echo, so the write cannot be verified. GitHub may have changed the mutation payload; update the action`, + ), + ); + } + + return decodeRoutedFields(echoedRepo, diverged, UPDATE_FEATURES.name).andThen((echoed) => { + const [first, ...rest] = diverged; + const verified = (routed: RoutedKey) => verifiedRoutedLine(routed, echoed, desired[routed.key]); + return Result.combine([verified(first), ...rest.map(verified)]).map( + ([lead, ...more]): ChangeLines => [lead as string, ...more], + ); + }); +} + +/** The one features mutation for the declared routed keys whose live value differs; nothing when all agree. */ +function planRoutedKeys( + ctx: RepositoryContext, + plan: RepositoryPlan, + desired: Record, +): Read { + return safeTry(async function* () { + const declaredRouted = GRAPHQL_ROUTED_KEYS.filter((routed) => routed.key in desired); + if (declaredRouted.length === 0) { + return ok(undefined); + } + + // The routed-state read supplies the mutation's node id, so the comparison is free and a + // converged repo issues no GraphQL write. + const liveRouted = yield* fetchRoutedState(ctx, declaredRouted); + const [first, ...rest] = declaredRouted.filter( + (routed) => desired[routed.key] !== liveRouted.values[routed.key], + ); + if (first === undefined) { + return ok(undefined); + } + + const diverged: readonly [RoutedKey, ...RoutedKey[]] = [first, ...rest]; + const variables: FeatureVariables = Object.assign( + { repositoryId: liveRouted.id }, + ...diverged.map((routed) => routed.variables(desired[routed.key])), + ); + plan.ops.push({ + role: "updateFeatures", + variables, + drift: diverged.map((routed) => + valueDrift( + `repository.${routed.key}`, + routed.show(desired[routed.key]), + routed.show(liveRouted.values[routed.key]), + ), + ) as [string, ...string[]], + // The mutation selects the post-state on purpose: a silently ignored field is the REST + // failure mode that forced these keys onto GraphQL, so each value is verified against the echo. + change: (response) => verifiedRoutedChange(response, diverged, desired), + }); + return ok(undefined); + }); +} + +/** The PATCH fields and topics as the GET reports them; a null field is GitHub's "unset" and is left out rather than declared as null. */ +function patchSnapshot(live: LiveRepository): Record { + const value: Record = {}; + for (const field of PATCH_FIELDS) { + const read = + field === "security_and_analysis" ? snapshotSecurityAndAnalysis(live[field]) : live[field]; + if (read !== undefined && read !== null) { + value[field] = read; + } + } + + if (live.topics !== undefined && live.topics !== null && live.topics.length > 0) { + value.topics = [...live.topics]; + } + + return value; +} + +/** Every readable toggle's answer; a denied one is noted and left out. */ +function probeToggles(ctx: RepositorySnapshotContext, notes: string[]): Read { + return safeTry(async function* () { + const probes: ToggleProbe[] = []; + for (const toggle of READABLE_TOGGLES) { + const read = yield* await readOrNote(ctx, notes, `repository.${toggle.key}`, () => + ctx.read[toggle.get].tryCall(toggle.live).map((answer) => + "error" in answer + ? // The declared 422 ("not applicable") is answered only to a granted token. + { live: undefined, concealable: answer.error.status === 404 } + : { live: answer.data, concealable: false }, + ), + ); + if (!("denied" in read)) { + probes.push({ toggle, ...read.value }); + } + } + return ok(probes); + }); +} + +/** The toggles' states by key, with an enforced one noted; all-404 answers are a note and no keys. */ +function snapshotToggles( + section: SectionMeta, + probes: readonly ToggleProbe[], + notes: string[], +): Record { + const value: Record = {}; + + // The toggle GETs share one grant and answer 404 for "off", the same 404 a fine-grained token + // missing the grant is concealed behind. One other answer proves the grant; all 404s prove + // nothing, so the toggles are left out rather than written as off. + if (probes.length > 0 && probes.every((probe) => probe.concealable)) { + notes.push( + `repository.${probes.map((probe) => probe.toggle.key).join("/")}: every toggle GET answered 404, which reads as off ` + + "but is also how a fine-grained token missing the grant is answered, so they are left out; " + + `if the token does ${sectionGrant(section)}, they are all off and can be declared false`, + ); + return value; + } + + for (const { toggle, live } of probes) { + const enabled = live === undefined ? false : toggle.isEnabled(live); + value[toggle.key] = enabled; + if (live !== undefined && toggle.isEnforced?.(live) === true) { + notes.push( + `repository.${toggle.key}: ${OWNER_ENFORCED}, so it reads back as ${enabled} but cannot be changed from the repository`, + ); + } + } + + return value; +} + +/** The routed keys' states by key from the features read; a denied read or an unreadable value is noted and left out. */ +function snapshotRoutedKeys( + ctx: RepositorySnapshotContext, + notes: string[], +): Read> { + return safeTry(async function* () { + const value: Record = {}; + const routed = yield* await readOrNote( + ctx, + notes, + GRAPHQL_ROUTED_KEYS.map((entry) => `repository.${entry.key}`).join(" and "), + () => ctx.read.featuresQuery.call(LiveFeatures, repoVariables(ctx)), + ); + if ("denied" in routed) { + return ok(value); + } + + const repository = yield* repositoryNode(routed.value); + for (const entry of GRAPHQL_ROUTED_KEYS) { + const decoded = entry.decode(repository[entry.field]); + if (decoded === undefined) { + notes.push( + `repository.${entry.key}: ${unreadableRoutedValue(entry, repository[entry.field], FEATURES_QUERY.name)}, so the snapshot leaves it out`, + ); + continue; + } + value[entry.key] = decoded; + } + return ok(value); + }); +} + export const repositorySection = { key: "repository", undeclaredDefault: "untouched", @@ -476,236 +809,44 @@ export const repositorySection = { ); const live = yield* ctx.read.get.call(LiveRepository); - if (Object.keys(patch).length > 0) { - // The PATCH is diff-gated and the fields pass through, so a declared key GitHub ignores would - // re-PATCH on every apply without converging. - const phantom = phantomKeys(patch, live); - if (phantom.length > 0) { - plan.notes.push( - phantomNote("repository", phantom, "repository", "this PATCH will re-run"), - ); - } - const drift = subsetDiff(patch, live, "repository"); - if (hasDrift(drift)) { - plan.ops.push({ - role: "update", - payload: patch, - drift, - change: `patched repository fields: ${Object.keys(patch).join(", ")}`, - }); - } - } + planPatch(plan, patch, live); if (declared.topics !== undefined) { - const names = normalizeTopics(declared.topics); - const drift = subsetDiff( - [...names].sort(), - [...(live.topics ?? [])].sort(), - "repository.topics", - ); - if (hasDrift(drift)) { - plan.ops.push({ - role: "topics", - payload: { names }, - drift, - change: `set topics: ${names.join(", ") || "(none)"}`, - }); - } + planTopics(plan, declared.topics, live); } + for (const toggle of READABLE_TOGGLES) { if (!(toggle.key in desired)) { continue; } - const want = desired[toggle.key] === true; - const probe = yield* ctx.read[toggle.get].probeAbsent(toggle.live); - const live = "missing" in probe ? undefined : probe.data; - const enabled = live === undefined ? false : toggle.isEnabled(live); - if (enabled === want) { - continue; - } - const enforced = live !== undefined && toggle.isEnforced?.(live) === true; - const role = want ? toggle.put : toggle.remove; - // The write's declared tolerable statuses (409 owner-enforced, 404/422 already off on a remove) - // mean nothing changed: a note, never a change line. A write declaring none tolerates nothing. - plan.ops.push({ - role, - drift: [ - valueDrift(`repository.${toggle.key}`, String(want), String(enabled), { - remedy: enforced - ? `the repository owner enforces ${toggle.label}, so apply cannot change it from the repository` - : undefined, - }), - ], - tolerate: { - outcome: (error) => ({ note: toggleTolerated(section, toggle, role, error.status) }), - }, - change: `${toggle.label}: ${want ? "enabled" : "disabled"}`, - }); - } - for (const toggle of WRITE_ONLY_TOGGLES) { - if (!(toggle.key in desired)) { - continue; - } - const want = desired[toggle.key] === true; - plan.notes.push( - cannotVerifyNote(`repository.${toggle.key}`, { - why: "GitHub exposes no endpoint to read this state back", - what: "it", - reasserts: `re-asserts the declared value (${JSON.stringify(desired[toggle.key])})`, - }), - ); - plan.ops.push({ - role: want ? toggle.put : toggle.remove, - drift: [], - change: `${toggle.label}: ${want ? "enabled" : "disabled"}`, - }); - } - const declaredRouted = GRAPHQL_ROUTED_KEYS.filter((routed) => routed.key in desired); - if (declaredRouted.length > 0) { - // The routed-state read supplies the mutation's node id, so the comparison is free and a - // converged repo issues no GraphQL write. - const liveRouted = yield* fetchRoutedState(ctx, declaredRouted); - const diverged = declaredRouted.filter( - (routed) => desired[routed.key] !== liveRouted.values[routed.key], - ); - const [first, ...rest] = diverged; - if (first !== undefined) { - const variables: FeatureVariables = Object.assign( - { repositoryId: liveRouted.id }, - ...diverged.map((routed) => routed.variables(desired[routed.key])), - ); - plan.ops.push({ - role: "updateFeatures", - variables, - drift: diverged.map((routed) => - valueDrift( - `repository.${routed.key}`, - routed.show(desired[routed.key]), - routed.show(liveRouted.values[routed.key]), - ), - ) as [string, ...string[]], - // The mutation selects the post-state on purpose: a silently ignored field is the REST - // failure mode that forced these keys onto GraphQL, so each value is verified against the echo. - change: (response) => { - const echoedRepo = ( - response as { updateRepository?: { repository?: Record } } - ).updateRepository?.repository; - if (!echoedRepo) { - return err( - sectionFailure( - "unverified", - `repository: GRAPHQL ${UPDATE_FEATURES.name} returned no repository echo, so the write cannot be verified. GitHub may have changed the mutation payload; update the action`, - ), - ); - } - return decodeRoutedFields(echoedRepo, diverged, UPDATE_FEATURES.name).andThen( - (echoed) => { - const verified = (routed: RoutedKey): Result => { - if (echoed[routed.key] !== desired[routed.key]) { - return err( - sectionFailure( - "unverified", - `repository: GRAPHQL ${UPDATE_FEATURES.name} was accepted, but GitHub ` + - `reports repository.${routed.key} ${routed.show(echoed[routed.key])} where ` + - `${routed.show(desired[routed.key])} was set, so the write did not take. ` + - `GitHub may restrict this setting on the repository`, - ), - ); - } - return ok(`${routed.label}: ${routed.changeText(echoed[routed.key])}`); - }; - return Result.combine([verified(first), ...rest.map(verified)]).map( - ([lead, ...more]): ChangeLines => [lead as string, ...more], - ); - }, - ); - }, - }); + + const op = yield* planReadableToggle(ctx, section, toggle, desired[toggle.key] === true); + if (op !== undefined) { + plan.ops.push(op); } } + planWriteOnlyToggles(plan, desired); + + yield* planRoutedKeys(ctx, plan, desired); return ok(plan); }); }, - // A null PATCH field is GitHub's "unset", so it is left out rather than declared as null. async snapshot(ctx) { const section = this; return safeTry(async function* () { const notes: string[] = []; const live = yield* ctx.read.get.call(LiveRepository); - const value: Record = {}; - for (const field of PATCH_FIELDS) { - const read = - field === "security_and_analysis" - ? snapshotSecurityAndAnalysis(live[field]) - : live[field]; - if (read !== undefined && read !== null) { - value[field] = read; - } - } - if (live.topics !== undefined && live.topics !== null && live.topics.length > 0) { - value.topics = [...live.topics]; - } - const probes: Array<{ - toggle: ReadableToggle; - live: LiveToggle | undefined; - concealable: boolean; - }> = []; - for (const toggle of READABLE_TOGGLES) { - const read = yield* await readOrNote(ctx, notes, `repository.${toggle.key}`, () => - ctx.read[toggle.get].tryCall(toggle.live).map((answer) => - "error" in answer - ? // The declared 422 ("not applicable") is answered only to a granted token. - { live: undefined, concealable: answer.error.status === 404 } - : { live: answer.data, concealable: false }, - ), - ); - if (!("denied" in read)) { - probes.push({ toggle, ...read.value }); - } - } - // The toggle GETs share one grant and answer 404 for "off", the same 404 a fine-grained token - // missing the grant is concealed behind. One other answer proves the grant; all 404s prove - // nothing, so the toggles are left out rather than written as off. - if (probes.length > 0 && probes.every((probe) => probe.concealable)) { - notes.push( - `repository.${probes.map((probe) => probe.toggle.key).join("/")}: every toggle GET answered 404, which reads as off ` + - "but is also how a fine-grained token missing the grant is answered, so they are left out; " + - `if the token does ${sectionGrant(section)}, they are all off and can be declared false`, - ); - } else { - for (const { toggle, live } of probes) { - const enabled = live === undefined ? false : toggle.isEnabled(live); - value[toggle.key] = enabled; - if (live !== undefined && toggle.isEnforced?.(live) === true) { - notes.push( - `repository.${toggle.key}: ${OWNER_ENFORCED}, so it reads back as ${enabled} but cannot be changed from the repository`, - ); - } - } - } + const value = patchSnapshot(live); + + const probes = yield* probeToggles(ctx, notes); + Object.assign(value, snapshotToggles(section, probes, notes)); for (const toggle of WRITE_ONLY_TOGGLES) { notes.push( `repository.${toggle.key}: GitHub exposes no endpoint to read ${toggle.label} back, so the snapshot leaves it out; declare it yourself to manage it`, ); } - const routed = yield* await readOrNote( - ctx, - notes, - GRAPHQL_ROUTED_KEYS.map((entry) => `repository.${entry.key}`).join(" and "), - () => ctx.read.featuresQuery.call(LiveFeatures, repoVariables(ctx)), - ); - if (!("denied" in routed)) { - const repository = yield* repositoryNode(routed.value); - for (const entry of GRAPHQL_ROUTED_KEYS) { - const decoded = entry.decode(repository[entry.field]); - if (decoded === undefined) { - notes.push( - `repository.${entry.key}: ${unreadableRoutedValue(entry, repository[entry.field], FEATURES_QUERY.name)}, so the snapshot leaves it out`, - ); - continue; - } - value[entry.key] = decoded; - } - } + + const routed = yield* snapshotRoutedKeys(ctx, notes); + Object.assign(value, routed); return ok({ value: value as RepositoryConfig, notes }); }); }, diff --git a/src/sections/repository/schema.ts b/src/sections/repository/schema.ts index daed27251..c564d293c 100644 --- a/src/sections/repository/schema.ts +++ b/src/sections/repository/schema.ts @@ -58,11 +58,7 @@ type GetOnlyKey = Exclude< keyof RepoPatchBody | UndocumentedPatchField | "topics" >; -/** - * Declared, a GET-only key drifts on every run: the diff sees the live value, the PATCH ignores the - * field, and nothing converges. Pinned both ways to the vendored spec, so a GET field appearing in the - * PATCH body drops out here at compile time and a new GET-only field fails to compile until listed. - */ +/** A declared GET-only key would drift on every run (the PATCH ignores it), so the parse refuses it; the satisfies and the MustBeNever below pin the list both ways to the vendored spec. */ const GET_ONLY_KEYS = [ "id", "node_id", @@ -181,14 +177,15 @@ type _ReviewerModesComplete = MustBeNever< | Exclude<(typeof REVIEWER_MODES)[number], NonNullable> >; -/** The `status` vocabulary the GET reports; the PATCH descriptor types the field as a bare string, so the GET's - * enum is the one GitHub publishes for it. */ +/** The GET's status vocabulary; the PATCH descriptor types the field as a bare string, so the GET's enum is the one GitHub publishes for it. */ const FeatureStatus = z.enum(FEATURE_STATUSES, { error: (issue) => `${describeValue(issue.input)} is not a feature status; use "enabled" or "disabled"`, }); -const listKeys = (keys: readonly string[]) => keys.map((key) => JSON.stringify(key)).join(", "); +function listKeys(keys: readonly string[]): string { + return keys.map((key) => JSON.stringify(key)).join(", "); +} /** GitHub answers 422 to an unknown sub-key here, so the closed shape says so before any request. */ function closedKeyError( @@ -254,17 +251,19 @@ const securityAndAnalysisShape = { secret_scanning_validity_checks: FeatureToggle.optional(), } satisfies Record; +/** The GET's dependabot_security_updates is the automated-security-fixes toggle, which has its own key. */ +function securityAndAnalysisHint(key: string): string | undefined { + return key === "dependabot_security_updates" + ? `"dependabot_security_updates" is reported by GitHub here but the PATCH rejects it; declare enable_automated_security_fixes instead` + : undefined; +} + /** The sub-keys the PATCH accepts, in lockstep with the shape; the snapshot reads exactly these back. */ export const SECURITY_AND_ANALYSIS_PATCH_FIELDS = Object.keys( securityAndAnalysisShape, ) as readonly (keyof typeof securityAndAnalysisShape)[]; -/** The GET's dependabot_security_updates is the automated-security-fixes toggle, which has its own key. */ -const securityAndAnalysisHint = (key: string): string | undefined => - key === "dependabot_security_updates" - ? `"dependabot_security_updates" is reported by GitHub here but the PATCH rejects it; declare enable_automated_security_fixes instead` - : undefined; - +/** The closed security_and_analysis mapping; its unknown-key refusal sends the dependabot key to its own toggle. */ const SecurityAndAnalysisConfig = z .strictObject(securityAndAnalysisShape, { error: closedKeyError( @@ -292,6 +291,7 @@ const COMMIT_MESSAGE_VOCABULARIES = { merge_commit_title: MERGE_COMMIT_TITLES, merge_commit_message: MERGE_COMMIT_MESSAGES, } as const satisfies { [K in CommitMessageKey]: readonly CommitMessageValue[] }; + type _CommitMessageVocabulariesComplete = MustBeNever< { [K in CommitMessageKey]: Exclude< @@ -303,10 +303,7 @@ type _CommitMessageVocabulariesComplete = MustBeNever< type Vocabulary = (typeof COMMIT_MESSAGE_VOCABULARIES)[K][number]; -/** - * The squash pairs GitHub documents: any other title/message pair answers 422 - * (invalid_squash_commit_setting_combo). The merge family has no documented matrix, so it gets none. - */ +/** The squash pairs GitHub documents; any other title/message pair answers 422 (invalid_squash_commit_setting_combo), and the merge family has no documented matrix. */ const SQUASH_COMMIT_PAIRS = { PR_TITLE: ["PR_BODY", "BLANK", "COMMIT_MESSAGES"], COMMIT_OR_PR_TITLE: ["COMMIT_MESSAGES"], @@ -327,7 +324,6 @@ function legalPairs(pairs: PairTable): string { interface CommitMessageFamily { readonly titleKey: string; readonly messageKey: string; - /** Title to the messages it may pair with; undefined when GitHub documents no matrix. */ readonly pairs: PairTable | undefined; readonly legalHint: string; } @@ -424,15 +420,8 @@ function commitMessagePairRules(): Record[] { // --- Topics ----------------------------------------------------------------------- -/** - * GitHub's topic rule, matched on the declared spelling: 1 to 50 characters, each a letter, digit, or hyphen, - * starting with a letter or digit. Uppercase passes and folds to lowercase on the wire (normalizeTopics), so the - * grammar spells [A-Za-z]: the published schema cannot fold, and a lowercase-only pattern there would refuse the - * `Copier` the runtime accepts. The pattern is the grammar on both sides; a refinement would not reach the schema. - */ const TOPIC_GRAMMAR = "[A-Za-z0-9][A-Za-z0-9-]{0,49}"; const TOPIC_PATTERN = new RegExp(`^${TOPIC_GRAMMAR}$`); -/** The comma form: the same grammar per segment, with the whitespace around a segment trimmed away. */ const TOPIC_LIST_PATTERN = new RegExp(`^\\s*${TOPIC_GRAMMAR}\\s*(,\\s*${TOPIC_GRAMMAR}\\s*)*$`); const MAX_TOPICS = 20; @@ -447,7 +436,11 @@ function declaredTopics(raw: string | readonly string[]): string[] { return typeof raw === "string" ? raw.split(",").map((t) => t.trim()) : [...raw]; } -/** The wire form: lowercased and deduped. */ +/** + * The wire form: lowercased and deduped. The grammar is a regex pattern so the published schema carries it, and + * it spells [A-Za-z] because that schema cannot fold case as this does: a lowercase-only pattern would refuse the + * `Copier` the runtime accepts. + */ export function normalizeTopics(raw: string | readonly string[]): string[] { return [...new Set(declaredTopics(raw).map((t) => t.toLowerCase()))]; } @@ -456,9 +449,9 @@ const topicName = z.string().regex(TOPIC_PATTERN, { error: (issue) => topicRefusal(String(issue.input)), }); -/** The comma form fails as one string, so the refusal names the segment at fault. */ const topicList = z.string().regex(TOPIC_LIST_PATTERN, { error: (issue) => { + // The comma form fails as one string, so the segment at fault is found here to name it. const segments = declaredTopics(String(issue.input)); const index = segments.findIndex((segment) => !TOPIC_PATTERN.test(segment)); const where = segments.length > 1 ? ` (entry ${index + 1} of the comma list)` : ""; @@ -500,12 +493,7 @@ function creationPolicy() { .optional(); } -/** - * Every PATCH field but `name`, typed as the API takes it, so null and a quoted boolean fail at - * parse instead of as GitHub's 422. `satisfies` pins the table both ways: a PATCH field missing here, - * or a key the PATCH lacks, fails to compile. `name` stays passthrough and out of the snapshot: a - * settings file reused on another repository would rename it. - */ +/** Every PATCH field but name, typed as the API takes it so null and a quoted boolean fail at parse rather than as a 422; name stays passthrough and out of the snapshot, since a settings file reused on another repository would rename it. */ const patchFieldShape = { description: CLEARABLE, homepage: CLEARABLE, @@ -543,8 +531,7 @@ export const PATCH_FIELDS = Object.keys( patchFieldShape, ) as readonly (keyof typeof patchFieldShape)[]; -// --- The section -------------------------------------------------------------------- - +/** The declared repository: mapping: the PATCH fields, topics, the feature toggles, and the two GraphQL-routed keys. */ export const RepositoryConfig = z .looseObject({ ...patchFieldShape, @@ -572,6 +559,7 @@ export const RepositoryConfig = z }), ) .meta({ id: "RepositoryConfig", allOf: commitMessagePairRules() }); + export type RepositoryConfig = z.infer; /** The parsed commit-message fields are the spec's unions; a widened enum would type them as string. */ diff --git a/src/sections/rulesets/index.ts b/src/sections/rulesets/index.ts index c68e86aaa..38f432736 100644 --- a/src/sections/rulesets/index.ts +++ b/src/sections/rulesets/index.ts @@ -88,11 +88,7 @@ function repeatedRuleTypes( return `${agree(repeated.size, "rule type", "rule types")} ${types}`; } -/** - * A summary or a full body: the list and the per-item GET share these fields. The API type leaves - * `source_type` optional; a body without it reads as repository-owned, the only kind the repository - * endpoints can write anyway. - */ +// The list summary and the per-item GET share these fields; a body without source_type reads as repository-owned, the only kind the repository endpoints can write. const LiveRuleset = z.looseObject({ id: z.number(), name: z.string(), @@ -109,6 +105,7 @@ const LiveRuleset = z.looseObject({ ) .optional(), }); + type LiveRuleset = z.infer; /** @@ -135,9 +132,6 @@ function comparableRuleset(live: LiveRuleset): Result>; }; -/** The spec: "At least one option must be enabled"; omitting the key allows all three. */ const atLeastOneMergeMethod = new z.core.$ZodCheckMinLength({ check: "min_length", minimum: 1, diff --git a/src/sections/rulesets/schema.ts b/src/sections/rulesets/schema.ts index fcc7cf4a5..e5f97d5f7 100644 --- a/src/sections/rulesets/schema.ts +++ b/src/sections/rulesets/schema.ts @@ -15,16 +15,10 @@ import { isPlainObject } from "../../plain-data.js"; import { rule as gatedRule, open } from "../shared/schema-helpers.js"; import { RULE_OVERRIDES, withOverrides } from "./rule-overrides.js"; -// --- Ref-name conditions ------------------------------------------------------ - /** The two tokens GitHub reads in a ref-name pattern; any other "~" means nothing, since no ref name contains one. */ const REF_NAME_TOKENS = ["~ALL", "~DEFAULT_BRANCH"] as const; -/** - * What git check-ref-format refuses in a ref name and a ruleset fnmatch pattern has no use for either (GitHub - * documents "\\" quoting and "[^...]" as unsupported): "~" outside the two tokens, "^", ":", "\\", space, "..", "@{", - * and control characters. A pattern carrying one is a typo. "*", "?", and "[" are pattern syntax and stay. - */ +// What git check-ref-format refuses in a ref name and a ruleset fnmatch pattern has no use for (GitHub documents "\\" quoting and "[^...]" as unsupported), so a pattern carrying one is a typo; "*", "?", and "[" are pattern syntax and stay. const REF_NAME_ILLEGAL = String.raw`[~^:\\ \x00-\x1f\x7f]|\.\.|@\{`; /** A token, or a value with no illegal sequence at any position; as a regex so the published schema carries the rule. */ @@ -54,10 +48,10 @@ function refNamePatterns(field: z.ZodOptional>) { .optional(); } +// The generated ref_name body; Conditions rebuilds it below with the ref-name grammar on its two lists. const refName = RulesetConditions.shape.ref_name.unwrap(); -// open() over the generated body at both levels, which only the section can mint (shared/schema-marks.ts): the -// published form keeps saying nothing about undeclared keys, and the parsed type carries no index signature. +// open() at both levels, which only the section can mint (shared/schema-marks.ts): the published form keeps saying nothing about undeclared keys, and the parsed type carries no index signature. const Conditions = open({ ...RulesetConditions.shape, ref_name: open({ @@ -67,8 +61,6 @@ const Conditions = open({ }).optional(), }).meta(RulesetConditions.meta() ?? {}); -// --- Bypass actors -------------------------------------------------------------- - /** The actor types whose actor_id GitHub requires; OrganizationAdmin ignores it and DeployKey documents it as null. */ const IDENTIFIED_ACTOR_TYPES: ReadonlySet = new Set([ "Integration", @@ -77,11 +69,7 @@ const IDENTIFIED_ACTOR_TYPES: ReadonlySet = new Set([ "User", ]); -/** - * The three refusals the descriptor states in prose alone. Rebuilt from the generated shape, not checked in place: - * a check clone keeps its parent, and the published schema would then meet the id twice. The id and prose come - * back through the meta. - */ +// The three refusals the descriptor states in prose alone, rebuilt from the generated shape rather than checked in place: a check clone keeps its parent, so the published schema would meet the id twice. const BypassActor = z .looseObject(BypassActorConfig.shape) .check( @@ -113,17 +101,13 @@ const BypassActor = z ) .meta(BypassActorConfig.meta() ?? {}); -// --- Rules ------------------------------------------------------------------------ - +// The generated rule shapes with the parameter checks the descriptor states in prose alone (rule-overrides.ts). const RULES = withOverrides(SPEC_RULES, RULE_OVERRIDES); +/** The e2e mock reads this list too, to answer GitHub's 422 for a rules[].type the descriptor does not know. */ export const KNOWN_RULE_TYPES: readonly string[] = RULES.map((known) => known.shape.type.value); -/** - * A rule type the spec does not know passes through untouched, so a type GitHub ships tomorrow reaches it the - * day it ships and a typo'd type comes back as GitHub's own 422 (the rulesets-invalid-rule-type scenario). - * The published schema says the same through `not`. - */ +// A rule type the spec does not know passes through untouched, so a type GitHub ships tomorrow reaches it the day it ships and a typo'd type comes back as GitHub's own 422 (the rulesets-invalid-rule-type scenario). const UnknownRule = z .looseObject({ type: z @@ -162,8 +146,6 @@ const RuleConfig = z .union([z.discriminatedUnion("type", [...RULES]), UnknownRule], { error: ruleUnionError }) .meta({ id: "RuleConfig" }); -// --- The ruleset -------------------------------------------------------------------- - export const RulesetConfig = open({ name: z.string(), // The file may omit both: target takes the default GitHub documents for a create, enforcement the value chosen diff --git a/src/sections/secret_scanning_custom_patterns/compilable-form.ts b/src/sections/secret_scanning_custom_patterns/compilable-form.ts index 453c539e4..1981f846e 100644 --- a/src/sections/secret_scanning_custom_patterns/compilable-form.ts +++ b/src/sections/secret_scanning_custom_patterns/compilable-form.ts @@ -40,22 +40,19 @@ interface GroupRewrite { form: string; } -/** - * The `(?` group openings the check knows. The flag-only group (`(?i)`, `(?im-s)`, and PCRE's empty - * `(?)`) is removed: flags change what a pattern matches, never whether it parses, except the - * extended-mode `x`, which changes how PCRE lexes what follows; `extendedMode()` names that case - * and `compileFailure()` passes it unchecked. An atomic group - * and a flagged non-capturing group are plain non-capturing groups to the check. A JavaScript-style - * named group and the lookarounds are their own spelling, listed so a name counts toward a - * duplicate and so no `(?` PCRE knows falls to the refusal of the ones it does not. - */ +/** The `(?` openings the check rewrites; a `(?#` comment is dropped before the table is read, and any other `(?` outside it (a subroutine call, a conditional) is refused as unrecognized rather than guessed at. */ const GROUP_REWRITES: readonly GroupRewrite[] = [ { syntax: new RegExp(`^\\(\\?P<${GROUP_NAME}>`), form: "(?<$1>" }, { syntax: new RegExp(`^\\(\\?'${GROUP_NAME}'`), form: "(?<$1>" }, + // JavaScript's own named group, listed so its name counts toward a duplicate. { syntax: new RegExp(`^\\(\\?<${GROUP_NAME}>`), form: "(?<$1>" }, + // A flag-only group (`(?i)`, `(?im-s)`, PCRE's empty `(?)`) is removed: flags change what a pattern matches, never + // whether it parses, except the extended-mode `x`, which extendedMode() names and compileFailure() passes unchecked. { syntax: /^\(\?(?:[imsx]*(?:-[imsx]+)?)\)/, form: "" }, + // A flagged non-capturing group and an atomic group are plain non-capturing groups to the check. { syntax: /^\(\?(?:[imsx]*(?:-[imsx]+)?):/, form: "(?:" }, { syntax: /^\(\?>/, form: "(?:" }, + // The lookarounds are their own spelling. { syntax: /^\(\? number; - /** `\1` to `\7` are octal only inside a class; outside they are backreferences, which pass through. */ inClassOnly?: true; } -/** - * PCRE's code point spellings, each reaching as far as PCRE reads it (`\x` takes zero to two hex - * digits, `\0` zero to two more octal digits), so a digit after a dropped token never joins one: - * `\0\E40` is NUL, 4, 0. A flagless RegExp reads several of these differently (`\x{41}` as an x - * repeated, `\a` as an a), so they render as their value. - */ +/** PCRE's code point spellings, each reaching exactly as far as PCRE reads (so `\0\E40` is NUL, 4, 0) and rendered as their value, since a flagless RegExp reads `\x{41}` as a repeated x and `\a` as an a. */ const CODE_POINT_ESCAPES: readonly CodePointEscape[] = [ { syntax: /^\\x\{([0-9A-Fa-f]+)\}/, value: (hex) => Number.parseInt(hex, 16) }, { @@ -141,6 +129,7 @@ const CODE_POINT_ESCAPES: readonly CodePointEscape[] = [ { syntax: /^\\o\{([0-7]+)\}/, value: (octal) => Number.parseInt(octal, 8) }, { syntax: /^\\(0[0-7]{0,2})/, value: (octal) => Number.parseInt(octal, 8) }, { + // `\1` to `\7` are octal only inside a class; outside they are backreferences, which pass through. syntax: /^\\([1-7][0-7]{0,2})/, value: (octal) => Number.parseInt(octal, 8), inClassOnly: true, @@ -156,143 +145,219 @@ const MALFORMED_ESCAPES: readonly [syntax: RegExp, reason: string][] = [ [/^\\c/, "\\c needs a printable ASCII character after it"], ]; +/** The `\Q...\E` quote at `index`, reaching to the end of the pattern when unclosed; an empty one is dropped. */ +function quoteAt(source: string, index: number): [Token, number] { + const end = source.indexOf("\\E", index + 2); + const literal = end === -1 ? source.slice(index + 2) : source.slice(index + 2, end); + const token: Token = literal === "" ? { kind: "dropped" } : { kind: "quote", literal }; + return [token, (end === -1 ? source.length : end + 2) - index]; +} + +/** The code point escape opening `rest`, with how far it reaches, or null when no spelling matches there. */ +function codePointAt(rest: string, inClass: boolean): [Token, number] | null { + for (const row of CODE_POINT_ESCAPES) { + if (row.inClassOnly && !inClass) { + continue; + } + + const match = row.syntax.exec(rest); + if (match === null) { + continue; + } + + const text = match[0]; + const value = row.value(match[1] as string); + const token: Token = + value > LAST_CODE_POINT + ? { kind: "refused", text, reason: `${text} is above U+10FFFF` } + : { kind: "codePoint", value }; + return [token, text.length]; + } + + return null; +} + /** The escape starting at `index` as PCRE reads it, with how far it reaches. */ function escapeAt(source: string, index: number, inClass: boolean): [Token, number] { if (source.startsWith("\\Q", index)) { - const end = source.indexOf("\\E", index + 2); - const literal = end === -1 ? source.slice(index + 2) : source.slice(index + 2, end); - const token: Token = literal === "" ? { kind: "dropped" } : { kind: "quote", literal }; - return [token, (end === -1 ? source.length : end + 2) - index]; + return quoteAt(source, index); } + if (source.startsWith("\\E", index)) { return [{ kind: "dropped" }, 2]; } + const rest = source.slice(index); - for (const row of CODE_POINT_ESCAPES) { - const match = row.inClassOnly && !inClass ? null : row.syntax.exec(rest); - if (match !== null) { - const text = match[0]; - const value = row.value(match[1] as string); - const token: Token = - value > LAST_CODE_POINT - ? { kind: "refused", text, reason: `${text} is above U+10FFFF` } - : { kind: "codePoint", value }; - return [token, text.length]; - } + const codePoint = codePointAt(rest, inClass); + if (codePoint !== null) { + return codePoint; } + const malformed = MALFORMED_ESCAPES.find(([syntax]) => syntax.test(rest)); if (malformed !== undefined) { return [{ kind: "refused", text: rest.slice(0, 2), reason: malformed[1] }, 2]; } + // A lone trailing backslash is a one-character escape the RegExp then refuses. const text = rest.slice(0, 2); return [{ kind: "escape", text }, text.length]; } -function tokenize(source: string): Token[] { - const tokens: Token[] = []; - let previous: Token | undefined; - let inClass = false; - let classHasMember = false; - const push = (token: Token, advance: number): number => { - tokens.push(token); - if (token.kind !== "dropped") { - previous = token; - classHasMember = inClass; +/** + * One walk of the tokenizer: `previous` is the last token PCRE sees (a dropped one never takes the + * slot), and `classHasMember` whether the open class has read a member yet, which decides how a + * `]` reads. + */ +interface Walk { + tokens: Token[]; + previous: Token | undefined; + inClass: boolean; + classHasMember: boolean; +} + +/** Records `token` and hands back `advance`; a dropped token leaves `previous` and the class membership untouched. */ +function push(walk: Walk, token: Token, advance: number): number { + walk.tokens.push(token); + if (token.kind !== "dropped") { + walk.previous = token; + walk.classHasMember = walk.inClass; + } + return advance; +} + +/** The next unit inside a class, with how far it reaches: the negation, a closing `]`, a POSIX class, or one character. */ +function classUnitAt(walk: Walk, rest: string): number { + const ch = rest[0] as string; + const { previous } = walk; + if (ch === "^" && previous?.kind === "classOpen" && !previous.negated) { + // The negation is the first thing PCRE reads after the `[` and what it drops: `[\Q\E^]` is negated. + previous.negated = true; + return 1; + } + + if (ch === "]" && !walk.classHasMember) { + // PCRE reads a `]` before any member as a literal one, where a flagless RegExp would close the class. + return push(walk, { kind: "escape", text: "\\]" }, 1); + } + + if (ch === "]") { + walk.inClass = false; + return push(walk, { kind: "classClose" }, 1); + } + + const posix = POSIX_CLASS.exec(rest); + if (posix === null) { + return push(walk, { kind: "literal", text: ch }, 1); + } + return push(walk, { kind: "posixClass" }, posix[0].length); +} + +/** The `(?` opening at the start of `rest` as PCRE reads it (a `(?#` comment is dropped), or null when `rest` opens with no `(?`. */ +function groupOpeningAt(rest: string): [Token, number] | null { + if (rest.startsWith("(?#")) { + const end = rest.indexOf(")"); + if (end === -1) { + return [{ kind: "refused", text: rest, reason: "missing ) after (?# comment" }, rest.length]; + } + return [{ kind: "dropped" }, end + 1]; + } + + if (!rest.startsWith("(?")) { + return null; + } + + for (const row of GROUP_REWRITES) { + const match = row.syntax.exec(rest); + if (match === null) { + continue; } - return advance; - }; + + const token: Token = { + kind: "group", + text: match[0], + form: match[0].replace(row.syntax, row.form), + name: match[1], + }; + return [token, match[0].length]; + } + + // PCRE's other `(?` openings (conditions, recursion, callouts, branch reset, `(?P=`) it may accept + // and Hyperscan refuses; refused here as one unit, so a dropped token cannot re-fuse one into a known form. + const text = rest.slice(0, 3); + return [{ kind: "refused", text, reason: "unrecognized character after (?" }, text.length]; +} + +/** The quantifier spelled at the start of `rest` (`*`, `+`, `?`, or a `{m,n}` brace), or null when `rest` opens with none. */ +function quantifierText(rest: string): string | null { + const ch = rest[0] as string; + if (ch === "*" || ch === "+" || ch === "?") { + return ch; + } + + if (ch !== "{") { + return null; + } + return BRACE_QUANTIFIER.exec(rest)?.[0] ?? null; +} + +/** What a quantifier spelled `text` is after `previous`: a modifier on a quantifier, a quantifier on a repeatable item, else PCRE's refusal. */ +function quantifierToken(previous: Token | undefined, text: string): Token { + // One modifier at most after a quantifier: `?` lazy, `+` possessive; anything more is a quantifier again, dangling. + if (previous?.kind === "quantifier" && text === "?") { + return { kind: "lazy" }; + } + + if (previous?.kind === "quantifier" && text === "+") { + return { kind: "possessive" }; + } + + if (repeatable(previous)) { + return { kind: "quantifier", text }; + } + return { kind: "refused", text, reason: NOT_REPEATABLE }; +} + +function tokenize(source: string): Token[] { + const walk: Walk = { tokens: [], previous: undefined, inClass: false, classHasMember: false }; let i = START_VERBS.exec(source)?.[0].length ?? 0; while (i < source.length) { const rest = source.slice(i); const ch = rest[0] as string; if (ch === "\\") { - const [token, advance] = escapeAt(source, i, inClass); - i += push(token, advance); + const [token, advance] = escapeAt(source, i, walk.inClass); + i += push(walk, token, advance); continue; } - if (inClass) { - if (ch === "^" && previous?.kind === "classOpen" && !previous.negated) { - // The negation is the first thing PCRE reads after the `[` and what it drops: `[\Q\E^]` is negated. - previous.negated = true; - i += 1; - continue; - } - if (ch === "]" && !classHasMember) { - // PCRE reads a `]` before any member as a literal one, where a flagless RegExp would close the class. - i += push({ kind: "escape", text: "\\]" }, 1); - continue; - } - if (ch === "]") { - inClass = false; - i += push({ kind: "classClose" }, 1); - continue; - } - const posix = POSIX_CLASS.exec(rest); - i += - posix === null - ? push({ kind: "literal", text: ch }, 1) - : push({ kind: "posixClass" }, posix[0].length); + + if (walk.inClass) { + i += classUnitAt(walk, rest); continue; } + if (ch === "[") { - i += push({ kind: "classOpen", negated: false }, 1); - inClass = true; - classHasMember = false; - continue; - } - if (rest.startsWith("(?#")) { - const end = rest.indexOf(")"); - i += - end === -1 - ? push( - { kind: "refused", text: rest, reason: "missing ) after (?# comment" }, - rest.length, - ) - : push({ kind: "dropped" }, end + 1); - continue; - } - const rewrite = GROUP_REWRITES.map((row) => ({ row, match: row.syntax.exec(rest) })).find( - (candidate) => candidate.match !== null, - ); - if (rewrite?.match) { - const { row, match } = rewrite; - i += push( - { - kind: "group", - text: match[0], - form: match[0].replace(row.syntax, row.form), - name: match[1], - }, - match[0].length, - ); + i += push(walk, { kind: "classOpen", negated: false }, 1); + walk.inClass = true; + walk.classHasMember = false; continue; } - if (rest.startsWith("(?")) { - // PCRE's other `(?` openings (conditions, recursion, callouts, branch reset, `(?P=`) it may accept - // and Hyperscan refuses; refused here as one unit, so a dropped token cannot re-fuse one into a known form. - const text = rest.slice(0, 3); - i += push({ kind: "refused", text, reason: "unrecognized character after (?" }, text.length); + + const group = groupOpeningAt(rest); + if (group !== null) { + const [token, advance] = group; + i += push(walk, token, advance); continue; } - const brace = ch === "{" ? BRACE_QUANTIFIER.exec(rest) : null; - if (ch === "*" || ch === "+" || ch === "?" || brace !== null) { - const text = brace === null ? ch : brace[0]; - // One modifier at most after a quantifier: `?` lazy, `+` possessive; anything more is a quantifier again, dangling. - if (previous?.kind === "quantifier" && ch === "?") { - i += push({ kind: "lazy" }, 1); - } else if (previous?.kind === "quantifier" && ch === "+") { - i += push({ kind: "possessive" }, 1); - } else if (repeatable(previous)) { - i += push({ kind: "quantifier", text }, text.length); - } else { - i += push({ kind: "refused", text, reason: NOT_REPEATABLE }, text.length); - } + + const quantifier = quantifierText(rest); + if (quantifier !== null) { + i += push(walk, quantifierToken(walk.previous, quantifier), quantifier.length); continue; } - i += push({ kind: "literal", text: ch }, 1); + + i += push(walk, { kind: "literal", text: ch }, 1); } - return tokens; + + return walk.tokens; } /** Every character `\Q...\E` quotes that would otherwise be syntax, spelled as its escape. */ diff --git a/src/sections/secret_scanning_custom_patterns/index.ts b/src/sections/secret_scanning_custom_patterns/index.ts index 5843593e7..222b56c33 100644 --- a/src/sections/secret_scanning_custom_patterns/index.ts +++ b/src/sections/secret_scanning_custom_patterns/index.ts @@ -12,6 +12,7 @@ import { isDeepStrictEqual } from "node:util"; import { ok, type Result } from "neverthrow"; import { z } from "zod"; import { agree } from "../../text.js"; +import type { UndeclaredPolicy } from "../../types.js"; import { missingDrift, undeclaredDrift, @@ -39,9 +40,10 @@ import { } from "../shared/snapshot-helpers.js"; import { SecretScanningPatternConfig, unverifiableRegexFields } from "./schema.js"; +const key = "secret_scanning_custom_patterns"; + const permission: SectionPermission = { repo: ["secret_scanning_alerts"] }; -/** The known entry keys the update PATCH accepts: everything but the immutable name. */ export const UPDATABLE_KEYS = [ "pattern", "start_delimiter", @@ -51,6 +53,7 @@ export const UPDATABLE_KEYS = [ ] as const; type UpdatableKey = (typeof UPDATABLE_KEYS)[number]; +/** Every route's denial hint: secret scanning switched off answers the same 404 as a missing grant. */ const NOT_ENABLED_HINT = "a 404 can also mean secret scanning is not enabled for the repository (it requires GitHub Advanced Security on private repositories)"; @@ -58,6 +61,7 @@ const NOT_ENABLED_HINT = export const STALE_VERSION_HINT = "the pattern changed on GitHub between this run's read and its write (stale custom_pattern_version); re-run the workflow"; +/** The update and the remove both carry custom_pattern_version, so both can answer the 412 their hint names. */ const ENDPOINTS = { list: { route: "GET /repos/{owner}/{repo}/secret-scanning/custom-patterns", @@ -87,6 +91,8 @@ const ENDPOINTS = { }, } as const satisfies Record; +type PatternOp = PlannedOp; + interface LivePattern { id: number; name: string; @@ -94,8 +100,7 @@ interface LivePattern { fields: Partial>; } -// A null or absent version is the API's own version-less form (no optimistic concurrency), but a -// PRESENT value of any other type must not bypass it. +// A null or absent version is the API's own version-less form (no optimistic concurrency), but a PRESENT value of any other type must not bypass it. const LivePatternEntry = z.looseObject({ id: z.number(), name: z.string(), @@ -159,7 +164,137 @@ function patternsByName( ); } -const key = "secret_scanning_custom_patterns"; +/** The PATCH for a declared pattern whose live fields diverge, or undefined when every declared field matches. */ +function updateOp( + entry: SecretScanningPatternConfig, + existing: LivePattern, +): PatternOp | undefined { + const divergent = Object.entries(declaredFields(entry)).filter( + ([field, value]) => !matches(value, existing.fields[field as UpdatableKey]), + ); + const drift = divergent.map(([field, value]) => { + const liveValue = existing.fields[field as UpdatableKey]; + // JSON.stringify(undefined) is not a string; spell absence out. + const liveRendered = liveValue === undefined ? "(absent)" : JSON.stringify(liveValue); + return valueDrift(`${key}[${entry.name}].${field}`, JSON.stringify(value), liveRendered); + }); + if (!hasDrift(drift)) { + return undefined; + } + + return { + role: "update", + params: { pattern_id: String(existing.id) }, + // The PATCH body REQUIRES the version key but accepts null: a version-less live pattern + // writes without the concurrency check. + payload: { + custom_pattern_version: existing.version ?? null, + ...Object.fromEntries(divergent), + }, + describe: `updating secret scanning pattern "${existing.name}"`, + drift, + change: `updated secret scanning custom pattern "${existing.name}"`, + }; +} + +/** The one bulk create, its drift and change lines one per pattern; undefined with nothing to create. */ +function createOp(toCreate: readonly SecretScanningPatternConfig[]): PatternOp | undefined { + const [first, ...rest] = toCreate; + if (first === undefined) { + return undefined; + } + + const missing = (p: SecretScanningPatternConfig): string => missingDrift(`${key}[${p.name}]`); + const created = (p: SecretScanningPatternConfig): string => + `created secret scanning custom pattern "${p.name}"`; + return { + role: "create", + payload: { patterns: toCreate.map(createBody) }, + describe: `creating secret scanning ${agree(toCreate.length, "pattern", "patterns")} ${toCreate.map((p) => `"${p.name}"`).join(", ")}`, + drift: [missing(first), ...rest.map(missing)], + change: () => ok([created(first), ...rest.map(created)] as const), + }; +} + +/** The one bulk delete of the undeclared patterns, their alerts resolved; undefined with nothing to delete. */ +function deleteOp( + toDelete: readonly LivePattern[], + listDefault: UndeclaredPolicy, +): PatternOp | undefined { + const [first, ...rest] = toDelete; + if (first === undefined) { + return undefined; + } + + const undeclared = (p: LivePattern): string => + undeclaredDrift(listDefault, { + label: `${key}[${p.name}]`, + action: "DELETE it and resolve its alerts", + }); + const deleted = (p: LivePattern): string => + `DELETED undeclared secret scanning custom pattern "${p.name}" (alerts resolved, not deleted)`; + // post_delete_action is ALWAYS "resolve_alerts": this action never destroys alert history + // (upstream defaults to delete_alerts), and there is no user knob. + return { + role: "remove", + payload: { patterns: toDelete.map(deleteBody), post_delete_action: "resolve_alerts" }, + describe: `deleting undeclared secret scanning ${agree(toDelete.length, "pattern", "patterns")} ${toDelete.map((p) => `"${p.name}"`).join(", ")}`, + drift: [undeclared(first), ...rest.map(undeclared)], + change: () => ok([deleted(first), ...rest.map(deleted)] as const), + }; +} + +/** The declared walk: an entry with no live pattern goes to the bulk create, the rest PATCH where their fields diverge. */ +function declaredWrites( + desired: readonly SecretScanningPatternConfig[], + liveByName: ReadonlyMap, +): { toCreate: SecretScanningPatternConfig[]; updates: PatternOp[] } { + const toCreate: SecretScanningPatternConfig[] = []; + const updates: PatternOp[] = []; + for (const entry of desired) { + const existing = liveByName.get(entry.name); + if (existing === undefined) { + toCreate.push(entry); + continue; + } + + const update = updateOp(entry, existing); + if (update !== undefined) { + updates.push(update); + } + } + + return { toCreate, updates }; +} + +/** The live patterns nobody declared, for the bulk delete; under the keep policy each earns a note on `plan` instead. */ +function undeclaredPatterns( + live: readonly LivePattern[], + declaredNames: ReadonlySet, + policy: UndeclaredPolicy, + plan: SectionPlan, +): LivePattern[] { + const toDelete: LivePattern[] = []; + for (const pattern of live) { + if (declaredNames.has(pattern.name)) { + continue; + } + + if (policy === "keep") { + plan.notes.push( + undeclaredNote({ + subject: `secret scanning custom pattern "${pattern.name}"`, + action: "DELETE it (its alerts are then resolved, not deleted)", + }), + ); + continue; + } + + toDelete.push(pattern); + } + + return toDelete; +} export const secretScanningPatternsSection = { // Verbatim: GitHub matches pattern names exactly. @@ -182,105 +317,28 @@ export const secretScanningPatternsSection = { 'the pattern endpoints accept no other field - in particular "state" and "push_protection_enabled" are read-only through this API surface - so the key would be dropped silently and never converge', }, async plan(ctx, declared) { - const { policy, entries: desired } = undeclaredPolicy(declared, defaultUndeclaredPolicy(this)); + const listDefault = defaultUndeclaredPolicy(this); + const { policy, entries: desired } = undeclaredPolicy(declared, listDefault); return ctx.read.list .listAll(LivePatternEntry) .map((entries) => entries.map(liveFrom)) .andThen((live) => patternsByName(this, live).map((liveByName) => { const declaredNames = new Set(desired.map((p) => p.name)); + const plan: SectionPlan = { ops: [], notes: [], drift: [] }; + const { toCreate, updates } = declaredWrites(desired, liveByName); + const toDelete = undeclaredPatterns(live, declaredNames, policy, plan); - const plan: SectionPlan> = { ops: [], notes: [], drift: [] }; - const toCreate: SecretScanningPatternConfig[] = []; - const updates: PlannedOp[] = []; - for (const entry of desired) { - const existing = liveByName.get(entry.name); - if (existing === undefined) { - toCreate.push(entry); - continue; - } - const divergent = Object.entries(declaredFields(entry)).filter( - ([field, value]) => !matches(value, existing.fields[field as UpdatableKey]), - ); - const drift = divergent.map(([field, value]) => { - const liveValue = existing.fields[field as UpdatableKey]; - // JSON.stringify(undefined) is not a string; spell absence out. - const liveRendered = liveValue === undefined ? "(absent)" : JSON.stringify(liveValue); - return valueDrift( - `${key}[${entry.name}].${field}`, - JSON.stringify(value), - liveRendered, - ); - }); - if (!hasDrift(drift)) { - continue; - } - updates.push({ - role: "update", - params: { pattern_id: String(existing.id) }, - // The PATCH body REQUIRES the version key but accepts null: a version-less live pattern - // writes without the concurrency check. - payload: { - custom_pattern_version: existing.version ?? null, - ...Object.fromEntries(divergent), - }, - describe: `updating secret scanning pattern "${existing.name}"`, - drift, - change: `updated secret scanning custom pattern "${existing.name}"`, - }); - } - - const toDelete: LivePattern[] = []; - for (const pattern of live) { - if (declaredNames.has(pattern.name)) { - continue; - } - if (policy === "keep") { - plan.notes.push( - undeclaredNote({ - subject: `secret scanning custom pattern "${pattern.name}"`, - action: "DELETE it (its alerts are then resolved, not deleted)", - }), - ); - continue; - } - toDelete.push(pattern); - } - - const [firstCreate, ...restCreate] = toCreate; - if (firstCreate !== undefined) { - const missing = (p: SecretScanningPatternConfig): string => - missingDrift(`${key}[${p.name}]`); - const created = (p: SecretScanningPatternConfig): string => - `created secret scanning custom pattern "${p.name}"`; - plan.ops.push({ - role: "create", - payload: { patterns: toCreate.map(createBody) }, - describe: `creating secret scanning ${agree(toCreate.length, "pattern", "patterns")} ${toCreate.map((p) => `"${p.name}"`).join(", ")}`, - drift: [missing(firstCreate), ...restCreate.map(missing)], - change: () => ok([created(firstCreate), ...restCreate.map(created)] as const), - }); + const create = createOp(toCreate); + if (create !== undefined) { + plan.ops.push(create); } plan.ops.push(...updates); - const [firstDelete, ...restDelete] = toDelete; - if (firstDelete !== undefined) { - const undeclared = (p: LivePattern): string => - undeclaredDrift(defaultUndeclaredPolicy(this), { - label: `${key}[${p.name}]`, - action: "DELETE it and resolve its alerts", - }); - const deleted = (p: LivePattern): string => - `DELETED undeclared secret scanning custom pattern "${p.name}" (alerts resolved, not deleted)`; - // post_delete_action is ALWAYS "resolve_alerts": this action never destroys alert history - // (upstream defaults to delete_alerts), and there is no user knob. - plan.ops.push({ - role: "remove", - payload: { patterns: toDelete.map(deleteBody), post_delete_action: "resolve_alerts" }, - describe: `deleting undeclared secret scanning ${agree(toDelete.length, "pattern", "patterns")} ${toDelete.map((p) => `"${p.name}"`).join(", ")}`, - drift: [undeclared(firstDelete), ...restDelete.map(undeclared)], - change: () => ok([deleted(firstDelete), ...restDelete.map(deleted)] as const), - }); + const remove = deleteOp(toDelete, listDefault); + if (remove !== undefined) { + plan.ops.push(remove); } + return plan; }), ); diff --git a/src/sections/secret_scanning_custom_patterns/schema.ts b/src/sections/secret_scanning_custom_patterns/schema.ts index 6b0593b37..9afa6c4a4 100644 --- a/src/sections/secret_scanning_custom_patterns/schema.ts +++ b/src/sections/secret_scanning_custom_patterns/schema.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { minLength, open, rule } from "../shared/schema-helpers.js"; import { compileFailure } from "./compilable-form.js"; +/** "" cannot mean "clear the delimiter" (the PATCH updates provided fields only), so the spelling fails at document validation, before any repository is touched. */ const DELIMITER_CLEAR_ERROR = "a delimiter cannot be cleared with an empty string; remove the pattern and redeclare it without the field instead"; @@ -59,8 +60,6 @@ export function unverifiableRegexFields(entry: unknown): string[] { export const SecretScanningPatternConfig = open({ name: z.string(), pattern: regexSource(), - // "" cannot mean "clear the delimiter" (the PATCH updates provided fields only), so the spelling - // fails at document validation, before any repository is touched. start_delimiter: regexSource().check(minLength(1, DELIMITER_CLEAR_ERROR)).optional(), end_delimiter: regexSource().check(minLength(1, DELIMITER_CLEAR_ERROR)).optional(), must_match: z.array(regexSource()).optional(), diff --git a/src/sections/shared/list/decl.ts b/src/sections/shared/list/decl.ts index 66ea63537..6000b722a 100644 --- a/src/sections/shared/list/decl.ts +++ b/src/sections/shared/list/decl.ts @@ -41,7 +41,7 @@ type Paramless = R extends Route : never : never; -type UpdateDecl = EndpointDecl & { +export type UpdateDecl = EndpointDecl & { readonly route: Extract; }; @@ -184,6 +184,7 @@ type DriftWording = Pick[1], "state" | "add" interface Listing { /** The query the list carries (milestones' state=all: the default listing omits closed items). */ readonly query?: Readonly>; + /** GitHub serves the whole list in one response and ignores page params (autolinks), so the page loop is skipped. */ readonly unpaginated?: true; } @@ -193,9 +194,12 @@ interface Listing { * under a read grant): plan() compares around it and notes it, snapshot() leaves the item out. */ interface ConcealedField { + /** The write field GitHub withholds, as a dotted path. */ readonly field: string; + /** Why GitHub withholds it ("GitHub returns it only to a token with write access to the ruleset"). */ readonly reason: string; + /** The grant that lifts it, as an imperative clause ("grant Administration write"). */ readonly remedy: string; } @@ -225,12 +229,11 @@ type MappingFacet = Ends exten interface Identity { /** The write field naming the resource, as the live item carries it ("name", "title", "config.url"). */ readonly field: F; + /** Folds a name to the key GitHub matches it by; `exactName` when GitHub matches exactly. */ readonly fold: (name: string) => Key; - /** - * Names an entry also answers to (a label's pre-rename `name`), so a live item under one is this - * entry's, renamed by the update, not undeclared. - */ + + /** Names an entry also answers to (a label's pre-rename `name`), so a live item under one is this entry's, renamed by the update, not undeclared. */ readonly aliases?: (entry: Entry) => readonly string[]; } @@ -247,7 +250,8 @@ type RenameFacet = F extends `${string}.${string}` /** * `Key` is the fold's output: the planner keys every live-versus-declared lookup by it, so an unfolded - * name cannot be looked up, and a section's brand (labels' NameKey) survives to `decl`. + * name cannot be looked up, and a section's brand (labels' NameKey) survives to `decl`. `M` infers from + * `mapping` alone (NoInfer on `secrets`), so a stray secret path is the error, never a re-inferred mapping. */ export type ListSectionDecl< K extends ListSectionKey, @@ -258,6 +262,20 @@ export type ListSectionDecl< M extends string = never, > = ListSectionDeclFields & MappingFacet; +/** + * The declaration a list section hands listSection(). The lens puts the entry and the live item in one vocabulary, + * so `fromLive` reads a live item in toWrite's terms: + * + * identity field -> verbatim + * other declared fields -> normalized as GitHub stores them (a color lowercased without "#", a null description as "") + * every other live field -> kept, so declared passthrough keys compare against what the API echoed + * + * `conflicts` are the clashes the identities cannot show, each naming the fix; any finding fails the section: + * + * declared -> sees only the writes and runs inside document validation, before ANY section writes + * (a settings-file mistake costs no request); `[i]` indexes the writes as the entries + * live -> runs after the read and before any write (a deploy key's material held by another key) + */ interface ListSectionDeclFields< K extends ListSectionKey, Ends extends ListEndpoints, @@ -266,71 +284,63 @@ interface ListSectionDeclFields< Key extends string, M extends string, > { + /** As SectionMeta.key. */ readonly key: K; + + /** As SectionMeta.permission. */ readonly permission: SectionPermission; + + /** As SectionMeta.undeclaredDefault: "delete" or "keep", since a list section enumerates its live resources. */ readonly undeclaredDefault: UndeclaredPolicy; + /** The output noun for change lines and notes ("label"). */ readonly noun: string; + /** The entry config slice (src/sections//schema.ts); the loose shape derives from it. */ readonly entry: z.ZodType>; + /** The fields of a live item the section reads (a list item, and the `get` body when the role exists); extras ride along. */ readonly live: z.ZodType; + + /** The roles the factory derives its handlers from, pinned to exactly those by OnlyListRoles. */ readonly endpoints: Ends & OnlyListRoles; + + /** How the list is read: a query on every page, or one unpaginated GET. */ readonly listing?: Listing; + + /** The field entries and live items are matched by, with its fold and aliases. */ readonly identity: Identity & RenameFacet; + /** The path params addressing one live item for every item role; unrepresentable when the routes disagree. */ readonly address: [Address] extends [never] ? never : (live: Live) => Address; + + /** The entry and the live item in one vocabulary, and the body the request spells. */ readonly lens: { - /** - * The entry in the terms the comparison runs in: what a converged live item reads back as, and the - * request body itself unless `wire` renders it. - */ + /** The entry as a converged live item reads back, and the request body itself unless `wire` renders it. */ readonly toWrite: (entry: Entry) => ListWrite; - /** - * A live item in the same terms as toWrite, so the two compare field by field; a live item the section cannot - * compare (a ruleset repeating a rule type) is the failure that ends the plan or snapshot. - * - * identity field -> verbatim - * other declared fields -> normalized as GitHub stores them (a color lowercased without "#", a null description as "") - * every other live field -> kept, so declared passthrough keys compare against what the API echoed - */ + + /** A live item in toWrite's terms (the rows above); a live item the section cannot compare (a ruleset repeating a rule type) is the failure that ends the plan or snapshot. */ readonly fromLive: (live: Live) => Result, SectionFailure>; - /** - * The write as the request body spells it, when that differs from the compared form (a milestone's due - * day sent as noon UTC): applied to every body the planner sends (create, recreate, update, and the - * updateConfig slice) and to nothing the comparison or the snapshot reads. Omitted, the write is the body. - */ + + /** The body as the request spells it when that differs from the compared form (a milestone's due day as noon UTC): every body the planner sends, nothing the comparison or the snapshot reads. */ readonly wire?: (write: ListWrite) => ListWrite; + /** Per entry field holding a list, the item key to pair by (see DeltaOptions.matchBy); `{}` when none does. */ readonly matchBy: Readonly & string, MatchKey>>>; }; - /** - * Whether the update body replaces the live item whole (a ruleset's PUT) or sets named fields only (a PATCH). - * Under `true` a non-empty live value under a key of the entry slice that the entry omits is drift, and apply - * refuses the write. Every declaration says which, so a replace-style section cannot inherit the declared-keys - * comparison by leaving it out. - */ + + /** Whether the update replaces the live item whole (a ruleset's PUT), so a non-empty live value the entry omits is drift and apply refuses the write; required, so a replace-style section cannot inherit the PATCH comparison by leaving it out. */ readonly replaces: boolean; - /** - * The body recreating a drifted item of a resource GitHub cannot edit (no update role), when the - * write alone would drop a live field the file leaves undeclared (a deploy key's read_only). - */ + + /** The body recreating a drifted item of a resource GitHub cannot edit (no update role), when the write alone would drop a live field the file leaves undeclared (a deploy key's read_only). */ readonly recreate?: "update" extends keyof Ends ? never : (live: Live, write: ListWrite) => ListWrite; - /** - * File-only checks over the entries beyond identity uniqueness (a deploy key's material must parse), one - * issue per finding with its path (`[2].key`); they join the module's validate hook, which the engine runs - * inside document validation, so a write with such an entry is unreachable and toWrite may treat it as a BUG. - */ + + /** File-only checks beyond identity uniqueness (a deploy key's material must parse), one issue per finding at its path (`[2].key`); the engine runs them inside document validation, so toWrite may treat such an entry as a BUG. */ readonly validate?: (entries: readonly Entry[]) => readonly DeclaredIssue[]; - /** - * Conflicts the identities cannot show, each naming the fix; any finding fails the section. - * - * `declared` -> sees only the writes and runs inside document validation, before ANY section writes - * (a settings-file mistake costs no request); `[i]` indexes the writes as the entries - * `live` -> runs after the read and before any write (a deploy key's material held by another key) - */ + + /** Conflicts the identities cannot show (the rows above); any finding fails the section. */ readonly conflicts?: { readonly declared?: (writes: readonly ListWrite[]) => readonly DeclaredIssue[]; readonly live?: ( @@ -338,37 +348,33 @@ interface ListSectionDeclFields< live: readonly ListComparable[], ) => readonly string[]; }; - /** - * The reason a live item is outside what the section manages (an inherited ruleset, a legacy - * service hook), or null when it is the section's own: plan() neither matches nor removes it, - * snapshot() leaves it out under `name` with the reason. - */ + + /** Why a live item is outside what the section manages (an inherited ruleset, a legacy service hook), or null for the section's own; plan() neither matches nor removes it, snapshot() leaves it out with the reason. */ readonly foreign?: (live: Live) => { readonly name: string; readonly reason: string } | null; + /** Read off the full body (the `get` role's when it exists); see ConcealedField. */ readonly concealed?: (live: Live) => readonly ConcealedField[]; - /** - * Write fields (dotted paths) holding a `$NAME` reference to a value GitHub never echoes back (a - * webhook's config.secret): left out of the comparison, resolved when the write executes, re-sent - * on every run under an unverifiable facet, and read back by a snapshot as a per-item reference. - * SecretPath admits a path only under `mapping`, and only where its carriers declare the facet; `mapping` - * alone infers `M`, so a stray path is the error, never a re-inferred mapping. - */ + + /** Dotted write paths holding a `$NAME` reference GitHub never echoes back (a webhook's config.secret): left out of the comparison, resolved at execution, re-sent every run under an unverifiable facet, and read back as a per-item reference. */ readonly secrets?: readonly SecretPath>[]; + + /** The undeclared wording: what apply does, and the optional rewordings of the keep-note and the delete drift. */ readonly prose: { /** What apply does to an undeclared live resource, as the note and drift spell it ("DELETE it"). */ readonly undeclaredAction: string; + + /** Rewords the keep-note (see NoteWording). */ readonly undeclaredNote?: NoteWording; + + /** Rewords the delete drift line (see DriftWording). */ readonly undeclaredDrift?: DriftWording; }; - /** - * The pairing itself is derived from `identity`, the very claims the planner's duplicate check reads, so the - * merge and the planner cannot disagree about which entries are one; a declaration adds only the nested keyed - * lists. - */ + + /** The nested keyed lists alone: the pairing derives from `identity`, the claims the planner's duplicate check reads, so the merge and the planner cannot disagree about which entries are one. */ readonly layering?: Pick; } -/** The module listSection() mints: SectionModule at the registry, plus its declaration. */ +/** The module listSection() mints: SectionModule at the registry, plus `decl`, the declaration the harness derives from (the mock's transformers, the fuzz witness). */ export interface ListSectionModule< K extends ListSectionKey, Ends extends ListEndpoints, @@ -392,7 +398,6 @@ export interface ListSectionModule< readonly snapshot: ( ctx: SnapshotContext, ) => Promise, SectionFailure>>; - /** The declaration, for the harness derivations (the mock's transformers, the fuzz witness). */ readonly decl: ListSectionDecl; } diff --git a/src/sections/shared/list/plan.ts b/src/sections/shared/list/plan.ts index edab4e1ad..963f709fd 100644 --- a/src/sections/shared/list/plan.ts +++ b/src/sections/shared/list/plan.ts @@ -5,15 +5,17 @@ * before the create that needs it is sent. */ -import { err, ok, Result, safeTry } from "neverthrow"; +import { err, ok, Result, type ResultAsync } from "neverthrow"; import { type Delta, deltas, omittedDeltas, phantomNote, phantomPaths, + type ReplaceSweep, refuseOmitted, } from "../../../engine/diff.js"; +import type { UndeclaredPolicy } from "../../../types.js"; import { type DeclaredIssue, declaredEntries, duplicateIssues } from "../../contract/declared.js"; import { missingDrift, @@ -27,9 +29,12 @@ import { defaultUndeclaredPolicy, type SectionMeta } from "../../contract/module import { type ExecTools, hasDrift, + type Late, + type PlainData, type PlanContext, plainData, type SectionPlan, + type Unverifiable, } from "../../contract/plan.js"; import { projectOntoSchema, replaceSweep } from "../snapshot-helpers.js"; import { @@ -39,6 +44,7 @@ import { type ListEndpoints, type ListSectionKey, type ListWrite, + type UpdateDecl, updateRole, } from "./decl.js"; import { readItem, readLive } from "./reads.js"; @@ -61,6 +67,34 @@ import { withoutPaths, } from "./write.js"; +type Op = SectionPlan["ops"][number]; + +/** One declared entry's write with the identities it claims; each claim is its alone, since validateList refused the document otherwise. */ +interface DeclaredWrite { + readonly write: ListWrite; + readonly name: string; + readonly claims: readonly Key[]; +} + +/** One live item beside its comparable form, keyed as the planner looks it up. */ +interface LiveItem { + readonly item: object; + readonly comparable: ListComparable; + readonly name: string; + readonly key: Key; +} + +/** What every step of one plan shares, resolved once from the declaration and the section. */ +interface Planning { + readonly decl: ErasedDecl; + readonly wire: (write: ListWrite) => ListWrite; + readonly update: UpdateDecl | undefined; + readonly remedies: typeof UPDATE_REMEDIES; + readonly sweep: ReplaceSweep | undefined; + readonly policy: UndeclaredPolicy; + readonly defaultPolicy: UndeclaredPolicy; +} + /** A live item with the comparison it takes part in, less what neither side can show. */ interface Comparison { readonly write: Fields; @@ -68,6 +102,19 @@ interface Comparison { readonly notes: string[]; } +/** The deltas between a declared entry and its live item, rendered by the entry's remedies, with the notes the compare raised. */ +interface Compared { + readonly found: readonly Delta[]; + readonly render: (delta: Delta) => string; + readonly notes: string[]; +} + +/** The ops and notes one declared entry contributes, in the order the plan carries them. */ +interface EntryGroup { + readonly ops: readonly Op[]; + readonly notes: readonly string[]; +} + function comparison( decl: ErasedDecl, label: string, @@ -127,238 +174,397 @@ export function validateList( return issues.map(under); } -export async function planList( +/** Every entry's write with its claims; the validated document types the identity fields as strings, so a non-string name is a BUG. */ +function declaredWrites( + decl: ErasedDecl, + entries: readonly object[], +): DeclaredWrite[] { + const { identity, lens, noun } = decl; + return entries.map((entry) => { + const write = lens.toWrite(entry); + const name = nameOf(write, identity.field); + const claims = identityClaims(identity, entry as Fields); + if (claims === null) { + throw new Error( + `BUG: the validated ${noun} entry ${JSON.stringify(entry)} claims a non-string name; the slice must type the identity fields as strings`, + ); + } + return { write, name, claims }; + }); +} + +/** + * The managed live items in comparable form, indexed by key. The duplicate-live guard runs before the section's own + * live conflicts: a duplicated live pair makes every other judgment a guess. + */ +function indexedLive( decl: ErasedDecl, section: SectionMeta, ctx: PlanContext, - declared: ErasedDeclared, -): Promise> { - return safeTry(async function* () { - const { key, noun, identity, lens, prose, endpoints, mapping } = decl; - const { fold } = identity; - const wire = lens.wire ?? ((write: ListWrite) => write); - const update = updateRole(endpoints); - const remedies = update === undefined ? RECREATE_REMEDIES : UPDATE_REMEDIES; - const sweep = decl.replaces ? replaceSweep(decl.entry) : undefined; - const defaultPolicy = defaultUndeclaredPolicy(section); - const { policy, entries } = undeclaredPolicy(declared, defaultPolicy); - - // Every identity an entry claims is its alone: validateList refused the document otherwise. - const writes = entries.map((entry) => { - const write = lens.toWrite(entry); - const name = nameOf(write, identity.field); - const claims = identityClaims(identity, entry as Fields); - if (claims === null) { - throw new Error( - `BUG: the validated ${noun} entry ${JSON.stringify(entry)} claims a non-string name; the slice must type the identity fields as strings`, - ); - } - return { write, name, claims }; - }); - - const live = yield* readLive(decl, ctx); - const liveItems = yield* Result.combine( +): ResultAsync<{ byKey: Map>; items: LiveItem[] }, SectionFailure> { + const { identity, lens, noun } = decl; + return readLive(decl, ctx).andThen((live) => + Result.combine( live.managed.map((item) => - lens.fromLive(item).map((comparable) => { + lens.fromLive(item).map((comparable): LiveItem => { const name = nameOf(comparable, identity.field); - return { item, comparable, name, key: fold(name) }; + return { item, comparable, name, key: identity.fold(name) }; }), ), + ).andThen((items) => + liveByIdentity( + section, + noun, + items, + (live) => live.key, + (live) => liveIdentity(live.name, decl.address(live.item)), + ).map((byKey) => ({ byKey, items })), + ), + ); +} + +/** The section's own live conflicts as the refusal that ends the plan, or undefined when there are none. */ +function liveConflictRefusal( + decl: ErasedDecl, + writes: readonly DeclaredWrite[], + items: readonly LiveItem[], +): SectionFailure | undefined { + const conflicts = + decl.conflicts?.live?.( + writes.map((entry) => entry.write), + items.map((live) => live.comparable), + ) ?? []; + if (conflicts.length === 0) { + return undefined; + } + return sectionFailure( + "refused", + `${decl.key}: the settings file conflicts with the live ${plural(decl.noun)}: ${conflicts.join("; ")}. Resolve each conflict on GitHub, then re-run`, + ); +} + +/** + * The undeclared deletes, in live order, under a delete policy: a delete frees what a create below would collide + * with (an autolink prefix that begins a declared one). None under keep. + */ +function undeclaredRemovals( + planning: Planning, + undeclared: readonly LiveItem[], +): Op[] { + const { decl, policy, defaultPolicy } = planning; + if (policy !== "delete") { + return []; + } + return undeclared.map(({ item, name }) => ({ + role: "remove", + params: decl.address(item), + describe: `deleting undeclared ${decl.noun} "${name}"`, + drift: [ + undeclaredDrift(defaultPolicy, { + label: `${decl.key}[${name}]`, + action: decl.prose.undeclaredAction, + ...decl.prose.undeclaredDrift, + }), + ], + change: `DELETED undeclared ${decl.noun} "${name}"`, + })); +} + +/** The keep-notes for the undeclared items under a keep policy; none under delete. */ +function undeclaredKeepNotes( + planning: Planning, + undeclared: readonly LiveItem[], +): string[] { + const { decl, policy } = planning; + if (policy !== "keep") { + return []; + } + return undeclared.map(({ name }) => + undeclaredNote({ + subject: `${decl.noun} "${name}"`, + action: decl.prose.undeclaredAction, + ...decl.prose.undeclaredNote, + }), + ); +} + +/** The one live item an entry's claims match, or none; two (a rename onto a taken name) are the refusal that ends the plan. */ +function matchedLive( + decl: ErasedDecl, + entry: DeclaredWrite, + liveByKey: ReadonlyMap>, +): Result | undefined, SectionFailure> { + const matches = entry.claims.flatMap((claim) => { + const match = liveByKey.get(claim); + return match === undefined ? [] : [match]; + }); + if (matches.length > 1) { + return err( + sectionFailure( + "refused", + `${decl.key}: the entry "${entry.name}" matches ${matches.length} separate live ${plural(decl.noun)} (${matches.map((m) => `"${m.name}"`).join(", ")}), so it cannot converge; delete all but one of them on GitHub, or declare each as its own entry`, + ), + ); + } + return ok(matches[0]); +} + +/** A body as the plan carries it: plain when no secret rides on it, else a thunk resolving the secrets at execution. */ +function bodyOf(body: Fields, secrets: readonly string[]): PlainData | Late { + if (secrets.length === 0) { + return plainData(body); + } + return (exec: ExecTools) => resolvedWrite(exec, body, secrets); +} + +/** The drift slot: the lines alone, or under the unverifiable facet when a secret rides on the write. */ +function facetDrift( + decl: ErasedDecl, + label: string, + secrets: readonly string[], + lines: readonly string[], +): Unverifiable | readonly string[] { + return facetOr(secrets.length === 0 ? null : secretFacet(decl, label, secrets), lines); +} + +/** The create of an entry no live item matches. */ +function createOp( + planning: Planning, + entry: DeclaredWrite, + label: string, +): Op { + const { decl, wire } = planning; + const { write, name } = entry; + const secrets = declaredSecrets(decl, write); + const wired = wire(write); + return { + role: "create", + payload: bodyOf(wired, secrets), + describe: `creating ${decl.noun} "${name}"`, + drift: facetDrift(decl, label, secrets, [missingDrift(label)]), + change: `created ${decl.noun} "${name}"`, + }; +} + +/** + * The entry compared with its live item: the item's full body read where a `get` role exists, concealed fields and + * secrets left out, and the omitted-key sweep of a replace write. The phantom note follows the concealment notes. + */ +async function compareWithLive( + planning: Planning, + ctx: PlanContext, + entry: DeclaredWrite, + existing: LiveItem, + label: string, +): Promise> { + const { decl, sweep, remedies } = planning; + const { lens, identity } = decl; + const body = await readItem(decl, ctx, existing.item); + if (body.isErr()) { + return err(body.error); + } + const comparable = lens.fromLive(body.value); + if (comparable.isErr()) { + return err(comparable.error); + } + const compared = comparison(decl, label, entry.write, body.value, comparable.value); + const found = deltas(compared.write, compared.live, { matchBy: lens.matchBy }); + if (sweep !== undefined) { + found.push( + ...omittedDeltas(compared.write, projectOntoSchema(decl.entry, compared.live), { + matchBy: lens.matchBy, + sweep, + }), ); - // The guard runs before the section's own live conflicts: a duplicated live pair makes every other judgment a guess. - const liveByKey = yield* liveByIdentity( - section, - noun, - liveItems, - (item) => item.key, - (item) => liveIdentity(item.name, decl.address(item.item)), + } + const render = (delta: Delta): string => + renderEntryDelta( + decl.key, + identity.field, + { want: entry.name, live: existing.name }, + delta, + remedies, ); - const liveConflicts = - decl.conflicts?.live?.( - writes.map((w) => w.write), - liveItems.map((l) => l.comparable), - ) ?? []; - if (liveConflicts.length > 0) { - return err( - sectionFailure( - "refused", - `${key}: the settings file conflicts with the live ${plural(noun)}: ${liveConflicts.join("; ")}. Resolve each conflict on GitHub, then re-run`, - ), - ); - } - const claimed = new Set(writes.flatMap((w) => w.claims)); - const undeclared = liveItems.filter((live) => !claimed.has(live.key)); + const notes = [...compared.notes]; + const phantom = phantomPaths(found); + if (phantom.length > 0) { + notes.push(phantomNote(label, phantom, decl.noun, remedies.phantom)); + } + return ok({ found, render, notes }); +} - const plan: SectionPlan = { ops: [], notes: [], drift: [] }; - // The undeclared deletes come first, in live order: a delete frees what a create below would collide - // with (an autolink prefix that begins a declared one); the declared entries follow in file order. - if (policy === "delete") { - for (const { item, name } of undeclared) { - plan.ops.push({ - role: "remove", - params: decl.address(item), - describe: `deleting undeclared ${noun} "${name}"`, - drift: [ - undeclaredDrift(defaultPolicy, { - label: `${key}[${name}]`, - action: prose.undeclaredAction, - ...prose.undeclaredDrift, - }), - ], - change: `DELETED undeclared ${noun} "${name}"`, - }); - } - } - for (const { write, name, claims } of writes) { - const matches = claims.flatMap((claim) => { - const match = liveByKey.get(claim); - return match === undefined ? [] : [match]; - }); - if (matches.length > 1) { - return err( - sectionFailure( - "refused", - `${key}: the entry "${name}" matches ${matches.length} separate live ${plural(noun)} (${matches.map((m) => `"${m.name}"`).join(", ")}), so it cannot converge; delete all but one of them on GitHub, or declare each as its own entry`, - ), - ); - } - const existing = matches[0]; - const label = `${key}[${name}]`; - const secrets = declaredSecrets(decl, write); - const wired = wire(write); - if (existing === undefined) { - plan.ops.push({ - role: "create", - payload: - secrets.length === 0 - ? plainData(wired) - : (exec: ExecTools) => resolvedWrite(exec, wired, secrets), - describe: `creating ${noun} "${name}"`, - drift: facetOr(secrets.length === 0 ? null : secretFacet(decl, label, secrets), [ - missingDrift(label), - ]), - change: `created ${noun} "${name}"`, - }); - continue; - } - const body = yield* await readItem(decl, ctx, existing.item); - const compared = comparison(decl, label, write, body, yield* lens.fromLive(body)); - plan.notes.push(...compared.notes); - const found = [ - ...deltas(compared.write, compared.live, { matchBy: lens.matchBy }), - ...(sweep === undefined - ? [] - : omittedDeltas(compared.write, projectOntoSchema(decl.entry, compared.live), { - matchBy: lens.matchBy, - sweep, - })), - ]; - const render = (delta: Delta): string => - renderEntryDelta(key, identity.field, { want: name, live: existing.name }, delta, remedies); - const phantom = phantomPaths(found); - if (phantom.length > 0) { - plan.notes.push(phantomNote(label, phantom, noun, remedies.phantom)); - } - if (update === undefined) { - const drift = found.map(render); - if (!hasDrift(drift)) { - continue; - } - // The differing fields ride on the recreate; the generic line alone would leave the reader guessing which field forces the replace. - plan.ops.push( - { - role: "remove", - params: decl.address(existing.item), - describe: `deleting ${noun} "${name}" before recreating it`, - drift: [ - `${label}: live settings differ from the settings file, and ${plural(noun)} cannot be edited; apply will delete and recreate it`, - ], - change: `deleted ${noun} "${name}" to recreate it with the declared settings`, - }, - { - role: "create", - payload: plainData(wire(decl.recreate?.(existing.item, write) ?? write)), - describe: `recreating ${noun} "${name}"`, - drift, - change: `recreated ${noun} "${name}"`, - }, - ); - continue; - } - const params = decl.address(existing.item); - // The mapping's deltas go through updateConfig, which sets named fields only; the general update never carries the mapping. - const inMapping = (field: string): boolean => - mapping !== undefined && pathOf(field)[0] === mapping; - const mappingDrift = found - .filter((delta) => mapping !== undefined && delta.path[0] === mapping) - .map(render); - const mappingSecrets = secrets.filter(inMapping); - if (mapping !== undefined && (hasDrift(mappingDrift) || mappingSecrets.length > 0)) { - const config = wired[mapping] as ListWrite; - plan.ops.push({ - role: "updateConfig", - params, - payload: - mappingSecrets.length === 0 - ? plainData(config) - : (exec: ExecTools) => - resolvedWrite( - exec, - config, - mappingSecrets.map((field) => pathOf(field).slice(1).join(".")), - ), - describe: `updating ${noun} "${name}" ${mapping}`, - drift: facetOr( - mappingSecrets.length === 0 ? null : secretFacet(decl, label, mappingSecrets), - mappingDrift, - ), - change: - mappingSecrets.length === 0 - ? `updated ${noun} "${name}" ${mapping}` - : `updated ${noun} "${name}" ${mapping} (the declared ${mappingSecrets.map(leafOf).join(" and ")} is re-sent every run)`, - }); - } - const generalDrift = found - .filter((delta) => mapping === undefined || delta.path[0] !== mapping) - .map(render); - const generalSecrets = secrets.filter((field) => !inMapping(field)); - if (!hasDrift(generalDrift) && generalSecrets.length === 0) { - continue; - } - const general = - mapping === undefined ? (wired as Fields) : withoutPaths(wired as Fields, [mapping]); - plan.ops.push({ - role: "update", - params, - payload: - generalSecrets.length === 0 - ? plainData(updateBody(decl, general)) - : (exec: ExecTools) => - resolvedWrite(exec, updateBody(decl, general) as ListWrite, generalSecrets), - before: refuseOmitted( - label, - found.flatMap((delta) => (delta.kind === "omitted" ? [render(delta)] : [])), - ), - describe: `updating ${noun} "${name}"`, - drift: facetOr( - generalSecrets.length === 0 ? null : secretFacet(decl, label, generalSecrets), - generalDrift, - ), - change: `updated ${noun} "${name}"`, - }); - } +/** + * The delete-then-create replacing a drifted item of a resource GitHub cannot edit; none when nothing drifted. The + * differing fields ride on the recreate, since the generic line alone would leave the reader guessing which field + * forces the replace. + */ +function recreateOps( + planning: Planning, + entry: DeclaredWrite, + existing: LiveItem, + drift: readonly string[], + label: string, +): Op[] { + const { decl, wire } = planning; + const { write, name } = entry; + if (!hasDrift(drift)) { + return []; + } + return [ + { + role: "remove", + params: decl.address(existing.item), + describe: `deleting ${decl.noun} "${name}" before recreating it`, + drift: [ + `${label}: live settings differ from the settings file, and ${plural(decl.noun)} cannot be edited; apply will delete and recreate it`, + ], + change: `deleted ${decl.noun} "${name}" to recreate it with the declared settings`, + }, + { + role: "create", + payload: plainData(wire(decl.recreate?.(existing.item, write) ?? write)), + describe: `recreating ${decl.noun} "${name}"`, + drift, + change: `recreated ${decl.noun} "${name}"`, + }, + ]; +} - if (policy === "keep") { - for (const { name } of undeclared) { - plan.notes.push( - undeclaredNote({ - subject: `${noun} "${name}"`, - action: prose.undeclaredAction, - ...prose.undeclaredNote, - }), - ); - } +/** + * The update of a drifted item: the mapping's deltas and secrets through updateConfig, which sets named fields only, + * and everything else through the general update, which never carries the mapping. Each op is absent when it has + * nothing to send. + */ +function updateOps( + planning: Planning, + entry: DeclaredWrite, + existing: LiveItem, + compared: Compared, + label: string, +): Op[] { + const { decl, wire } = planning; + const { mapping, noun } = decl; + const { found, render } = compared; + const { write, name } = entry; + const secrets = declaredSecrets(decl, write); + const wired = wire(write); + const params = decl.address(existing.item); + const inMapping = (field: string): boolean => + mapping !== undefined && pathOf(field)[0] === mapping; + const ops: Op[] = []; + + const mappingDrift = found + .filter((delta) => mapping !== undefined && delta.path[0] === mapping) + .map(render); + const mappingSecrets = secrets.filter(inMapping); + if (mapping !== undefined && (hasDrift(mappingDrift) || mappingSecrets.length > 0)) { + const config = wired[mapping] as ListWrite; + const relative = mappingSecrets.map((field) => pathOf(field).slice(1).join(".")); + ops.push({ + role: "updateConfig", + params, + payload: bodyOf(config, relative), + describe: `updating ${noun} "${name}" ${mapping}`, + drift: facetDrift(decl, label, mappingSecrets, mappingDrift), + change: + mappingSecrets.length === 0 + ? `updated ${noun} "${name}" ${mapping}` + : `updated ${noun} "${name}" ${mapping} (the declared ${mappingSecrets.map(leafOf).join(" and ")} is re-sent every run)`, + }); + } + + const generalDrift = found + .filter((delta) => mapping === undefined || delta.path[0] !== mapping) + .map(render); + const generalSecrets = secrets.filter((field) => !inMapping(field)); + if (hasDrift(generalDrift) || generalSecrets.length > 0) { + const general = + mapping === undefined ? (wired as Fields) : withoutPaths(wired as Fields, [mapping]); + ops.push({ + role: "update", + params, + payload: bodyOf(updateBody(decl, general), generalSecrets), + before: refuseOmitted( + label, + found.flatMap((delta) => (delta.kind === "omitted" ? [render(delta)] : [])), + ), + describe: `updating ${noun} "${name}"`, + drift: facetDrift(decl, label, generalSecrets, generalDrift), + change: `updated ${noun} "${name}"`, + }); + } + return ops; +} + +/** One declared entry's group: a create, a recreate pair, or the update ops, with the notes its compare raised. */ +async function entryGroup( + planning: Planning, + ctx: PlanContext, + entry: DeclaredWrite, + liveByKey: ReadonlyMap>, +): Promise> { + const { decl, update } = planning; + const label = `${decl.key}[${entry.name}]`; + const matched = matchedLive(decl, entry, liveByKey); + if (matched.isErr()) { + return err(matched.error); + } + const existing = matched.value; + if (existing === undefined) { + return ok({ ops: [createOp(planning, entry, label)], notes: [] }); + } + const compared = await compareWithLive(planning, ctx, entry, existing, label); + if (compared.isErr()) { + return err(compared.error); + } + const { found, render, notes } = compared.value; + if (update === undefined) { + return ok({ ops: recreateOps(planning, entry, existing, found.map(render), label), notes }); + } + return ok({ ops: updateOps(planning, entry, existing, compared.value, label), notes }); +} + +export async function planList( + decl: ErasedDecl, + section: SectionMeta, + ctx: PlanContext, + declared: ErasedDeclared, +): Promise> { + const update = updateRole(decl.endpoints); + const defaultPolicy = defaultUndeclaredPolicy(section); + const { policy, entries } = undeclaredPolicy(declared, defaultPolicy); + const planning: Planning = { + decl, + wire: decl.lens.wire ?? ((write: ListWrite) => write), + update, + remedies: update === undefined ? RECREATE_REMEDIES : UPDATE_REMEDIES, + sweep: decl.replaces ? replaceSweep(decl.entry) : undefined, + policy, + defaultPolicy, + }; + const writes = declaredWrites(decl, entries); + + const live = await indexedLive(decl, section, ctx); + if (live.isErr()) { + return err(live.error); + } + const { byKey, items } = live.value; + const refusal = liveConflictRefusal(decl, writes, items); + if (refusal !== undefined) { + return err(refusal); + } + const claimed = new Set(writes.flatMap((entry) => entry.claims)); + const undeclared = items.filter((item) => !claimed.has(item.key)); + + const plan: SectionPlan = { ops: undeclaredRemovals(planning, undeclared), notes: [], drift: [] }; + for (const entry of writes) { + const group = await entryGroup(planning, ctx, entry, byKey); + if (group.isErr()) { + return err(group.error); } - return ok(plan); - }); + plan.ops.push(...group.value.ops); + plan.notes.push(...group.value.notes); + } + plan.notes.push(...undeclaredKeepNotes(planning, undeclared)); + return ok(plan); } diff --git a/src/sections/shared/roles.ts b/src/sections/shared/roles.ts index 6fc311cc5..5c5e30a13 100644 --- a/src/sections/shared/roles.ts +++ b/src/sections/shared/roles.ts @@ -27,12 +27,13 @@ export function permissionForRole(role: string): string | undefined { return roleForPermission(permission) === role ? permission : undefined; } -// Maps, not records: a declared permission named like a prototype member ("constructor") must -// pass through, not resolve to Object.prototype's. +/** Maps, not records: a declared permission named like a prototype member ("constructor") must pass through, not resolve to Object.prototype's. */ export const ROLE_FOR_PERMISSION: ReadonlyMap = new Map([ ["push", "write"], ["pull", "read"], ]); + +/** The GET vocabulary back to the grant's. */ const PERMISSION_FOR_ROLE: ReadonlyMap = new Map( [...ROLE_FOR_PERMISSION].map(([permission, role]) => [role, permission]), ); @@ -40,20 +41,23 @@ const PERMISSION_FOR_ROLE: ReadonlyMap = new Map( /** The grant PUT's own vocabulary; anything else it accepts is a custom org role, named exactly as the org spelled it. */ const STANDARD_PERMISSIONS = ["pull", "triage", "push", "maintain", "admin"] as const; -/** - * A permission the file alone shows GitHub will not take is refused at parse; a custom org role name passes. - * The two wrong spellings hide until apply: "write" on an existing Write collaborator converges with zero drift - * (the live role_name IS "write") while the same entry on a new one PUTs {"permission":"write"} and 422s. - * - * One regex, so the published schema carries the same rule as a `pattern`; a pattern has no flags, hence the case classes. - */ +/** The spellings the custom-role arm refuses in any case: the standard permissions, which pass lowercase alone, and the GET vocabulary (see permissionError). */ const REFUSED_FOLDED = [...STANDARD_PERMISSIONS, ...PERMISSION_FOR_ROLE.keys()]; + +/** A case class per letter, since a published-schema `pattern` has no flags. */ const caseless = (word: string) => [...word].map((c) => `[${c.toUpperCase()}${c}]`).join(""); + +/** One regex, so the published schema carries the same rule as a `pattern`: a standard permission, or one non-blank line that is no refused spelling. */ const PERMISSION_PATTERN = new RegExp( `^(?:${STANDARD_PERMISSIONS.join("|")}|(?!(?:${REFUSED_FOLDED.map(caseless).join("|")})$)\\S(?:.*\\S)?)$`, ); -/** Every suggested fix is one the pattern accepts, so a reader never chases a second refusal. */ +/** + * The refusal of a permission the file alone shows GitHub will not take; a custom org role name passes. The GET + * vocabulary hides until apply: "write" on an existing Write collaborator converges with zero drift (the live + * role_name IS "write") while the same entry on a new one PUTs {"permission":"write"} and 422s. Every suggested + * fix is one the pattern accepts, so a reader never chases a second refusal. + */ function permissionError(declared: string): string { const options = `${STANDARD_PERMISSIONS.map((p) => `"${p}"`).join(", ")}, or a custom org role name`; const shown = JSON.stringify(declared); diff --git a/src/sections/shared/schema-helpers.ts b/src/sections/shared/schema-helpers.ts index 8e233def8..32364e690 100644 --- a/src/sections/shared/schema-helpers.ts +++ b/src/sections/shared/schema-helpers.ts @@ -10,12 +10,10 @@ import { renamedKeyError } from "./renamed-key.js"; import { type Knob, knobOf, markKnob, OPEN_CATCHALL } from "./schema-marks.js"; import { schemaNode } from "./schema-node.js"; -/** - * The one value set of the `_undeclared` knob (a wrapper's, a file's top level) and the `undeclared` run input. - * Described in docs/sections/shared.docs.yml and docs/schema.docs.yml. - */ +/** The one value set of the `_undeclared` knob (a wrapper's, a file's top level) and the `undeclared` run input; described in docs/sections/shared.docs.yml and docs/schema.docs.yml. */ export const UNDECLARED_POLICIES = ["keep", "delete"] as const; +/** The knob's schema, shared by every wrapper so the published document has one UndeclaredPolicy definition. */ export const UndeclaredPolicySchema = z.enum(UNDECLARED_POLICIES).meta({ id: "UndeclaredPolicy" }); /** @@ -45,20 +43,22 @@ export function conditional( }; } +/** The one value set of the `_layering` directive and the `layering` run input; described in docs/sections/shared.docs.yml and docs/schema.docs.yml. */ +export const LAYERINGS = ["replace", "shallow", "deep"] as const; + /** - * The one value set of the `_layering` directive and the `layering` run input; engine/layers.ts acts on it and - * re-exports it to the flows. Described in docs/sections/shared.docs.yml and docs/schema.docs.yml. + * engine/layers.ts acts on it and re-exports it to the flows. * * replace -> the higher list replaces the whole lower list * shallow -> union by key; a same-key entry is swapped for the higher one * deep -> union by key; a same-key pair merges field by field, nested keyed lists included */ -export const LAYERINGS = ["replace", "shallow", "deep"] as const; - export type Layering = (typeof LAYERINGS)[number]; +/** The directive's schema, shared by the top-level wrappers. */ export const LayeringSchema = z.enum(LAYERINGS); +/** The refusal of the pre-v3 `undeclared` spelling, which only the policy wrappers ever took (their `renamed`). */ const renamedPolicyKeyError = renamedKeyError( "wrapper's policy", "undeclared", @@ -66,16 +66,14 @@ const renamedPolicyKeyError = renamedKeyError( "in v3 (a directive, like _layering) - write _undeclared: keep or _undeclared: delete", ); -/** - * The wrapper kinds, one per minting helper, each named as its published definition is (``) and with - * the directives its key error names. Only a policy wrapper ever spelled the policy without its underscore, so only - * those name the rename. - */ +/** What the two policy wrappers share; only they ever spelled the policy without its underscore, so only they name the rename. */ const POLICY_WRAPPER = { name: "UndeclaredPolicyList", directives: '"_undeclared" and, on a top-level section, "_layering"', renamed: true, } as const; + +/** The wrapper kinds, one per minting helper, each named as its published definition is (``) and with the directives its key error names. */ const WRAPPER_KINDS = { knobbed: { kind: "knobbed", ...POLICY_WRAPPER }, nestedKnobbed: { kind: "nestedKnobbed", ...POLICY_WRAPPER }, @@ -87,6 +85,7 @@ const WRAPPER_KINDS = { renamed: false, }, } as const; + type WrapperKind = (typeof WRAPPER_KINDS)[keyof typeof WRAPPER_KINDS]; /** @@ -232,6 +231,29 @@ function routeKnobs(schema: z.ZodType): z.ZodType { } } +/** The form a value routes to: the list for an array, the wrapper for a mapping, null for anything else. */ +function formOf(value: unknown, list: z.ZodType, wrapper: z.ZodType): z.ZodType | null { + if (Array.isArray(value)) { + return list; + } + if (typeof value === "object" && value !== null) { + return wrapper; + } + return null; +} + +/** + * The issue for a value that is neither a list nor a mapping. A null list has no empty state of its own, so the issue + * carries `legal`, from which engine/validate.ts names the fix instead of the type prose. + */ +function neitherFormIssue(value: unknown, beside: string): z.core.$ZodSuperRefineIssue { + const message = `Invalid input: expected a list of entries, or a mapping with "entries" (and ${beside}), but this section parsed as ${value === null ? "null" : typeof value}`; + if (value === null) { + return { code: "custom", message, params: { legal: "a list of entries ([] for none)" } }; + } + return { code: "custom", message }; +} + function routedForms(knob: Knob, list: z.ZodType, wrapper: z.ZodType): z.ZodType { const beside = knob.kind === "layered" @@ -240,30 +262,21 @@ function routedForms(knob: Knob, list: z.ZodType, wrapper: z.ZodType): z.ZodType return z .custom(() => true) .transform((value, ctx) => { - const form = Array.isArray(value) - ? list - : typeof value === "object" && value !== null - ? wrapper - : null; + const form = formOf(value, list, wrapper); if (form === null) { - ctx.addIssue({ - code: "custom", - message: `Invalid input: expected a list of entries, or a mapping with "entries" (and ${beside}), but this section parsed as ${value === null ? "null" : typeof value}`, - // A null list has no empty state of its own: engine/validate.ts names the fix from this, not the type prose. - ...(value === null ? { params: { legal: "a list of entries ([] for none)" } } : {}), - }); + ctx.addIssue(neitherFormIssue(value, beside)); return z.NEVER; } const parsed = form.safeParse(value); - if (!parsed.success) { - for (const issue of parsed.error.issues) { - ctx.addIssue({ ...issue }); - } - // The raw value, not z.NEVER: a rule the section composed onto the routed shape runs beside the failed - // entry (rule(), below) and must meet the entries, raw where they failed. The parse fails regardless. - return value; + if (parsed.success) { + return parsed.data; + } + for (const issue of parsed.error.issues) { + ctx.addIssue({ ...issue }); } - return parsed.data; + // The raw value, not z.NEVER: a rule the section composed onto the routed shape runs beside the failed + // entry (rule(), below) and must meet the entries, raw where they failed. The parse fails regardless. + return value; }); } @@ -376,6 +389,7 @@ export function boundedString( /** GitHub's documented cap on one variable's value, 48 KB, counted in UTF-8 bytes as GitHub does. */ export const MAX_VARIABLE_VALUE_BYTES = 48 * 1024; +/** A variable's value, refused past the cap with the measured size and the way out. */ const variableValue = boundedString( MAX_VARIABLE_VALUE_BYTES, "utf8 bytes", @@ -383,11 +397,10 @@ const variableValue = boundedString( `the variable value is ${bytes} bytes of UTF-8; GitHub caps a variable at 48 KB (${MAX_VARIABLE_VALUE_BYTES} bytes). Shorten it, or move the content into a file the workflow reads`, ); -/** - * The pattern doubles as the published schema's `pattern`, so it spells the case-insensitive prefix without a flag; - * the API uppercases before it compares, so `github_token` is the reserved GITHUB_TOKEN. - */ +/** Doubles as the published schema's `pattern`, so it spells the case-insensitive prefix without a flag; the API uppercases before it compares, so `github_token` is the reserved GITHUB_TOKEN. */ const GITHUB_NAME_PATTERN = /^(?![Gg][Ii][Tt][Hh][Uu][Bb]_)[A-Za-z_][A-Za-z0-9_]*$/; + +/** The rule a refused name's message ends with, after the reason. */ const GITHUB_NAME_RULE = "GitHub accepts ASCII letters, digits, and underscores, not starting with a digit or with the reserved GITHUB_ prefix (in any case: names are stored uppercased)"; @@ -412,4 +425,6 @@ function githubName(noun: "secret" | "variable") { /** Secret and variable names share GitHub's one rule; the noun only tells the problem line which it is reading. */ export const secretName = githubName("secret"); + +/** The same rule under the variable noun. */ const variableName = githubName("variable"); diff --git a/src/sections/shared/schema-marks.ts b/src/sections/shared/schema-marks.ts index 5806184e9..39db45d6a 100644 --- a/src/sections/shared/schema-marks.ts +++ b/src/sections/shared/schema-marks.ts @@ -16,13 +16,13 @@ export interface Knob { readonly wrapper: z.ZodObject; } +/** A WeakMap, not z.registry: the registry's metadata type is mapped through zod's `$replace`, which recurses into a schema held as a value until tsc gives up (TS2589). */ +const KNOBS = new WeakMap(); + /** * Keyed by the minted wrapper, not the union: a union's clones (.meta(), .describe(), .check()) keep its options by - * reference, so the pair resolves through them. A WeakMap, not z.registry: the registry's metadata type is mapped - * through zod's `$replace`, which recurses into a schema held as a value until tsc gives up (TS2589). + * reference, so knobOf() resolves the pair through them. */ -const KNOBS = new WeakMap(); - export function markKnob(knob: Knob): void { KNOBS.set(knob.wrapper, knob); } diff --git a/src/sections/shared/secrets-and-variables/keyed-values.ts b/src/sections/shared/secrets-and-variables/keyed-values.ts index a990fc93a..78e1768ef 100644 --- a/src/sections/shared/secrets-and-variables/keyed-values.ts +++ b/src/sections/shared/secrets-and-variables/keyed-values.ts @@ -21,9 +21,12 @@ import type { Read, SnapshotContext } from "../../contract/plan.js"; import { knobbedSnapshot } from "../snapshot-helpers.js"; export interface KeyedValuesFamily { + /** The section key the family mints; the factory derives the path segment from it. */ readonly key: K; + /** The fine-grained-PAT Repository permission gating the family. */ readonly resource: PatResource; + /** The output noun for notes and change lines ("Actions secret", "Copilot agents variable"). */ readonly noun: string; } diff --git a/src/sections/shared/secrets-and-variables/named-scope.ts b/src/sections/shared/secrets-and-variables/named-scope.ts index 489fab471..de04a4032 100644 --- a/src/sections/shared/secrets-and-variables/named-scope.ts +++ b/src/sections/shared/secrets-and-variables/named-scope.ts @@ -25,10 +25,13 @@ export interface Named { export interface ScopeProse { /** The drift-line prefix, e.g. "actions_secrets" or "environments[prod].variables". */ label: string; + /** The noun for notes ("Actions secret"; a nested scope says "prod environment secret"). */ noun: string; + /** Where the values live when "the repo" understates it ("the environment"). */ where?: string; + /** Appended to change and describe lines (` in environment "prod"`). */ suffix?: string; } @@ -78,11 +81,16 @@ export function missingNamedDrift(scope: ScopeProse, name: string): string { } export interface UndeclaredDeletion { - /** The live name as the API listed it. */ + /** The live name as the API listed it; the DELETE path takes it. */ readonly name: string; + /** What the write is doing, in settings-file terms, for its error prose. */ readonly describe: string; + + /** The one drift line, as undeclaredDrift words it. */ readonly drift: readonly [string]; + + /** The change line (`DELETED undeclared "NAME"`). */ readonly change: string; } @@ -103,20 +111,19 @@ export function undeclaredRemoval< export interface NamedScope extends ScopeProse { /** The parsed identities of the enveloped list, all pages. */ readonly list: () => PromiseLike>; - /** - * The planned DELETE of an undeclared live item; function-valued so a builder demanding an unsupplied - * facet fails. - */ + + /** The planned DELETE of an undeclared live item; function-valued so a builder demanding an unsupplied facet fails. */ readonly remove: (deletion: UndeclaredDeletion) => Remove; } export interface NamedPlanOpts { + /** The declared entries, unwrapped from either form. */ entries: readonly Entry[]; + + /** The policy in force: the wrapper's `_undeclared`, else the default. */ policy: UndeclaredPolicy; - /** - * The DEFAULT `policy` was unwrapped against (the section's undeclaredDefault, or environments' - * fixed nested default); undeclaredDrift derives its knob clause from it. - */ + + /** The DEFAULT `policy` was unwrapped against (the section's undeclaredDefault, or environments' fixed nested default); undeclaredDrift derives its knob clause from it. */ defaultPolicy: UndeclaredPolicy; } @@ -124,8 +131,10 @@ export interface NamedPlanOpts { export interface UndeclaredWords { /** The noun of the change and describe lines (`DELETED undeclared "NAME"`). */ readonly what: string; + /** The keep-note's apply clause, after "to have apply". */ readonly noteAction: string; + /** The drift line's apply clause, after "so apply will". */ readonly driftAction: string; } diff --git a/src/sections/shared/secrets-and-variables/repo-secrets.ts b/src/sections/shared/secrets-and-variables/repo-secrets.ts index 7eb7df1b4..6468a353b 100644 --- a/src/sections/shared/secrets-and-variables/repo-secrets.ts +++ b/src/sections/shared/secrets-and-variables/repo-secrets.ts @@ -51,11 +51,7 @@ export type RepoSecretsKey = | "codespaces_secrets" | "agents_secrets"; -/** - * The factory derives the routes from THIS map, so a key paired with another family's segment (which the - * mock would faithfully serve, hiding the swap) is unrepresentable; the `satisfies` pins each VALUE to the - * segment its own KEY spells, so a fifth family breaking the `_secrets` naming must say so here. - */ +/** The routes derive from THIS map, so a key paired with another family's segment (which the mock would faithfully serve) is unrepresentable; the `satisfies` pins each value to the segment its key spells. */ const SECRETS_SEGMENTS = { actions_secrets: "actions", dependabot_secrets: "dependabot", @@ -63,10 +59,7 @@ const SECRETS_SEGMENTS = { agents_secrets: "agents", } as const satisfies { [K in RepoSecretsKey]: SegmentOfSecretsKey }; -/** - * The factory derives the runtime shape from THIS map, so a key paired with another family's config - * (structurally identical, invisible to every gate) is unrepresentable. - */ +/** The runtime shape derives from THIS map, so a key paired with another family's config (structurally identical, invisible to every gate) is unrepresentable. */ const SECRETS_ENTRIES = { actions_secrets: ActionsSecretConfig, dependabot_secrets: DependabotSecretConfig, @@ -120,12 +113,7 @@ type _WidePlanIsEveryFamilyPlan = MustBeNever< PlanMisfits >; -/** - * Checked HERE as a fresh object literal, once per family key: the factory hands ../../registry.ts a module - * IDENTIFIER, where excess-property checking no longer runs, so a `known` key no entry type carries any - * more would otherwise compile silently. The intersection admits a key present in ANY constituent, but a - * key only one family dropped breaks SecretEntry and the shared plan signature first. - */ +/** Checked HERE as a fresh literal, once per family key: ../../registry.ts receives a module IDENTIFIER, where excess-property checking no longer runs, so a `known` key no entry type carries would otherwise compile silently. */ const CLOSED_SURFACE = { known: { name: true, value: true }, consequence: "the API body carries only the sealed value, so the key would silently do nothing", @@ -134,6 +122,10 @@ const CLOSED_SURFACE = { ClosedSurfaceOf<"codespaces_secrets"> & ClosedSurfaceOf<"agents_secrets">; +/** + * The intersection CLOSED_SURFACE satisfies admits a key present in ANY family, but a key only one family + * dropped breaks SecretEntry and the shared plan signature first. + */ type ClosedSurfaceOf = NonNullable["closedSurface"]>; /** The module shape repoSecretsSection() mints (SectionModule at the registry). */ @@ -159,10 +151,7 @@ export interface RepoSecretsSectionModule { */ export function repoSecretsSection( family: KeyedValuesFamily & { - /** - * The fine-grained "Codespaces secrets" permission gates even the GETs (list, public-key) at write; - * the writes are write-graded by method already. - */ + /** The fine-grained "Codespaces secrets" permission gates even the GETs (list, public-key) at write; the writes are write-graded by method already. */ readonly accessGrade?: "write"; }, ): RepoSecretsSectionModule { diff --git a/src/sections/shared/secrets-and-variables/repo-variables.ts b/src/sections/shared/secrets-and-variables/repo-variables.ts index 85c2b17af..67f916402 100644 --- a/src/sections/shared/secrets-and-variables/repo-variables.ts +++ b/src/sections/shared/secrets-and-variables/repo-variables.ts @@ -42,20 +42,13 @@ import { export type RepoVariablesKey = "actions_variables" | "agents_variables"; -/** - * The factory derives the routes from THIS map, so a key paired with the other family's segment (which - * the mock would faithfully serve, hiding the swap) is unrepresentable; the `satisfies` pins each VALUE - * to the segment its own KEY spells. - */ +/** The routes derive from THIS map, so a key paired with the other family's segment (which the mock would faithfully serve) is unrepresentable; the `satisfies` pins each value to the segment its key spells. */ const VARIABLES_SEGMENTS = { actions_variables: "actions", agents_variables: "agents", } as const satisfies { [K in RepoVariablesKey]: SegmentOfVariablesKey }; -/** - * The factory derives the runtime shape from THIS map, so a key paired with the other family's config - * (structurally identical, invisible to every gate) is unrepresentable. - */ +/** The runtime shape derives from THIS map, so a key paired with the other family's config (structurally identical, invisible to every gate) is unrepresentable. */ const VARIABLES_ENTRIES = { actions_variables: ActionsVariableConfig, agents_variables: AgentsVariableConfig, diff --git a/src/sections/shared/secrets-and-variables/sealed-box.ts b/src/sections/shared/secrets-and-variables/sealed-box.ts index e11673a59..d28dfbfe0 100644 --- a/src/sections/shared/secrets-and-variables/sealed-box.ts +++ b/src/sections/shared/secrets-and-variables/sealed-box.ts @@ -12,16 +12,13 @@ import { err, ok, type Result } from "neverthrow"; /** An X25519 public key's length; parseSealingKey names it in a wrong-length rejection. */ export const SEALED_BOX_PUBLIC_KEY_BYTES = 32; +/** The ephemeral public key opens the wire format, its first 32 bytes. */ const EPHEMERAL_KEY_BYTES = SEALED_BOX_PUBLIC_KEY_BYTES; /** XSalsa20's extended nonce, which crypto_box_seal derives instead of transmitting. */ const NONCE_BYTES = 24; -/** - * hsalsa20's "expand 32-byte k" constant as the host-order word view hsalsa reads. - * hsalsa byte-swaps its inputs and its output itself on a big-endian host, so the - * views stay raw here: little-endian words would be swapped twice there. - */ +/** hsalsa20's "expand 32-byte k" constant as the raw host-order words hsalsa reads: it byte-swaps its inputs and output itself on a big-endian host, so a little-endian view would be swapped twice. */ const HSALSA_SIGMA = new Uint32Array(new TextEncoder().encode("expand 32-byte k").buffer); /** crypto_box_beforenm runs hsalsa20 with an all-zero 16-byte input. */ diff --git a/src/sections/shared/secrets-and-variables/secrets-engine.ts b/src/sections/shared/secrets-and-variables/secrets-engine.ts index 056f98085..b59f961de 100644 --- a/src/sections/shared/secrets-and-variables/secrets-engine.ts +++ b/src/sections/shared/secrets-and-variables/secrets-engine.ts @@ -33,6 +33,7 @@ export interface SecretEntry { /** One live secret's identity, the item schema every family's list reads with. */ export const LiveSecretName = z.looseObject({ name: z.string() }); + export type LiveSecretName = z.infer; /** The sealed PUT body; an alias (not an interface) so it is JSON-plain to the plan contract. */ @@ -48,28 +49,32 @@ export type SealedSecretPayload = { interface SealedSecretWrite { /** The secret's uppercase name - the write path's {secret_name}. */ readonly name: string; + /** What the write is doing, in settings-file terms, for its error prose. */ readonly describe: string; + /** Reads the scope's sealing key (once per plan) and seals the resolved plaintext, at execution time. */ readonly payload: (exec: ExecTools) => Promise>; + /** The missing-secret line, or empty when the name exists (the PUT recurs by declaration). */ readonly drift: readonly string[]; + + /** The change line; its verb follows the listing, since the executor does not surface the PUT's 201/204. */ readonly change: string; } /** `Put`/`Remove` are the section's exact PlannedOp arms, so a wrong role or params fails to compile. */ export interface SecretsPlanScope extends NamedScope { - /** - * GET the {key_id, key} sealing key for this scope, at EXECUTION time: an environment's key exists - * only once the PUT that creates the environment has landed, so the read rides the first PUT's thunk. - */ + /** GET the {key_id, key} sealing key for this scope at EXECUTION time: an environment's key exists only once the PUT creating the environment has landed, so the read rides the first PUT's thunk. */ readonly publicKey: ( exec: ExecTools, describe: string, ) => PromiseLike>; + /** The declaration behind `publicKey`, named in the prose of a key the endpoint cannot supply. */ readonly publicKeyEndpoint: EndpointDecl; + /** The planned sealed PUT; function-valued so a builder demanding an unsupplied facet fails. */ readonly put: (write: SealedSecretWrite) => Put; } @@ -101,10 +106,25 @@ export function listSecretValues( export interface SealingKey { readonly keyId: string; + /** Seal one ALREADY-RESOLVED plaintext into the {encrypted_value, key_id} PUT body. */ seal(plaintext: string): SealedSecretPayload; } +/** Why a sealing-key field is unusable, or null for a non-empty string. */ +function fieldDefect(label: string, value: unknown): string | null { + if (value === undefined) { + return `${label} is missing`; + } + if (typeof value !== "string") { + return `${label} is not a string`; + } + if (value === "") { + return `${label} is empty`; + } + return null; +} + /** * A malformed key fails here with the endpoint and scope named, rather than as a bare primitive error * inside a seal. @@ -133,14 +153,6 @@ export function parseSealingKey( typeof publicKey !== "string" || publicKey === "" ) { - const fieldDefect = (label: string, value: unknown): string | null => - value === undefined - ? `${label} is missing` - : typeof value !== "string" - ? `${label} is not a string` - : value === "" - ? `${label} is empty` - : null; const defect = fieldDefect("key_id", keyId) ?? fieldDefect("key", publicKey); return unusable(`no usable {key_id, key} pair (${defect})`); } diff --git a/src/sections/shared/secrets-and-variables/variables-engine.ts b/src/sections/shared/secrets-and-variables/variables-engine.ts index 895048d08..de018c686 100644 --- a/src/sections/shared/secrets-and-variables/variables-engine.ts +++ b/src/sections/shared/secrets-and-variables/variables-engine.ts @@ -36,12 +36,11 @@ interface VariableCreate { readonly payload: PlainPayload; readonly drift: readonly [string]; readonly change: string; - /** What the write is doing, in settings-file terms, for its error prose. */ readonly describe: string; } +/** `liveName` addresses the request path: it names what exists, whatever casing the file uses. */ interface VariableUpdate { - /** The LIVE name addresses the request path: it names what exists, whatever casing the file uses. */ readonly liveName: string; readonly payload: PlainPayload; readonly drift: readonly [string, ...string[]]; @@ -57,6 +56,8 @@ export interface VariablesPlanScope< > extends NamedScope { /** The planned POST; the builders are function-valued so one demanding an unsupplied facet fails. */ readonly create: (write: VariableCreate) => Create; + + /** The planned PATCH, addressed by the live name. */ readonly update: (write: VariableUpdate) => Update; } @@ -111,18 +112,18 @@ export function planVariables< // GitHub stores the name uppercased whatever casing the file uses, so the live name never drifts; // only the value and passthrough fields can. - const [first, ...rest] = [ - ...(existing.value !== variable.value - ? [ - valueDrift( - `${label}.value`, - JSON.stringify(variable.value), - JSON.stringify(existing.value), - ), - ] - : []), - ...subsetDiff(extraKeys, existing, label), - ]; + const lines: string[] = []; + if (existing.value !== variable.value) { + lines.push( + valueDrift( + `${label}.value`, + JSON.stringify(variable.value), + JSON.stringify(existing.value), + ), + ); + } + lines.push(...subsetDiff(extraKeys, existing, label)); + const [first, ...rest] = lines; if (first === undefined) { continue; } diff --git a/src/sections/shared/setup-schema.ts b/src/sections/shared/setup-schema.ts index 7fddc42ad..06a7661b2 100644 --- a/src/sections/shared/setup-schema.ts +++ b/src/sections/shared/setup-schema.ts @@ -13,18 +13,18 @@ type Names = readonly [string, ...string[]]; /** * A setup's `languages` vocabulary: what its PATCH accepts, and what only its GET reports. Declared - * `as const satisfies SetupLanguages`, so the slice's inferred type keeps the literal names. + * `as const satisfies SetupLanguages`, so the slice's inferred type keeps the literal names. A GET-only + * name folds onto the declarable name it stands for, or onto null when it has none and is left out with + * a note wherever it is read: + * + * javascript, typescript -> "javascript-typescript" (code scanning) + * rust -> null (code quality) */ export interface SetupLanguages { /** The PATCH body's enum; the slice's `languages` items and the published schema come from it. */ readonly declarable: Declarable; - /** - * The GET's names the PATCH does not take, each with the declarable name it folds onto, or null - * when it has none and is left out with a note wherever it is read. - * - * javascript, typescript -> "javascript-typescript" (code scanning) - * rust -> null (code quality) - */ + + /** The GET's names the PATCH does not take, each with its fold (the rows above). */ readonly getOnly: Readonly>; } diff --git a/src/sections/shared/setup-section.ts b/src/sections/shared/setup-section.ts index e36bfd176..bab6f9221 100644 --- a/src/sections/shared/setup-section.ts +++ b/src/sections/shared/setup-section.ts @@ -107,9 +107,29 @@ export interface SetupSectionModule { ) => Promise, SectionFailure>>; } +/** What the factory's plan closes over for one setup: the key's facts from SETUPS beside the noun and the minted dictionary. */ +interface Minted { + readonly key: SetupKey; + readonly noun: string; + readonly slice: z.ZodObject; + readonly languages: SetupLanguages; + readonly endpoints: WideEndpoints; +} + /** The GET body: the whole configuration as a mapping, which subsetDiff compares the declared keys against. */ const LiveSetup = z.looseObject({}); +/** The PATCH name a reported language folds onto, or null when the vocabulary has none for it. */ +function declarableName(vocabulary: SetupLanguages, name: string): string | null { + if (vocabulary.declarable.includes(name)) { + return name; + } + if (Object.hasOwn(vocabulary.getOnly, name)) { + return vocabulary.getOnly[name] ?? null; + } + return null; +} + /** * The GET body in the PATCH's vocabulary: `languages` with the GET-only names folded onto their * declarable name (once each), and the names with none set aside. Every other key rides through @@ -126,39 +146,29 @@ function inPatchVocabulary( const folded = new Set(); const undeclarable: string[] = []; for (const name of reported) { - const declarable = vocabulary.declarable.includes(name) - ? name - : Object.hasOwn(vocabulary.getOnly, name) - ? vocabulary.getOnly[name] - : undefined; - if (typeof declarable === "string") { - folded.add(declarable); - } else { + const declarable = declarableName(vocabulary, name); + if (declarable === null) { undeclarable.push(name); + continue; } + folded.add(declarable); } return { live: { ...live, languages: [...folded] }, undeclarable }; } -export function setupSection(setup: { - key: K; - /** The fine-grained-PAT permission gating both endpoints. */ - permission: SectionPermission; - /** What else a 403 here can mean (the feature not enabled, an archived repository). */ - grantCaveat: string; - /** The output noun for change lines ("code scanning default setup"). */ - noun: string; -}): SetupSectionModule { - const { key, permission, grantCaveat, noun } = setup; - const { path, slice, languages, read }: Setup = SETUPS[key]; - const readGrade: { accessGrade?: "write" } = read; - const endpoints: SetupEndpoints = { +/** The GET/PATCH pair at one setup's path, the GET graded as SETUPS says. */ +function setupEndpoints( + path: Setup["path"], + noun: string, + read: { accessGrade?: "write" }, +): SetupEndpoints { + return { get: { route: `GET /repos/{owner}/{repo}/${path}`, statuses: { 200: `the current ${noun} configuration` }, // A fine-grained token conceals a denied GET as 404; reading it as "not configured" would be wrong, so it is a denial. primaryRead: { notFound: "denied" }, - ...readGrade, + ...read, }, update: { route: `PATCH /repos/{owner}/{repo}/${path}`, @@ -169,9 +179,15 @@ export function setupSection(setup: { }, }, }; +} - const wide: WideEndpoints = endpoints; - const plan: WidePlan = async (ctx, declared) => { +/** + * The verbatim-PATCH plan over the wide dictionary: the declared keys compared against the GET body in the PATCH's + * vocabulary, a key outside the slice that the GET never echoes noted, and the one PATCH planned on any drift. + */ +function setupPlan(minted: Minted): WidePlan { + const { key, noun, slice, languages, endpoints } = minted; + return async (ctx, declared) => { const desired: Record = declared; const planned: SectionPlan> = { ops: [], notes: [], drift: [] }; const read = await ctx.read.get.call(LiveSetup); @@ -206,11 +222,11 @@ export function setupSection(setup: { tolerate: { statuses: [409], outcome: (error) => ({ - failure: `${key}: PATCH ${expand(wide.update, ctx)}: ${error.status} ${error.message}. A ${noun} configuration run is already in progress on the repository; re-run the workflow after it finishes`, + failure: `${key}: PATCH ${expand(endpoints.update, ctx)}: ${error.status} ${error.message}. A ${noun} configuration run is already in progress on the repository; re-run the workflow after it finishes`, }), }, change: (response) => - parseLive(section, wide.update, LiveConfigurationRun, response).map((run) => { + parseLive(minted, endpoints.update, LiveConfigurationRun, response).map((run) => { if (run?.run_id === undefined) { return `applied ${noun}`; } @@ -220,14 +236,22 @@ export function setupSection(setup: { }); return ok(planned); }; +} - // The GET always answers with the whole configuration (a not-configured setup included), so - // the snapshot is that body, in the PATCH's vocabulary, on the slice's keys; the PATCH takes the - // same keys back verbatim. SETUPS pairs the slice with its key, so its projection IS the section's - // declared type; the casts are the wide-port and per-key boundaries. - const snapshot = async ( - ctx: SnapshotContext, GraphqlDict, K>, - ): Promise, SectionFailure>> => +/** + * The GET always answers with the whole configuration (a not-configured setup included), so the snapshot is that + * body, in the PATCH's vocabulary, on the slice's keys; the PATCH takes the same keys back verbatim. SETUPS pairs the + * slice with its key, so its projection IS the section's declared type; the casts are the wide-port and per-key + * boundaries. + */ +function setupSnapshot( + key: K, + slice: z.ZodObject, + languages: SetupLanguages, +): ( + ctx: SnapshotContext, GraphqlDict, K>, +) => Promise, SectionFailure>> { + return async (ctx) => (ctx as SnapshotContext).read.get .call(LiveSetup) .map((reported) => { @@ -238,16 +262,32 @@ export function setupSection(setup: { : []; return { value: projectOntoSchema(slice, live) as SetupDeclared, notes }; }); +} - const section: SetupSectionModule = { +export function setupSection(setup: { + /** The setup the factory mints; SETUPS pairs it with its path, slice, and vocabulary. */ + key: K; + + /** The fine-grained-PAT permission gating both endpoints. */ + permission: SectionPermission; + + /** What else a 403 here can mean (the feature not enabled, an archived repository). */ + grantCaveat: string; + + /** The output noun for change lines ("code scanning default setup"). */ + noun: string; +}): SetupSectionModule { + const { key, permission, grantCaveat, noun } = setup; + const { path, slice, languages, read }: Setup = SETUPS[key]; + const endpoints = setupEndpoints(path, noun, read); + return { key, undeclaredDefault: "untouched", permission, grantCaveat, endpoints, shape: requirePlainMapping(slice), - plan, - snapshot, + plan: setupPlan({ key, noun, slice, languages, endpoints }), + snapshot: setupSnapshot(key, slice, languages), }; - return section; } diff --git a/src/sections/shared/snapshot-helpers.ts b/src/sections/shared/snapshot-helpers.ts index dd1e76755..a4f42eb14 100644 --- a/src/sections/shared/snapshot-helpers.ts +++ b/src/sections/shared/snapshot-helpers.ts @@ -13,7 +13,9 @@ import type { UndeclaredPolicyList } from "../../types.js"; import type { SectionFailure } from "../contract/errors.js"; import { defaultUndeclaredPolicy, type SectionMeta } from "../contract/module.js"; import type { SnapshotContext } from "../contract/plan.js"; -import { schemaNode } from "./schema-node.js"; +import { type SchemaNode, schemaNode } from "./schema-node.js"; + +type ObjectNode = Extract; /** * A live value projected onto a schema slice, so server-assigned fields fall away without a hand @@ -31,6 +33,29 @@ export function projectOntoSchema(schema: z.ZodType, live: unknown): T { return project(schema, live) as T; } +/** + * A mapping projected onto an object node: each declared key recursively, dropped when it projects to nothing, and + * under a z.looseObject node every other live key as it came; an open() node keeps the declared ones alone. + */ +function projectObject(node: ObjectNode, live: Record): Record { + const out: Record = {}; + for (const [key, child] of Object.entries(node.shape)) { + const projected = project(child, live[key]); + if (projected !== undefined) { + out[key] = projected; + } + } + if (node.openness !== "open") { + return out; + } + for (const [key, value] of Object.entries(live)) { + if (!(key in node.shape) && value !== undefined) { + out[key] = value; + } + } + return out; +} + function project(schema: z.ZodType, live: unknown): unknown { if (live === undefined) { return undefined; @@ -42,26 +67,8 @@ function project(schema: z.ZodType, live: unknown): unknown { switch (node.kind) { case "wrapper": return project(node.inner, live); - case "object": { - if (!isPlainObject(live)) { - return live; - } - const out: Record = {}; - for (const [key, child] of Object.entries(node.shape)) { - const projected = project(child, live[key]); - if (projected !== undefined) { - out[key] = projected; - } - } - if (node.openness === "open") { - for (const [key, value] of Object.entries(live)) { - if (!(key in node.shape) && value !== undefined) { - out[key] = value; - } - } - } - return out; - } + case "object": + return isPlainObject(live) ? projectObject(node, live) : live; case "array": return Array.isArray(live) ? live.map((item) => project(node.element, item)) : live; case "record": diff --git a/src/sections/teams/index.ts b/src/sections/teams/index.ts index 5722d10ce..e42138be2 100644 --- a/src/sections/teams/index.ts +++ b/src/sections/teams/index.ts @@ -5,8 +5,9 @@ * listSection: the live list carries no role, so each team's access is a separate probe. */ -import { ok, type Result, safeTry } from "neverthrow"; +import { ok, okAsync, type Result, safeTry } from "neverthrow"; import { z } from "zod"; +import type { UndeclaredPolicy } from "../../types.js"; import { undeclaredDrift, undeclaredNote, @@ -59,21 +60,26 @@ const ENDPOINTS = { type TeamsContext = PlanContext; -// The repo object under the repository media type, of which only role_name is read. Nullish, -// because a server ignoring the media type answers a bare 204, which still means "the team has access". +type TeamsOp = PlannedOp; + +// Nullish because a server ignoring the repository media type answers a bare 204, which still means "the team has access". const LiveTeamRepo = z.looseObject({ role_name: z.string().optional() }).nullish(); -/** - * A listed team: the slug the probe and the grant address, its id (which tells two same-fold slugs - * apart), and how its access was granted (present only in a repository listing; absent reads as direct). - */ +// The id tells two same-fold slugs apart; access_source appears only in a repository listing, and absent reads as direct. const LiveTeam = z.looseObject({ id: z.number().optional(), slug: z.string(), access_source: z.string().optional(), }); + type LiveTeam = z.infer; +/** What the access probe answers for one team. */ +type TeamAccess = { access: false } | { access: true; role: string | undefined }; + +/** The role a listed team reads back, or why the snapshot leaves the team out. */ +type SnapshotRole = { role: string } | { leftOut: string }; + /** Access granted above the repository (the organization, the enterprise), which no repository call revokes. */ function inheritedAccess(team: LiveTeam): string | undefined { return team.access_source !== undefined && team.access_source !== "direct" @@ -87,10 +93,7 @@ function inheritedAccessReason(repo: string, source: string): string { } /** The probe plan() and snapshot() share, under the media type LiveTeamRepo describes. */ -function probeTeamRole( - ctx: TeamsContext, - slug: string, -): Read<{ access: false } | { access: true; role: string | undefined }> { +function probeTeamRole(ctx: TeamsContext, slug: string): Read { return ctx.read.probe .probeAbsent(LiveTeamRepo, { params: { org: ctx.repo.owner, team_slug: slug }, @@ -106,6 +109,7 @@ function probeTeamRole( /** A team's slug is its lowercased name; the brand marks a name or slug already folded to the key every lookup reads. */ declare const teamSlugKey: unique symbol; + type TeamKey = string & { readonly [teamSlugKey]: true }; function teamKey(nameOrSlug: string): TeamKey { @@ -129,6 +133,111 @@ function teamsBySlug( ); } +/** The drift line a declared team's probe earns, or undefined once its live role matches. */ +function declaredTeamDrift( + repo: string, + team: TeamConfig, + role: string, + probe: TeamAccess, +): string | undefined { + if (!probe.access) { + return `teams[${team.name}]: no access to ${repo}; apply will grant "${role}"`; + } + + const wantRole = roleForPermission(role); + const liveRole = probe.role ?? ""; + if (liveRole === wantRole) { + return undefined; + } + + return valueDrift(`teams[${team.name}]`, JSON.stringify(wantRole), JSON.stringify(liveRole), { + remedy: "apply will set the declared permission", + }); +} + +/** A keep-note or the revocation for each live team the file does not name; access granted above the repository is beyond either. */ +function undeclaredTeams( + ctx: TeamsContext, + section: SectionMeta<"teams">, + live: ReadonlyMap, + declaredSlugs: ReadonlySet, + policy: UndeclaredPolicy, +): { ops: TeamsOp[]; notes: string[] } { + const ops: TeamsOp[] = []; + const notes: string[] = []; + for (const [slugKey, team] of live) { + if (declaredSlugs.has(slugKey)) { + continue; + } + + const inherited = inheritedAccess(team); + if (inherited !== undefined) { + // Only a revocation would act on it, so only the policy that would revoke is told it cannot. + if (policy === "delete") { + notes.push( + `teams[${team.slug}]: ${inheritedAccessReason(ctx.repo.slug, inherited)}, so "_undeclared: delete" cannot revoke it; left untouched`, + ); + } + continue; + } + + if (policy === "keep") { + notes.push( + undeclaredNote({ + subject: `team "${team.slug}"`, + state: "has access but is not declared", + manage: "its access", + action: "REVOKE its access", + }), + ); + continue; + } + + ops.push({ + role: "revoke", + params: { org: ctx.repo.owner, team_slug: team.slug }, + drift: [ + undeclaredDrift(defaultUndeclaredPolicy(section), { + label: `teams[${team.slug}]`, + action: "REVOKE its access", + keep: "its access", + }), + ], + change: `REVOKED undeclared team "${team.slug}"`, + }); + } + + return { ops, notes }; +} + +/** Inherited access is never probed; a 404 or a roleless answer leaves the team out with the reason. */ +function snapshotRole(ctx: TeamsContext, section: SectionMeta, team: LiveTeam): Read { + const inherited = inheritedAccess(team); + if (inherited !== undefined) { + return okAsync({ + leftOut: `${inheritedAccessReason(ctx.repo.slug, inherited)}, and declaring it would grant direct access`, + }); + } + + return probeTeamRole(ctx, team.slug).map((probe): SnapshotRole => { + if (!probe.access) { + // No write follows to surface a denial, so the note names both readings of the 404. + return { + leftOut: + `listed with access to ${ctx.repo.slug}, but the access probe answered 404, read here as no access. ` + + "A fine-grained token missing the grant gets the same answer; if the team does have access, " + + `${sectionGrant(section)}, then snapshot again`, + }; + } + if (probe.role === undefined) { + return { + leftOut: `has access to ${ctx.repo.slug}, but GitHub reported no role for it; add the entry with the intended permission`, + }; + } + return { role: probe.role }; + }); +} + export const teamsSection = { ...identifiedBy("teams", "name", "team", { fold: teamKey }), undeclaredDefault: "keep", @@ -149,7 +258,7 @@ export const teamsSection = { declared, defaultUndeclaredPolicy(section), ); - const plan: SectionPlan> = { ops: [], notes: [], drift: [] }; + const plan: SectionPlan = { ops: [], notes: [], drift: [] }; // The listing is read BEFORE the declared walk, so the undeclared teams are judged against the state the grants // below start from; a declared team's role still comes from the probe, which names a custom role. const live = yield* ctx.read.list @@ -158,27 +267,14 @@ export const teamsSection = { const declaredSlugs = new Set(desired.map((team) => teamKey(team.name))); for (const team of desired) { const role = team.permission ?? DEFAULT_ROLE; - const params = { org: ctx.repo.owner, team_slug: team.name }; const probe = yield* probeTeamRole(ctx, team.name); - const wantRole = roleForPermission(role); - let drift: string; - if (!probe.access) { - drift = `teams[${team.name}]: no access to ${ctx.repo.slug}; apply will grant "${role}"`; - } else { - const liveRole = probe.role ?? ""; - if (liveRole === wantRole) { - continue; - } - drift = valueDrift( - `teams[${team.name}]`, - JSON.stringify(wantRole), - JSON.stringify(liveRole), - { remedy: "apply will set the declared permission" }, - ); + const drift = declaredTeamDrift(ctx.repo.slug, team, role, probe); + if (drift === undefined) { + continue; } plan.ops.push({ role: "grant", - params, + params: { org: ctx.repo.owner, team_slug: team.name }, payload: { permission: role }, describe: `granting team "${team.name}" access`, drift: [drift], @@ -186,44 +282,9 @@ export const teamsSection = { }); } - for (const [slugKey, team] of live) { - if (declaredSlugs.has(slugKey)) { - continue; - } - const inherited = inheritedAccess(team); - if (inherited !== undefined) { - // Only a revocation would act on it, so only the policy that would revoke is told it cannot. - if (policy === "delete") { - plan.notes.push( - `teams[${team.slug}]: ${inheritedAccessReason(ctx.repo.slug, inherited)}, so "_undeclared: delete" cannot revoke it; left untouched`, - ); - } - continue; - } - if (policy === "keep") { - plan.notes.push( - undeclaredNote({ - subject: `team "${team.slug}"`, - state: "has access but is not declared", - manage: "its access", - action: "REVOKE its access", - }), - ); - continue; - } - plan.ops.push({ - role: "revoke", - params: { org: ctx.repo.owner, team_slug: team.slug }, - drift: [ - undeclaredDrift(defaultUndeclaredPolicy(section), { - label: `teams[${team.slug}]`, - action: "REVOKE its access", - keep: "its access", - }), - ], - change: `REVOKED undeclared team "${team.slug}"`, - }); - } + const undeclared = undeclaredTeams(ctx, section, live, declaredSlugs, policy); + plan.ops.push(...undeclared.ops); + plan.notes.push(...undeclared.notes); return ok(plan); }); }, @@ -245,39 +306,12 @@ export const teamsSection = { const entries: TeamConfig[] = []; for (const team of teams.values()) { const label = `teams[${team.slug}]`; - const inherited = inheritedAccess(team); - if (inherited !== undefined) { - notes.push( - leftOutOfSnapshot( - label, - `${inheritedAccessReason(ctx.repo.slug, inherited)}, and declaring it would grant direct access`, - ), - ); - continue; - } - const probe = yield* probeTeamRole(ctx, team.slug); - if (!probe.access) { - // No write follows to surface a denial, so the note names both readings of the 404. - notes.push( - leftOutOfSnapshot( - label, - `listed with access to ${ctx.repo.slug}, but the access probe answered 404, read here as no access. ` + - "A fine-grained token missing the grant gets the same answer; if the team does have access, " + - `${sectionGrant(section)}, then snapshot again`, - ), - ); - continue; - } - if (probe.role === undefined) { - notes.push( - leftOutOfSnapshot( - label, - `has access to ${ctx.repo.slug}, but GitHub reported no role for it; add the entry with the intended permission`, - ), - ); + const probed = yield* snapshotRole(ctx, section, team); + if ("leftOut" in probed) { + notes.push(leftOutOfSnapshot(label, probed.leftOut)); continue; } - const permission = yield* readBackPermission(section, label, probe.role, notes); + const permission = yield* readBackPermission(section, label, probed.role, notes); if (permission === undefined) { continue; } diff --git a/src/sections/teams/schema.ts b/src/sections/teams/schema.ts index aa5725645..6a32e27d2 100644 --- a/src/sections/teams/schema.ts +++ b/src/sections/teams/schema.ts @@ -4,9 +4,7 @@ import { z } from "zod"; import { PermissionSchema } from "../shared/roles.js"; import { open } from "../shared/schema-helpers.js"; -// The name is the team_slug in every API path. A display name ("Core Team") probes /teams/Core%20Team, 404s, and the -// section reads that as "no access": check reports a lie and the grant PUT 404s in turn. Uppercase passes; GitHub -// folds the slug itself. The lookahead keeps "." and ".." out: as path segments they resolve to another URL entirely. +// A display name ("Core Team") would probe /teams/Core%20Team, 404, and read as "no access", so only a slug passes (uppercase too, GitHub folds it); the lookahead keeps "." and ".." out, which resolve to another URL as path segments. const SLUG_PATTERN = /^(?=.*[A-Za-z0-9])[A-Za-z0-9._-]+$/; function slugError(declared: string): string { diff --git a/src/sections/webhooks/schema.ts b/src/sections/webhooks/schema.ts index e33f6727e..e87f32c8a 100644 --- a/src/sections/webhooks/schema.ts +++ b/src/sections/webhooks/schema.ts @@ -5,8 +5,7 @@ import { describeValue } from "../../plain-data.js"; import { open, rule } from "../shared/schema-helpers.js"; import { REPOSITORY_WEBHOOK_EVENTS, WEBHOOK_EVENTS_REFERENCE } from "./events.js"; -// GitHub 422s each of these at apply time ("is not a valid event", "Url is not a valid URL"); the REST wire types are -// bare strings, so the parser carries the list GitHub's webhooks description publishes and refuses the rest naming it. +// The REST wire types are bare strings and GitHub 422s a bad event only at apply time ("is not a valid event"), so the parser carries the list GitHub's webhooks description publishes. const WebhookEvent = z.enum([...REPOSITORY_WEBHOOK_EVENTS, "*"] as const, { error: (issue) => `${describeValue(issue.input)} is not an event GitHub delivers to repository webhooks ("*" means every event); ` + @@ -14,6 +13,7 @@ const WebhookEvent = z.enum([...REPOSITORY_WEBHOOK_EVENTS, "*"] as const, { "so an event GitHub added since arrives in the release that bumps that package", }); +// Only url is required; an omitted content_type or insecure_ssl takes GitHub's default ("form", "0"). const WebhookDeliveryConfig = z .looseObject({ url: z.url({ @@ -37,6 +37,7 @@ const WebhookDeliveryConfig = z }) .meta({ id: "WebhookDeliveryConfig" }); +/** name admits only "web", the one hook kind the REST API creates. */ export const WebhookConfig = open({ name: z.literal("web").optional(), config: WebhookDeliveryConfig, @@ -59,4 +60,5 @@ export const WebhookConfig = open({ }), ) .meta({ id: "WebhookConfig" }); + export type WebhookConfig = z.infer; diff --git a/src/sections/workflows/index.ts b/src/sections/workflows/index.ts index cb5f1e871..867fb34d6 100644 --- a/src/sections/workflows/index.ts +++ b/src/sections/workflows/index.ts @@ -29,6 +29,11 @@ function workflowPath(declared: string): string { return declared.includes("/") ? declared : `.github/workflows/${declared}`; } +/** Every disabled_* live state is "disabled": the effective state plan() compares and snapshot() reads back. */ +function effectiveState(workflow: LiveWorkflow): "active" | "disabled" { + return workflow.state === "active" ? "active" : "disabled"; +} + /** * The workflows that still have a file, by path, under the duplicate-live guard; plan() and snapshot() * both index through it. A "deleted" workflow has no file behind it anymore, so it is absent. @@ -92,7 +97,7 @@ export const workflowsSection = { ); continue; } - const liveState = match.state === "active" ? "active" : "disabled"; + const liveState = effectiveState(match); if (liveState === workflow.state) { continue; } @@ -117,8 +122,7 @@ export const workflowsSection = { return plan; }); }, - // Every disabled_* live state reads back as "disabled", the effective state apply compares; a - // "deleted" workflow has no file and would only plan as unfixable drift, so it is left out. + // A "deleted" workflow has no file and would only plan as unfixable drift, so it is left out. async snapshot(ctx) { return ctx.read.list .listAllEnveloped("workflows", LiveWorkflow) @@ -129,10 +133,7 @@ export const workflowsSection = { return { value: undefined, notes: [] }; } return { - value: present.map((w) => ({ - path: w.path, - state: w.state === "active" ? ("active" as const) : ("disabled" as const), - })), + value: present.map((w) => ({ path: w.path, state: effectiveState(w) })), notes: [], }; }); diff --git a/src/types.ts b/src/types.ts index a58b8ff28..335754d0b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,15 +8,13 @@ export type UndeclaredPolicy = "keep" | "delete"; /** * The wrapper knobbed() and nestedKnobbed() build. The underscored keys are this action's DIRECTIVES, never GitHub - * settings; each key's meaning is published from docs/sections/shared.docs.yml, the one source the JSON Schema and the docs render from. + * settings; each key's meaning is published from docs/sections/shared.docs.yml, the one source the JSON Schema and the + * docs render from. Only a TOP-LEVEL section's wrapper takes `_layering` (see nestedKnobbed()); its values are + * LAYERINGS in src/sections/shared/schema-helpers.ts, pinned in src/sections/contract/keyed-list.ts. */ export interface UndeclaredPolicyList { _undeclared?: UndeclaredPolicy; entries: E[]; - /** - * Only a TOP-LEVEL section's wrapper takes it (see nestedKnobbed()); the values are LAYERINGS in - * src/sections/shared/schema-helpers.ts, pinned in src/sections/contract/keyed-list.ts. - */ _layering?: "replace" | "shallow" | "deep"; } diff --git a/src/upstream-gaps/gap.ts b/src/upstream-gaps/gap.ts index 71c4928ed..52edd6969 100644 --- a/src/upstream-gaps/gap.ts +++ b/src/upstream-gaps/gap.ts @@ -5,15 +5,13 @@ import type { Endpoints } from "@octokit/types"; /** * Routes the pinned @octokit/types does not carry yet. The gap file carries a MustBeNever tripwire that * fails typecheck when octokit ships a route; the graduate script then deletes the file - * (documentedInSpec: true) or rewrites it spec-only (documentedInSpec: false). + * (documentedInSpec: true) or rewrites it spec-only (documentedInSpec: false). `documentedInSpec` is false + * only for features GitHub's OpenAPI descriptor ALSO lags; those are excluded from the descriptor slice the + * e2e validator loads and exempted from its unknown-route check via UNDOCUMENTED_ROUTES. */ export interface OctokitGap { readonly kind: "octokit"; readonly routes: readonly [R, ...R[]]; - /** - * False only for features GitHub's OpenAPI descriptor ALSO lags; those are excluded from the descriptor - * slice the e2e validator loads and exempted from its unknown-route check via UNDOCUMENTED_ROUTES. - */ readonly documentedInSpec: boolean; } diff --git a/test/action/inputs.test.ts b/test/action/inputs.test.ts index 93291fe34..10fb4ae9a 100644 --- a/test/action/inputs.test.ts +++ b/test/action/inputs.test.ts @@ -12,6 +12,8 @@ const ENV_KEYS = [ "GITHUB_SERVER_URL", "GITHUB_RUN_ID", ]; + +/** The environment as the file found it, restored after each test. */ const saved = new Map(ENV_KEYS.map((key) => [key, process.env[key]])); afterEach(() => { diff --git a/test/cli/execution.test.ts b/test/cli/execution.test.ts index 632ce6048..cddf434d3 100644 --- a/test/cli/execution.test.ts +++ b/test/cli/execution.test.ts @@ -18,17 +18,27 @@ import { runCli } from "./streams.js"; type Inputs = Partial>; interface Case { + /** The arm's name, which is the test's. */ readonly name: string; + /** The runner's inputs; `dir` is the side's own scratch directory for the files the arm writes or misses. */ readonly inputs: (dir: string) => Inputs; + /** A settings document written to `/settings.yml` before the run, for the arms that read one the fixtures lack. */ readonly settings?: string; + + /** The reads the mock answers; an unrouted GET answers 404. */ readonly routes: Record; + /** The writes the arm may perform; anything else is an unrouted mutation and throws. */ readonly mutations?: readonly string[]; + + /** The GITHUB_* context both faces run under. */ readonly env: ConfigEnv; + /** Where the arm ends on both faces; without it, two faces failing alike would pass as equal. */ readonly ends: { readonly code: number; readonly result: string }; + /** The private targets both faces must key by placeholder and name nowhere, under the default private-repos: redact. */ readonly redacted?: readonly string[]; } @@ -41,34 +51,39 @@ const DOCS = { name: "docs", color: "0075ca", description: "Documentation" }; const FLEET_ENV: ConfigEnv = { GITHUB_REPOSITORY: "admin/fleet" }; /** The settings.yml of a remote target, as the contents endpoint hands it back through getRepoFile. */ -const remoteSettings = ( - slug: string, - hasWiki: boolean, - isPrivate = false, -): Record => ({ - [`GET /repos/${slug}`]: { data: { has_wiki: hasWiki, private: isPrivate } }, - [`GET /repos/${slug}/contents/.github/settings.yml`]: { - data: `repository:\n has_wiki: false\n`, - }, -}); +function remoteSettings(slug: string, hasWiki: boolean, isPrivate = false): Record { + return { + [`GET /repos/${slug}`]: { data: { has_wiki: hasWiki, private: isPrivate } }, + [`GET /repos/${slug}/contents/.github/settings.yml`]: { + data: `repository:\n has_wiki: false\n`, + }, + }; +} -const labels = (slug: string, entries: unknown[]): Record => ({ - [`GET /repos/${slug}`]: { data: { private: false } }, - [`GET /repos/${slug}/labels?per_page=100&page=1`]: { data: entries }, -}); +function labels(slug: string, entries: unknown[]): Record { + return { + [`GET /repos/${slug}`]: { data: { private: false } }, + [`GET /repos/${slug}/labels?per_page=100&page=1`]: { data: entries }, + }; +} /** The token cannot read the repository's variables: under warn the section is skipped and the run ends partial. */ -const variablesDenied = (slug: string): Record => ({ - [`GET /repos/${slug}/actions/variables?per_page=30&page=1`]: { - error: { status: 403, message: "Resource not accessible by personal access token", body: "" }, - }, -}); +function variablesDenied(slug: string): Record { + return { + [`GET /repos/${slug}/actions/variables?per_page=30&page=1`]: { + error: { status: 403, message: "Resource not accessible by personal access token", body: "" }, + }, + }; +} /** A document declaring the section the token can read beside the one it cannot. */ const WITH_VARIABLES = "repository:\n has_wiki: false\nactions_variables:\n - name: REGION\n value: eu-west-1\n"; + +/** The input under which a section the token cannot read is skipped instead of failing the run. */ const WARN = { "on-missing-permission": "warn" } as const; +/** The runner's input sets, each run through both faces. */ const cases: Case[] = [ { name: "check, one repository, clean", @@ -196,25 +211,39 @@ const cases: Case[] = [ /** What both faces are held to, with each side's scratch directory folded to one spelling. */ interface Observed { + /** The exit code. */ readonly code: number; + + /** The action's outputs; the CLI prints them as `name=value` lines on stdout. */ readonly outputs: Partial>; + + /** The action's plain log lines; the CLI's other stdout lines. */ readonly logs: readonly string[]; + + /** The action's leveled lines as `level: text`; the CLI's stderr lines. */ readonly annotations: readonly string[]; + /** Every file under the side's scratch directory, by relative path, byte for byte: no snapshot line dates itself. */ readonly files: Record; } +/** The outputs the CLI prints on stdout, so a stdout line in their `name=value` shape is an output and not a log line. */ const OUTPUT_NAMES: readonly OutputName[] = ["result", "skipped-sections", "repos-result"]; + +/** A stdout line that is an output: its name and its value. */ const OUTPUT_LINE = new RegExp(`^(${OUTPUT_NAMES.join("|")})=(.*)$`); /** The environment keys the action's parse reads beside the inputs, held to the case's env on both faces. */ const CONTEXT_KEYS = ["GITHUB_REPOSITORY", "GITHUB_SERVER_URL", "GITHUB_RUN_ID", "GITHUB_TOKEN"]; + +/** The INPUT_* variables the action's face reads, cleared before each run so a stale one cannot leak in. */ const INPUT_KEYS = Object.keys(INPUT_DECLS).map((name) => `INPUT_${name.toUpperCase()}`); function api(c: Case): MockApi { return new MockApi({ ...c.routes }).allowMutations(...(c.mutations ?? [])); } +/** Folds a side's scratch directory to `` in a line, so the two sides compare equal. */ const fold = (dir: string) => (line: string) => line.replaceAll(dir, ""); /** The two faces run at different instants; the run's one moment notice is the one line that may differ. */ diff --git a/test/cli/inputs.test.ts b/test/cli/inputs.test.ts index 7eea60721..78567a172 100644 --- a/test/cli/inputs.test.ts +++ b/test/cli/inputs.test.ts @@ -52,17 +52,21 @@ beforeAll(async () => { }); interface Case { + /** The case's name, which is the test's. */ readonly name: string; + /** The argv after the program name: the subcommand and its flags. */ readonly argv: readonly string[]; + /** The same inputs as the runner would present them. */ readonly inputs: Inputs; + + /** The GITHUB_* context the parse runs under. */ readonly env?: ConfigEnv; - /** - * A flag the subcommand does not expose: commander refuses it before - * parseConfig runs, where the action's path refuses the same input by name. - */ + + /** A flag the subcommand does not expose: commander refuses it before parseConfig runs, where the action's path refuses the same input by name. */ readonly unknownFlag?: InputName; + /** A value the action accepts and only the CLI refuses, with the line it prints. */ readonly cliRefuses?: string; } @@ -368,11 +372,10 @@ function base(mode: Mode): Inputs { } } -/** - * The snapshot's fleet inputs need the directory form: they unset the base's - * snapshot-file (an empty value reads as unset) and name a snapshot-dir. - */ +/** The snapshot's fleet inputs in the directory form: an empty snapshot-file unsets the base's (an empty value reads as unset) beside a snapshot-dir. */ const SNAPSHOT_FLEET: Inputs = { "snapshot-file": "", "snapshot-dir": "snaps", repos: "*" }; + +/** The snapshot inputs that need the fleet form beside them. */ const SNAPSHOT_FLEET_INPUTS = new Set([ "snapshot-dir", "repos", @@ -500,6 +503,56 @@ describe("the per-mode flag split", () => { }, }; + /** The base argv of the first subcommand exposing `name`. */ + function subcommandExposing(name: InputName): string[] { + if (inputsForMode("check").includes(name) || name === "token") { + return check; + } + if (inputsForMode("render").includes(name)) { + return ["render"]; + } + return ["snapshot", "--token", "ghp"]; + } + + /** That argv minus every `--name value` pair, so a test's own repeats are the flag's only occurrences. */ + function argvWithout(name: InputName): string[] { + const base = subcommandExposing(name); + return base.filter((argument, index) => { + const value = index > 0 && base[index - 1] === `--${name}`; + return argument !== `--${name}` && !value; + }); + } + + /** A single-value flag given twice is refused by commander, naming the flag and the second value. */ + async function expectRepeatRefused(name: InputName): Promise { + // Long values: a token value is masked, and a one-letter mask would eat the message. + const actual = await throughArgv( + [...argvWithout(name), `--${name}`, "first-value", `--${name}`, "second-value"], + {}, + ); + // The token's echoed value is masked (registered before the parse); the rest print as given. + const echoed = name === "token" ? "***" : "second-value"; + expect(actual.code, name).toBe(1); + expect(actual.stderr, name).toBe( + `error: option '--${name} ' argument '${echoed}' is invalid. --${name} takes one value and was given more than once\n`, + ); + } + + /** A list flag given twice reaches the parsed config with both values, in order. */ + async function expectRepeatKept(name: InputName, repeated: Repeated): Promise { + const [first, second] = repeated.pair; + const actual = await throughArgv( + [...repeated.argv, `--${name}`, first, `--${name}`, second], + repeated.env ?? {}, + ); + expect(actual.stderr, name).toBe(""); + expect(actual.parsed, name).toBeDefined(); + expect(actual.parsed === undefined ? [] : repeated.kept(actual.parsed), name).toEqual([ + first, + second, + ]); + } + test("a repeated flag accumulates exactly for the inputs declared list, and is refused for the rest", async () => { const exposed = new Set(exposedInputs()); for (const name of Object.keys(REPEATED) as InputName[]) { @@ -508,46 +561,14 @@ describe("the per-mode flag split", () => { // The record and the declaration agree on which inputs are lists. expect(repeated !== "single", `${name} declares list: ${decl.list}`).toBe(decl.list === true); expect(isList(name), name).toBe(decl.list === true); + if (repeated === "single" && !exposed.has(name)) { + continue; // the mode is the subcommand; an unsupported input has no flag + } if (repeated === "single") { - if (!exposed.has(name)) { - continue; // the mode is the subcommand; an unsupported input has no flag - } - // The base argv of the first subcommand exposing the flag, minus this flag, so the two - // repeats below are its only occurrences. - const base = - inputsForMode("check").includes(name) || name === "token" - ? check - : inputsForMode("render").includes(name) - ? ["render"] - : ["snapshot", "--token", "ghp"]; - const flag = base.filter((argument, index) => { - const value = index > 0 && base[index - 1] === `--${name}`; - return argument !== `--${name}` && !value; - }); - // Long values: a token value is masked, and a one-letter mask would eat the message. - const actual = await throughArgv( - [...flag, `--${name}`, "first-value", `--${name}`, "second-value"], - {}, - ); - // The token's echoed value is masked (registered before the parse); the rest print as given. - const echoed = name === "token" ? "***" : "second-value"; - expect(actual.code, name).toBe(1); - expect(actual.stderr, name).toBe( - `error: option '--${name} ' argument '${echoed}' is invalid. --${name} takes one value and was given more than once\n`, - ); + await expectRepeatRefused(name); continue; } - const [first, second] = repeated.pair; - const actual = await throughArgv( - [...repeated.argv, `--${name}`, first, `--${name}`, second], - repeated.env ?? {}, - ); - expect(actual.stderr, name).toBe(""); - expect(actual.parsed, name).toBeDefined(); - expect(actual.parsed === undefined ? [] : repeated.kept(actual.parsed), name).toEqual([ - first, - second, - ]); + await expectRepeatKept(name, repeated); } }); diff --git a/test/config/biome-schema.test.ts b/test/config/biome-pin.test.ts similarity index 54% rename from test/config/biome-schema.test.ts rename to test/config/biome-pin.test.ts index 25a7a9110..2f57f240b 100644 --- a/test/config/biome-schema.test.ts +++ b/test/config/biome-pin.test.ts @@ -1,6 +1,8 @@ /** - * biome.json's $schema URL names a biome version by hand, and `biome ci` only emits an info notice when it lags the installed CLI, so a dependency - * bump leaves it drifting silently. + * The fleet lints with one biome, the version the managed biome.json's $schema URL names, while this repository's + * own lint, format, and pre-commit hook run the @biomejs/biome devDependency. A Dependabot bump of that devDependency + * would otherwise lint with a newer biome than the gate, silently: `biome ci` only emits an info notice when the + * schema lags the CLI. */ import { describe, expect, test } from "bun:test"; @@ -13,8 +15,8 @@ function biomeSchemaVersion(schemaUrl: string): string | null { return schemaUrl.match(/^https:\/\/biomejs\.dev\/schemas\/([^/]+)\/schema\.json$/)?.[1] ?? null; } -describe("biome.json $schema", () => { - test("names the installed @biomejs/biome version", () => { +describe("@biomejs/biome devDependency", () => { + test("is the fleet's biome, the version the managed biome.json's $schema names", () => { const biome = JSON.parse(readFileSync(join(ROOT, "biome.json"), "utf8")) as { $schema?: string; }; @@ -23,14 +25,14 @@ describe("biome.json $schema", () => { }; const installed = pkg.devDependencies?.["@biomejs/biome"]; expect(installed, "package.json has no @biomejs/biome devDependency").toBeDefined(); - const pinned = biomeSchemaVersion(biome.$schema ?? ""); + const fleet = biomeSchemaVersion(biome.$schema ?? ""); expect( - pinned, + fleet, `biome.json $schema "${biome.$schema}" is not a biomejs.dev schema URL`, ).not.toBeNull(); expect( - pinned, - `biome.json $schema names ${pinned} but @biomejs/biome is ${installed}; run \`bun x biome migrate --write\``, - ).toBe(installed as string); + installed, + `@biomejs/biome is pinned at ${installed} but the fleet's biome.json names ${fleet}; the gate lints with the fleet's biome, so pin the devDependency to ${fleet}`, + ).toBe(fleet as string); }); }); diff --git a/test/config/package-json.test.ts b/test/config/package-json.test.ts index ecb73b4ec..073dad7d1 100644 --- a/test/config/package-json.test.ts +++ b/test/config/package-json.test.ts @@ -14,11 +14,49 @@ import tsdown from "../../tsdown.config.js"; import { ROOT } from "../root.js"; import { readSettingsSchema } from "../settings-schema.js"; +/** The built lib/settings.schema.json, whose $id names the repository the package must point at. */ const schema = readSettingsSchema(); /** tsdown types its options loosely (a glob, a list, or a map); this config is the map form. */ const build = tsdown as { entry: Record; outDir: string }; +/** The export targets and the bin paths package.json names, each as a ./-relative path. */ +function manifestTargets(): string[] { + return [ + ...Object.values(pkg.exports).map((entry) => + typeof entry === "string" ? entry : entry.default, + ), + ...Object.values(pkg.bin).map((bin) => `./${bin}`), + ]; +} + +/** The import paths the library page's entry table names, plus the two its trailing sentence says ride along, sorted. */ +function documentedExportPaths(page: string): string[] { + const entries = page.match(/^## The two entries\n([\s\S]*?)^## /m)?.[1] ?? ""; + const importPaths = [ + ...entries.matchAll(/^\| `@vivswan\/github-settings-as-code(\/[^`]+)?` \|/gm), + ].map((match) => `.${match[1] ?? ""}`); + const rideAlong = entries.match(/Two more paths ride along: (.+)$/m)?.[1] ?? ""; + return [ + ...importPaths, + ...[...rideAlong.matchAll(/`(\.\/[^`]+)`/g)].map((match) => match[1] ?? ""), + ].sort(); +} + +/** The bin names the library page's CLI sentence lists, sorted. */ +function documentedBinNames(page: string): string[] { + const sentence = page.match(/The package's `bin` entries, (.+?), run the same flows/)?.[1] ?? ""; + return [...sentence.matchAll(/`([^`]+)`/g)].map((match) => match[1] ?? "").sort(); +} + +/** Whether `files` packs `target`: an entry names a file or a directory, with or without a trailing slash. */ +function isPacked(target: string): boolean { + return pkg.files.some((entry) => { + const path = `./${entry.replace(/\/$/, "")}`; + return target === path || target.startsWith(`${path}/`); + }); +} + /** Every package the library entries reach through static imports and re-exports, type-only ones included (the bundled .d.ts names them). */ function packagesImportedByTheLibrary(): string[] { const seen = new Set(); @@ -66,12 +104,7 @@ describe("package.json as the npm manifest", () => { test("the exports map and the bins name exactly the library build, the schema, and the manifest", () => { const built = Object.keys(build.entry).map((name) => `./${build.outDir}/${name}.js`); - const targets = [ - ...Object.values(pkg.exports).map((entry) => - typeof entry === "string" ? entry : entry.default, - ), - ...Object.values(pkg.bin).map((bin) => `./${bin}`), - ]; + const targets = manifestTargets(); expect(targets.filter((target) => !built.includes(target)).sort()).toEqual([ "./lib/settings.schema.json", "./package.json", @@ -79,23 +112,11 @@ describe("package.json as the npm manifest", () => { expect(built.filter((module) => !targets.includes(module))).toEqual([]); // The KEYS are public too: the library page's entry table names every import path and its trailing sentence the two paths that ride along. const page = readFileSync(join(ROOT, "docs/reference/library.md"), "utf8"); - const entries = page.match(/^## The two entries\n([\s\S]*?)^## /m)?.[1] ?? ""; - const importPaths = [ - ...entries.matchAll(/^\| `@vivswan\/github-settings-as-code(\/[^`]+)?` \|/gm), - ].map((match) => `.${match[1] ?? ""}`); - const rideAlong = entries.match(/Two more paths ride along: (.+)$/m)?.[1] ?? ""; - const documentedPaths = [ - ...importPaths, - ...[...rideAlong.matchAll(/`(\.\/[^`]+)`/g)].map((match) => match[1] ?? ""), - ].sort(); + const documentedPaths = documentedExportPaths(page); expect(documentedPaths).not.toEqual([]); expect(Object.keys(pkg.exports).sort()).toEqual(documentedPaths); // The bin NAMES likewise: the CLI section names every bin entry, and both must run the one CLI build. - const sentence = - page.match(/The package's `bin` entries, (.+?), run the same flows/)?.[1] ?? ""; - const documentedBins = [...sentence.matchAll(/`([^`]+)`/g)] - .map((match) => match[1] ?? "") - .sort(); + const documentedBins = documentedBinNames(page); expect(documentedBins).not.toEqual([]); expect(Object.keys(pkg.bin).sort()).toEqual(documentedBins); // The bins run the CLI entry and each module export its namesake entry, so a swapped target cannot pass as "some built module". @@ -112,19 +133,15 @@ describe("package.json as the npm manifest", () => { } const declarations: string[] = []; for (const entry of Object.values(pkg.exports)) { - if (typeof entry !== "string") { - expect(entry.types).toBe(entry.default.replace(/\.js$/, ".d.ts")); - declarations.push(entry.types); + if (typeof entry === "string") { + continue; } + expect(entry.types).toBe(entry.default.replace(/\.js$/, ".d.ts")); + declarations.push(entry.types); } - // npm packs `files` (and the manifest, always), so a target outside them installs as a dangling path. An entry names a file or a directory, - // with or without a trailing slash. + // npm packs `files` (and the manifest, always), so a target outside them installs as a dangling path. for (const target of [...targets, ...declarations].filter((t) => t !== "./package.json")) { - const shipped = pkg.files.some((entry) => { - const path = `./${entry.replace(/\/$/, "")}`; - return target === path || target.startsWith(`${path}/`); - }); - expect(shipped, target).toBe(true); + expect(isPacked(target), target).toBe(true); } }); diff --git a/test/config/package-pins.test.ts b/test/config/package-pins.test.ts index 28fe55338..b12962448 100644 --- a/test/config/package-pins.test.ts +++ b/test/config/package-pins.test.ts @@ -1,9 +1,12 @@ +/** + * A from-scratch `bun install` (nightly's float-canary) honors every range in package.json, so a range specifier floats there untested against + * main (zod ^4.4.3 floating to 4.5.4 broke every open Dependabot PR when a lockfile-regenerating workflow still existed). Exact pins leave only + * transitive ranges to float. `bun add` writes "^" by default. + */ + import { describe, expect, test } from "bun:test"; import pkg from "../../package.json"; -// A from-scratch `bun install` (nightly's float-canary) honors every range in package.json, so a range specifier floats there untested against -// main (zod ^4.4.3 floating to 4.5.4 broke every open Dependabot PR when a lockfile-regenerating workflow still existed). Exact pins leave only -// transitive ranges to float. `bun add` writes "^" by default. const EXACT_VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/; const DEPENDENCY_FIELDS = [ diff --git a/test/count-nouns.test.ts b/test/count-nouns.test.ts index 72d1ee854..8dc3833c6 100644 --- a/test/count-nouns.test.ts +++ b/test/count-nouns.test.ts @@ -10,26 +10,22 @@ import { parseSync, Visitor } from "oxc-parser"; import { ROOT } from "./root.js"; import { withTempDir } from "./temp-dir.js"; +/** The directories whose authored strings the rule covers. */ const SCANNED_DIRS = ["src", ".github/scripts"]; /** GitHub's own prose, rendered verbatim from its descriptor; the rule is for the strings this repository authors. */ const UNSCANNED_DIR = "src/generated/"; -/** - * A word then "(s)", read in the source text. A lambda parameter `(s) =>` has no word before its paren, and the `n` of a `\n` escape is - * not a word either, so neither matches. - */ +/** A word then "(s)" in the source text; a lambda's `(s) =>` has no word before its paren and the `n` of a `\n` escape is no word either, so neither matches. */ const COUNT_PARENTHETICAL = /(? { REMAINDERS.some((remainder) => remainder.test(sentence)); const quoted = (text: string): string[] => [...text.matchAll(/"((?:\\.|[^"\\])*)"/g)].map((m) => JSON.parse(`"${m[1]}"`) as string); + // The machine fix each sentence opening names, first match wins: "it holds a run of stars" sits before "it holds ". + const FIXES: ReadonlyArray string]> = [ + ["it is wrapped in whitespace", (view) => view.trim()], + ["it holds a run of stars", (view) => view.replace(/\*{2,}/g, "*")], + [ + "it holds ", + (view, sentence) => { + const chars = quoted(sentence.slice(0, sentence.indexOf(", which"))); + return view + .split("") + .filter((char) => !chars.includes(char)) + .join(""); + }, + ], + [ + "its ", + (view, sentence) => { + const where = [...sentence.matchAll(/ at (\d+)/g)].map((m) => Number(m[1])); + return view + .split("") + .filter((_, i) => !where.includes(i)) + .join(""); + }, + ], + ['it starts with "./"', (view) => view.replace(/^(!?)(?:\.\/)+/, "$1")], + [ + "the class ", + (view, sentence) => { + const parts = quoted(sentence); + const cls = parts[0] ?? ""; + return view.replace(cls, sentence.endsWith("so delete it") ? "" : (parts.at(-1) ?? "")); + }, + ], + ]; const applyFix = (view: string, sentence: string): string => { - if (sentence.startsWith("it is wrapped in whitespace")) { - return view.trim(); - } - if (sentence.startsWith("it holds a run of stars")) { - return view.replace(/\*{2,}/g, "*"); - } - if (sentence.startsWith("it holds ")) { - const chars = quoted(sentence.slice(0, sentence.indexOf(", which"))); - return view - .split("") - .filter((char) => !chars.includes(char)) - .join(""); - } - if (sentence.startsWith("its ")) { - const where = [...sentence.matchAll(/ at (\d+)/g)].map((m) => Number(m[1])); - return view - .split("") - .filter((_, i) => !where.includes(i)) - .join(""); - } - if (sentence.startsWith('it starts with "./"')) { - return view.replace(/^(!?)(?:\.\/)+/, "$1"); - } - if (sentence.startsWith("the class ")) { - const parts = quoted(sentence); - const cls = parts[0] ?? ""; - return view.replace(cls, sentence.endsWith("so delete it") ? "" : (parts.at(-1) ?? "")); + const fix = FIXES.find(([opening]) => sentence.startsWith(opening)); + if (fix !== undefined) { + return fix[1](view, sentence); } if (isRemainder(sentence)) { return view; diff --git a/test/docs/claims.ts b/test/docs/claims.ts index f70f00c09..c728197f7 100644 --- a/test/docs/claims.ts +++ b/test/docs/claims.ts @@ -5,13 +5,13 @@ import type { SectionKey } from "../../src/schema.js"; +/** The word stems that claim deletion, as a regex alternation. */ const DELETE_STEMS = String.raw`delet\w*|remov\w*|drop\w*|clear\w*`; + +/** The word stems that claim keeping, as a regex alternation. */ const KEEP_STEMS = String.raw`kept|keep\w*|retain\w*|preserv\w*`; -/** - * Prose display names of the delete-by-default sections; a new one fails the enumeration pins through deleteEnumerationProblems until it is added - * here. - */ +/** Prose display names of the delete-by-default sections; a new one fails the enumeration pins through deleteEnumerationProblems until it is added here. */ const DELETE_DEFAULT_DISPLAY_NAMES: Partial> = { labels: "labels", autolinks: "autolinks", @@ -90,6 +90,30 @@ function defaultClaimWindows(text: string): string[] { return [...text.matchAll(/([^.;:!?]{0,80})by default/g)].map((match) => match[1] ?? ""); } +/** What one stem match in a window claims against `policy`: the policy's own claim, or the problem it is. */ +function readClaim( + window: string, + match: RegExpMatchArray, + policy: UndeclaredClaim, +): { own: true } | { problem: string } { + const stem = match[1] ?? ""; + const family: UndeclaredClaim = CLAIM_FAMILY.delete.test(stem) ? "delete" : "keep"; + const negation = stemNegation(window.slice(0, match.index)); + if ("doubleNegation" in negation) { + return { + problem: `a double negation governs "${negation.doubleNegation} ${stem}" in "...${window.trim()} by default"; reword it - double negatives are not resolved`, + }; + } + const flipped: UndeclaredClaim = family === "delete" ? "keep" : "delete"; + const effective = negation.negated ? flipped : family; + if (effective === policy) { + return { own: true }; + } + return { + problem: `"...${window.trim()} by default" claims the opposite of the "${policy}" default${negation.negated ? " (negated claim)" : ""}`, + }; +} + /** * A mixed-family negated clause ("not deleted but kept by default") is REJECTED as ambiguous rather than parsed: the negator lands in both stems' * spans and the flipped reading contradicts the plain one, so the fix is rewording. @@ -100,30 +124,11 @@ export function defaultClaimProblems(text: string, policy: UndeclaredClaim): str return [`no "by default" clause states the "${policy}" default`]; } const stemRe = new RegExp(String.raw`\b(${CLAIM_STEMS})\b`, "gi"); - const problems: string[] = []; - let ownClaims = 0; - for (const window of windows) { - for (const match of window.matchAll(stemRe)) { - const stem = match[1] ?? ""; - const family: UndeclaredClaim = CLAIM_FAMILY.delete.test(stem) ? "delete" : "keep"; - const negation = stemNegation(window.slice(0, match.index)); - if ("doubleNegation" in negation) { - problems.push( - `a double negation governs "${negation.doubleNegation} ${stem}" in "...${window.trim()} by default"; reword it - double negatives are not resolved`, - ); - continue; - } - const flipped: UndeclaredClaim = family === "delete" ? "keep" : "delete"; - const effective = negation.negated ? flipped : family; - if (effective === policy) { - ownClaims++; - } else { - problems.push( - `"...${window.trim()} by default" claims the opposite of the "${policy}" default${negation.negated ? " (negated claim)" : ""}`, - ); - } - } - } + const readings = windows.flatMap((window) => + [...window.matchAll(stemRe)].map((match) => readClaim(window, match, policy)), + ); + const problems = readings.flatMap((reading) => ("problem" in reading ? [reading.problem] : [])); + const ownClaims = readings.length - problems.length; if (ownClaims === 0 && problems.length === 0) { problems.push(`no "by default" clause claims the "${policy}" default`); } diff --git a/test/docs/coverage.test.ts b/test/docs/coverage.test.ts index eff3ff5d7..6bd04f40f 100644 --- a/test/docs/coverage.test.ts +++ b/test/docs/coverage.test.ts @@ -90,32 +90,33 @@ describe("coverage gaps anti-test", () => { } // Each REST {param} becomes a placeholder segment, since matchesTemplate takes a CONCRETE path. + function collisionsOf( + gap: string, + routes: ReadonlyArray, + ops: ReadonlyArray, + ): string[] { + const { method, target } = parseGap(gap); + if (method === "GraphQL") { + return ops + .filter((op) => rootFields(op).includes(target)) + .map((op) => `${gap} -> ${op.name}`); + } + const concrete = target.replace(/\{[^}]+\}/g, "_param_"); + return routes + .filter( + (route) => + endpointMethod(route) === method && matchesTemplate(endpointPath(route), concrete), + ) + .map((route) => `${gap} -> ${route}`); + } + + /** Every gap endpoint that a registered endpoint or operation answers, as `gap -> match` lines in row order. */ function gapCollisions( rows: readonly GapRow[], routes: ReadonlyArray, ops: ReadonlyArray, ): string[] { - const collisions: string[] = []; - for (const row of rows) { - for (const gap of row.endpoints) { - const { method, target } = parseGap(gap); - if (method === "GraphQL") { - for (const op of ops) { - if (rootFields(op).includes(target)) { - collisions.push(`${gap} -> ${op.name}`); - } - } - continue; - } - const concrete = target.replace(/\{[^}]+\}/g, "_param_"); - for (const route of routes) { - if (endpointMethod(route) === method && matchesTemplate(endpointPath(route), concrete)) { - collisions.push(`${gap} -> ${route}`); - } - } - } - } - return collisions; + return rows.flatMap((row) => row.endpoints.flatMap((gap) => collisionsOf(gap, routes, ops))); } test("no gap endpoint matches a registered endpoint or GraphQL operation", () => { diff --git a/test/docs/diagrams.test.ts b/test/docs/diagrams.test.ts index 86b17fb5a..05e76094d 100644 --- a/test/docs/diagrams.test.ts +++ b/test/docs/diagrams.test.ts @@ -13,6 +13,7 @@ import { join } from "node:path"; import { ROOT } from "../root.js"; import { fencedBlocks } from "./markdown.js"; +/** The tree whose every page is under test. */ const DOCS = join(ROOT, "docs"); /** The URL prefix docs/ pages use to reach a repository file (relative links may not leave docs/). */ @@ -20,6 +21,8 @@ export const REPO_FILE_URL = "https://github.com/Vivswan/github-settings-as-code /** A repo-relative path as a label token spells it: a known top-level directory, then segments. */ const PATH_TOKEN = /^(?:src|test|docs|lib|\.github)\/[\w./-]*$/; + +/** An exported symbol as a label token spells it: an identifier, with `()` allowed after a function's. */ const SYMBOL_TOKEN = /^[A-Za-z_]\w*(?:\(\))?$/; /** Whether `file` exports `name` at its top level, under any declaration keyword. */ @@ -68,6 +71,46 @@ function nodeLabels(mermaid: string): { labels: string[]; problems: string[] } { return { labels, problems }; } +/** A segment's code reading: the path it opens with, or the one an earlier segment bound, and the symbols after it; null for a caption. */ +function codeSegment( + tokens: readonly string[], + bound: string | undefined, +): { path: string; symbols: readonly string[] } | null { + const [head] = tokens; + if (head !== undefined && PATH_TOKEN.test(head)) { + return { path: head, symbols: tokens.slice(1) }; + } + if (bound === undefined) { + return null; + } + return { path: bound, symbols: tokens }; +} + +/** The problems of the symbols a segment lists after `path`, whose file exists at `file`. */ +function symbolProblems( + label: string, + path: string, + file: string, + symbols: readonly string[], +): string[] { + const problems: string[] = []; + for (const symbol of symbols) { + if (!SYMBOL_TOKEN.test(symbol)) { + problems.push( + `"${label}": "${symbol}" is neither a symbol nor a path; after a path, a label lists only exported symbols`, + ); + continue; + } + const name = symbol.replace(/\(\)$/, ""); + if (path.endsWith("/")) { + problems.push(`"${label}": ${path} is a directory, so it exports no ${name}`); + } else if (!exportsSymbol(file, name)) { + problems.push(`"${label}": ${path} exports no ${name}`); + } + } + return problems; +} + export function labelProblems(label: string, root: string): string[] { const problems: string[] = []; const missing = new Set(); @@ -77,16 +120,8 @@ export function labelProblems(label: string, root: string): string[] { .trim() .split(/[\s,]+/) .filter((token) => token !== ""); - const [head] = tokens; - let path: string; - let symbols: string[]; - if (head !== undefined && PATH_TOKEN.test(head)) { - path = head; - symbols = tokens.slice(1); - } else if (bound !== undefined) { - path = bound; - symbols = tokens; - } else { + const code = codeSegment(tokens, bound); + if (code === null) { if (segment.includes("/") || segment.includes("()")) { problems.push( `"${label}": "${segment.trim()}" looks like code but reads as a caption; a code segment starts with a src/, test/, docs/, lib/, or .github/ path`, @@ -94,29 +129,16 @@ export function labelProblems(label: string, root: string): string[] { } continue; } - bound = path; - const file = join(root, path); + bound = code.path; + const file = join(root, code.path); if (!existsSync(file)) { - if (!missing.has(path)) { - missing.add(path); - problems.push(`"${label}": ${path} does not exist`); + if (!missing.has(code.path)) { + missing.add(code.path); + problems.push(`"${label}": ${code.path} does not exist`); } continue; } - for (const symbol of symbols) { - if (!SYMBOL_TOKEN.test(symbol)) { - problems.push( - `"${label}": "${symbol}" is neither a symbol nor a path; after a path, a label lists only exported symbols`, - ); - continue; - } - const name = symbol.replace(/\(\)$/, ""); - if (path.endsWith("/")) { - problems.push(`"${label}": ${path} is a directory, so it exports no ${name}`); - } else if (!exportsSymbol(file, name)) { - problems.push(`"${label}": ${path} exports no ${name}`); - } - } + problems.push(...symbolProblems(label, code.path, file, code.symbols)); } return problems; } @@ -137,6 +159,34 @@ function insideGeneratedRegion(lines: readonly string[], line: number): boolean return open; } +/** The problems of the "Demonstrated by:" line the concept diagram opening at `index` needs before the next heading. */ +function demonstrationProblems(lines: readonly string[], index: number, root: string): string[] { + const fenceEnd = lines.indexOf("```", index + 1); + const close = fenceEnd === -1 ? lines.length : fenceEnd; + const after = lines.slice(close + 1); + const nextHeading = after.findIndex((text) => /^#{1,6}\s/.test(text)); + const section = nextHeading === -1 ? after : after.slice(0, nextHeading); + const demo = section.find((text) => text.startsWith("Demonstrated by:")); + if (demo === undefined) { + return [ + `line ${index + 1}: the diagram has no "Demonstrated by:" line before the next heading`, + ]; + } + const links = [...demo.matchAll(/\]\(([^)]+)\)/g)].map((m) => m[1] ?? ""); + if (links.length === 0) { + return [`line ${index + 1}: the "Demonstrated by:" line links nothing`]; + } + const problems: string[] = []; + for (const link of links) { + if (!link.startsWith(REPO_FILE_URL)) { + problems.push(`line ${index + 1}: "${link}" is not a ${REPO_FILE_URL} link`); + } else if (!existsSync(join(root, link.slice(REPO_FILE_URL.length)))) { + problems.push(`line ${index + 1}: "${link}" names a file that does not exist`); + } + } + return problems; +} + export function diagramProblems(markdown: string, root: string): string[] { const problems: string[] = []; for (const block of fencedBlocks(markdown, "mermaid")) { @@ -151,31 +201,7 @@ export function diagramProblems(markdown: string, root: string): string[] { if (line !== "```mermaid" || insideGeneratedRegion(lines, index)) { continue; } - let close = lines.indexOf("```", index + 1); - if (close === -1) { - close = lines.length; - } - const after = lines.slice(close + 1); - const nextHeading = after.findIndex((text) => /^#{1,6}\s/.test(text)); - const section = nextHeading === -1 ? after : after.slice(0, nextHeading); - const demo = section.find((text) => text.startsWith("Demonstrated by:")); - if (demo === undefined) { - problems.push( - `line ${index + 1}: the diagram has no "Demonstrated by:" line before the next heading`, - ); - continue; - } - const links = [...demo.matchAll(/\]\(([^)]+)\)/g)].map((m) => m[1] ?? ""); - if (links.length === 0) { - problems.push(`line ${index + 1}: the "Demonstrated by:" line links nothing`); - } - for (const link of links) { - if (!link.startsWith(REPO_FILE_URL)) { - problems.push(`line ${index + 1}: "${link}" is not a ${REPO_FILE_URL} link`); - } else if (!existsSync(join(root, link.slice(REPO_FILE_URL.length)))) { - problems.push(`line ${index + 1}: "${link}" names a file that does not exist`); - } - } + problems.push(...demonstrationProblems(lines, index, root)); } return problems; } diff --git a/test/docs/guides.test.ts b/test/docs/guides.test.ts index 4b127f4fa..627ca4be9 100644 --- a/test/docs/guides.test.ts +++ b/test/docs/guides.test.ts @@ -1,3 +1,12 @@ +/** + * The guides' fence vocabulary is closed; plain `yaml` is reserved for workflow files so a settings example cannot dodge validation by dropping its tag. + * yaml settings -> a complete settings document + * yaml layer -> one layer of a merge, valid once its directives and removal entries are stripped + * yaml -> a workflow file + * mermaid -> a diagram, pinned to real code in diagrams.test.ts + * text, bash -> never yaml + */ + import { describe, expect, test } from "bun:test"; import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join, posix } from "node:path"; @@ -29,14 +38,7 @@ function guidePages(): string[] { .sort(); } -/** - * The closed fence vocabulary; plain `yaml` is reserved for workflow files so a settings example cannot dodge validation by dropping its tag. - * yaml settings -> a complete settings document - * yaml layer -> one layer of a merge, valid once its directives and removal entries are stripped - * yaml -> a workflow file - * mermaid -> a diagram, pinned to real code in diagrams.test.ts - * text, bash -> never yaml - */ +/** The closed fence vocabulary the header lists. */ const ALLOWED_FENCE_INFO = new Set([ "yaml settings", "yaml layer", @@ -47,6 +49,52 @@ const ALLOWED_FENCE_INFO = new Set([ "bash", ]); +/** The keys a workflow file carries at its top level. */ +const WORKFLOW_TOP_KEYS = new Set([ + "name", + "run-name", + "on", + "permissions", + "env", + "defaults", + "concurrency", + "jobs", +]); + +/** The document a yaml block parses to, or null when it does not parse. */ +function parsedYaml(block: string): unknown { + try { + return parseYaml(block); + } catch { + return null; + } +} + +/** A mapping whose `jobs` is a non-null object (a list passes too) and whose keys are all workflow keys, so a settings document smuggled in with a decorative jobs key still fails on its section keys. */ +function isWorkflowFile(doc: unknown): boolean { + const record = + typeof doc === "object" && doc !== null && !Array.isArray(doc) + ? (doc as Record) + : null; + return ( + record !== null && + typeof record.jobs === "object" && + record.jobs !== null && + Object.keys(record).every((key) => WORKFLOW_TOP_KEYS.has(key)) + ); +} + +/** A mapping carrying a section key: a settings example by shape. */ +function isSettingsShaped(doc: unknown): boolean { + const known = new Set(SECTION_KEYS); + return ( + typeof doc === "object" && + doc !== null && + !Array.isArray(doc) && + Object.keys(doc).some((key) => known.has(key)) + ); +} + /** * Fence-policy violations for one document. fencedBlocks (markdown.ts) needs the exact info string on a backtick fence preceded only by indent, * so a wrong tag, a tilde fence, or a blockquoted fence hides an example from it; column zero and exactly three backticks are the guides' own @@ -160,10 +208,10 @@ async function linkDestinations( return destinations; } -/** - * docs/ served under a path no real link names, so a destination that climbs out (even back into a literal "docs/") lands outside the prefix. - */ +/** An origin no real link names, under which docs/ is served for the resolution below. */ const SITE_ORIGIN = "https://docs-site.invalid"; + +/** The path docs/ is served under, which no real link names either, so a destination that climbs out (even back into a literal "docs/") lands outside it. */ const SITE_ROOT = "/docs-root-7c1e/"; /** @@ -229,10 +277,13 @@ function headingSlugs(markdown: string, source: string): Set { interface RefusalRow { /** Which gate the table documents, from its header's second cell. */ readonly gate: "validation" | "fold"; + /** The first cell, the row's name in a failure. */ readonly has: string; + /** The refused layer(s), flow YAML, one document each. */ readonly inputs: string[]; + /** The message the second cell quotes, whole. */ readonly quoted: string; } @@ -254,6 +305,56 @@ function isDelimiterRow(line: string | undefined): boolean { return line !== undefined && /^\s*\|?(\s*:?-+:?\s*\|)*\s*:?-+:?\s*\|?\s*$/.test(line); } +/** The gate a refusal table's second header cell names. */ +const REFUSAL_GATES: Record = { + "Caught by": "validation", + "The fold says": "fold", +}; + +/** The gate the table a piped header line opens documents, given the line after the header. */ +function tableGate(line: string, next: string | undefined, source: string): RefusalRow["gate"] { + // A piped line opens a table only when the delimiter row follows it; + // one that renders as prose is an authoring slip, not a row to skip. + if (!isDelimiterRow(next)) { + throw new Error(`${source}: refusal table row outside a known table: ${line.trim()}`); + } + const header = tableCells(line); + if (header.length !== 2) { + throw new Error(`${source}: refusal table header is not two cells: ${line.trim()}`); + } + const [has = "", says = ""] = header; + const gate = has === "The layer has" ? (REFUSAL_GATES[says] ?? null) : null; + if (gate === null) { + throw new Error(`${source}: unknown refusal table header "${line.trim()}"`); + } + return gate; +} + +/** One row of a table under `gate`; a row that cannot be reproduced fails by name. */ +function refusalRow(line: string, gate: RefusalRow["gate"], source: string): RefusalRow { + const cells = tableCells(line); + if (cells.length !== 2) { + throw new Error(`${source}: refusal table row is not two cells: ${line.trim()}`); + } + const [has = "", says = ""] = cells; + const trigger = has.match(/\((`[^`]+`(?: or `[^`]+`)*)\)$/); + if (!trigger) { + throw new Error( + `${source}: refusal row "${has}" does not end with its layer in parentheses as a code span (or code spans joined by "or"), so it cannot be reproduced`, + ); + } + const quoted = says.match(/^(?:Validation: )?`([^`]+)`$/); + if (!quoted) { + throw new Error(`${source}: refusal row "${has}" does not quote one message in a code span`); + } + return { + gate, + has, + inputs: [...(trigger[1] ?? "").matchAll(/`([^`]+)`/g)].map((match) => match[1] ?? ""), + quoted: quoted[1] ?? "", + }; +} + /** * The layering guide's refusal tables are the single source; a row missing its input or its message fails here by name. * Rows are recognized more permissively than GFM renders them, so every divergence adds a pin and never loses one. @@ -263,10 +364,6 @@ function isDelimiterRow(line: string | undefined): boolean { * header + delimiter row -> opens a table (the delimiter's cell count is not checked); a blank line closes it */ function refusalRows(section: readonly string[], source: string): RefusalRow[] { - const GATES: Record = { - "Caught by": "validation", - "The fold says": "fold", - }; const rows: RefusalRow[] = []; let gate: RefusalRow["gate"] | null = null; for (let index = 0; index < section.length; index++) { @@ -275,48 +372,15 @@ function refusalRows(section: readonly string[], source: string): RefusalRow[] { gate = null; continue; } - if (gate === null) { - if (!line.includes("|")) { - continue; // prose between the tables - } - // A piped line opens a table only when the delimiter row follows it; - // one that renders as prose is an authoring slip, not a row to skip. - if (!isDelimiterRow(section[index + 1])) { - throw new Error(`${source}: refusal table row outside a known table: ${line.trim()}`); - } - const header = tableCells(line); - if (header.length !== 2) { - throw new Error(`${source}: refusal table header is not two cells: ${line.trim()}`); - } - const [has = "", says = ""] = header; - gate = has === "The layer has" ? (GATES[says] ?? null) : null; - if (gate === null) { - throw new Error(`${source}: unknown refusal table header "${line.trim()}"`); - } - index++; // the delimiter row, the only line a table skips + if (gate !== null) { + rows.push(refusalRow(line, gate, source)); continue; } - const cells = tableCells(line); - if (cells.length !== 2) { - throw new Error(`${source}: refusal table row is not two cells: ${line.trim()}`); + if (!line.includes("|")) { + continue; // prose between the tables } - const [has = "", says = ""] = cells; - const trigger = has.match(/\((`[^`]+`(?: or `[^`]+`)*)\)$/); - if (!trigger) { - throw new Error( - `${source}: refusal row "${has}" does not end with its layer in parentheses as a code span (or code spans joined by "or"), so it cannot be reproduced`, - ); - } - const quoted = says.match(/^(?:Validation: )?`([^`]+)`$/); - if (!quoted) { - throw new Error(`${source}: refusal row "${has}" does not quote one message in a code span`); - } - rows.push({ - gate, - has, - inputs: [...(trigger[1] ?? "").matchAll(/`([^`]+)`/g)].map((match) => match[1] ?? ""), - quoted: quoted[1] ?? "", - }); + gate = tableGate(line, section[index + 1], source); + index++; // the delimiter row, the only line a table skips } return rows; } @@ -362,21 +426,48 @@ describe("docs/ guide pages", () => { expect(orphans, "no README, guide, or root page links these docs/ pages").toEqual([]); }); + /** Every link target in `file` outside its fences, the external ones (a scheme, mailto:) skipped. */ + function linkTargets(file: { label: string; path: string }): string[] { + const markdown = linesOutsideFences(readFileSync(file.path, "utf8"), file.label).join("\n"); + return [...markdown.matchAll(/\]\(([^)]+)\)/g)] + .map((match) => match[1] ?? "") + .filter((target) => !/^[a-z]+:\/\//.test(target) && !target.startsWith("mailto:")); + } + + /** Why a target's #fragment matches no heading of the markdown file it names; null for a target with no fragment to check, or a good one. */ + function fragmentProblem( + file: { label: string; path: string }, + target: string, + slugsOf: (path: string) => Set, + ): string | null { + const hash = target.indexOf("#"); + if (hash === -1) { + return null; + } + const fragment = target.slice(hash + 1); + const path = target.slice(0, hash); + const resolved = path === "" ? file.path : join(file.path, "..", path); + if (!resolved.endsWith(".md")) { + return null; // only markdown targets have slugified headings + } + if (!existsSync(resolved)) { + return `${file.label}: (${target}) target file does not exist`; + } + if (!slugsOf(resolved).has(fragment)) { + return `${file.label}: (${target}) fragment matches no heading`; + } + return null; + } + test("every relative link in the guides, README, and CONTRIBUTING resolves to a real file", () => { const broken: string[] = []; for (const file of linkScanFiles()) { - const markdown = linesOutsideFences(readFileSync(file.path, "utf8"), file.label).join("\n"); - for (const match of markdown.matchAll(/\]\(([^)]+)\)/g)) { - const target = match[1] ?? ""; - if (/^[a-z]+:\/\//.test(target) || target.startsWith("#") || target.startsWith("mailto:")) { - continue; - } + for (const target of linkTargets(file)) { const path = target.split("#")[0] ?? ""; if (path === "") { continue; } - const resolved = join(file.path, "..", path); - if (!existsSync(resolved)) { + if (!existsSync(join(file.path, "..", path))) { broken.push(`${file.label}: (${target})`); } } @@ -405,28 +496,10 @@ describe("docs/ guide pages", () => { }; const broken: string[] = []; for (const file of files) { - const markdown = linesOutsideFences(readFileSync(file.path, "utf8"), file.label).join("\n"); - for (const match of markdown.matchAll(/\]\(([^)]+)\)/g)) { - const target = match[1] ?? ""; - if (/^[a-z]+:\/\//.test(target) || target.startsWith("mailto:")) { - continue; - } - const hash = target.indexOf("#"); - if (hash === -1) { - continue; - } - const fragment = target.slice(hash + 1); - const path = target.slice(0, hash); - const resolved = path === "" ? file.path : join(file.path, "..", path); - if (!resolved.endsWith(".md")) { - continue; // only markdown targets have slugified headings - } - if (!existsSync(resolved)) { - broken.push(`${file.label}: (${target}) target file does not exist`); - continue; - } - if (!slugsOf(resolved).has(fragment)) { - broken.push(`${file.label}: (${target}) fragment matches no heading`); + for (const target of linkTargets(file)) { + const problem = fragmentProblem(file, target, slugsOf); + if (problem !== null) { + broken.push(problem); } } } @@ -547,53 +620,15 @@ describe("docs/ guide pages", () => { test(`docs/${page}: plain yaml blocks are workflow files, everything else is tagged`, () => { // Requiring every plain yaml block to parse as a workflow means a settings example cannot dodge validation by dropping its tag. for (const block of fencedBlocks(markdown, "yaml")) { - let doc: unknown; - try { - doc = parseYaml(block); - } catch { - doc = null; - } - // A settings document smuggled in with a decorative jobs key still fails on its section keys. - const WORKFLOW_TOP_KEYS = new Set([ - "name", - "run-name", - "on", - "permissions", - "env", - "defaults", - "concurrency", - "jobs", - ]); - const record = - typeof doc === "object" && doc !== null && !Array.isArray(doc) - ? (doc as Record) - : null; - const isWorkflow = - record !== null && - typeof record.jobs === "object" && - record.jobs !== null && - Object.keys(record).every((key) => WORKFLOW_TOP_KEYS.has(key)); expect( - isWorkflow, + isWorkflowFile(parsedYaml(block)), `docs/${page} has a plain yaml block that is not a workflow file (starts "${block.split("\n")[0]}"); tag settings examples as \`\`\`yaml settings`, ).toBe(true); } // A text block that parses to a mapping carrying a section key is a settings example hiding from validation. - const known = new Set(SECTION_KEYS); for (const block of fencedBlocks(markdown, "text")) { - let doc: unknown; - try { - doc = parseYaml(block); - } catch { - continue; - } - const settingsShaped = - typeof doc === "object" && - doc !== null && - !Array.isArray(doc) && - Object.keys(doc).some((key) => known.has(key)); expect( - settingsShaped, + isSettingsShaped(parsedYaml(block)), `docs/${page} has a text block shaped like a settings document (starts "${block.split("\n")[0]}"); fence it as \`\`\`yaml settings`, ).toBe(false); } diff --git a/test/docs/library-examples.test.ts b/test/docs/library-examples.test.ts index 6a6bbbe56..37a95265c 100644 --- a/test/docs/library-examples.test.ts +++ b/test/docs/library-examples.test.ts @@ -17,7 +17,10 @@ import { withTempDir } from "../temp-dir.js"; // The tsc spawn per test times out bun's 5s default under parallel machine load. setDefaultTimeout(60_000); +/** The page under test, as the diagnostics label it. */ const PAGE = "docs/reference/library.md"; + +/** The package name the fences import from. */ const PACKAGE = "@vivswan/github-settings-as-code"; /** Where the package's two import paths land when the fences compile against this checkout. */ @@ -95,6 +98,20 @@ export function examplesProgram(fences: readonly Fence[]): { /** `tsc --pretty false` prints `(,): error TS: `, continuation lines indented. */ const DIAGNOSTIC = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/; +/** The diagnostics in tsc's stdout, each rendered by `render` with its indented continuation lines appended. */ +function diagnosticsOf(stdout: string, render: (match: RegExpMatchArray) => string): string[] { + const problems: string[] = []; + for (const raw of stdout.split("\n")) { + const match = raw.match(DIAGNOSTIC); + if (match) { + problems.push(render(match)); + } else if (raw.startsWith(" ") && problems.length > 0) { + problems[problems.length - 1] += `\n${raw}`; + } + } + return problems; +} + /** * Every diagnostic tsc reports for the page's fences, as `