From 07f5776e01618e65c0c8190006d797273812f289 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:41:43 -0400 Subject: [PATCH 01/11] chore(lint): adopt the fleet's biome floor, the repository's layer in biome.local.jsonc The fleet now ships biome.json as a managed lint floor that extends the repo-owned biome.local.jsonc and applies last. This adopts that file ahead of the sync, byte for byte, so the floor gates this repository's own lint and the next sync records the file as its own write with no hold. The repository's layer moves into biome.local.jsonc unchanged: vcs, the excludes, the formatter, the recommended preset with the unused-import and import-restriction rules, the never-throw and rule-gate plugins, and their per-path overrides. Two overrides join it with their reasons beside them: the package entry points exist to re-export, and a test table is data, not a function body. @biomejs/biome follows the fleet's pin, the one biome the gate lints with, so lint, format, and the pre-commit hook judge the tree the same way. The schema test becomes a pin test: it refuses a devDependency the managed biome.json does not name, where it used to ask for a schema migration. The lint fixture copier walks `extends`, so the plugin tests still lint under the plugins, which now live in the local layer. --- biome.json | 129 +++------------- biome.local.jsonc | 146 ++++++++++++++++++ bun.lock | 20 +-- docs/reference/architecture.md | 2 +- lint/never-throw.grit | 2 +- lint/rule-gate.grit | 2 +- package.json | 2 +- src/private-open.ts | 2 +- ...biome-schema.test.ts => biome-pin.test.ts} | 20 +-- test/lint/fixture-lint.ts | 49 +++--- test/lint/never-throw.test.ts | 2 +- test/lint/rule-gate.test.ts | 2 +- 12 files changed, 225 insertions(+), 153 deletions(-) create mode 100644 biome.local.jsonc rename test/config/{biome-schema.test.ts => biome-pin.test.ts} (54%) 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..1157e398d --- /dev/null +++ b/biome.local.jsonc @@ -0,0 +1,146 @@ +// 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" + } + } + } + } + ] +} diff --git a/bun.lock b/bun.lock index 832302c59..6022cb9ef 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ "@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=="], 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..0d31e6d85 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "@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/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/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/lint/fixture-lint.ts b/test/lint/fixture-lint.ts index fe4b613a0..c14a62bda 100644 --- a/test/lint/fixture-lint.ts +++ b/test/lint/fixture-lint.ts @@ -1,6 +1,6 @@ /** - * Lints a fixture tree under the repository's own biome.json and every plugin it names, so a plugin's test pins the - * configuration's wiring (reach, exemptions) along with the pattern. + * Lints a fixture tree under the repository's own biome configuration, every layer biome.json extends and every + * plugin a layer names, so a plugin's test pins the configuration's wiring (reach, exemptions) along with the pattern. * * the fixture tree is the root -> the `overrides` globs resolve against it; `vcs.useIgnoreFile` wants a git repository there * the root sits under a `src/` parent -> a glob matching the absolute path would reach every file src/** excludes @@ -13,38 +13,51 @@ import { ROOT } from "../root.js"; const BIOME = join(ROOT, "node_modules", ".bin", "biome"); -interface BiomeConfiguration { +/** The keys of a biome layer the copy follows: the layers it extends, and the plugins it or its overrides name. */ +interface BiomeLayer { + extends?: string[]; plugins?: string[]; overrides?: { plugins?: string[] }[]; } -const CONFIGURATION = (() => { - const biomeJson = readFileSync(join(ROOT, "biome.json"), "utf8"); - const config = JSON.parse(biomeJson) as BiomeConfiguration; - const plugins = [ - ...(config.plugins ?? []), - ...(config.overrides ?? []).flatMap((override) => override.plugins ?? []), - ]; - const entries: [string, string][] = [ - ["biome.json", biomeJson], - ...plugins.map((path): [string, string] => [path, readFileSync(join(ROOT, path), "utf8")]), - ]; - return Object.fromEntries(entries); -})(); - interface Diagnostic { category: string; message: string; location: { path: string; start: { line: number } }; } +/** + * Every configuration layer from biome.json down its `extends` chain, and every plugin any layer names, keyed by + * repository-relative path. An extended path is relative to the layer naming it; a plugin path is relative to the root. + */ +function configurationFiles(): Record { + const files: Record = {}; + const layers = ["biome.json"]; + for (const path of layers) { + const text = readFileSync(join(ROOT, path), "utf8"); + files[path] = text; + + const layer = Bun.JSONC.parse(text) as BiomeLayer; + layers.push(...(layer.extends ?? []).map((extended) => join(dirname(path), extended))); + const plugins = [ + ...(layer.plugins ?? []), + ...(layer.overrides ?? []).flatMap((override) => override.plugins ?? []), + ]; + for (const plugin of plugins) { + files[plugin] = readFileSync(join(ROOT, plugin), "utf8"); + } + } + + return files; +} + /** * `path:line -> "category: message"`, one entry per line; when a line also carries a built-in rule's diagnostic * (a throw inside finally trips noUnsafeFinally too), the plugin's wins, since the plugin is what these tests pin. */ export function lint(tmp: string, files: Record): Record { const dir = join(tmp, "src", "checkout"); - for (const [path, text] of Object.entries({ ...CONFIGURATION, ...files })) { + for (const [path, text] of Object.entries({ ...configurationFiles(), ...files })) { mkdirSync(dirname(join(dir, path)), { recursive: true }); writeFileSync(join(dir, path), text); } diff --git a/test/lint/never-throw.test.ts b/test/lint/never-throw.test.ts index d07072384..41518df02 100644 --- a/test/lint/never-throw.test.ts +++ b/test/lint/never-throw.test.ts @@ -1,5 +1,5 @@ /** - * The plugin's verdict on every throw class, and its biome.json wiring, pinned by linting a fixture tree with the + * The plugin's verdict on every throw class, and its biome.local.jsonc wiring, pinned by linting a fixture tree with the * repository's own configuration; a pattern that silently stops matching would otherwise let a throw pass unseen. */ diff --git a/test/lint/rule-gate.test.ts b/test/lint/rule-gate.test.ts index 84dc45f0e..c311092c9 100644 --- a/test/lint/rule-gate.test.ts +++ b/test/lint/rule-gate.test.ts @@ -1,5 +1,5 @@ /** - * The rule-gate plugin's verdict on each refinement form, and its biome.json wiring, pinned by linting a fixture tree + * The rule-gate plugin's verdict on each refinement form, and its biome.local.jsonc wiring, pinned by linting a fixture tree * with the repository's own configuration; a snippet that silently stopped matching would let a bare refinement * ship, and zod would skip it once a nested value failed. */ From f66de39b8f1e75b11f76e5741e6e5d07fb047660 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:41:45 -0400 Subject: [PATCH 02/11] fix(deps): bump zod to 4.6.7, past CVE-2023-54404 that the fleet's trivy step fails on zod 4.6.5 carries the unbounded-array validation advisory, and the fleet's standard-checks job fails every pull request on it until a release at or past 4.6.6 is installed. --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 6022cb9ef..c460a7967 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ "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", @@ -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/package.json b/package.json index 0d31e6d85..ef324d83e 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "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", From d5eb2a31f94110176b0d7807c7493011187ca697 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:27:19 -0400 Subject: [PATCH 03/11] refactor(e2e-mock): pass the lint floor and the member-shape check The fleet's TypeScript lint floor and the member-shape check now hold on test/e2e/mock. Behavior is unchanged: the mock's own tests and the section tests that drive it pass, and the e2e job proves the rest after merge. Splits, each pipeline stage now one named function above its caller: - routes.ts: runPipeline is admitRequest (wire checks, prefix strip), then serveCoreRoute (the four core paths, sharing contentsDenial), then the GraphQL leg, then serveSectionRequest (route match, check-mode barrier, resolveRestTarget, restFault, restDenial) and answerSectionRequest (body allowlist, handler, response guard, chaos hook). handleGraphqlRequest is parseGraphqlRequest, resolveGraphqlTarget, graphqlDenial, and graphqlResponse around the same barriers. Both exported signatures are unchanged. - server.ts: handleRequest is a module function over a ServerRun record holding the former closure state, split into parseIncoming, recordResult, writeResult, and isClientTeardown; the existing helpers moved above their callers unchanged. - core-paths.ts: handleIssueReport takes an IssueReportRequest and an IssueReportRun instead of nine parameters, and is serveMarkerLabelCreate and serveIssueRoute over one admitIssueRoute (target, fault hook, grade, in the section order) and the per-method listIssues, createIssue, patchIssue. - state.ts: buildState's clone-or-default ternaries are seededOr, keeping the truthiness test, and the nested labels ternary is seededLabels; applyRuleInputToLiteral's twin dispatch is applyTwinToLiteral and its two toggle cases are toggleNestedRule. Comments: every record field comment is one line directly above its field, members are blank-line separated, and the half-commented records (LoggedRequest, PipelineResult, MockResponse, HandlerContext, MockState, MultiRepoSpec, ServerOptions, MockHandle) are fully commented, while PipelineOptions and the handlerTestContext options are bare with their notes on the record or the function. Commented module constants stand alone with one-line comments; the longer notes moved into the doc comment of the function that acts on them. secrets.ts: the private half of the test keypair sits beside its one reader, because the secret scanner's allow directive must share the literal's line and the member-shape check refuses a trailing comment on a module constant. --- test/e2e/mock/chaos.ts | 6 +- test/e2e/mock/contract.ts | 74 +- test/e2e/mock/core-paths.ts | 276 +++++--- test/e2e/mock/graphql-pipeline.test.ts | 4 +- test/e2e/mock/handler-test-ctx.ts | 6 +- test/e2e/mock/list-fragment.ts | 20 +- test/e2e/mock/node-id.ts | 5 +- test/e2e/mock/request-body.ts | 8 +- test/e2e/mock/routes.ts | 927 +++++++++++++++---------- test/e2e/mock/secrets.ts | 7 +- test/e2e/mock/server.ts | 429 +++++++----- test/e2e/mock/state.ts | 574 ++++++++------- test/e2e/mock/support.ts | 50 +- 13 files changed, 1383 insertions(+), 1003 deletions(-) diff --git a/test/e2e/mock/chaos.ts b/test/e2e/mock/chaos.ts index 56e3bfc65..bcfc6a5bd 100644 --- a/test/e2e/mock/chaos.ts +++ b/test/e2e/mock/chaos.ts @@ -12,11 +12,7 @@ import type { } from "./contract.js"; import type { Json, MockResponse } from "./support.js"; -/** - * Stable keys so a scenario or the fuzzer faults an inline route like a section endpoint, at the same pipeline point - * (after target resolution, before the permission gate). The issue-report values derive from ISSUE_REPORT_ENDPOINTS so - * they cannot drift from the declared routes. - */ +/** Stable keys so a scenario or the fuzzer faults an inline route like a section endpoint, at the same pipeline point; the issue-report values derive from ISSUE_REPORT_ENDPOINTS so they cannot drift from the declared routes. */ const CORE_FAULT_KEYS = { "core.discoveryList": "GET /user/repos (multi-repo discovery listing)", "core.contentsGet": "GET /repos/{owner}/{repo}/contents/{path} (settings-file fetch)", diff --git a/test/e2e/mock/contract.ts b/test/e2e/mock/contract.ts index ebcab2ce6..862397fa2 100644 --- a/test/e2e/mock/contract.ts +++ b/test/e2e/mock/contract.ts @@ -11,29 +11,34 @@ import type { MockResponse } from "./support.js"; * requests_contain substring-matches "METHOD pathname?query". */ export interface LoggedRequest { + /** The HTTP method as sent; a GraphQL request is always a POST, so `graphql` carries its real kind. */ method: string; + + /** The path with the GHES base prefix stripped, so the runner's rules match one spelling on every server. */ pathname: string; + + /** The raw query string without its "?", empty when the request sent none. */ query: string; + + /** The status answered; 0 until the pipeline sets it, and 0 for a connection drop that answered nothing. */ status: number; + /** The masked resource that denied this request, when a denial fired. */ deniedBy?: string; + + /** The request body as server.ts parsed it: JSON, or the raw text when it did not parse. */ body?: unknown; + /** Attached by server.ts, not the pipeline, for the OpenAPI validator; unset for an off-spec response. */ responseBody?: unknown; - /** - * Set by server.ts when the whole response is deliberately off the OpenAPI contract (a raw media type, a transport - * fault, a chaos body); the validator skips status AND body. - */ + + /** Set by server.ts when the whole response is deliberately off the OpenAPI contract (a raw media type, a transport fault, a chaos body); the validator skips status AND body. */ offSpec?: boolean; - /** - * The handler rejected a body deliberately off the request schema (a passthrough user typo answered with GitHub's real - * 422); the validator skips only the request-body SCHEMA check. Copied from MockResponse.requestOffSpec. - */ + + /** The handler rejected a body deliberately off the request schema (a passthrough user typo answered with GitHub's real 422), so the validator skips only the request-body SCHEMA check; copied from MockResponse.requestOffSpec. */ requestOffSpec?: boolean; - /** - * Set when a /graphql request resolved to a declared operation. Where the HTTP method cannot be exact (a GraphQL read - * is a POST) consumers read this: the runner's write classification, the "GRAPHQL " spelling, the coverage tripwire. - */ + + /** Set when a /graphql request resolved to a declared operation; consumers needing more than the POST read it (the runner's write classification, the "GRAPHQL " spelling, the coverage tripwire). */ graphql?: { operationName: string; kind: "read" | "write" }; } @@ -62,22 +67,17 @@ export interface FaultOption { export interface PipelineRunState { /** Per-endpoint chaos-corruption counts, mutated in place so `times` is honored. */ corruptCounts: Map; + /** Per-endpoint fault fire counts, mutated in place so `times` is honored. */ faultCounts: Map; - /** - * Keys whose read was fatally denied this run, per target so one repo's denial never arms another repo's write. The - * engine aborts a section at its first fatal denied read, so a later write for the same key proves broken sequencing. - */ + + /** Keys whose read was fatally denied this run, per target so one repo's denial never arms another's write; the engine aborts a section at its first fatal denied read, so a later write for the same key proves broken sequencing. */ deniedReadSections: Set; - /** - * Per slug, the repository.get attempts that faulted at the transport barrier. The probe retries up to 1 + MAX_RETRIES - * of them; routes.ts bounds the probe's denial-barrier exemption with this count. - */ + + /** Per slug, the repository.get attempts that faulted at the transport barrier; the probe retries up to 1 + MAX_RETRIES of them, and routes.ts bounds the probe's denial-barrier exemption with this count. */ probeGetFaults: Map; - /** - * Slugs whose repository.get delivered a real response. The probe delivers at most once, so any later repository.get - * is not the probe and arms the barrier normally. - */ + + /** Slugs whose repository.get delivered a real response; the probe delivers at most once, so any later repository.get is not the probe and arms the barrier normally. */ probeGetDelivered: Set; } @@ -99,32 +99,34 @@ export type WorkingState = | { mode: "single"; state: MockState } | { mode: "multi"; multi: MultiMockState }; +/** + * What one request runs against. `checkMode` is the scenario's mode ORed with the server's one-way enterCheckMode() + * override, so the convergence re-run (same server, check-mode child) arms the barrier although the scenario is still + * apply-mode. + */ export interface PipelineOptions extends PipelineRunState { scenario: Scenario; working: WorkingState; basePrefix?: string; corrupt?: CorruptOption; faults?: FaultOption[]; - /** - * The scenario's mode ORed with the server's one-way enterCheckMode() override, so the convergence re-run (same - * server, check-mode child) arms the barrier although the scenario is still apply-mode. - */ checkMode: boolean; } export interface PipelineResult { + /** What the transport writes, unless `wire` takes over. */ response: MockResponse; + + /** The entry server.ts appends to the request log, its status already that of the answer. */ log: LoggedRequest; + + /** A mock-contract violation server.ts collects; a scenario with any fails. */ violation?: string; - /** - * "drop" destroys the socket before any response bytes leave: a true network failure the client's retries absorb - * (times 1) or exhaust into a hard connectivity error. The log still records the attempt with status 0. - */ + + /** "drop" destroys the socket before any response bytes leave, a true network failure the client's retries absorb (times 1) or exhaust into a hard connectivity error; the log still records the attempt with status 0. */ wire?: { kind: "raw"; text: string } | { kind: "drop" }; - /** - * A deliberate off-contract body the validator must skip, else it re-reports a fault the test already asserts. - * Raw-media-type bodies are exempted separately in server.ts, keyed on the request's Accept header, not this flag. - */ + + /** A deliberate off-contract body the validator must skip, else it re-reports a fault the test already asserts; raw-media-type bodies are exempted separately in server.ts, keyed on the request's Accept header. */ offSpecBody?: boolean; } diff --git a/test/e2e/mock/core-paths.ts b/test/e2e/mock/core-paths.ts index b5cb4d829..4a1f43704 100644 --- a/test/e2e/mock/core-paths.ts +++ b/test/e2e/mock/core-paths.ts @@ -336,145 +336,193 @@ export type IssueReportOutcome = deniedBy?: never; }; +/** The request fields the issue-channel routes read; routes.ts passes its parsed request, the base prefix already stripped. */ +export interface IssueReportRequest { + method: string; + pathname: string; + query: Record; + body: unknown; +} + /** - * Null when the path is not an issue-channel route, so the caller falls through to section matching. `takeCoreFault` is - * consulted per route after target resolution and before the permission gate and any state mutation, the section order. - * POST /repos/{o}/{r}/labels (marker) -> ensure-create (Issues: write) - * GET /repos/{o}/{r}/issues -> list (Issues: read) - * POST /repos/{o}/{r}/issues -> create (Issues: write) - * PATCH /repos/{o}/{r}/issues/{number} -> update (Issues: write) + * The run the issue-channel routes serve against. `state` is the single-repo state, read only when `multi` is absent; + * `takeCoreFault` is the pipeline's hook, consulted per route after target resolution and before the permission gate + * and any state mutation, the section order. */ -export function handleIssueReport( - method: string, - pathname: string, - query: Record, - body: unknown, - scenario: Scenario, - multi: MultiMockState | undefined, - state: MockState | undefined, - faults: FaultOption[] | undefined, - takeCoreFault: (key: CoreFaultKey) => PipelineResult | null, -): IssueReportOutcome | null { - // The marker-label ensure-create writes even in check mode, so it is served here, before the check-mode barrier, but - // only for the marker name on a delivery target; any other marker POST falls through to the labels.create section - // route and its barrier. - const labelsMatch = pathname.match(/^\/repos\/([^/]+\/[^/]+)\/labels$/); - if (labelsMatch && method === "POST" && asObject(body).name === MARKER_LABEL) { - const slug = decodeURIComponent(labelsMatch[1] ?? ""); - if (!isReportDeliveryTarget(slug, scenario, multi, faults)) { - return null; - } - const resolved = resolveIssueTarget(method, pathname, slug, multi, state); - if (!("state" in resolved)) { - return resolved; - } - const faulted = takeCoreFault("core.reportLabelCreate"); +export interface IssueReportRun { + scenario: Scenario; + multi: MultiMockState | undefined; + state: MockState | undefined; + faults: FaultOption[] | undefined; + takeCoreFault: (key: CoreFaultKey) => PipelineResult | null; +} + +/** Target, fault hook, then grade, the section order; the state on admission, else the outcome to answer. */ +function admitIssueRoute( + request: IssueReportRequest, + run: IssueReportRun, + slug: string, + coreKey: CoreFaultKey | null, +): { state: MockState } | IssueReportOutcome { + const resolved = resolveIssueTarget(request.method, request.pathname, slug, run.multi, run.state); + if (!("state" in resolved)) { + return resolved; + } + if (coreKey) { + const faulted = run.takeCoreFault(coreKey); if (faulted) { return { faulted }; } - const denied = gradeIssueAccess(slug, "write", scenario, multi); - if (denied) { - return denied; - } - const coreKey = "core.reportLabelCreate" as const; - if (findLabel(resolved.state, MARKER_LABEL)) { - return { response: { status: 422, body: { message: "Validation Failed" } }, coreKey }; - } - const payload = asObject(body); - const label: Json = { - id: resolved.state.nextId++, - name: MARKER_LABEL, - color: payload.color ?? "ededed", - default: false, - description: payload.description ?? null, + } + const level = request.method === "GET" ? "read" : "write"; + const denied = gradeIssueAccess(slug, level, run.scenario, run.multi); + if (denied) { + return denied; + } + return resolved; +} + +/** + * The marker-label ensure-create writes even in check mode, so it is served here, before the check-mode barrier, but + * only for the marker name on a delivery target; any other marker POST falls through to the labels.create section + * route and its barrier. + */ +function serveMarkerLabelCreate( + request: IssueReportRequest, + run: IssueReportRun, +): IssueReportOutcome | null { + const labelsMatch = request.pathname.match(/^\/repos\/([^/]+\/[^/]+)\/labels$/); + if (!labelsMatch || request.method !== "POST" || asObject(request.body).name !== MARKER_LABEL) { + return null; + } + const slug = decodeURIComponent(labelsMatch[1] ?? ""); + if (!isReportDeliveryTarget(slug, run.scenario, run.multi, run.faults)) { + return null; + } + const coreKey = "core.reportLabelCreate" as const; + const admitted = admitIssueRoute(request, run, slug, coreKey); + if (!("state" in admitted)) { + return admitted; + } + if (findLabel(admitted.state, MARKER_LABEL)) { + return { response: { status: 422, body: { message: "Validation Failed" } }, coreKey }; + } + const payload = asObject(request.body); + const label: Json = { + id: admitted.state.nextId++, + name: MARKER_LABEL, + color: payload.color ?? "ededed", + default: false, + description: payload.description ?? null, + }; + admitted.state.labels.push(label); + return { response: { status: 201, body: label }, coreKey }; +} + +/** GitHub's default is newest first; the number stands in for created_at, which the seeded issues do not carry. */ +function listIssues(state: MockState, query: Record): IssueReportOutcome { + if (query.sort !== undefined && query.sort !== "created") { + return coreViolation(`issues list sort "${query.sort}" is not modelled`); + } + const newestFirst = (query.direction ?? "desc") === "desc"; + const matched = state.issues + .filter((issue) => issueMatchesQuery(issue, query)) + .sort((a, b) => (newestFirst ? 1 : -1) * (Number(b.number) - Number(a.number))); + return { response: paged(matched, query), coreKey: "core.issuesList" }; +} + +function createIssue(state: MockState, slug: string, body: unknown): IssueReportOutcome { + const payload = asObject(body); + const number = nextNumber(state.issues); + const labels = Array.isArray(payload.labels) + ? payload.labels.map((l) => labelObject(String(l))) + : []; + const issue: Json = { + number, + title: payload.title ?? "", + body: payload.body ?? "", + state: "open", + labels, + user: { login: TOKEN_USER_LOGIN, id: 1, type: "User" }, + html_url: issueUrl(slug, number), + }; + state.issues.push(issue); + return { response: { status: 201, body: issue }, coreKey: "core.issueCreate" }; +} + +/** The marker-reattach PATCH sends label names; expanded as on create, so the repaired label set is observable. */ +function patchIssue(state: MockState, issueNumber: number, body: unknown): IssueReportOutcome { + const issue = state.issues.find((i) => Number(i.number) === issueNumber); + if (!issue) { + return { + response: { status: 404, body: { message: "Not Found" } }, + coreKey: "core.issuePatch", }; - resolved.state.labels.push(label); - return { response: { status: 201, body: label }, coreKey }; } - const issuesMatch = pathname.match(/^\/repos\/([^/]+\/[^/]+)\/issues(?:\/(\d+))?$/); + const payload = asObject(body); + if (payload.body !== undefined) { + issue.body = payload.body; + } + if (payload.state !== undefined) { + issue.state = payload.state; + } + if (Array.isArray(payload.labels)) { + issue.labels = payload.labels.map((l) => labelObject(String(l))); + } + return { response: ok(issue), coreKey: "core.issuePatch" }; +} + +/** + * The issue routes, null for any other path so the caller falls through to section matching; issue traffic to a + * non-delivery slug (an accidental delivery) falls through the same way, to a loud no-route violation. + * GET /repos/{o}/{r}/issues -> list (Issues: read) + * POST /repos/{o}/{r}/issues -> create (Issues: write) + * PATCH /repos/{o}/{r}/issues/{number} -> update (Issues: write) + */ +function serveIssueRoute( + request: IssueReportRequest, + run: IssueReportRun, +): IssueReportOutcome | null { + const issuesMatch = request.pathname.match(/^\/repos\/([^/]+\/[^/]+)\/issues(?:\/(\d+))?$/); if (!issuesMatch) { return null; } const slug = decodeURIComponent(issuesMatch[1] ?? ""); - // Issue traffic to a non-delivery slug (an accidental delivery) falls through to a loud no-route violation. - if (!isReportDeliveryTarget(slug, scenario, multi, faults)) { + if (!isReportDeliveryTarget(slug, run.scenario, run.multi, run.faults)) { return null; } const issueNumber = issuesMatch[2] ? Number(issuesMatch[2]) : undefined; - const level: "read" | "write" = method === "GET" ? "read" : "write"; - const resolved = resolveIssueTarget(method, pathname, slug, multi, state); - if (!("state" in resolved)) { - return resolved; + const admitted = admitIssueRoute(request, run, slug, issueRouteKey(request.method, issueNumber)); + if (!("state" in admitted)) { + return admitted; } - const coreKey = issueRouteKey(method, issueNumber); - if (coreKey) { - const faulted = takeCoreFault(coreKey); - if (faulted) { - return { faulted }; - } - } - const denied = gradeIssueAccess(slug, level, scenario, multi); - if (denied) { - return denied; - } - const repoState = resolved.state; + + const { method } = request; if (method === "GET" && issueNumber === undefined) { - if (query.sort !== undefined && query.sort !== "created") { - return coreViolation(`issues list sort "${query.sort}" is not modelled`); - } - // GitHub's default is newest first; the number stands in for created_at, which the seeded issues do not carry. - const newestFirst = (query.direction ?? "desc") === "desc"; - const matched = repoState.issues - .filter((issue) => issueMatchesQuery(issue, query)) - .sort((a, b) => (newestFirst ? 1 : -1) * (Number(b.number) - Number(a.number))); - return { response: paged(matched, query), coreKey: "core.issuesList" }; + return listIssues(admitted.state, request.query); } if (method === "POST" && issueNumber === undefined) { - const payload = asObject(body); - const number = nextNumber(repoState.issues); - const labels = Array.isArray(payload.labels) - ? payload.labels.map((l) => labelObject(String(l))) - : []; - const issue: Json = { - number, - title: payload.title ?? "", - body: payload.body ?? "", - state: "open", - labels, - user: { login: TOKEN_USER_LOGIN, id: 1, type: "User" }, - html_url: issueUrl(slug, number), - }; - repoState.issues.push(issue); - return { response: { status: 201, body: issue }, coreKey: "core.issueCreate" }; + return createIssue(admitted.state, slug, request.body); } if (method === "PATCH" && issueNumber !== undefined) { - const issue = repoState.issues.find((i) => Number(i.number) === issueNumber); - if (!issue) { - return { - response: { status: 404, body: { message: "Not Found" } }, - coreKey: "core.issuePatch", - }; - } - const payload = asObject(body); - if (payload.body !== undefined) { - issue.body = payload.body; - } - if (payload.state !== undefined) { - issue.state = payload.state; - } - // The marker-reattach PATCH sends label names; expanded as on create, so the repaired label set is observable. - if (Array.isArray(payload.labels)) { - issue.labels = payload.labels.map((l) => labelObject(String(l))); - } - return { response: ok(issue), coreKey: "core.issuePatch" }; + return patchIssue(admitted.state, issueNumber, request.body); } - return coreViolation(`unexpected ${method} on ${pathname}`); + return coreViolation(`unexpected ${method} on ${request.pathname}`); } /** - * Wire attempts the probe can make, from the client's own MAX_RETRIES. Once a slug's repository.get has faulted this many - * times the probe has given up, so the next repository.get is not a probe retry and the exemption expires. + * Null when the path is not an issue-channel route, so the caller falls through to section matching. + * POST /repos/{o}/{r}/labels (marker) -> ensure-create (Issues: write) + * .../issues -> the issue routes */ +export function handleIssueReport( + request: IssueReportRequest, + run: IssueReportRun, +): IssueReportOutcome | null { + return serveMarkerLabelCreate(request, run) ?? serveIssueRoute(request, run); +} + +/** Wire attempts the probe can make, from the client's own MAX_RETRIES; once a slug's repository.get has faulted this many times the probe has given up, so the next repository.get is not a probe retry and the exemption expires. */ export const PROBE_RETRY_BUDGET = 1 + MAX_RETRIES; /** diff --git a/test/e2e/mock/graphql-pipeline.test.ts b/test/e2e/mock/graphql-pipeline.test.ts index e87904ba1..e3a8da03f 100644 --- a/test/e2e/mock/graphql-pipeline.test.ts +++ b/test/e2e/mock/graphql-pipeline.test.ts @@ -31,8 +31,8 @@ function scenario(overrides: Partial = {}): Scenario { ); } -// Fixture operations, tagged onto the real repository section so the -// permission gate resolves its real requirement (administration). +// Fixture operations, tagged onto the real repository section so the permission gate resolves its real requirement (administration). + const G_READ: TaggedGraphqlOp = { name: "RepoToggles", kind: "read", diff --git a/test/e2e/mock/handler-test-ctx.ts b/test/e2e/mock/handler-test-ctx.ts index c578f6daf..b5e9c5bc8 100644 --- a/test/e2e/mock/handler-test-ctx.ts +++ b/test/e2e/mock/handler-test-ctx.ts @@ -9,6 +9,10 @@ import { requestHeaders } from "./dispatch.js"; import { type MockState, named } from "./state.js"; import type { Handler } from "./support.js"; +/** + * @param opts.headers - request headers in any casing; the context lower-cases them like the pipeline does + * @param opts.grade - the token's grade on the endpoint's permission; write, the mask default, unless a test narrows it + */ export function handlerTestContext( key: SectionEndpointKey, state: MockState, @@ -16,9 +20,7 @@ export function handlerTestContext( body?: unknown; params?: Record; query?: Record; - /** Request headers in any casing; the context lower-cases them like the pipeline does. */ headers?: Record; - /** The token's grade on the endpoint's permission; write, the mask default, unless a test narrows it. */ grade?: MaskGrade; } = {}, ): Parameters[0] { diff --git a/test/e2e/mock/list-fragment.ts b/test/e2e/mock/list-fragment.ts index 1fc2f35ce..575743dc1 100644 --- a/test/e2e/mock/list-fragment.ts +++ b/test/e2e/mock/list-fragment.ts @@ -17,23 +17,17 @@ import { asObject, type Handler, type Json, noContent, ok, paged } from "./suppo export interface ListMockSpec { /** The MockState collection the section's live list is served from. */ readonly collection: (state: MockState) => Json[]; + /** GET-shape fields the server fills on create when the body omits them (a label's color, its null description). */ readonly defaults: Json; - /** - * The server-owned fields of one item (minted on create, re-applied after every update); a body can never set - * them. `siblings` is the repository's collection (the item itself included once stored), for a per-repository - * sequence like a milestone number. - */ + + /** Minted on create and re-applied after every update, so a body can never set them; `siblings` is the repository's collection (the item itself included once stored), for a per-repository sequence like a milestone number. */ readonly owned: (id: number, slug: string, item: Json, siblings: readonly Json[]) => Json; - /** - * What the server holds unique per repository, so a create repeating it answers GitHub's 422: - * the folded identity field, or another key (deploy keys repeat titles but never material). - */ + + /** What the server holds unique per repository, so a create repeating it answers GitHub's 422: the folded identity field, or another key (deploy keys repeat titles but never material). */ readonly unique: "identity" | ((item: Json) => string); - /** - * A create GitHub refuses against the items already stored (an autolink prefix that begins, or is begun by, a - * live one): the 422 message, or undefined when the server accepts it. Runs after the uniqueness rule. - */ + + /** A create GitHub refuses against the items already stored (an autolink prefix that begins, or is begun by, a live one): the 422 message, or undefined when the server accepts it; runs after the uniqueness rule. */ readonly rejects?: (item: Json, siblings: readonly Json[]) => string | undefined; } diff --git a/test/e2e/mock/node-id.ts b/test/e2e/mock/node-id.ts index 28027ad0e..a8819b2c4 100644 --- a/test/e2e/mock/node-id.ts +++ b/test/e2e/mock/node-id.ts @@ -45,10 +45,7 @@ export function decodeNodeId( }; } -/** - * GitHub Apps are not repo-scoped, so their ids carry this sentinel and the pipeline's mutation-target - * resolution skips the "app" family (GLOBAL_NODE_FAMILIES in routes.ts). - */ +/** GitHub Apps are not repo-scoped, so their ids carry this sentinel and the pipeline's mutation-target resolution skips the "app" family (GLOBAL_NODE_FAMILIES in routes.ts). */ const GLOBAL_NODE_SLUG = "-"; export function mintAppNodeId(appSlug: string): string { diff --git a/test/e2e/mock/request-body.ts b/test/e2e/mock/request-body.ts index a956af1af..630471820 100644 --- a/test/e2e/mock/request-body.ts +++ b/test/e2e/mock/request-body.ts @@ -10,13 +10,9 @@ import type { Route } from "../../../src/sections/contract/endpoints.js"; import { sharedValidator } from "../openapi/validate.js"; import type { MockResponse } from "./support.js"; -/** - * Fields GitHub accepts (verified live) that its descriptor omits, so the spec-derived allowlist would - * wrongly drop them. Each entry retires when upstream documents the field; request-body.test.ts pins - * that every entry is still absent from the spec. - * PATCH /repos/{owner}/{repo} has_discussions -> src/sections/repository/index.ts, UndocumentedPatchField - */ +/** Fields GitHub accepts (verified live) but its descriptor omits, so the spec-derived allowlist would wrongly drop them; request-body.test.ts pins each as still absent from the spec, and an entry retires once upstream documents it. */ export const UNDOCUMENTED_BODY_FIELDS: ReadonlyMap = new Map([ + // has_discussions pairs with UndocumentedPatchField in src/sections/repository/schema.ts. ["PATCH /repos/{owner}/{repo}", ["has_discussions"]], ]); diff --git a/test/e2e/mock/routes.ts b/test/e2e/mock/routes.ts index ab7428e29..b45f97afc 100644 --- a/test/e2e/mock/routes.ts +++ b/test/e2e/mock/routes.ts @@ -13,7 +13,11 @@ import type { SectionKey } from "../../../src/schema.js"; import { endpointPath, toleratedStatuses } from "../../../src/sections/contract/endpoints.js"; import { toleratedGraphqlErrors } from "../../../src/sections/contract/graphql.js"; import { denialPosture, endpointPermission } from "../../../src/sections/contract/operations.js"; -import { allGraphqlOps, type TaggedGraphqlOp } from "../../../src/sections/registry.js"; +import { + allGraphqlOps, + type TaggedEndpoint, + type TaggedGraphqlOp, +} from "../../../src/sections/registry.js"; import type { PermissionMask } from "../scenario.js"; import { applyFault, type CoreFaultKey, takeCorruption, takeFault } from "./chaos.js"; import { @@ -56,21 +60,54 @@ import { import { GRAPHQL_HANDLERS, HANDLERS } from "./handlers.js"; import { decodeNodeId, type NodeFamily } from "./node-id.js"; import { acceptedBody } from "./request-body.js"; -import type { MockState } from "./state.js"; +import type { MockState, MultiMockState } from "./state.js"; import { asObject, type GraphqlErrorReply, type GraphqlHandler, + type GraphqlHandlerResult, type Json, type MockResponse, } from "./support.js"; -/** - * Global families carry the GLOBAL_NODE_SLUG sentinel instead of a repository, so target resolution must not read a slug - * off them. Apps are the one case: a force-push allowance can name a GitHub App, whose id comes from GET /apps/{app_slug}. - */ +/** Families carrying the GLOBAL_NODE_SLUG sentinel instead of a repository, so target resolution must not read a slug off them; apps are the one case (a force-push allowance can name a GitHub App, whose id comes from GET /apps/{app_slug}). */ const GLOBAL_NODE_FAMILIES: ReadonlySet = new Set(["app"]); +/** The request as server.ts hands it over: the path and query still raw, the base prefix still on. */ +interface RawRequest { + method: string; + rawPath: string; + query: Record; + rawQuery: string; + headers: Headers; + body: unknown; +} + +/** The request past the wire checks, its path stripped of the base prefix. */ +interface PipelineRequest { + method: string; + pathname: string; + query: Record; + headers: Headers; + body: unknown; +} + +/** One request's run through the stages: the options, the multi-repo state when there is one, and the base log every stage spells its entry from. */ +interface Run { + options: PipelineOptions; + multi: MultiMockState | undefined; + baseLog: LoggedRequest; +} + +/** Where a request lands: the state it reaches, the mask it is graded against, and the slug the denial barrier keys on ("" in single-repo mode). */ +interface Target { + state: MockState; + mask: PermissionMask; + targetSlug: string; +} + +type MatchedEndpoint = NonNullable>; + /** GraphQL mutations nest their target ids under input objects, so a top-level scan would miss them. */ function decodedNodeIds(value: unknown, out: Array<{ slug: string }>): void { if (typeof value === "string") { @@ -115,6 +152,65 @@ function resolveMutationTarget( return { slug: first }; } +/** A read addresses its target by the $owner/$repo variables the declaration contract requires. */ +function resolveReadTarget( + opName: string, + variables: Json, +): { slug: string } | { violation: string } { + const { owner, repo } = variables as { owner?: unknown; repo?: unknown }; + if (typeof owner !== "string" || typeof repo !== "string") { + return { + violation: `GraphQL read ${opName} carries no $owner/$repo variables to resolve its target slug`, + }; + } + return { slug: `${owner}/${repo}` }; +} + +/** + * Target resolution, where the GraphQL leg differs from REST: the path carries no slug. + * read -> the $owner/$repo variables the declaration contract requires + * mutation -> the self-describing node ids the mock minted (state.ts); no decodable id is a violation, never a guess + * A mutation resolves its target in single-repo mode too: a garbage or foreign id must not look green until a multi + * scenario runs it. + */ +function resolveGraphqlTarget( + op: TaggedGraphqlOp, + variables: Json, + options: PipelineOptions, +): Target | { violation: string } { + const { scenario, working } = options; + const target = op.kind === "write" ? resolveMutationTarget(op.name, variables) : null; + if (target !== null && "violation" in target) { + return target; + } + + if (working.mode === "single") { + if (target !== null && target.slug !== working.state.slug) { + return { + violation: `GraphQL mutation ${op.name} carries node ids of "${target.slug}", but this single-repo run serves only "${working.state.slug}"`, + }; + } + return { state: working.state, mask: scenario.token_permissions ?? {}, targetSlug: "" }; + } + + const addressed = target ?? resolveReadTarget(op.name, variables); + if ("violation" in addressed) { + return addressed; + } + const state = working.multi.repos.get(addressed.slug); + if (!state) { + return { violation: `GraphQL ${op.name} names no known target slug ("${addressed.slug}")` }; + } + return { + state, + mask: effectiveMask( + scenario.token_permissions ?? {}, + working.multi.permissions.get(addressed.slug), + ), + targetSlug: addressed.slug, + }; +} + /** * The denial barrier's shared half for both wires. The `arms` predicate stays at each call site on purpose: tolerated * outcomes differ by wire (status subsets vs error types), and the visibility-probe exemption is REST-only. @@ -147,23 +243,12 @@ function denialBarrier( ); } -/** - * The GraphQL leg of the pipeline, in the REST stage order. Target resolution is where it differs: the path carries no slug. - * read -> the $owner/$repo variables the declaration contract requires - * mutation -> the self-describing node ids the mock minted (state.ts); no decodable id is a violation, never a guess - * - * `ops`/`handlers` are injectable for direct testing; production takes the declared tables. - */ -export function handleGraphqlRequest( +/** The wire checks and the dispatch: the declared operation the body names, or the violation to answer. */ +function parseGraphqlRequest( request: { method: string; body: unknown }, - options: PipelineOptions, - baseLog: LoggedRequest, - ops: Readonly> = allGraphqlOps(), - handlers: Record = GRAPHQL_HANDLERS, -): PipelineResult { - const { scenario, working } = options; - const violation = violationFor(baseLog); - + ops: Readonly>, + violation: (message: string) => PipelineResult, +): { key: string; op: TaggedGraphqlOp; variables: Json } | PipelineResult { if (request.method !== "POST") { return violation(`GraphQL requests must be POST, got ${request.method}`); } @@ -177,15 +262,85 @@ export function handleGraphqlRequest( "GraphQL request body must carry query (string), operationName (string), and variables (object)", ); } - const variables = body.variables; - const dispatched = graphqlOpForBody(body, ops); if (!dispatched) { return violation( `no GraphQL operation named "${String(body.operationName)}" is declared by any section`, ); } - const { key, op } = dispatched; + return { key: dispatched.key, op: dispatched.op, variables: body.variables }; +} + +/** + * The permission gate. A GraphQL denial is HTTP 200 on the real wire, with data:null and typed errors[]. A denied read + * whose error type the operation tolerates reads as "resource absent" and must not arm (the toleratedStatuses mirror); + * advisory reads are exempt as on REST. + */ +function graphqlDenial( + op: TaggedGraphqlOp, + requirement: Requirement, + target: Target, + options: PipelineOptions, + log: LoggedRequest, +): PipelineResult | null { + const grading = gradeRequirement(target.mask, requirement); + if (grading.allowed) { + return null; + } + const errors = graphqlDenialErrors(options.scenario.denial_style, op.kind); + const response: MockResponse = { status: 200, body: { data: null, errors } }; + const denied: LoggedRequest = { ...log, status: 200, deniedBy: grading.deniedBy }; + const arms = + op.kind === "read" && + op.advisory !== true && + !toleratedGraphqlErrors(op).includes((errors[0] as GraphqlErrorReply).type); + const barrierViolation = denialBarrier( + options, + denied, + op.section, + target.targetSlug, + op.kind, + arms, + ); + return { response, log: denied, violation: barrierViolation }; +} + +/** Response guard, the status-subset analog: an undeclared error type is a mock design bug, not a scenario outcome. */ +function graphqlResponse( + key: string, + op: TaggedGraphqlOp, + result: GraphqlHandlerResult, +): MockResponse | { violation: string } { + if (result.errors === undefined) { + return { status: 200, body: { data: result.data } }; + } + const declared = toleratedGraphqlErrors(op); + const undeclared = result.errors.filter((entry) => !declared.includes(entry.type)); + if (undeclared.length > 0) { + return { + violation: `GraphQL handler "${key}" answered undeclared error type(s) [${undeclared.map((e) => `${e.type}: "${e.message}"`).join(", ")}]; the operation declares only [${declared.join(", ")}] as tolerated outcomes`, + }; + } + return { status: 200, body: { data: null, errors: result.errors } }; +} + +/** + * The GraphQL leg of the pipeline, in the REST stage order. `ops`/`handlers` are injectable for direct testing; + * production takes the declared tables. + */ +export function handleGraphqlRequest( + request: { method: string; body: unknown }, + options: PipelineOptions, + baseLog: LoggedRequest, + ops: Readonly> = allGraphqlOps(), + handlers: Record = GRAPHQL_HANDLERS, +): PipelineResult { + const violation = violationFor(baseLog); + const parsed = parseGraphqlRequest(request, ops, violation); + if (!("op" in parsed)) { + return parsed; + } + const { key, op, variables } = parsed; const graphqlLog: LoggedRequest = { ...baseLog, graphql: { operationName: op.name, kind: op.kind }, @@ -199,43 +354,11 @@ export function handleGraphqlRequest( return violationFor(graphqlLog)(`GraphQL execution-phase read in check mode (${op.name})`); } - // Target resolution before the fault barrier, so a fault never masks an unknown-target violation. A mutation resolves - // its target in single-repo mode too: a garbage or foreign id must not look green until a multi scenario runs it. - const target = op.kind === "write" ? resolveMutationTarget(op.name, variables) : null; - if (target !== null && "violation" in target) { + // Target resolution before the fault barrier, so a fault never masks an unknown-target violation. + const target = resolveGraphqlTarget(op, variables, options); + if ("violation" in target) { return violation(target.violation); } - let state: MockState; - let mask: PermissionMask = scenario.token_permissions ?? {}; - let targetSlug = ""; - if (working.mode === "single") { - state = working.state; - if (target !== null && target.slug !== state.slug) { - return violation( - `GraphQL mutation ${op.name} carries node ids of "${target.slug}", but this single-repo run serves only "${state.slug}"`, - ); - } - } else { - let slug: string; - if (target !== null) { - slug = target.slug; - } else { - const { owner, repo } = variables as { owner?: unknown; repo?: unknown }; - if (typeof owner !== "string" || typeof repo !== "string") { - return violation( - `GraphQL read ${op.name} carries no $owner/$repo variables to resolve its target slug`, - ); - } - slug = `${owner}/${repo}`; - } - const repoState = working.multi.repos.get(slug); - if (!repoState) { - return violation(`GraphQL ${op.name} names no known target slug ("${slug}")`); - } - state = repoState; - mask = effectiveMask(scenario.token_permissions ?? {}, working.multi.permissions.get(slug)); - targetSlug = slug; - } // Faults address GraphQL operations by the same "section.role" key as REST (assertFaultKeys unions the two). const taken = takeFault(key, options); @@ -243,28 +366,14 @@ export function handleGraphqlRequest( return applyFault(taken.kind, { ...graphqlLog }, taken.fired); } - // A GraphQL denial is HTTP 200 on the real wire, with data:null and typed errors[]. const section = SECTION_BY_KEY.get(op.section); if (!section) { return violation(`BUG: no section module registered for key "${op.section}"`); } - const requirement: Requirement = { - permission: endpointPermission(section, op), - kind: op.kind, - }; - const grading = gradeRequirement(mask, requirement); - if (!grading.allowed) { - const errors = graphqlDenialErrors(scenario.denial_style, op.kind); - const response: MockResponse = { status: 200, body: { data: null, errors } }; - const log: LoggedRequest = { ...graphqlLog, status: 200, deniedBy: grading.deniedBy }; - // A denied read whose error type the operation tolerates reads as "resource absent" and must not arm (the - // toleratedStatuses mirror); advisory reads are exempt as on REST. - const arms = - op.kind === "read" && - op.advisory !== true && - !toleratedGraphqlErrors(op).includes((errors[0] as GraphqlErrorReply).type); - const barrierViolation = denialBarrier(options, log, op.section, targetSlug, op.kind, arms); - return { response, log, violation: barrierViolation }; + const requirement: Requirement = { permission: endpointPermission(section, op), kind: op.kind }; + const denied = graphqlDenial(op, requirement, target, options, graphqlLog); + if (denied) { + return denied; } const handler = handlers[key]; @@ -272,23 +381,10 @@ export function handleGraphqlRequest( // Unreachable after assertGraphqlHandlerCompleteness at construction; loud rather than a silent undefined call. return violation(`no GraphQL handler registered for dispatched operation "${key}"`); } - const result = handler({ state, op, variables }); - - // Response guard, the status-subset analog: an undeclared error type is a mock design bug, not a scenario outcome. - if (result.errors !== undefined) { - const declared = toleratedGraphqlErrors(op); - const undeclared = result.errors.filter((entry) => !declared.includes(entry.type)); - if (undeclared.length > 0) { - return violation( - `GraphQL handler "${key}" answered undeclared error type(s) [${undeclared.map((e) => `${e.type}: "${e.message}"`).join(", ")}]; the operation declares only [${declared.join(", ")}] as tolerated outcomes`, - ); - } + const response = graphqlResponse(key, op, handler({ state: target.state, op, variables })); + if ("violation" in response) { + return violation(response.violation); } - const response: MockResponse = { - status: 200, - body: - result.errors !== undefined ? { data: null, errors: result.errors } : { data: result.data }, - }; const corrupted = takeCorruption(key, options, response, graphqlLog); if (corrupted) { @@ -298,318 +394,326 @@ export function handleGraphqlRequest( return { response, log: { ...graphqlLog, status: 200 } }; } -/** Run the pipeline for one parsed request. Appends nothing to the logs itself: the caller owns the arrays. */ -export function runPipeline( - request: { - method: string; - rawPath: string; - query: Record; - rawQuery: string; - headers: Headers; - body: unknown; - }, - options: PipelineOptions, -): PipelineResult { - const { scenario, working } = options; - const multi = working.mode === "multi" ? working.multi : undefined; - const singleState = working.mode === "single" ? working.state : undefined; - const strippedForLog = - options.basePrefix && request.rawPath.startsWith(options.basePrefix) - ? request.rawPath.slice(options.basePrefix.length) || "/" - : request.rawPath; - const baseLog: LoggedRequest = { - method: request.method, - pathname: strippedForLog, - query: request.rawQuery, - body: request.body, - status: 0, - }; - const violation = violationFor(baseLog); +/** One core-fault hook, so every core handler consumes the same per-run counts as the section fault barrier. */ +function takeCoreFault(coreKey: CoreFaultKey, run: Run): PipelineResult | null { + const taken = takeFault(coreKey, run.options); + return taken ? applyFault(taken.kind, { ...run.baseLog }, taken.fired) : null; +} - if (!request.headers.get("authorization")) { - return violation( - `request ${request.method} ${strippedForLog} is missing the Authorization header`, +/** + * /user/repos is a user-level call, not per-slug gated, so it is served before route matching; its hooks fire only on + * a legit request, never masking a violation. + */ +function serveUserRepos(request: PipelineRequest, run: Run): PipelineResult | null { + const userRepos = handleUserRepos(request.method, request.pathname, request.query, run.multi); + if (!userRepos) { + return null; + } + if (!userRepos.violation) { + const faulted = takeCoreFault("core.discoveryList", run); + if (faulted) { + return faulted; + } + const corrupted = takeCorruption( + "core.discoveryList", + run.options, + userRepos.response, + run.baseLog, ); + if (corrupted) { + return corrupted; + } } - if (!request.headers.get("x-github-api-version")) { + return { + response: userRepos.response, + log: { ...run.baseLog, status: userRepos.response.status }, + violation: userRepos.violation, + }; +} + +/** + * The settings-file fetch and the git ref read are both Contents-gated reads: a Contents-denied slug gets the read + * denial, which drives the action's 404 disambiguation and its "grant Contents: read" advice. + */ +function contentsDenial(slug: string, multi: MultiMockState, run: Run): PipelineResult | null { + const { scenario } = run.options; + const mask = effectiveMask(scenario.token_permissions ?? {}, multi.permissions.get(slug)); + const grading = gradeResource(mask, "contents", "read"); + if (grading.allowed) { + return null; + } + const response = denialResponse(scenario.denial_style, "read"); + return { + response, + log: { ...run.baseLog, status: response.status, deniedBy: grading.deniedBy }, + }; +} + +/** + * The settings-file fetch. Target before fault hook: an unknown slug must never steal a fault injected for the + * legitimate target. A known target's fault fires before the permission gate (a wire failure ignores permissions) and + * after the wire violations. + */ +function serveContents(request: PipelineRequest, run: Run): PipelineResult | null { + const slug = contentsSlug(request.pathname); + if (slug === null) { + return null; + } + const { multi, baseLog } = run; + const violation = violationFor(baseLog); + if (!multi) { + return violation("settings-file fetch (contents) is not implemented in single-repo mode"); + } + if (request.method !== "GET") { + return violation(`contents fetch must be GET, got ${request.method}`); + } + if (request.headers.get("accept") !== RAW_CONTENTS_ACCEPT) { return violation( - `request ${request.method} ${strippedForLog} is missing the x-github-api-version header`, + `contents fetch must send Accept: ${RAW_CONTENTS_ACCEPT}, got "${request.headers.get("accept") ?? ""}"`, ); } - let pathname = request.rawPath; - if (options.basePrefix) { - if (!pathname.startsWith(options.basePrefix)) { - return violation( - `request path "${pathname}" is missing the required base prefix "${options.basePrefix}"`, - ); + const knownTarget = multi.repos.has(slug); + if (knownTarget) { + const contentsFault = takeCoreFault("core.contentsGet", run); + if (contentsFault) { + return contentsFault; } - pathname = pathname.slice(options.basePrefix.length) || "/"; } - - // One core-fault hook, so every core handler consumes the same per-run counts as the section fault barrier. - const takeCoreFault = (coreKey: CoreFaultKey): PipelineResult | null => { - const taken = takeFault(coreKey, options); - return taken ? applyFault(taken.kind, { ...baseLog }, taken.fired) : null; - }; - - // /user/repos is a user-level call, not per-slug gated, so it is served before route matching; its hooks fire only on - // a legit request, never masking a violation. - const userRepos = handleUserRepos(request.method, pathname, request.query, multi); - if (userRepos) { - if (!userRepos.violation) { - const faulted = takeCoreFault("core.discoveryList"); - if (faulted) { - return faulted; - } - const corrupted = takeCorruption("core.discoveryList", options, userRepos.response, baseLog); - if (corrupted) { - return corrupted; - } - } - return { - response: userRepos.response, - log: { ...baseLog, status: userRepos.response.status }, - violation: userRepos.violation, - }; + const denied = contentsDenial(slug, multi, run); + if (denied) { + return denied; } - // The settings-file fetch is gated like a section read: a Contents-denied slug gets the read denial, which drives the - // action's 404 disambiguation and its "grant Contents: read" advice. - const cSlug = contentsSlug(pathname); - if (cSlug !== null) { - if (!multi) { - return violation("settings-file fetch (contents) is not implemented in single-repo mode"); - } - if (request.method !== "GET") { - return violation(`contents fetch must be GET, got ${request.method}`); - } - if (request.headers.get("accept") !== RAW_CONTENTS_ACCEPT) { - return violation( - `contents fetch must send Accept: ${RAW_CONTENTS_ACCEPT}, got "${request.headers.get("accept") ?? ""}"`, - ); - } - // Target before fault hook: an unknown slug must never steal a fault injected for the legitimate target. A known - // target's fault fires before the permission gate (a wire failure ignores permissions) and after the violations above. - const knownTarget = multi.repos.has(cSlug); - if (knownTarget) { - const contentsFault = takeCoreFault("core.contentsGet"); - if (contentsFault) { - return contentsFault; - } + const response = contentsResponse(multi, slug); + if (knownTarget) { + const corrupted = takeCorruption("core.contentsGet", run.options, response, baseLog); + if (corrupted) { + return corrupted; } - const mask = effectiveMask(scenario.token_permissions ?? {}, multi.permissions.get(cSlug)); - const grading = gradeResource(mask, "contents", "read"); - if (!grading.allowed) { - const response = denialResponse(scenario.denial_style, "read"); - return { response, log: { ...baseLog, status: response.status, deniedBy: grading.deniedBy } }; - } - const response = contentsResponse(multi, cSlug); - if (knownTarget) { - const corrupted = takeCorruption("core.contentsGet", options, response, baseLog); - if (corrupted) { - return corrupted; - } - } - // The raw body's validation skip is decided by the request's Accept media type in server.ts, not marked here. - return { response, log: { ...baseLog, status: response.status } }; } + // The raw body's validation skip is decided by the request's Accept media type in server.ts, not marked here. + return { response, log: { ...baseLog, status: response.status } }; +} - // The git ref read proves Contents readable after a contents 404; gated on the same grade, so a Contents-denied slug - // is never mistaken for a fileless one. - const refRequest = gitRefRequest(pathname); - if (refRequest !== null) { - if (!multi) { - return violation("git ref read is not implemented in single-repo mode"); - } - if (request.method !== "GET") { - return violation(`git ref read must be GET, got ${request.method}`); - } - const mask = effectiveMask( - scenario.token_permissions ?? {}, - multi.permissions.get(refRequest.slug), - ); - const grading = gradeResource(mask, "contents", "read"); - if (!grading.allowed) { - const response = denialResponse(scenario.denial_style, "read"); - return { response, log: { ...baseLog, status: response.status, deniedBy: grading.deniedBy } }; - } - const response = gitRefResponse(multi, refRequest.slug, refRequest.ref); - return { response, log: { ...baseLog, status: response.status } }; +/** + * The git ref read proves Contents readable after a contents 404; gated on the same grade, so a Contents-denied slug + * is never mistaken for a fileless one. + */ +function serveGitRef(request: PipelineRequest, run: Run): PipelineResult | null { + const refRequest = gitRefRequest(request.pathname); + if (refRequest === null) { + return null; + } + const { multi, baseLog } = run; + const violation = violationFor(baseLog); + if (!multi) { + return violation("git ref read is not implemented in single-repo mode"); + } + if (request.method !== "GET") { + return violation(`git ref read must be GET, got ${request.method}`); } + const denied = contentsDenial(refRequest.slug, multi, run); + if (denied) { + return denied; + } + const response = gitRefResponse(multi, refRequest.slug, refRequest.ref); + return { response, log: { ...baseLog, status: response.status } }; +} - // Report delivery writes even in check mode, so the issue channel is served before the check-mode barrier below. - const issueReport = handleIssueReport( - request.method, - pathname, - request.query, - request.body, - scenario, +/** Report delivery writes even in check mode, so the issue channel is served before the check-mode barrier. */ +function serveIssueReport(request: PipelineRequest, run: Run): PipelineResult | null { + const { options, multi, baseLog } = run; + const issueReport = handleIssueReport(request, { + scenario: options.scenario, multi, - singleState, - options.faults, - takeCoreFault, - ); - if (issueReport) { - if (issueReport.faulted) { - return issueReport.faulted; - } - if (issueReport.coreKey) { - const corrupted = takeCorruption(issueReport.coreKey, options, issueReport.response, baseLog); - if (corrupted) { - return corrupted; - } + state: options.working.mode === "single" ? options.working.state : undefined, + faults: options.faults, + takeCoreFault: (key) => takeCoreFault(key, run), + }); + if (!issueReport) { + return null; + } + if (issueReport.faulted) { + return issueReport.faulted; + } + if (issueReport.coreKey) { + const corrupted = takeCorruption(issueReport.coreKey, options, issueReport.response, baseLog); + if (corrupted) { + return corrupted; } - return { - response: issueReport.response, - log: { - ...baseLog, - status: issueReport.response.status, - ...(issueReport.deniedBy ? { deniedBy: issueReport.deniedBy } : {}), - }, - ...(issueReport.violation ? { violation: issueReport.violation } : {}), - }; } + return { + response: issueReport.response, + log: { + ...baseLog, + status: issueReport.response.status, + ...(issueReport.deniedBy ? { deniedBy: issueReport.deniedBy } : {}), + }, + ...(issueReport.violation ? { violation: issueReport.violation } : {}), + }; +} - if (pathname === "/graphql") { - return handleGraphqlRequest({ method: request.method, body: request.body }, options, baseLog); +/** The core paths served before section matching, each answering null for a path that is not its own. */ +function serveCoreRoute(request: PipelineRequest, run: Run): PipelineResult | null { + return ( + serveUserRepos(request, run) ?? + serveContents(request, run) ?? + serveGitRef(request, run) ?? + serveIssueReport(request, run) + ); +} + +/** + * Multi-repo routing follows the endpoint's scope; only the bare org probe grades against the global mask alone. + * /repos/... endpoint -> the slug's MockState, its per-slug mask over the global mask + * bare org probe (/orgs/{org}) -> the shared org state, the global mask + * /orgs/{org}/teams/.../repos/{owner}/{repo} -> the addressed slug's state (the tail names it), the hybrid mask + * The hybrid mask matches the oracle's orgMask model: GitHub grants administration per ADDRESSED repo (adding a repo + * to a team needs admin on that repo) while org_members is org-wide. + */ +function resolveRestTarget( + request: PipelineRequest, + endpoint: TaggedEndpoint, + options: PipelineOptions, +): Target | { violation: string } { + const { scenario, working } = options; + const global = scenario.token_permissions ?? {}; + if (working.mode === "single") { + return { state: working.state, mask: global, targetSlug: "" }; } - const matched = matchEndpoint(request.method, pathname); - if (!matched) { - return violation(`no route in routes.ts for ${request.method} ${pathname}`); + const unknownTarget = `multi-repo request ${request.method} ${request.pathname} names no known target slug`; + const repoScoped = endpointPath(endpoint.route).startsWith("/repos/"); + const slug = slugFromPath(request.pathname); + const repoState = slug ? working.multi.repos.get(slug) : undefined; + if (repoScoped) { + if (!slug || !repoState) { + return { violation: unknownTarget }; + } + return { + state: repoState, + mask: effectiveMask(global, working.multi.permissions.get(slug)), + targetSlug: slug, + }; } - const { key, endpoint } = matched; - // Check-mode barrier before the fault barrier: a write in check mode is the one bug this barrier exists to catch, and - // a synthetic fault must not mask it. - if (options.checkMode && request.method !== "GET") { - return violation(`write in check mode: ${request.method} ${pathname} (endpoint "${key}")`); + // A team-repo tail naming an unknown slug is a violation: falling back to orgState would let a buggy write + // silently mutate shared org state. + if (slug && !repoState) { + return { violation: unknownTarget }; } - // Check mode runs no execution thunk, so an execution-phase read arriving here came from a plan() body. - if (options.checkMode && endpoint.phase === "execution") { - return violation(`execution-phase read in check mode: GET ${pathname} (endpoint "${key}")`); - } - - // Multi-repo routing follows the endpoint's scope; only the bare org probe grades against the global mask alone. - // /repos/... endpoint -> the slug's MockState, its per-slug mask over the global mask - // bare org probe (/orgs/{org}) -> the shared org state, the global mask - // /orgs/{org}/teams/.../repos/{owner}/{repo} -> the addressed slug's state (the tail names it), the hybrid mask below - let state: MockState; - let mask: PermissionMask = scenario.token_permissions ?? {}; - let targetSlug = ""; - switch (working.mode) { - case "single": { - state = working.state; - break; - } - case "multi": { - const repoScoped = endpointPath(endpoint.route).startsWith("/repos/"); - const slug = slugFromPath(pathname); - const repoState = slug ? working.multi.repos.get(slug) : undefined; - if (repoScoped) { - if (!slug || !repoState) { - return violation( - `multi-repo request ${request.method} ${pathname} names no known target slug`, - ); - } - state = repoState; - mask = effectiveMask(scenario.token_permissions ?? {}, working.multi.permissions.get(slug)); - targetSlug = slug; - } else { - // A team-repo tail naming an unknown slug is a violation: falling back to orgState would let a buggy write - // silently mutate shared org state. - if (slug && !repoState) { - return violation( - `multi-repo request ${request.method} ${pathname} names no known target slug`, - ); - } - state = repoState ?? working.multi.orgState; - targetSlug = slug ?? ""; - // Hybrid grading, matching the oracle's orgMask model: GitHub grants administration per ADDRESSED repo (adding a - // repo to a team needs admin on that repo) while org_members is org-wide. - const global = scenario.token_permissions ?? {}; - if (slug) { - mask = { - ...effectiveMask(global, working.multi.permissions.get(slug)), - org_members: global.org_members, - }; - } else { - mask = global; - } - } - break; - } + if (!slug) { + return { state: working.multi.orgState, mask: global, targetSlug: "" }; } + return { + state: repoState ?? working.multi.orgState, + mask: { + ...effectiveMask(global, working.multi.permissions.get(slug)), + org_members: global.org_members, + }, + targetSlug: slug, + }; +} - // Fault barrier after target resolution (a fault never masks the unknown-target violation) and before the permission - // gate (a wire failure happens regardless of permissions). +/** + * Fault barrier after target resolution (a fault never masks the unknown-target violation) and before the permission + * gate (a wire failure happens regardless of permissions). A faulted probe attempt spends its retry budget, so the + * exemption cannot outlast the probe's own retries. + */ +function restFault(key: string, targetSlug: string, run: Run): PipelineResult | null { + const { options, baseLog } = run; const taken = takeFault(key, options); - if (taken) { - // A faulted probe attempt spends its retry budget, so the exemption cannot outlast the probe's own retries. - if (key === "repository.get") { - options.probeGetFaults.set(targetSlug, (options.probeGetFaults.get(targetSlug) ?? 0) + 1); - } - return applyFault(taken.kind, { ...baseLog }, taken.fired); + if (!taken) { + return null; } + if (key === "repository.get") { + options.probeGetFaults.set(targetSlug, (options.probeGetFaults.get(targetSlug) ?? 0) + 1); + } + return applyFault(taken.kind, { ...baseLog }, taken.fired); +} - // The visibility probe's denial must not arm the repository-section barrier. Decided against the pre-delivery state, - // then the delivery is recorded so any later repository.get for the slug is not the probe. +/** + * The permission gate. The visibility probe's denial must not arm the repository-section barrier: decided against the + * pre-delivery state, then the delivery is recorded so any later repository.get for the slug is not the probe. A read + * arms the denial barrier only when the engine itself sees it fail. + */ +function restDenial( + matched: MatchedEndpoint, + requirement: Requirement, + target: Target, + run: Run, +): PipelineResult | null { + const { key, endpoint } = matched; + const { options, multi, baseLog } = run; + const { scenario } = options; const isVisibilityProbe = key === "repository.get" && - probeExpected(targetSlug, scenario, multi) && - !options.probeGetDelivered.has(targetSlug) && - (options.probeGetFaults.get(targetSlug) ?? 0) < PROBE_RETRY_BUDGET; + probeExpected(target.targetSlug, scenario, multi) && + !options.probeGetDelivered.has(target.targetSlug) && + (options.probeGetFaults.get(target.targetSlug) ?? 0) < PROBE_RETRY_BUDGET; if (key === "repository.get") { - options.probeGetDelivered.add(targetSlug); - } - - const requirement = endpointRequirement(endpoint); - const grading = gradeRequirement(mask, requirement); - if (!grading.allowed) { - const response = denialResponse(scenario.denial_style, requirement.kind); - const log: LoggedRequest = { ...baseLog, status: response.status, deniedBy: grading.deniedBy }; - // A read arms the denial barrier only when the engine itself sees it fail. - const arms = - requirement.kind === "read" && - !isVisibilityProbe && - endpoint.advisory !== true && - !toleratedStatuses(endpoint).includes(response.status); - const barrierViolation = denialBarrier( - options, - baseLog, - endpoint.section, - targetSlug, - requirement.kind, - arms, - ); - return { response, log, violation: barrierViolation }; - } + options.probeGetDelivered.add(target.targetSlug); + } + + const grading = gradeRequirement(target.mask, requirement); + if (grading.allowed) { + return null; + } + const response = denialResponse(scenario.denial_style, requirement.kind); + const log: LoggedRequest = { ...baseLog, status: response.status, deniedBy: grading.deniedBy }; + const arms = + requirement.kind === "read" && + !isVisibilityProbe && + endpoint.advisory !== true && + !toleratedStatuses(endpoint).includes(response.status); + const barrierViolation = denialBarrier( + options, + baseLog, + endpoint.section, + target.targetSlug, + requirement.kind, + arms, + ); + return { response, log, violation: barrierViolation }; +} +/** + * Body allowlist, handler, response guard, chaos hook. The handler sees only what GitHub keeps of the body + * (request-body.ts): an undocumented key is dropped on an open body and is the 422 on a closed one, so no handler can + * echo a key GitHub never stores. The response guard runs before the chaos hook, which deliberately goes + * off-contract, so statusAllowed holds on every request, not only the ones a curated test drives. + */ +function answerSectionRequest( + request: PipelineRequest, + matched: MatchedEndpoint, + requirement: Requirement, + target: Target, + run: Run, +): PipelineResult { + const { key, endpoint } = matched; + const { options, baseLog } = run; + const violation = violationFor(baseLog); const handler = HANDLERS[key]; if (!handler) { // Unreachable after assertHandlerCompleteness at construction; loud rather than a silent undefined call. return violation(`no handler registered for matched endpoint "${key}"`); } - // The handler sees only what GitHub keeps of the body (request-body.ts): an undocumented key is dropped on an open - // body and is the 422 below on a closed one, so no handler can echo a key GitHub never stores. + const accepted = acceptedBody(endpoint.route, request.body); const response = "rejected" in accepted ? accepted.rejected : handler({ - state, + state: target.state, endpoint, param: paramAccessor(key, endpoint, matched.params), query: request.query, body: accepted.body, headers: requestHeaders(request.headers), grants: (kind) => - gradeRequirement(mask, { permission: requirement.permission, kind }).allowed, + gradeRequirement(target.mask, { permission: requirement.permission, kind }).allowed, }); - - // Before the chaos hook, which deliberately goes off-contract, so statusAllowed holds on every request, not only the - // ones a curated test drives. if (!statusAllowed(key, response.status)) { return violation( `handler "${key}" returned status ${response.status}, which is neither declared [${[...declaredStatuses(key)].join(", ")}] nor a >= 400 error`, @@ -630,3 +734,116 @@ export function runPipeline( }, }; } + +/** The section leg: route match, the check-mode barrier, target resolution, the fault barrier, the permission gate, then the handler. */ +function serveSectionRequest(request: PipelineRequest, run: Run): PipelineResult { + const { options, baseLog } = run; + const violation = violationFor(baseLog); + const matched = matchEndpoint(request.method, request.pathname); + if (!matched) { + return violation(`no route in routes.ts for ${request.method} ${request.pathname}`); + } + const { key, endpoint } = matched; + + // Check-mode barrier before the fault barrier: a write in check mode is the one bug this barrier exists to catch, and + // a synthetic fault must not mask it. + if (options.checkMode && request.method !== "GET") { + return violation( + `write in check mode: ${request.method} ${request.pathname} (endpoint "${key}")`, + ); + } + // Check mode runs no execution thunk, so an execution-phase read arriving here came from a plan() body. + if (options.checkMode && endpoint.phase === "execution") { + return violation( + `execution-phase read in check mode: GET ${request.pathname} (endpoint "${key}")`, + ); + } + + const target = resolveRestTarget(request, endpoint, options); + if ("violation" in target) { + return violation(target.violation); + } + + const faulted = restFault(key, target.targetSlug, run); + if (faulted) { + return faulted; + } + + const requirement = endpointRequirement(endpoint); + const denied = restDenial(matched, requirement, target, run); + if (denied) { + return denied; + } + + return answerSectionRequest(request, matched, requirement, target, run); +} + +/** + * The wire checks every request clears first, and the base prefix stripped off its path; the log spells the stripped + * path even when the prefix check fails, so the violation names the request as the runner reads it. + */ +function admitRequest( + raw: RawRequest, + options: PipelineOptions, +): { request: PipelineRequest; run: Run } | PipelineResult { + const stripped = + options.basePrefix && raw.rawPath.startsWith(options.basePrefix) + ? raw.rawPath.slice(options.basePrefix.length) || "/" + : raw.rawPath; + const baseLog: LoggedRequest = { + method: raw.method, + pathname: stripped, + query: raw.rawQuery, + body: raw.body, + status: 0, + }; + const violation = violationFor(baseLog); + + if (!raw.headers.get("authorization")) { + return violation(`request ${raw.method} ${stripped} is missing the Authorization header`); + } + if (!raw.headers.get("x-github-api-version")) { + return violation( + `request ${raw.method} ${stripped} is missing the x-github-api-version header`, + ); + } + if (options.basePrefix && !raw.rawPath.startsWith(options.basePrefix)) { + return violation( + `request path "${raw.rawPath}" is missing the required base prefix "${options.basePrefix}"`, + ); + } + + const { working } = options; + return { + request: { + method: raw.method, + pathname: stripped, + query: raw.query, + headers: raw.headers, + body: raw.body, + }, + run: { options, multi: working.mode === "multi" ? working.multi : undefined, baseLog }, + }; +} + +/** Run the pipeline for one parsed request. Appends nothing to the logs itself: the caller owns the arrays. */ +export function runPipeline(raw: RawRequest, options: PipelineOptions): PipelineResult { + const admitted = admitRequest(raw, options); + if ("response" in admitted) { + return admitted; + } + const { request, run } = admitted; + + const core = serveCoreRoute(request, run); + if (core) { + return core; + } + if (request.pathname === "/graphql") { + return handleGraphqlRequest( + { method: request.method, body: request.body }, + options, + run.baseLog, + ); + } + return serveSectionRequest(request, run); +} diff --git a/test/e2e/mock/secrets.ts b/test/e2e/mock/secrets.ts index 1ef6e0748..5744a0651 100644 --- a/test/e2e/mock/secrets.ts +++ b/test/e2e/mock/secrets.ts @@ -11,8 +11,6 @@ import sodium from "libsodium-wrappers"; export const MOCK_SECRETS_PUBLIC_KEY = "G68uvmju1lvQh0Pd06U8yh3vlO0JsWLMQR7v3mIpSWc="; -const MOCK_SECRETS_PRIVATE_KEY = "RoPFQaBuTO6VMxNqrLqb3QyW2FOWCmRBwDpyziVyXHs="; // gitleaks:allow - export const MOCK_SECRETS_KEY_ID = "568250167242549743"; export function mockSodiumReady(): Promise { @@ -25,12 +23,15 @@ export function mockSodiumReady(): Promise { * ciphertext's base64 round-trip. */ export function unsealSecretValue(encryptedValueB64: string): string | null { + // The private half sits beside its one reader: gitleaks' allow directive must share the literal's line, a trailing + // comment the member-shape check refuses on a module constant. + const privateKey = "RoPFQaBuTO6VMxNqrLqb3QyW2FOWCmRBwDpyziVyXHs="; // gitleaks:allow try { const sealed = sodium.from_base64(encryptedValueB64, sodium.base64_variants.ORIGINAL); const opened = sodium.crypto_box_seal_open( sealed, sodium.from_base64(MOCK_SECRETS_PUBLIC_KEY, sodium.base64_variants.ORIGINAL), - sodium.from_base64(MOCK_SECRETS_PRIVATE_KEY, sodium.base64_variants.ORIGINAL), + sodium.from_base64(privateKey, sodium.base64_variants.ORIGINAL), ); return sodium.to_string(opened); } catch { diff --git a/test/e2e/mock/server.ts b/test/e2e/mock/server.ts index b4c2f3edc..f80099a9f 100644 --- a/test/e2e/mock/server.ts +++ b/test/e2e/mock/server.ts @@ -16,6 +16,8 @@ import { type FaultOption, type LoggedRequest, newPipelineRunState, + type PipelineResult, + type PipelineRunState, type WorkingState, } from "./contract.js"; import { assertGraphqlHandlerCompleteness, assertHandlerCompleteness } from "./handlers.js"; @@ -26,28 +28,58 @@ import { buildMultiState, buildState, type MultiMockState, type MultiRepoSpec } export interface ServerOptions { /** GHES-style path prefix every request must carry (e.g. "/api/v3"). */ basePrefix?: string; + + /** One chaos corruption, addressed by the same key as a fault; assertFaultKeys refuses an unknown key at construction. */ corrupt?: CorruptOption; + + /** Transport faults by endpoint key; assertFaultKeys refuses an unknown or duplicate key at construction. */ faults?: FaultOption[]; } export interface MockHandle { + /** The FULL base the runner points GITHUB_API_URL at, GHES prefix included; the runner appends nothing. */ url: string; + + /** The live state the handlers mutate, shared with the pipeline, so a test can assert what a request stored. */ working: WorkingState; + + /** Every request in arrival order, violations and faults included; the runner's rules match against it. */ requests: LoggedRequest[]; + + /** Mock-contract violations in arrival order; a scenario with any fails. */ violations: string[]; - /** - * Live fire count per injected fault key. A key that never appears here never reached its fault hook, so the - * iteration proved nothing about fault handling; fuzz and scenario consumers assert non-vacuity against it. - */ + + /** Live fire count per injected fault key; a key never appearing here never reached its fault hook, so the iteration proved nothing about fault handling, and fuzz and scenario consumers assert non-vacuity against it. */ faultCounts: ReadonlyMap; + /** * One-way: the convergence re-run spawns a check-mode child against this same apply-mode server, so the runner arms * the barrier before the re-run to make an unexpected write a violation. */ enterCheckMode(): void; + stop(): Promise; } +/** What the request handler reads and appends per request; `checkModeOverride` is enterCheckMode()'s one-way flip. */ +interface ServerRun { + scenario: Scenario; + working: WorkingState; + options: ServerOptions; + runState: PipelineRunState; + requests: LoggedRequest[]; + violations: string[]; + checkModeOverride: boolean; +} + +/** One request as the pipeline reads it: the URL parsed, the query flattened, the headers normalized, the body read. */ +interface Incoming { + url: URL; + query: Record; + headers: Headers; + body: unknown; +} + /** A null settings object stays null: the no-file case contentsResponse answers 404 for. */ function multiStateFor(scenario: Scenario): MultiMockState | undefined { if (!scenario.repos && !scenario.discovery) { @@ -64,162 +96,68 @@ function multiStateFor(scenario: Scenario): MultiMockState | undefined { return buildMultiState(repos, scenario.discovery?.pool, scenario.owner_kind); } -/** - * Port 0 so many scenarios run concurrently without contention. `url` is the FULL base the runner points GITHUB_API_URL - * at, GHES prefix included; the runner appends nothing. - */ -export async function startMockServer( - scenario: Scenario, - options: ServerOptions = {}, -): Promise { - assertHandlerCompleteness(); - assertGraphqlHandlerCompleteness(); - assertFaultKeys(options.faults, options.corrupt); - // Awaited once here: the secret-family PUT handlers unseal synchronously, so the WASM must be ready before the first request. - await mockSodiumReady(); - - const multi = multiStateFor(scenario); - const working: WorkingState = multi - ? { mode: "multi", multi } - : { mode: "single", state: buildState(scenario.live_state, scenario.owner_kind) }; - const requests: LoggedRequest[] = []; - const violations: string[] = []; - const runState = newPipelineRunState(); - let checkModeOverride = false; - - const server = createServer((req, res) => { - void handleRequest(req, res); - }); - - async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise { - try { - // req.url is the path + query; the base is only for URL's parser. - const url = new URL(req.url ?? "/", "http://localhost"); - const query: Record = {}; - for (const [k, v] of url.searchParams) { - query[k] = v; - } - const headers = headersFrom(req); - const body = await readBody(req); - - const result = runPipeline( - { - method: req.method ?? "GET", - rawPath: url.pathname, - query, - rawQuery: url.search.replace(/^\?/, ""), - headers, - body, - }, - { - scenario, - working, - basePrefix: options.basePrefix, - corrupt: options.corrupt, - faults: options.faults, - ...runState, - checkMode: scenario.inputs?.mode === "check" || checkModeOverride, - }, - ); - - // Off-contract responses are skipped by the validator entirely. Derived from `wire` presence rather than the - // constructor's flag, so a future wire kind cannot forget to opt out; keyed on the REQUEST media type, so every - // raw endpoint inherits the skip. - const rawMediaType = (headers.get("accept") ?? "").includes(".raw"); - const offSpec = result.wire !== undefined || result.offSpecBody || rawMediaType; - result.log.offSpec = offSpec; - // Handlers return live state objects and validateLog runs at scenario end, so logging by reference would let a - // later mutation rewrite an earlier logged body. - result.log.responseBody = offSpec ? undefined : structuredClone(result.response.body); - requests.push(result.log); - if (result.violation) { - violations.push(result.violation); - } - - // Destroyed before any bytes leave on purpose: a flushed response head lets the client resolve it, and octokit - // swallows the body-read failure, delivering the truncated body as a SUCCESS. The log line labels the trace; - // nothing else of this fault is observable. - if (result.wire?.kind === "drop") { - console.log( - `[mock] injecting connection drop (intentional fault, expected in passing runs) for ${req.method} ${url.pathname}`, - ); - req.socket.destroy(); - return; - } - - const status = result.response.status; - if (result.wire?.kind === "raw") { - sendBody(res, status, { "content-type": "application/json" }, result.wire.text); - return; - } - const extraHeaders = { - ...result.response.headers, - ...nextLink(req, url, result.response.nextPage), - }; - if (result.response.body === null || result.response.body === undefined) { - res.writeHead(status, extraHeaders); - res.end(); - return; - } - sendBody( - res, - status, - { "content-type": "application/json", ...extraHeaders }, - JSON.stringify(result.response.body), - ); - } catch (error) { - // A connection the CLIENT tore down mid-request (the runner killing a timed-out child) has nobody to answer. - // `destroyed` alone is too broad: node auto-destroys a fully consumed request stream, so it is paired with - // !complete to keep a genuine post-read handler bug loud. - const code = (error as NodeJS.ErrnoException).code; - if ( - (req.destroyed && !req.complete) || - code === "ECONNRESET" || - code === "ERR_STREAM_PREMATURE_CLOSE" - ) { - return; - } - // A crash here would make every later request ECONNREFUSED and mask the real fault; an ordinary 500 would let a - // failure-expecting scenario pass on it. A violation fails the scenario loudly. - violations.push( - `mock request handling failed for ${req.method} ${req.url}: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - console.error("[mock] request handling failed:", error); - if (!res.headersSent) { - res.writeHead(500); +function headersFrom(req: IncomingMessage): Headers { + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers)) { + if (value === undefined) { + continue; + } + if (Array.isArray(value)) { + for (const item of value) { + headers.append(name, item); } - res.end(); + } else { + headers.set(name, value); } } + return headers; +} - await new Promise((resolve) => { - server.listen(0, resolve); - }); - const address = server.address(); - if (address === null || typeof address !== "object") { - throw new Error("BUG: mock server has no bound TCP port"); +/** A malformed JSON body from the CLIENT surfaces as the raw text, so the pipeline can log it instead of throwing here. */ +async function readBody(req: IncomingMessage): Promise { + if (req.method === "GET" || req.method === "HEAD") { + return undefined; + } + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); } + const text = Buffer.concat(chunks).toString("utf8"); + if (!text) { + return undefined; + } + try { + return JSON.parse(text); + } catch { + return text; + } +} - const base = `http://localhost:${address.port}`; - return { - url: options.basePrefix ? `${base}${options.basePrefix}` : base, - working, - requests, - violations, - faultCounts: runState.faultCounts, - enterCheckMode() { - checkModeOverride = true; - }, - async stop() { - // Sever kept-alive client connections so close() cannot hang on an idle socket. - server.closeAllConnections(); - await new Promise((resolve) => { - server.close(() => resolve()); - }); - }, - }; +/** req.url is the path + query; the base is only for URL's parser. */ +async function parseIncoming(req: IncomingMessage): Promise { + const url = new URL(req.url ?? "/", "http://localhost"); + const query: Record = {}; + for (const [k, v] of url.searchParams) { + query[k] = v; + } + return { url, query, headers: headersFrom(req), body: await readBody(req) }; +} + +/** + * Off-contract responses are skipped by the validator entirely. Derived from `wire` presence rather than the + * constructor's flag, so a future wire kind cannot forget to opt out; keyed on the REQUEST media type, so every raw + * endpoint inherits the skip. The body is cloned: handlers return live state objects and validateLog runs at scenario + * end, so logging by reference would let a later mutation rewrite an earlier logged body. + */ +function recordResult(run: ServerRun, result: PipelineResult, headers: Headers): void { + const rawMediaType = (headers.get("accept") ?? "").includes(".raw"); + const offSpec = result.wire !== undefined || result.offSpecBody || rawMediaType; + result.log.offSpec = offSpec; + result.log.responseBody = offSpec ? undefined : structuredClone(result.response.body); + run.requests.push(result.log); + if (result.violation) { + run.violations.push(result.violation); + } } /** @@ -249,39 +187,164 @@ function sendBody( res.end(body); } -function headersFrom(req: IncomingMessage): Headers { - const headers = new Headers(); - for (const [name, value] of Object.entries(req.headers)) { - if (value === undefined) { - continue; - } - if (Array.isArray(value)) { - for (const item of value) { - headers.append(name, item); - } - } else { - headers.set(name, value); - } +/** + * A dropped connection is destroyed before any bytes leave on purpose: a flushed response head lets the client resolve + * it, and octokit swallows the body-read failure, delivering the truncated body as a SUCCESS. The log line labels the + * trace; nothing else of this fault is observable. + */ +function writeResult( + req: IncomingMessage, + res: ServerResponse, + url: URL, + result: PipelineResult, +): void { + if (result.wire?.kind === "drop") { + console.log( + `[mock] injecting connection drop (intentional fault, expected in passing runs) for ${req.method} ${url.pathname}`, + ); + req.socket.destroy(); + return; } - return headers; -} -/** A malformed JSON body from the CLIENT surfaces as the raw text, so the pipeline can log it instead of throwing here. */ -async function readBody(req: IncomingMessage): Promise { - if (req.method === "GET" || req.method === "HEAD") { - return undefined; + const status = result.response.status; + if (result.wire?.kind === "raw") { + sendBody(res, status, { "content-type": "application/json" }, result.wire.text); + return; } - const chunks: Buffer[] = []; - for await (const chunk of req) { - chunks.push(chunk as Buffer); - } - const text = Buffer.concat(chunks).toString("utf8"); - if (!text) { - return undefined; + + const extraHeaders = { + ...result.response.headers, + ...nextLink(req, url, result.response.nextPage), + }; + if (result.response.body === null || result.response.body === undefined) { + res.writeHead(status, extraHeaders); + res.end(); + return; } + + sendBody( + res, + status, + { "content-type": "application/json", ...extraHeaders }, + JSON.stringify(result.response.body), + ); +} + +/** + * A connection the CLIENT tore down mid-request (the runner killing a timed-out child) has nobody to answer. + * `destroyed` alone is too broad: node auto-destroys a fully consumed request stream, so it is paired with !complete + * to keep a genuine post-read handler bug loud. + */ +function isClientTeardown(req: IncomingMessage, error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code; + return ( + (req.destroyed && !req.complete) || + code === "ECONNRESET" || + code === "ERR_STREAM_PREMATURE_CLOSE" + ); +} + +/** + * A crash here would make every later request ECONNREFUSED and mask the real fault, and an ordinary 500 would let a + * failure-expecting scenario pass on it, so a handling failure becomes a violation that fails the scenario loudly. + */ +async function handleRequest( + req: IncomingMessage, + res: ServerResponse, + run: ServerRun, +): Promise { try { - return JSON.parse(text); - } catch { - return text; + const incoming = await parseIncoming(req); + const result = runPipeline( + { + method: req.method ?? "GET", + rawPath: incoming.url.pathname, + query: incoming.query, + rawQuery: incoming.url.search.replace(/^\?/, ""), + headers: incoming.headers, + body: incoming.body, + }, + { + scenario: run.scenario, + working: run.working, + basePrefix: run.options.basePrefix, + corrupt: run.options.corrupt, + faults: run.options.faults, + ...run.runState, + checkMode: run.scenario.inputs?.mode === "check" || run.checkModeOverride, + }, + ); + recordResult(run, result, incoming.headers); + writeResult(req, res, incoming.url, result); + } catch (error) { + if (isClientTeardown(req, error)) { + return; + } + + run.violations.push( + `mock request handling failed for ${req.method} ${req.url}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + console.error("[mock] request handling failed:", error); + if (!res.headersSent) { + res.writeHead(500); + } + res.end(); } } + +/** Port 0 so many scenarios run concurrently without contention. */ +export async function startMockServer( + scenario: Scenario, + options: ServerOptions = {}, +): Promise { + assertHandlerCompleteness(); + assertGraphqlHandlerCompleteness(); + assertFaultKeys(options.faults, options.corrupt); + // Awaited once here: the secret-family PUT handlers unseal synchronously, so the WASM must be ready before the first request. + await mockSodiumReady(); + + const multi = multiStateFor(scenario); + const run: ServerRun = { + scenario, + working: multi + ? { mode: "multi", multi } + : { mode: "single", state: buildState(scenario.live_state, scenario.owner_kind) }, + options, + runState: newPipelineRunState(), + requests: [], + violations: [], + checkModeOverride: false, + }; + const server = createServer((req, res) => { + void handleRequest(req, res, run); + }); + + await new Promise((resolve) => { + server.listen(0, resolve); + }); + const address = server.address(); + if (address === null || typeof address !== "object") { + throw new Error("BUG: mock server has no bound TCP port"); + } + + const base = `http://localhost:${address.port}`; + return { + url: options.basePrefix ? `${base}${options.basePrefix}` : base, + working: run.working, + requests: run.requests, + violations: run.violations, + faultCounts: run.runState.faultCounts, + enterCheckMode() { + run.checkModeOverride = true; + }, + async stop() { + // Sever kept-alive client connections so close() cannot hang on an idle socket. + server.closeAllConnections(); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }, + }; +} diff --git a/test/e2e/mock/state.ts b/test/e2e/mock/state.ts index 5efe3bc0d..28dcd6caf 100644 --- a/test/e2e/mock/state.ts +++ b/test/e2e/mock/state.ts @@ -49,155 +49,153 @@ interface LabelsGenerate { export interface LiveState { /** Partial repo object merged (deep) over repo.json. */ repo?: Json; + /** Replaces the baseline; a seed may be sparse ({name, color}), buildState completes it. */ labels?: Json[] | { generate: LabelsGenerate }; + /** Repository rulesets (summary + full bodies), replaces the baseline. */ rulesets?: Json[]; + /** Branch protection keyed by branch name; null means "unprotected". */ branch_protection?: Record; - /** - * GraphQL-only fields of LITERAL rules (bypassForcePushActors as actor strings, requiresDeployments, - * requiredDeploymentEnvironments), keyed by branch: the REST GET shape cannot carry them, so the rules - * query merges them into the node it projects from branch_protection. - */ + + /** GraphQL-only fields of LITERAL rules, keyed by branch (bypassForcePushActors as actor strings, requiresDeployments, requiredDeploymentEnvironments); the rules query merges them into the node it projects from branch_protection. */ branch_protection_graphql?: Record; - /** - * WILDCARD-pattern classic rules, invisible to every REST protection endpoint like on GitHub and served - * only by the GraphQL rules query. Seeds use the internal rule shape (GraphQL field names, - * bypassForcePushActors as actor strings); buildState completes them and stamps their ids. - */ + + /** WILDCARD-pattern classic rules in the internal rule shape (GraphQL field names, actor strings), served only by the GraphQL rules query and invisible to REST like on GitHub; buildState completes them and stamps their ids. */ branch_protection_rules?: Json[]; + /** Branch names that exist on the repo (drives the advisory branch probe). */ branches?: string[]; + /** Deployment environments keyed by name (GET shape). */ environments?: Record; + /** Per-environment Actions variables (GET shape), keyed by environment name. */ environment_variables?: Record; - /** - * Per-environment deployment branch-policy patterns (GET shape {id, name, type}), keyed by environment. - * Served only while the environment's deployment_branch_policy enables custom_branch_policies; the - * endpoints 404 otherwise, like GitHub. - */ + + /** Per-environment deployment branch-policy patterns (GET shape {id, name, type}), keyed by environment; served only while the environment's deployment_branch_policy enables custom_branch_policies, the endpoints 404 otherwise like GitHub. */ environment_branch_policies?: Record; - /** - * Per-environment enabled custom deployment protection rules (GET shape), keyed by environment. Seed - * apps from PROTECTION_RULE_APPS so the available-Apps listing agrees with them. - */ + + /** Per-environment enabled custom deployment protection rules (GET shape), keyed by environment; seed apps from PROTECTION_RULE_APPS so the available-Apps listing agrees with them. */ environment_protection_rules?: Record; - /** - * A plain string takes the next contiguous position; the object form seeds an explicit, possibly HOLE-Y - * position, since live GitHub does not renumber on unpin. Seeded names should exist in `environments`: - * a pin's target is always a real environment. - */ + + /** A plain string takes the next contiguous position; the object form seeds an explicit, possibly HOLE-Y position, since live GitHub does not renumber on unpin; a seeded name should exist in `environments`. */ pinned_environments?: Array; + /** Autolinks, replaces the baseline; a sparse seed is completed like a label. */ autolinks?: Json[]; + /** GET /actions/permissions body. */ actions_permissions?: Json; + /** GET /actions/permissions/selected-actions body. */ selected_actions?: Json; + /** GET /actions/permissions/workflow body. */ workflow_permissions?: Json; + /** GET /actions/permissions/access body. */ actions_access?: Json; + /** GET /actions/permissions/artifact-and-log-retention body. */ actions_retention?: Json; + /** GET /actions/cache/retention-limit body. */ cache_retention_limit?: Json; + /** GET /actions/cache/storage-limit body. */ cache_storage_limit?: Json; + /** GET /actions/oidc/customization/sub body. */ oidc_customization_sub?: Json; + /** GET /actions/permissions/fork-pr-contributor-approval body. */ fork_pr_contributor_approval?: Json; + /** GET /actions/permissions/fork-pr-workflows-private-repos body. */ fork_pr_workflows_private_repos?: Json; - /** - * Actions secrets list items (GET shape: {name, created_at, updated_at}). Values are never part of the - * GET shape; the mock tracks a digest of each uploaded value separately. - */ + + /** Actions secrets list items (GET shape: {name, created_at, updated_at}); values are never part of the GET shape, the mock tracks a digest of each uploaded value separately. */ actions_secrets?: Json[]; + /** Dependabot secrets list items, same GET shape as actions_secrets. */ dependabot_secrets?: Json[]; + /** Codespaces secrets list items, same GET shape as actions_secrets. */ codespaces_secrets?: Json[]; + /** Copilot agents secrets list items, same GET shape as actions_secrets. */ agents_secrets?: Json[]; + /** Per-environment Actions secrets, same GET shape as actions_secrets; digests are tracked per environment. */ environment_secrets?: Record; + /** Workflows list items ({id, name, path, state}), replaces the baseline. */ workflows?: Json[]; + /** GET /pages body, or null for "Pages not enabled". */ pages?: Json | null; + /** GET /code-scanning/default-setup body. */ code_scanning?: Json; + /** GET /code-quality/setup body. */ code_quality?: Json; - /** - * Stored check suite preferences ({auto_trigger_checks}): write-only on the real API (no GET exists), - * the PATCH echoes them under a `preferences` wrapper. - */ + + /** Stored check suite preferences ({auto_trigger_checks}): write-only on the real API (no GET exists), the PATCH echoes them under a `preferences` wrapper. */ check_suite_preferences?: Json; + /** Direct collaborators (GET shape with role_name), replaces the baseline. */ collaborators?: Json[]; - /** - * Pending repository invitations; typically only {invitee: {login}, permissions, expired} is seeded and - * buildState completes the rest of the spec's required shape. - */ + + /** Pending repository invitations; typically only {invitee: {login}, permissions, expired} is seeded and buildState completes the rest of the spec's required shape. */ invitations?: Json[]; + /** Team access keyed by team slug; null means "no access". */ teams?: Record; + /** Milestones (GET shape), replaces the baseline. */ milestones?: Json[]; + /** GET /interaction-limits body ({limit, origin, expires_at}); default none. */ interaction_limits?: Json; - /** - * GET /interaction-limits/pulls/creation-cap body; the spec requires both fields in every response, so - * the default is an unconfigured repo's disabled cap. - */ + + /** GET /interaction-limits/pulls/creation-cap body; the spec requires both fields in every response, so the default is an unconfigured repo's disabled cap. */ pull_creation_cap?: Json; + /** When true, both creation-cap endpoints answer GitHub's documented 405 (the cap is not available here). */ pull_creation_cap_unavailable?: boolean; - /** - * The creation-cap bypass list (simple-user GET shape); a seed may be just a login, buildState completes - * it via bypassUser, the same completion the PUT handler applies. - */ + + /** The creation-cap bypass list (simple-user GET shape); a seed may be just a login, which buildState completes via bypassUser, the same completion the PUT handler applies. */ pull_bypass_list?: Json[]; + /** Actions repository variables (GET shape); GitHub stores names uppercased, so seed uppercase names. */ actions_variables?: Json[]; + /** Copilot agents repository variables, same GET shape and uppercase-name rule as actions_variables. */ agents_variables?: Json[]; + /** When true, an org- or user-level limit is in effect: repo-level PUT/DELETE answer 409, matching GitHub. */ interaction_limits_org_override?: boolean; - /** - * Repository webhooks; a sparse seed is completed to the spec's shape by completeHook. A seeded - * config.secret is STORED verbatim but every GET echoes it as "********", matching GitHub. - */ + + /** Repository webhooks; a sparse seed is completed to the spec's shape by completeHook, and a seeded config.secret is STORED verbatim but every GET echoes it as "********", matching GitHub. */ hooks?: Json[]; - /** - * Deploy keys (GET shape), replaces the (empty) baseline; a sparse seed is completed like a label, - * its key material stored comment-free the way GitHub normalizes a created key. - */ + + /** Deploy keys (GET shape), replaces the (empty) baseline; a sparse seed is completed like a label, its key material stored comment-free the way GitHub normalizes a created key. */ deploy_keys?: Json[]; - /** - * Issues the repo already has; the private-report issue channel lists, creates, and patches these, so - * a seeded report issue exercises the update-in-place path. - */ + + /** Issues the repo already has; the private-report issue channel lists, creates, and patches these, so a seeded report issue exercises the update-in-place path. */ issues?: Json[]; - /** - * Custom property values ({property_name, value}); seed names from CUSTOM_PROPERTY_DEFINITIONS so the - * PATCH handler's defined-property check agrees with them. - */ + + /** Custom property values ({property_name, value}); seed names from CUSTOM_PROPERTY_DEFINITIONS so the PATCH handler's defined-property check agrees with them. */ custom_property_values?: Json[]; + /** Secret scanning custom patterns in GET shape. */ secret_scanning_patterns?: Json[]; } -/** - * The runtime enum the scenario schema keys `live_state` off (test/e2e/scenario.ts), so a typo'd family - * fails scenario LOAD instead of being accepted and silently unseeded. The `satisfies` and the - * MustBeNever pin keep it in lockstep with the interface in both directions. - */ +/** The runtime enum the scenario schema keys `live_state` off (test/e2e/scenario.ts), so a typo'd family fails scenario LOAD instead of being silently unseeded; the `satisfies` and the MustBeNever pin hold it equal to LiveState in both directions. */ export const LIVE_STATE_KEYS = [ "repo", "labels", @@ -249,117 +247,193 @@ export const LIVE_STATE_KEYS = [ "custom_property_values", "secret_scanning_patterns", ] as const satisfies readonly (keyof LiveState)[]; + type _LiveStateKeysComplete = MustBeNever< Exclude >; export interface MockState { - /** - * The "owner/name" identity node ids and per-slug routing key off, fixed at construction. Never read it - * off the repo body: a PATCH that writes `full_name` must not move the identity minted ids carry. - */ + /** The "owner/name" identity node ids and per-slug routing key off, fixed at construction; never read it off the repo body, since a PATCH that writes `full_name` must not move the identity minted ids carry. */ readonly slug: string; + + /** Whether the owner is an organization or a personal account; the grant vocabularies differ by it. */ ownerKind: OwnerKind; + /** The org body, or null when the owner is a personal account. */ org: Json | null; + + /** The repo body, re-slugged for a multi-repo target; REST surfaces pass it through restRepoSurface, which strips the GraphQL-only fields it also carries. */ repo: Json; + + /** Labels in GET shape, completed from LABELS_MOCK as a created label would be. */ labels: Json[]; + + /** Repository rulesets (summary + full bodies), completed from RULESETS_MOCK. */ rulesets: Json[]; + + /** Classic protection in GET shape keyed by branch name; null marks a branch left unprotected. */ branch_protection: Record; + /** GraphQL-only fields of literal rules, keyed by branch name. */ branch_protection_graphql: Record; + /** Wildcard classic rules in the internal rule shape (see LiveState). */ branch_protection_rules: Json[]; + + /** The branch names that exist on the repo, which the advisory branch probe answers from. */ branches: string[]; + + /** Deployment environments in GET shape, keyed by name. */ environments: Record; + + /** Per-environment Actions variables in GET shape, keyed by environment name. */ environment_variables: Record; + + /** Per-environment branch-policy patterns, served only while custom_branch_policies is enabled (see LiveState). */ environment_branch_policies: Record; + + /** Per-environment enabled custom deployment protection rules in GET shape, keyed by environment. */ environment_protection_rules: Record; - /** - * Pins in rank order; positions mirror verified GitHub behavior. - * new pin -> _pinned_position_counter + 1 (monotonic) - * unpin -> leaves a HOLE, no renumbering - * reorder -> the only path that renormalizes to contiguous 1..N - */ + + /** Pins in rank order; positions mirror verified GitHub behavior: a new pin takes _pinned_position_counter + 1 (monotonic), an unpin leaves a HOLE with no renumbering, and reorder is the only path that renormalizes to contiguous 1..N. */ pinned_environments: Array<{ name: string; position: number }>; - /** - * Starts at the seeded maximum. Underscore prefix: mock bookkeeping, excluded from the idempotence - * snapshot (snapshotFamilies in apply-idempotence-proof.ts). - */ + + /** Starts at the seeded maximum; underscore prefix: mock bookkeeping, excluded from the idempotence snapshot (snapshotFamilies in apply-idempotence-proof.ts). */ _pinned_position_counter: number; + + /** Autolinks in GET shape, completed from AUTOLINKS_MOCK. */ autolinks: Json[]; + + /** GET /actions/permissions body; GitHub's real default when unseeded, since the body carries required fields. */ actions_permissions: Json; + + /** GET /actions/permissions/selected-actions body. */ selected_actions: Json; + + /** GET /actions/permissions/workflow body. */ workflow_permissions: Json; + + /** GET /actions/permissions/access body. */ actions_access: Json; + + /** GET /actions/permissions/artifact-and-log-retention body. */ actions_retention: Json; + + /** GET /actions/cache/retention-limit body. */ cache_retention_limit: Json; + + /** GET /actions/cache/storage-limit body. */ cache_storage_limit: Json; + + /** GET /actions/oidc/customization/sub body. */ oidc_customization_sub: Json; + + /** GET /actions/permissions/fork-pr-contributor-approval body. */ fork_pr_contributor_approval: Json; + + /** GET /actions/permissions/fork-pr-workflows-private-repos body. */ fork_pr_workflows_private_repos: Json; + /** Actions secrets in GET shape ({name, created_at, updated_at}). */ actions_secrets: Json[]; + /** Dependabot secrets, same GET shape as actions_secrets. */ dependabot_secrets: Json[]; + /** Codespaces secrets, same GET shape as actions_secrets. */ codespaces_secrets: Json[]; + /** Copilot agents secrets, same GET shape as actions_secrets. */ agents_secrets: Json[]; + /** Per-environment Actions secrets (GET shape), keyed by environment name. */ environment_secrets: Record; - /** - * Shared by EVERY secret family, feeding each write's deterministic updated_at. Underscore prefix: mock - * bookkeeping, excluded from the idempotence snapshot (snapshotFamilies in apply-idempotence-proof.ts). - */ + + /** Shared by EVERY secret family, feeding each write's deterministic updated_at; underscore prefix: mock bookkeeping, excluded from the idempotence snapshot (snapshotFamilies in apply-idempotence-proof.ts). */ _secret_write_counter: number; - /** - * sha256 of each uploaded secret's UNSEALED value, never the plaintext and never served: a re-seal - * produces different ciphertext for the same plaintext, so only a plaintext-derived digest lets the - * state snapshot prove a second apply re-wrote the same value. - */ + + /** sha256 of each uploaded secret's UNSEALED value, never the plaintext and never served; a re-seal yields different ciphertext for the same plaintext, so only this digest lets the snapshot prove a second apply re-wrote the same value. */ actions_secret_digests: Record; + + /** The Dependabot secrets' digests, as actions_secret_digests. */ dependabot_secret_digests: Record; + + /** The Codespaces secrets' digests, as actions_secret_digests. */ codespaces_secret_digests: Record; + + /** The Copilot agents secrets' digests, as actions_secret_digests. */ agents_secret_digests: Record; + + /** The environment secrets' digests, keyed by environment name then secret name. */ environment_secret_digests: Record>; + + /** Workflows list items ({id, name, path, state}). */ workflows: Json[]; + + /** GET /pages body, or null while Pages is not enabled. */ pages: Json | null; + + /** GET /code-scanning/default-setup body. */ code_scanning: Json; + + /** GET /code-quality/setup body; GitHub's fresh-repo default, nullable fields spelled out, when unseeded. */ code_quality: Json; + + /** Stored check suite preferences ({auto_trigger_checks}); write-only on the real API, the PATCH echoes them under a `preferences` wrapper. */ check_suite_preferences: Json; + + /** Direct collaborators in GET shape, role_name included. */ collaborators: Json[]; + /** Pending repository invitations in the repository-invitation GET shape. */ invitations: Json[]; + + /** Team access keyed by folded team slug; null means no access. */ teams: Record; + + /** Milestones in GET shape, completed from MILESTONES_MOCK. */ milestones: Json[]; + /** The active interaction limit, or null when none is set. */ interaction_limits: Json | null; + + /** When true an org- or user-level limit is in effect and the repo-level PUT/DELETE answer 409, matching GitHub. */ interaction_limits_org_override: boolean; + /** The pull request creation cap ({enabled, max_open_pull_requests}). */ pull_creation_cap: Json; + + /** When true both creation-cap endpoints answer GitHub's documented 405. */ pull_creation_cap_unavailable: boolean; + /** The creation-cap bypass list, simple-user objects in GET shape. */ pull_bypass_list: Json[]; + + /** Actions repository variables in GET shape; GitHub stores names uppercased. */ actions_variables: Json[]; + /** Copilot agents repository variables, same GET shape as actions_variables. */ agents_variables: Json[]; + /** Repository webhooks; config.secret is stored real, GETs echo "********". */ hooks: Json[]; + /** Deploy keys in GET shape; stored key material carries no comment. */ deploy_keys: Json[]; + /** Report issues the private-report issue channel lists/creates/patches. */ issues: Json[]; + /** Custom property values set on the repo ({property_name, value}). */ custom_property_values: Json[]; + /** Secret scanning custom patterns in GET shape. */ secret_scanning_patterns: Json[]; - /** - * Feeds each create's and update's fresh custom_pattern_version, deterministic rather than a clock. - * Underscore prefix: mock bookkeeping, excluded from the idempotence snapshot (snapshotFamilies in - * apply-idempotence-proof.ts). - */ + + /** Feeds each create's and update's fresh custom_pattern_version, deterministic rather than a clock; underscore prefix: mock bookkeeping, excluded from the idempotence snapshot (snapshotFamilies in apply-idempotence-proof.ts). */ _secret_scanning_version_counter: number; + + /** The next minted resource id, started past every seeded id so a pinned seed id never collides with a minted one. */ nextId: number; } @@ -393,14 +467,13 @@ function stampNodeIds(state: MockState): void { } } -/** - * Repo fields only GraphQL serves, mirroring live GitHub: the issue creation policy is REST-blind in both - * directions (the repo PATCH answers 200 and ignores it, no GET returns it) and the sponsor button has no - * REST field. They live on state.repo so live_state.repo seeds them and the snapshot sees them; every - * REST-served or REST-accepted repo body passes through restRepoSurface, which strips them. - */ +/** Repo fields only GraphQL serves, kept on state.repo so live_state.repo seeds them and the snapshot sees them; restRepoSurface strips them from every REST-served or REST-accepted repo body. */ const GRAPHQL_ONLY_REPO_FIELDS = ["has_sponsorships_enabled", "issue_creation_policy"] as const; +/** + * The REST view of a repo body, mirroring live GitHub: the issue creation policy is REST-blind in both directions + * (the repo PATCH answers 200 and ignores it, no GET returns it) and the sponsor button has no REST field. + */ export function restRepoSurface(repo: Json): Json { const view = { ...repo }; for (const field of GRAPHQL_ONLY_REPO_FIELDS) { @@ -461,11 +534,7 @@ function generateLabels(gen: LabelsGenerate): Json[] { })); } -/** - * The list collections buildState completes from the same spec their handlers create with, so a - * seed is served exactly as a created item would be: the spec's defaults under the seed and the - * server-owned fields minted over it (a seed may pin only its id). The state test pins the key set. - */ +/** The specs buildState completes seeded list collections with, the same ones their handlers create with, so a seed is served exactly as a created item would be; state.test.ts pins the key set. */ export const LIST_MOCKS = { labels: LABELS_MOCK, autolinks: AUTOLINKS_MOCK, @@ -599,10 +668,7 @@ export function completeInvitation(seed: Json, id: number, repo: Json, slug: str return completed; } -/** - * The ONE source of protection-rule App slugs: the create handler resolves integration_id against it and - * the fuzz generator draws declared slugs from it, so a generated rule can always be enabled. - */ +/** The ONE source of protection-rule App slugs: the create handler resolves integration_id against it and the fuzz generator draws declared slugs from it, so a generated rule can always be enabled. */ export const PROTECTION_RULE_APPS: readonly Json[] = [ { id: 3515, @@ -624,10 +690,7 @@ export const PROTECTION_RULE_APPS: readonly Json[] = [ }, ]; -/** - * The ONE source of defined custom property names: the values PATCH answers 422 for a name outside it, - * and the fuzz generator draws declared names (with type-appropriate values) from it. - */ +/** The ONE source of defined custom property names: the values PATCH answers 422 for a name outside it, and the fuzz generator draws declared names (with type-appropriate values) from it. */ export const CUSTOM_PROPERTY_DEFINITIONS: ReadonlyArray<{ property_name: string; value_type: "string" | "true_false" | "multi_select"; @@ -641,6 +704,22 @@ export const CUSTOM_PROPERTY_DEFINITIONS: ReadonlyArray<{ { property_name: "compliance", value_type: "multi_select", allowed_values: ["soc2", "hipaa"] }, ]; +/** The family cloned, so a handler's in-place mutation never reaches the scenario's live_state, or its baseline when unseeded. */ +function seededOr(seed: T | undefined, baseline: B): T | B { + return seed ? clone(seed) : baseline; +} + +/** A labels seed is the explicit list or a generator; a missing family starts empty. */ +function seededLabels(seed: LiveState["labels"]): Json[] { + if (seed === undefined) { + return []; + } + if (Array.isArray(seed)) { + return clone(seed); + } + return generateLabels(seed.generate); +} + /** * `slug` (multi-repo targets) re-slugs the repo BEFORE any family completion runs, so bodies derived * from the repo (hook urls, the invitation scaffold) name the target, not the fixture. @@ -674,13 +753,6 @@ export function buildState( } const stateSlug = slug ?? String(fullName); - const labels = - ls.labels === undefined - ? [] - : Array.isArray(ls.labels) - ? clone(ls.labels) - : generateLabels(ls.labels.generate); - const pinnedSeed = normalizePinnedSeed(ls.pinned_environments ?? []); const state: MockState = { @@ -688,58 +760,50 @@ export function buildState( ownerKind, org: ownerKind === "user" ? null : clone(orgFixture as Json), repo, - labels, - rulesets: ls.rulesets ? clone(ls.rulesets) : [], + labels: seededLabels(ls.labels), + rulesets: seededOr(ls.rulesets, []), branch_protection: named(ls.branch_protection), branch_protection_graphql: named(ls.branch_protection_graphql), branch_protection_rules: (ls.branch_protection_rules ?? []).map((rule) => completeRule(clone(rule)), ), - branches: ls.branches ? clone(ls.branches) : [], + branches: seededOr(ls.branches, []), environments: named(ls.environments), environment_variables: named(ls.environment_variables), environment_branch_policies: named(ls.environment_branch_policies), environment_protection_rules: named(ls.environment_protection_rules), pinned_environments: pinnedSeed, _pinned_position_counter: Math.max(0, ...pinnedSeed.map((pin) => pin.position)), - autolinks: ls.autolinks ? clone(ls.autolinks) : [], - // GitHub's real defaults, not {}: each body carries required fields, so - // an unseeded GET must still answer a spec-valid shape. - actions_permissions: ls.actions_permissions - ? clone(ls.actions_permissions) - : { enabled: true, allowed_actions: "all" }, - selected_actions: ls.selected_actions ? clone(ls.selected_actions) : {}, - workflow_permissions: ls.workflow_permissions - ? clone(ls.workflow_permissions) - : { default_workflow_permissions: "read", can_approve_pull_request_reviews: false }, - actions_access: ls.actions_access ? clone(ls.actions_access) : { access_level: "none" }, - actions_retention: ls.actions_retention - ? clone(ls.actions_retention) - : { days: 90, maximum_allowed_days: 400 }, - cache_retention_limit: ls.cache_retention_limit - ? clone(ls.cache_retention_limit) - : { max_cache_retention_days: 7 }, - cache_storage_limit: ls.cache_storage_limit - ? clone(ls.cache_storage_limit) - : { max_cache_size_gb: 10 }, - oidc_customization_sub: ls.oidc_customization_sub - ? clone(ls.oidc_customization_sub) - : { use_default: true }, - fork_pr_contributor_approval: ls.fork_pr_contributor_approval - ? clone(ls.fork_pr_contributor_approval) - : { approval_policy: "first_time_contributors_new_to_github" }, - fork_pr_workflows_private_repos: ls.fork_pr_workflows_private_repos - ? clone(ls.fork_pr_workflows_private_repos) - : { - run_workflows_from_fork_pull_requests: false, - send_write_tokens_to_workflows: false, - send_secrets_and_variables: false, - require_approval_for_fork_pr_workflows: true, - }, - actions_secrets: ls.actions_secrets ? clone(ls.actions_secrets) : [], - dependabot_secrets: ls.dependabot_secrets ? clone(ls.dependabot_secrets) : [], - codespaces_secrets: ls.codespaces_secrets ? clone(ls.codespaces_secrets) : [], - agents_secrets: ls.agents_secrets ? clone(ls.agents_secrets) : [], + autolinks: seededOr(ls.autolinks, []), + // GitHub's real defaults, not {}: each body carries required fields, so an unseeded GET must still answer a + // spec-valid shape. + actions_permissions: seededOr(ls.actions_permissions, { + enabled: true, + allowed_actions: "all", + }), + selected_actions: seededOr(ls.selected_actions, {}), + workflow_permissions: seededOr(ls.workflow_permissions, { + default_workflow_permissions: "read", + can_approve_pull_request_reviews: false, + }), + actions_access: seededOr(ls.actions_access, { access_level: "none" }), + actions_retention: seededOr(ls.actions_retention, { days: 90, maximum_allowed_days: 400 }), + cache_retention_limit: seededOr(ls.cache_retention_limit, { max_cache_retention_days: 7 }), + cache_storage_limit: seededOr(ls.cache_storage_limit, { max_cache_size_gb: 10 }), + oidc_customization_sub: seededOr(ls.oidc_customization_sub, { use_default: true }), + fork_pr_contributor_approval: seededOr(ls.fork_pr_contributor_approval, { + approval_policy: "first_time_contributors_new_to_github", + }), + fork_pr_workflows_private_repos: seededOr(ls.fork_pr_workflows_private_repos, { + run_workflows_from_fork_pull_requests: false, + send_write_tokens_to_workflows: false, + send_secrets_and_variables: false, + require_approval_for_fork_pr_workflows: true, + }), + actions_secrets: seededOr(ls.actions_secrets, []), + dependabot_secrets: seededOr(ls.dependabot_secrets, []), + codespaces_secrets: seededOr(ls.codespaces_secrets, []), + agents_secrets: seededOr(ls.agents_secrets, []), environment_secrets: named(ls.environment_secrets), _secret_write_counter: 0, actions_secret_digests: named(), @@ -747,47 +811,44 @@ export function buildState( codespaces_secret_digests: named(), agents_secret_digests: named(), environment_secret_digests: named(), - workflows: ls.workflows ? clone(ls.workflows) : [], - pages: ls.pages !== undefined ? clone(ls.pages) : null, - code_scanning: ls.code_scanning ? clone(ls.code_scanning) : {}, - // GitHub's fresh-repo default, not {}: the GET body's nullable fields - // are spelled out so an unseeded read answers a realistic shape. - code_quality: ls.code_quality - ? clone(ls.code_quality) - : { - state: "not-configured", - languages: [], - runner_type: null, - runner_label: null, - updated_at: null, - schedule: null, - ai_findings_option: null, - }, - check_suite_preferences: ls.check_suite_preferences - ? clone(ls.check_suite_preferences) - : { auto_trigger_checks: [] }, - collaborators: ls.collaborators ? clone(ls.collaborators) : [], + workflows: seededOr(ls.workflows, []), + pages: seededOr(ls.pages, null), + code_scanning: seededOr(ls.code_scanning, {}), + // GitHub's fresh-repo default, not {}: the GET body's nullable fields are spelled out so an unseeded read + // answers a realistic shape. + code_quality: seededOr(ls.code_quality, { + state: "not-configured", + languages: [], + runner_type: null, + runner_label: null, + updated_at: null, + schedule: null, + ai_findings_option: null, + }), + check_suite_preferences: seededOr(ls.check_suite_preferences, { auto_trigger_checks: [] }), + collaborators: seededOr(ls.collaborators, []), invitations: (ls.invitations ?? []).map((invitation) => completeInvitation(clone(invitation), takeId(), repo, stateSlug), ), teams: named(foldedTeamSeed(ls.teams)), - milestones: ls.milestones ? clone(ls.milestones) : [], - interaction_limits: ls.interaction_limits ? clone(ls.interaction_limits) : null, + milestones: seededOr(ls.milestones, []), + interaction_limits: seededOr(ls.interaction_limits, null), interaction_limits_org_override: ls.interaction_limits_org_override ?? false, - // An unconfigured repo's cap is disabled; the spec requires - // max_open_pull_requests in every response, so the default carries one. - pull_creation_cap: ls.pull_creation_cap - ? clone(ls.pull_creation_cap) - : { enabled: false, max_open_pull_requests: 1 }, + // An unconfigured repo's cap is disabled; the spec requires max_open_pull_requests in every response, so the + // default carries one. + pull_creation_cap: seededOr(ls.pull_creation_cap, { + enabled: false, + max_open_pull_requests: 1, + }), pull_creation_cap_unavailable: ls.pull_creation_cap_unavailable ?? false, pull_bypass_list: (ls.pull_bypass_list ?? []).map((user) => bypassUser(clone(user), takeId())), - actions_variables: ls.actions_variables ? clone(ls.actions_variables) : [], - agents_variables: ls.agents_variables ? clone(ls.agents_variables) : [], + actions_variables: seededOr(ls.actions_variables, []), + agents_variables: seededOr(ls.agents_variables, []), hooks: (ls.hooks ?? []).map((hook) => completeHook(clone(hook), takeId(), stateSlug)), - deploy_keys: ls.deploy_keys ? clone(ls.deploy_keys) : [], - issues: ls.issues ? clone(ls.issues) : [], - custom_property_values: ls.custom_property_values ? clone(ls.custom_property_values) : [], - secret_scanning_patterns: ls.secret_scanning_patterns ? clone(ls.secret_scanning_patterns) : [], + deploy_keys: seededOr(ls.deploy_keys, []), + issues: seededOr(ls.issues, []), + custom_property_values: seededOr(ls.custom_property_values, []), + secret_scanning_patterns: seededOr(ls.secret_scanning_patterns, []), _secret_scanning_version_counter: 0, nextId, }; @@ -803,12 +864,13 @@ export function buildState( // on; the pipeline resolves the slug from the request path and dispatches into the per-slug state. export interface MultiRepoSpec { - /** - * The settings.yml body the contents endpoint serves, or null for NO file (the contents 404 path: the - * defaults document applies, or the target is skipped without one). - */ + /** The settings.yml body the contents endpoint serves, or null for NO file (the contents 404 path: the defaults document applies, or the target is skipped without one). */ settingsYaml: string | null; + + /** The slug's starting state; absent, the fixture baseline. */ liveState?: LiveState; + + /** The slug's mask, merged OVER the scenario's global mask; absent is {}, which inherits the global denials. */ permissions?: PermissionMask; } @@ -827,17 +889,17 @@ export interface DiscoveryRepoSpec { export interface MultiMockState { /** Per-target working state, keyed by "owner/name" slug. */ repos: Map; + /** The raw settings.yml each slug serves (null = no file), keyed by slug. */ settings: Map; + /** Per-slug permission mask, merged OVER the scenario's global mask (grading.ts), so {} inherits its denials. */ permissions: Map; + /** The repo objects `/user/repos` enumerates (discovery pool). */ discoveryPool: Json[]; - /** - * Shared state for the org-level endpoints (the `GET /orgs/{org}` probe), which are NOT repo-scoped; - * only its `org` field is read. Team-repo routes still resolve to the addressed repo's state via their - * {owner}/{repo} tail. - */ + + /** Shared state for the org-level endpoints (the `GET /orgs/{org}` probe), which are NOT repo-scoped; only its `org` field is read, and team-repo routes still resolve to the addressed repo's state via their {owner}/{repo} tail. */ orgState: MockState; } @@ -1038,10 +1100,7 @@ export function protectionFromPut(payload: Json): Json { /** The users a force_push_bypassers entry can name; the actor-user lookup answers NOT_FOUND for anything else. */ export const BYPASS_ACTOR_USERS: readonly string[] = ["octocat", "release-bot"]; -/** - * The org teams the actor-team lookup resolves; an unknown org answers NOT_FOUND, a known org with an - * unknown team answers team: null (GitHub's nullable-field shape). - */ +/** The org teams the actor-team lookup resolves; an unknown org answers NOT_FOUND, a known org with an unknown team answers team: null (GitHub's nullable-field shape). */ export const BYPASS_ACTOR_TEAMS: readonly string[] = [ `${ADMIN_OWNER}/platform`, `${ADMIN_OWNER}/release-guild`, @@ -1250,6 +1309,50 @@ const STATUS_CLASSIC_BY_TWIN: Record = Object.fromEntries( Object.entries(GRAPHQL_STATUS_CHECK_TWINS).map(([classic, twin]) => [twin, classic]), ); +/** A switched-off rule has no sub-object in the GET shape, so off deletes it; on creates an empty one where none exists, so the twin writes that follow have a home. */ +function toggleNestedRule(protection: Json, field: string, on: boolean, empty: Json): void { + if (!on) { + delete protection[field]; + return; + } + if (!isPlainObject(protection[field])) { + protection[field] = empty; + } +} + +/** + * A translated twin lands back on the stored REST GET shape under its classic key (a boolean twin as {enabled}, a + * review twin inside required_pull_request_reviews, a status-check twin inside required_status_checks); a field with + * no classic destination (a future twin) stays on the extras so a read-back echoes it. + */ +function applyTwinToLiteral(protection: Json, extras: Json, key: string, value: unknown): void { + const classicBoolean = CLASSIC_BY_TWIN[key]; + if (classicBoolean !== undefined) { + protection[classicBoolean] = { enabled: value === true }; + return; + } + + const classicReview = REVIEW_CLASSIC_BY_TWIN[key]; + if (classicReview !== undefined) { + if (!isPlainObject(protection.required_pull_request_reviews)) { + protection.required_pull_request_reviews = {}; + } + (protection.required_pull_request_reviews as Json)[classicReview] = value; + return; + } + + const classicStatus = STATUS_CLASSIC_BY_TWIN[key]; + if (classicStatus !== undefined) { + if (!isPlainObject(protection.required_status_checks)) { + protection.required_status_checks = { strict: false, contexts: [] }; + } + (protection.required_status_checks as Json)[classicStatus] = value; + return; + } + + extras[key] = value; +} + /** * Translated twins land back on the stored REST GET shape and GraphQL-only fields on the extras family, * so both views keep agreeing: GitHub has one underlying rule. @@ -1284,44 +1387,16 @@ export function applyRuleInputToLiteral( extras.requiredDeploymentEnvironments = dropMissingEnvironments(value, state); break; case "requiresStatusChecks": - if (value !== true) { - delete protection.required_status_checks; - } else if (!isPlainObject(protection.required_status_checks)) { - protection.required_status_checks = { strict: false, contexts: [] }; - } + toggleNestedRule(protection, "required_status_checks", value === true, { + strict: false, + contexts: [], + }); break; case "requiresApprovingReviews": - if (value !== true) { - delete protection.required_pull_request_reviews; - } else if (!isPlainObject(protection.required_pull_request_reviews)) { - protection.required_pull_request_reviews = {}; - } + toggleNestedRule(protection, "required_pull_request_reviews", value === true, {}); break; - default: { - const classicBoolean = CLASSIC_BY_TWIN[key]; - if (classicBoolean !== undefined) { - protection[classicBoolean] = { enabled: value === true }; - break; - } - const classicReview = REVIEW_CLASSIC_BY_TWIN[key]; - if (classicReview !== undefined) { - if (!isPlainObject(protection.required_pull_request_reviews)) { - protection.required_pull_request_reviews = {}; - } - (protection.required_pull_request_reviews as Json)[classicReview] = value; - break; - } - const classicStatus = STATUS_CLASSIC_BY_TWIN[key]; - if (classicStatus !== undefined) { - if (!isPlainObject(protection.required_status_checks)) { - protection.required_status_checks = { strict: false, contexts: [] }; - } - (protection.required_status_checks as Json)[classicStatus] = value; - break; - } - // A field with no classic destination (a future twin) stays on the extras so a read-back echoes it. - extras[key] = value; - } + default: + applyTwinToLiteral(protection, extras, key, value); } } return { ok: true }; @@ -1353,10 +1428,7 @@ export function environmentFromPut(payload: Json): Json { return { ...rest, protection_rules: rules }; } -/** - * The team grant PUT's own vocabulary, which the team listing's `permission` also spells; the invitation PATCH - * speaks the GET's instead. - */ +/** The team grant PUT's own vocabulary, which the team listing's `permission` also spells; the invitation PATCH speaks the GET's instead. */ export const GRANT_PERMISSIONS: ReadonlySet = new Set([ "pull", "triage", @@ -1371,18 +1443,10 @@ const COLLABORATOR_GRANT_PERMISSIONS: ReadonlySet = new Set([ "triage_plus", ]); -/** - * The custom repository roles the mock's organization defines, so a grant naming one converges (the - * snapshot round trips read them back) while any other spelling is refused the way GitHub refuses a - * role the organization never defined. - */ +/** The custom repository roles the mock's organization defines, so a grant naming one converges (the snapshot round trips read them back) while any other spelling is refused the way GitHub refuses a role the organization never defined. */ const CUSTOM_REPOSITORY_ROLES: ReadonlySet = new Set(["security-team", "security-auditor"]); -/** - * What a personal account's repository takes. The spec text calls the PUT's `permission` "only valid on - * organization-owned repositories", but live GitHub honors these three there and 422s triage, maintain, and every - * custom role name (an organization feature); the invitation PATCH narrows to their read vocabulary the same way. - */ +/** The spec calls the PUT's `permission` org-only, but live GitHub honors these three on a personal account's repository and 422s triage, maintain, and every custom role; the invitation PATCH narrows to their read vocabulary the same way. */ const PERSONAL_GRANT_PERMISSIONS: ReadonlySet = new Set(["pull", "push", "admin"]); /** diff --git a/test/e2e/mock/support.ts b/test/e2e/mock/support.ts index 6c550892e..65ac12db9 100644 --- a/test/e2e/mock/support.ts +++ b/test/e2e/mock/support.ts @@ -34,17 +34,19 @@ import type { MockState } from "./state.js"; export type Json = Record; export interface MockResponse { + /** The HTTP status; the pipeline's response guard holds it to the endpoint's declared set or a >= 400 error. */ status: number; + + /** The JSON body; null or undefined sends an empty one. */ body: unknown; + + /** Extra response headers, sent beside the content type. */ headers?: Record; + /** The page after this one, when one exists; server.ts renders it as the `Link` header the client walks. */ nextPage?: number; - /** - * Marks a reply that REJECTS a deliberately off-spec request body: settings pass through to the API - * verbatim, so scenarios send user typos the schema forbids and the handler answers GitHub's real 4xx. - * The OpenAPI validator skips only the request-body SCHEMA check for these; body-presence checks and - * response validation still apply. - */ + + /** Marks a reply REJECTING a deliberately off-spec request body (settings pass through verbatim, so scenarios send user typos the schema forbids); the OpenAPI validator then skips only the request-body SCHEMA check. */ requestOffSpec?: boolean; } @@ -53,8 +55,12 @@ export interface MockResponse { * AFTER the handler returns, so it is not passed here. */ interface HandlerContext { + /** The target repository's state, resolved by the pipeline from the request path. */ state: MockState; + + /** The matched declaration, for the route and the declared statuses. */ endpoint: TaggedEndpoint; + /** * Extraction and routing share one template walk, so a handler cannot disagree with its own ENDPOINTS * declaration about a param's position; the raw path is deliberately NOT exposed. @@ -62,14 +68,16 @@ interface HandlerContext { * token missing from the route -> throws a mock BUG naming the handler, the route, and the declared tokens */ param(name: string): string; + + /** The query string flattened to one value per key, the last repeat winning. */ query: Record; + + /** The body after the pipeline's allowlist: only the fields GitHub keeps of it (request-body.ts). */ body: unknown; - /** - * The request headers, lower-cased names, frozen (requestHeaders in dispatch.ts is the one minter). For the - * endpoints whose reply GitHub shapes by media type (the teams probe's 200 body vs bare 204), so a client that - * drops its Accept header meets GitHub's real answer here instead of passing. - */ + + /** Lower-cased and frozen (requestHeaders in dispatch.ts is the one minter), read by the endpoints GitHub shapes by media type (the teams probe's 200 body vs bare 204), so a client that drops its Accept header meets GitHub's real answer. */ headers: Readonly>; + /** * For a field GitHub reveals only above the endpoint's own grade (a ruleset's bypass_actors, * admin-only), so the mock omits it like GitHub does. @@ -169,11 +177,7 @@ export function branchPoliciesEnabled(state: MockState, env: string): boolean { return flags?.custom_branch_policies === true; } -/** - * ONE handler registered under both teams.org and custom_properties.org, so the two cannot drift. - * matchEndpoint resolves the shared route to the FIRST declaring section (teams); the custom_properties - * registration exists for the completeness assertion. - */ +/** Registered under both teams.org and custom_properties.org so the two cannot drift; matchEndpoint resolves the shared route to the FIRST declaring section (teams), and the custom_properties registration exists for the completeness assertion. */ export const orgProbeHandler: Handler = ({ state }) => { if (state.org === null) { return { status: 404, body: { message: "Not Found" } }; @@ -408,11 +412,13 @@ export const INTERACTION_ORG_LIMIT = { expires_at: "2027-07-01T00:00:00Z", } as const; +/** The 409 the repo-level PUT and DELETE answer while an org- or user-level limit is in effect (interaction_limits_org_override). */ export const INTERACTION_ORG_CONFLICT = { status: 409, body: { message: "Conflict: an organization or user interaction limit is in effect" }, } as const; +/** GitHub's documented 405 from both creation-cap endpoints while the cap is unavailable (pull_creation_cap_unavailable). */ export const CAP_UNAVAILABLE_405 = { status: 405, body: { message: "Method Not Allowed: the pull request creation cap is not available" }, @@ -484,6 +490,7 @@ export const SECRET_SCANNING_UPDATABLE_KEYS = [ "must_not_match", ] as const; +/** GitHub's 412 for a PATCH whose custom_pattern_version is not the stored one: the pattern changed under the client. */ export const SECRET_SCANNING_STALE_VERSION = { status: 412, body: { message: "Precondition Failed: the custom pattern was modified" }, @@ -558,10 +565,7 @@ export function maskHookSecret(hook: Json): Json { return config.secret === undefined ? hook : { ...hook, config: maskedConfig(config) }; } -/** - * GitHub's reply to a grant PUT whose `permission` the owner's repository does not take (state.ts, grantablePermission). - * The schema types the field as a free string, so the body is off the documented contract, not the schema. - */ +/** GitHub's reply to a grant PUT whose `permission` the owner's repository does not take (state.ts, grantablePermission); the schema types the field as a free string, so the body is off the documented contract, not the schema. */ export const PERMISSION_NOT_GRANTABLE: MockResponse = { status: 422, body: { message: "Validation Failed", errors: [{ field: "permission", code: "invalid" }] }, @@ -584,11 +588,7 @@ export function storedKeyMaterial(key: string): string { return match ? `${match[1]} ${match[2]}` : key.trim(); } -/** - * Mock-only realism: the runtime never refuses a rules[].type (an unknown one passes through verbatim); this set - * exists so a typo'd type answers GitHub's real 422 shape instead of being stored silently. The schema's list is - * generated from the descriptor, so the mock knows exactly the types the descriptor does. - */ +/** Mock-only realism: the runtime never refuses a rules[].type (an unknown one passes through verbatim), so this set answers GitHub's real 422 for a typo'd type instead of storing it silently; KNOWN_RULE_TYPES is generated from the descriptor. */ const RULESET_RULE_TYPES: ReadonlySet = new Set(KNOWN_RULE_TYPES); /** GitHub's 422 for an unrecognized rules[].type, or null when all types are real. */ From 5ebc723851dbf63203add1ff41d031274d97cdc9 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:28:23 -0400 Subject: [PATCH 04/11] refactor(scripts): split the functions over the lint floor and reshape member comments The fleet's biome floor (cognitive complexity 15, 80 lines per function, no nested ternary, no negation-else) and the platform's member-shape check both judge the 27 TypeScript files under .github/scripts. This brings every one of them to zero findings with no biome-ignore and no config override. Each function over the floor is split into named steps, with every early return, thrown message, and order of side effects kept: arch-lint's drawnEdges and requestExpression, endpoint-docs' resolveRest and resolveGraphql, gen-action-docs' gatedRead, yamlData and keyMapping, gen-docs' PageReader cursor, parseGaps, fenceFault and mapEntries, generated-regions' markerInside and a node-type table in scanMarkdown, schema-descriptions' sitesClaimedBy and describeSite, nightly-steps' noBumpReason, commitBump and openBumpPr, release-pipeline's publishOptions and reportConfirmed, anchor's readConfigText, parseConfigObject, commitAnchor, anchorAttempt, newestReleaseMerge and noMergeVerdict, npm's shownVerdict, and tags' alreadyPast. The nested ternaries in keyPath, literalModuleRequest and the config-kind message became if-chains, and npm's hand-written release comparator became Bun.semver.order. Field and constant comments are one line directly above their item, every field of a half-commented record is commented or the record's restating comments are gone, and commented members and constants stand alone with a blank line. A reason that needed more than a line moved into the enclosing doc comment: generated.ts's dependency order, gen-docs' sections page and mermaid node ids. --- .github/scripts/arch-lint.ts | 60 +++-- .github/scripts/auto-fix-steps.ts | 4 +- .github/scripts/check-compat-markers.ts | 18 +- .github/scripts/coverage-data.ts | 7 +- .github/scripts/endpoint-docs.ts | 62 +++-- .github/scripts/gen-action-docs.ts | 141 +++++----- .github/scripts/gen-docs.ts | 294 ++++++++++++--------- .github/scripts/gen-inputs-table.ts | 6 +- .github/scripts/gen-openapi.ts | 47 ++-- .github/scripts/gen-settings-schema.ts | 5 +- .github/scripts/generated.ts | 13 +- .github/scripts/graduate-upstream-gaps.ts | 1 + .github/scripts/lib/count-word.ts | 1 + .github/scripts/lib/generated-regions.ts | 90 ++++--- .github/scripts/lib/region-driver.ts | 3 + .github/scripts/lib/schema-descriptions.ts | 103 +++++--- .github/scripts/lib/schema-id.ts | 6 +- .github/scripts/lib/workflow-step.ts | 6 +- .github/scripts/nightly-steps.ts | 87 +++--- .github/scripts/package-smoke.ts | 19 +- .github/scripts/post-green-steps.ts | 7 +- .github/scripts/release-pipeline.ts | 83 +++--- .github/scripts/release-pipeline/anchor.ts | 206 +++++++++------ .github/scripts/release-pipeline/draft.ts | 4 +- .github/scripts/release-pipeline/git.ts | 4 +- .github/scripts/release-pipeline/npm.ts | 67 +++-- .github/scripts/release-pipeline/tags.ts | 115 +++++--- 27 files changed, 896 insertions(+), 563 deletions(-) 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; } From 5daa95e14eddb444ee2d36880a9bfd30893ae141 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:29:27 -0400 Subject: [PATCH 05/11] refactor(sections): bring the contract and shared helpers under the lint floor Brings src/sections/contract and src/sections/shared under the fleet's biome floor and the member-shape check with behavior unchanged: the covering unit tests, the typecheck, and both gates pass. Functions over the floor are split into named steps, each above its caller: - the list planner (shared/list/plan.ts): declaredWrites, indexedLive, liveConflictRefusal, undeclaredRemovals, undeclaredKeepNotes, matchedLive, bodyOf, facetDrift, createOp, compareWithLive, recreateOps, updateOps, entryGroup - failureFor: requestLines, denialAdvice, rejectionAdvice, and a FailureContext record for the optional context - plainData's walk: renderPath, rejectPayload, nonPlainAt, requirePlain - listGraphqlConnection: connectionOf, connectionPage, and the loop; listPages takes a PageWalk record in place of seven parameters - routedForms: formOf, neitherFormIssue - setupSection: setupEndpoints, setupPlan, setupSnapshot, declarableName - project's object case: projectObject - the nested ternaries in parseSealingKey's field defect and the setup language fold become early returns; the variable drift lines are pushed, not spliced Member comments become one line per field. A multi-sentence note moves into the record's own doc comment with its rows (EndpointDeclFields, SectionMeta, SectionModuleBase, SetupLanguages, ListSectionDeclFields), and a record whose comments only restated its names is left bare. ReadPhase and PermissionOverride name the read phase and the permission override once, so the EndpointDecl and GraphqlOpDecl arms carry no field comments. UpdateDecl is exported for the planner's shared record. --- src/sections/contract/declared.ts | 1 - src/sections/contract/docs.ts | 13 +- src/sections/contract/drift.ts | 11 + src/sections/contract/endpoints.ts | 81 ++- src/sections/contract/errors.ts | 107 ++- src/sections/contract/graphql.ts | 35 +- src/sections/contract/keyed-list.ts | 27 +- src/sections/contract/live.ts | 10 +- src/sections/contract/module.ts | 81 +-- src/sections/contract/owner.ts | 6 +- src/sections/contract/permissions.ts | 5 +- src/sections/contract/plan.ts | 153 ++-- src/sections/contract/requests.ts | 138 ++-- src/sections/shared/list/decl.ts | 133 ++-- src/sections/shared/list/plan.ts | 652 ++++++++++++------ src/sections/shared/roles.ts | 24 +- src/sections/shared/schema-helpers.ts | 87 ++- src/sections/shared/schema-marks.ts | 8 +- .../secrets-and-variables/keyed-values.ts | 3 + .../secrets-and-variables/named-scope.ts | 27 +- .../secrets-and-variables/repo-secrets.ts | 27 +- .../secrets-and-variables/repo-variables.ts | 11 +- .../secrets-and-variables/sealed-box.ts | 7 +- .../secrets-and-variables/secrets-engine.ts | 36 +- .../secrets-and-variables/variables-engine.ts | 29 +- src/sections/shared/setup-schema.ts | 16 +- src/sections/shared/setup-section.ts | 114 ++- src/sections/shared/snapshot-helpers.ts | 49 +- 28 files changed, 1115 insertions(+), 776 deletions(-) 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..dbe5032d7 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,55 @@ 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) { + return err({ + kind: "malformed", + message: `${section.key}: GRAPHQL ${op.name} returned a response without a "${op.connection.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({ page }); +} + +/** The cursor loop lives here so paging cannot drift between sections. */ export async function listGraphqlConnection( ctx: SectionContext, section: SectionMeta, @@ -400,42 +453,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/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": From 75568ea070e969b51a3b15c37802f3cc4242b977 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:29:33 -0400 Subject: [PATCH 06/11] refactor(test-sections): clear the fleet lint floor and member-shape check The fleet's TypeScript lint floor and the member-shape check now gate every repository, and test/sections/ carried 44 floor findings and 153 member-shape findings. Its 49 files are at zero on both, with every test asserting what it asserted before. Member shape: a multi-line comment on a field or a module constant is one line; a half-commented record or const block is all-commented or bare; a blank line separates commented members and surrounds a commented module constant. A note too long for one line moved to where it still reads: a module header (teams, section-failure, the deploy_keys generators), the test body beside the assertion it explains (autolinks, milestones), the record's own doc comment (the branches schema cases), or trailing comments on the array entries it describes (raw-sibling's RAW_VALUES). Lint floor: - A compile-time-only expression statement carrying @ts-expect-error is prefixed with void, which keeps the directive anchored and drops no value silently. - Each nested ternary is a small function; the GraphQL status fold the fake and the branches test both spelled now lives once in fragment-fake.ts. - Each function over the cognitive-complexity cap is split into named steps above its caller: the fnmatch walk in the branches mock into a token reader and a bracket-class reader; the branches and environments fuzz generators per protection kind or per family, every RNG stream drawn in the same order; the fragment fake's REST and GraphQL routing and its denial; the refusal-message census's exclusion ladder, literal walk, and position walk; and the loop bodies of the registry, repository, docs-registry, and graphql-queries tests. The shared-route contract test compares the projected declarations with toEqual instead of serializing a hand-rolled key sort to a string; the assertion pins the same fact. --- test/sections/autolinks/autolinks.test.ts | 2 +- test/sections/autolinks/generators.ts | 4 +- test/sections/branches/branches.test.ts | 20 +- test/sections/branches/generators.ts | 153 ++++--- test/sections/branches/mock.ts | 136 +++--- test/sections/branches/schema.test.ts | 8 +- test/sections/branches/snapshot-row.ts | 4 +- test/sections/contract/contract.test.ts | 34 +- test/sections/contract/owner-gate.test.ts | 4 + .../sections/contract/section-failure.test.ts | 6 +- test/sections/contract/validate-hook.test.ts | 15 +- test/sections/deploy_keys/deploy_keys.test.ts | 16 + test/sections/deploy_keys/generators.ts | 12 +- test/sections/deploy_keys/mock.ts | 6 +- test/sections/deploy_keys/schema.test.ts | 1 + test/sections/docs-registry.test.ts | 30 +- test/sections/duplicate-live.test.ts | 18 +- test/sections/environments/generators.ts | 163 ++++--- test/sections/environments/snapshot-row.ts | 4 +- test/sections/fragment-fake.ts | 168 +++++--- test/sections/graphql-queries.test.ts | 36 +- test/sections/milestones/generators.ts | 1 + test/sections/milestones/milestones.test.ts | 2 +- test/sections/milestones/mock.ts | 1 + test/sections/pages/mock.ts | 7 +- test/sections/plan-idempotence.test.ts | 22 +- test/sections/raw-sibling.test.ts | 28 +- test/sections/refusal-messages.test.ts | 406 +++++++++++------- test/sections/registry.test.ts | 208 ++++----- test/sections/repository/mock.ts | 3 +- test/sections/repository/repository.test.ts | 34 +- test/sections/repository/snapshot-row.ts | 3 +- test/sections/rulesets/generators.ts | 5 +- test/sections/rulesets/mock.ts | 6 +- test/sections/rulesets/schema.test.ts | 1 + test/sections/rulesets/snapshot-row.ts | 4 +- .../generators.ts | 6 +- test/sections/shared/list-section.test.ts | 7 +- test/sections/shared/sealed-box.test.ts | 5 + .../shared/secret-variable-schema.test.ts | 1 + test/sections/shared/secrets-engine.test.ts | 12 +- test/sections/shared/setup-section.test.ts | 32 +- test/sections/snapshot-denial.test.ts | 2 + test/sections/snapshot-roundtrip.ts | 3 + test/sections/teams/snapshot-row.ts | 3 +- test/sections/teams/teams.test.ts | 6 +- test/sections/webhooks/events.test.ts | 5 +- test/sections/webhooks/mock.ts | 7 +- test/sections/webhooks/snapshot-row.ts | 3 +- 49 files changed, 985 insertions(+), 678 deletions(-) diff --git a/test/sections/autolinks/autolinks.test.ts b/test/sections/autolinks/autolinks.test.ts index 2624ebdfe..5f84849bd 100644 --- a/test/sections/autolinks/autolinks.test.ts +++ b/test/sections/autolinks/autolinks.test.ts @@ -9,7 +9,6 @@ import { REPO, unwrap } from "../section-run.js"; import { validatedInput } from "../validated-input.js"; import { autolinksMockHandlers } from "./mock.js"; -/** The list is unpaginated: one bare GET, no page params. */ const LIST = "GET /repos/o/r/autolinks"; const liveAutolinks = [ { id: 1, key_prefix: "JIRA-", url_template: "https://x.test/", is_alphanumeric: true }, @@ -79,6 +78,7 @@ describe("autolinks", () => { notes: [], drift: [], }); + // One bare GET: the list is unpaginated, so no page params. expect(api.calls.map((c) => `${c.method} ${c.path}`)).toEqual([LIST]); }); diff --git a/test/sections/autolinks/generators.ts b/test/sections/autolinks/generators.ts index bb3f3044d..a97257d95 100644 --- a/test/sections/autolinks/generators.ts +++ b/test/sections/autolinks/generators.ts @@ -13,10 +13,10 @@ import { } from "../../e2e/generators/gen-support.js"; import type { Rng } from "../../e2e/generators/prng.js"; -// No pool prefix begins another: a suffixed collision ("TICKET-" and "TICKET--1") would be the -// pair the section refuses before its first create. +// No pool prefix begins another: a suffixed collision ("TICKET-" and "TICKET--1") would be the pair the section refuses before its first create. const KEY_PREFIXES = ["JIRA-", "TICKET-", "REF-"] as const; +/** One autolink over the slice; genAutolinks overrides key_prefix from a shrinking pool, so a run never repeats one. */ const genAutolink = generatorFromSlice(AutolinkConfig, { fields: { key_prefix: (rng) => rng.pick(KEY_PREFIXES), diff --git a/test/sections/branches/branches.test.ts b/test/sections/branches/branches.test.ts index 3c481634c..bfd5887e3 100644 --- a/test/sections/branches/branches.test.ts +++ b/test/sections/branches/branches.test.ts @@ -36,7 +36,7 @@ import { buildState, completeRule, type LiveState, ruleWireNode } from "../../e2 import type { Json } from "../../e2e/mock/support.js"; import { captureIo } from "../../io/capture.js"; import { MockApi, withListing } from "../../mock-api.js"; -import { registryFake } from "../fragment-fake.js"; +import { graphqlErrorStatus, registryFake } from "../fragment-fake.js"; import { provePlanIdempotent } from "../plan-idempotence.js"; import { failureOf, REPO, unwrap } from "../section-run.js"; import { proveSnapshotRoundTrip } from "../snapshot-roundtrip.js"; @@ -46,6 +46,7 @@ import { branchesMockGraphqlHandlers, branchesMockHandlers, wildcardMatches } fr /** The bare-list form of the section's value; the tests never hand plan() the `{_layering, entries}` wrapper. */ type Desired = Extract, readonly unknown[]>; +/** The section's plan over the bare list, failures unwrapped. */ const plan = async (api: GitHubClient, desired: Desired) => unwrap( await branchesSection.plan( @@ -134,7 +135,7 @@ function liveRepo(live: LiveState): GitHubClient & { writes: Recorded[] } { const types = [...new Set(result.errors.map((e) => e.type))].sort(); return { error: { - status: types.includes("FORBIDDEN") ? 403 : types.includes("NOT_FOUND") ? 404 : 422, + status: graphqlErrorStatus(types), message: result.errors.map((e) => e.message).join("; "), body: JSON.stringify(result.errors), graphqlTypes: types, @@ -170,9 +171,15 @@ function ruleNode( }; } +/** The protection GET of main. */ const PROTECTION = "GET /repos/o/r/branches/main/protection"; + +/** The branch probe: a 404 on the protection GET reads as "unprotected" only when the branch exists. */ const PROBE = "GET /repos/o/r/branches/main"; + +/** The route params of every operation on main. */ const MAIN = { branch: "main" }; + /** The classic PUT body for `{enforce_admins: true}`: the omitted core keys null-filled. */ const NULL_FILLED = { enforce_admins: true, @@ -180,6 +187,7 @@ const NULL_FILLED = { required_pull_request_reviews: null, restrictions: null, }; + /** The whole failure for a user actor whose GraphQL lookup answers `user: null`. */ const GHOST_ACTOR_ERROR = 'branches: force_push_bypassers actor "ghost": the GraphQL lookup succeeded but returned no node id, so the allowance cannot be applied; re-run the workflow, and report this if it persists'; @@ -1420,13 +1428,13 @@ describe("branches plan contract", () => { // Compile-time only. const ctx = planContext(branchesSection, new MockApi({}), REPO); // @ts-expect-error a write role is not a read: the port has no `putProtection` - ctx.read.putProtection; + void ctx.read.putProtection; // @ts-expect-error nor a GraphQL mutation - ctx.read.updateRule; + void ctx.read.updateRule; // @ts-expect-error nor the raw client - ctx.api; + void ctx.api; // @ts-expect-error an "absent" primary read offers no must-succeed call - ctx.read.getProtection.call; + void ctx.read.getProtection.call; }); test("a planned operation can only name a declared write role, with the facets its route demands", () => { diff --git a/test/sections/branches/generators.ts b/test/sections/branches/generators.ts index 9147dcf00..0c1eb3db0 100644 --- a/test/sections/branches/generators.ts +++ b/test/sections/branches/generators.ts @@ -10,6 +10,7 @@ import { PROTECTION_RULE_APPS, } from "../../e2e/mock/state.js"; +/** The keys the classic PUT null-fills when omitted; one is drawn when every other draw left the protection empty. */ const PROTECTION_CORE_KEYS = [ "required_status_checks", "enforce_admins", @@ -17,74 +18,8 @@ const PROTECTION_CORE_KEYS = [ "restrictions", ] as const; -export function genBranches(rng: Rng): Json[] { - // Later families fork their own streams so recorded seeds keep reproducing. The GraphQL rule - // surface is gated onto a MINORITY of entries so most iterations stay pure-REST and the - // zero-GraphQL guarantee for existing users keeps getting exercised. - const sigRng = rng.fork("required-signatures"); - const bprRng = rng.fork("bpr"); - return Array.from({ length: rng.int(2) + 1 }, (_, i) => { - const name = `${rng.pick(["main", "release", "dev"])}-${i}`; - if (rng.bool(0.3)) { - return { name, protection: null }; - } - // The handler null-fills the omitted core keys, so any subset is valid input. - const protection: Json = {}; - if (rng.bool(0.6)) { - protection.required_pull_request_reviews = { - required_approving_review_count: rng.int(3) + 1, - }; - } - if (rng.bool(0.5)) { - protection.enforce_admins = rng.bool(); - } - if (rng.bool(0.4)) { - // Both spellings the PUT takes: bare names, or {context, app_id} items (-1 lets any App report). - protection.required_status_checks = rng.bool(0.3) - ? { strict: rng.bool(), checks: [{ context: "ci", app_id: -1 }, { context: "lint" }] } - : { strict: rng.bool(), contexts: [] }; - } - if (rng.bool(0.3)) { - protection.restrictions = null; - } - if (Object.keys(protection).length === 0) { - const key = rng.pick(PROTECTION_CORE_KEYS); - protection[key] = key === "enforce_admins" ? true : null; - } - if (sigRng.bool(0.3)) { - protection.required_signatures = sigRng.bool(); - } - // The first entry stays literal so every generated document reaches the protection read (the - // fault target); the draw is consumed either way. - if (bprRng.bool(0.25) && i > 0) { - // A wildcard entry may declare only keys with GraphQL twins. - const wildcard: Json = {}; - if (bprRng.bool(0.6)) { - wildcard.enforce_admins = bprRng.bool(); - } - if (bprRng.bool(0.4)) { - wildcard.required_status_checks = bprRng.bool(0.3) - ? null - : { strict: bprRng.bool(), contexts: [] }; - } - if (bprRng.bool(0.4)) { - wildcard.required_pull_request_reviews = { - required_approving_review_count: bprRng.int(3) + 1, - }; - } - if (Object.keys(wildcard).length === 0) { - wildcard.required_linear_history = true; - } - addRoutedGraphqlKeys(bprRng, wildcard); - return { - name: `${rng.pick(["main", "release", "dev"])}-${i}/*`, - protection: bprRng.bool(0.15) ? null : wildcard, - }; - } - addRoutedGraphqlKeys(bprRng, protection); - return { name, protection }; - }); -} +/** presenceLiveState seeds every one of these as a live environment, so the mock's silent drop of a name it lacks never turns a fully-granted apply into a read-back failure. */ +export const FUZZ_DEPLOYMENT_ENVIRONMENTS = ["fuzz-deploy-a", "fuzz-deploy-b"] as const; /** * Actors come from the mock's known rosters so a generated allowance always resolves; environment @@ -113,7 +48,83 @@ function addRoutedGraphqlKeys(bprRng: Rng, protection: Json): void { } /** - * presenceLiveState seeds every one of these as a live environment, so the verified silent-drop - * behavior (the mock keeps only EXISTING names) never turns a fully-granted apply into a read-back failure. + * A literal entry's protection: any subset of the core keys, since the handler null-fills the omitted + * ones, never empty, with the signature toggle drawn from its own stream. */ -export const FUZZ_DEPLOYMENT_ENVIRONMENTS = ["fuzz-deploy-a", "fuzz-deploy-b"] as const; +function genLiteralProtection(rng: Rng, sigRng: Rng): Json { + const protection: Json = {}; + if (rng.bool(0.6)) { + protection.required_pull_request_reviews = { + required_approving_review_count: rng.int(3) + 1, + }; + } + if (rng.bool(0.5)) { + protection.enforce_admins = rng.bool(); + } + if (rng.bool(0.4)) { + // Both spellings the PUT takes: bare names, or {context, app_id} items (-1 lets any App report). + protection.required_status_checks = rng.bool(0.3) + ? { strict: rng.bool(), checks: [{ context: "ci", app_id: -1 }, { context: "lint" }] } + : { strict: rng.bool(), contexts: [] }; + } + if (rng.bool(0.3)) { + protection.restrictions = null; + } + if (Object.keys(protection).length === 0) { + const key = rng.pick(PROTECTION_CORE_KEYS); + protection[key] = key === "enforce_admins" ? true : null; + } + if (sigRng.bool(0.3)) { + protection.required_signatures = sigRng.bool(); + } + return protection; +} + +/** A wildcard entry's protection: only keys with GraphQL twins, never empty. */ +function genWildcardProtection(bprRng: Rng): Json { + const wildcard: Json = {}; + if (bprRng.bool(0.6)) { + wildcard.enforce_admins = bprRng.bool(); + } + if (bprRng.bool(0.4)) { + wildcard.required_status_checks = bprRng.bool(0.3) + ? null + : { strict: bprRng.bool(), contexts: [] }; + } + if (bprRng.bool(0.4)) { + wildcard.required_pull_request_reviews = { + required_approving_review_count: bprRng.int(3) + 1, + }; + } + if (Object.keys(wildcard).length === 0) { + wildcard.required_linear_history = true; + } + return wildcard; +} + +export function genBranches(rng: Rng): Json[] { + // Later families fork their own streams so recorded seeds keep reproducing. The GraphQL rule + // surface is gated onto a MINORITY of entries so most iterations stay pure-REST and the + // zero-GraphQL guarantee for existing users keeps getting exercised. + const sigRng = rng.fork("required-signatures"); + const bprRng = rng.fork("bpr"); + return Array.from({ length: rng.int(2) + 1 }, (_, i) => { + const name = `${rng.pick(["main", "release", "dev"])}-${i}`; + if (rng.bool(0.3)) { + return { name, protection: null }; + } + const protection = genLiteralProtection(rng, sigRng); + // The first entry stays literal so every generated document reaches the protection read (the + // fault target); the draw is consumed either way. + if (bprRng.bool(0.25) && i > 0) { + const wildcard = genWildcardProtection(bprRng); + addRoutedGraphqlKeys(bprRng, wildcard); + return { + name: `${rng.pick(["main", "release", "dev"])}-${i}/*`, + protection: bprRng.bool(0.15) ? null : wildcard, + }; + } + addRoutedGraphqlKeys(bprRng, protection); + return { name, protection }; + }); +} diff --git a/test/sections/branches/mock.ts b/test/sections/branches/mock.ts index d970fba10..505cd8964 100644 --- a/test/sections/branches/mock.ts +++ b/test/sections/branches/mock.ts @@ -36,6 +36,65 @@ import { type SectionRestHandlers, } from "../../e2e/mock/support.js"; +/** + * The members of the bracket class whose body starts at `start`, as regex-class text, with the index of + * the closing bracket (-1 when none closes it). Members walk one at a time: a backslash escapes the next + * character (so an escaped "]" does not close the class), an unescaped "-" keeps its range meaning, + * everything else is literal. + */ +function classMembers(pattern: string, start: number): { members: string; close: number } { + let members = ""; + for (let j = start; j < pattern.length; j++) { + const member = pattern[j] as string; + if (member === "]") { + return { members, close: j }; + } + const escaped = member === "\\" && j + 1 < pattern.length; + const literal = escaped ? (pattern[++j] as string) : member; + members += !escaped && literal === "-" ? "-" : literal.replace(/[\]\\^[-]/g, "\\$&"); + } + return { members, close: -1 }; +} + +/** The regex of the bracket class opening at `open` and the index of its closing bracket, or null when none closes it, so the bracket is literal. */ +function bracketClass(pattern: string, open: number): { regex: string; close: number } | null { + // Ruby negates on "!" or "^"; a leading "]" closes the class at once (an empty class matches nothing). + const negated = pattern[open + 1] === "!" || pattern[open + 1] === "^"; + const { members, close } = classMembers(pattern, open + (negated ? 2 : 1)); + if (close < 0) { + return null; + } + // No class consumes a slash (fnmatch's FNM_PATHNAME), a positive one listing it included. + return { regex: negated ? `[^/${members}]` : `(?!/)[${members}]`, close }; +} + +/** The regex of the pattern token at `at` and the index of its last character. */ +function tokenAt(pattern: string, at: number): { regex: string; last: number } { + const ch = pattern[at] as string; + if (ch === "*" && pattern[at + 1] === "*" && pattern[at + 2] === "/") { + return { regex: "(?:[^/]*/)*", last: at + 2 }; + } + if (ch === "*") { + return { regex: "[^/]*", last: pattern[at + 1] === "*" ? at + 1 : at }; + } + if (ch === "?") { + return { regex: "[^/]", last: at }; + } + if (ch === "\\") { + // A backslash escapes the next character (Ruby has no FNM_NOESCAPE here); a trailing one is dropped. + const next = pattern[at + 1]; + if (next === undefined) { + return { regex: "", last: at }; + } + return { regex: next.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"), last: at + 1 }; + } + if (ch === "[") { + const cls = bracketClass(pattern, at); + return cls === null ? { regex: "\\[", last: at } : { regex: cls.regex, last: cls.close }; + } + return { regex: ch.replace(/[.+^${}()|\\]/g, "\\$&"), last: at }; +} + /** * GitHub's fnmatch (Ruby's, FNM_PATHNAME) for classic rule patterns: `*` and `?` stop at a slash, * only a double star followed by a slash crosses one (a bare double star is `*`), a bracket class @@ -45,52 +104,9 @@ import { export function wildcardMatches(pattern: string, branch: string): boolean { let regex = ""; for (let i = 0; i < pattern.length; i++) { - const ch = pattern[i] as string; - if (ch === "*" && pattern[i + 1] === "*" && pattern[i + 2] === "/") { - regex += "(?:[^/]*/)*"; - i += 2; - } else if (ch === "*") { - regex += "[^/]*"; - if (pattern[i + 1] === "*") { - i++; - } - } else if (ch === "?") { - regex += "[^/]"; - } else if (ch === "\\") { - // A backslash escapes the next character (Ruby has no FNM_NOESCAPE here); a trailing one is dropped. - const next = pattern[i + 1]; - if (next !== undefined) { - regex += next.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"); - i++; - } - } else if (ch === "[") { - // Ruby negates on "!" or "^"; a leading "]" closes the class at once (an empty class matches nothing). - const negated = pattern[i + 1] === "!" || pattern[i + 1] === "^"; - const start = i + (negated ? 2 : 1); - // Members walk one at a time: a backslash escapes the next character (so an escaped "]" does - // not close the class), an unescaped "-" keeps its range meaning, everything else is literal. - let members = ""; - let close = -1; - for (let j = start; j < pattern.length; j++) { - const member = pattern[j] as string; - if (member === "]") { - close = j; - break; - } - const escaped = member === "\\" && j + 1 < pattern.length; - const literal = escaped ? (pattern[++j] as string) : member; - members += !escaped && literal === "-" ? "-" : literal.replace(/[\]\\^[-]/g, "\\$&"); - } - if (close < 0) { - regex += "\\["; - } else { - // No class consumes a slash (fnmatch's FNM_PATHNAME), a positive one listing it included. - regex += negated ? `[^/${members}]` : `(?!/)[${members}]`; - i = close; - } - } else { - regex += ch.replace(/[.+^${}()|\\]/g, "\\$&"); - } + const token = tokenAt(pattern, i); + regex += token.regex; + i = token.last; } return new RegExp(`^${regex}$`).test(branch); } @@ -185,11 +201,7 @@ function actorsAsGitHubReadsBack(payload: Json): Json { return out; } -/** - * The boolean controls the protection PUT takes: the GET-wrapped ones minus required_signatures, - * which is its own sub-resource. GitHub 422s a non-boolean there, the {url, enabled} wrapper a - * copied GET response carries included; null passes only under the NULLABLE_CONTROLS. - */ +/** The boolean controls the protection PUT takes (the GET-wrapped ones minus required_signatures, its own sub-resource): GitHub 422s a non-boolean there, a copied GET's {url, enabled} wrapper included, and null passes only under NULLABLE_CONTROLS. */ const PUT_BOOLEAN_CONTROLS = [...BOOLEAN_CONTROL_SET].filter( (key) => key !== "required_signatures", ); @@ -240,6 +252,21 @@ function missingRestrictionListResponse(payload: Json): MockResponse | null { return missing === undefined ? null : validationFailed(`"${missing}" wasn't supplied.`); } +/** The branches a `protected` query filter keeps: the protected ones, the unprotected ones, or all when it is absent. */ +function filteredByProtection( + all: string[], + protectedNames: string[], + filter: string | undefined, +): string[] { + if (filter === "true") { + return all.filter((name) => protectedNames.includes(name)); + } + if (filter === "false") { + return all.filter((name) => !protectedNames.includes(name)); + } + return all; +} + export const branchesMockHandlers: SectionRestHandlers<"branches"> = { "branches.listProtected": ({ state, param, query }) => { // A branch a wildcard rule matches is protected too, as on GitHub, where the REST view serves @@ -249,12 +276,7 @@ export const branchesMockHandlers: SectionRestHandlers<"branches"> = { ].filter((name) => effectiveProtection(state, name) !== null); // A protected branch exists even when the branches family does not list it. const all = [...new Set([...state.branches, ...protectedNames])]; - const names = - query.protected === "true" - ? all.filter((name) => protectedNames.includes(name)) - : query.protected === "false" - ? all.filter((name) => !protectedNames.includes(name)) - : all; + const names = filteredByProtection(all, protectedNames, query.protected); const slug = `${param("owner")}/${param("repo")}`; return paged( names.map((name) => ({ diff --git a/test/sections/branches/schema.test.ts b/test/sections/branches/schema.test.ts index fb2104b82..d2e375666 100644 --- a/test/sections/branches/schema.test.ts +++ b/test/sections/branches/schema.test.ts @@ -15,14 +15,16 @@ const WILDCARD_KEYS = "required_signatures, required_status_checks, required_pull_request_reviews, " + "force_push_bypassers, required_deployments"; -const cases: Array<{ +/** One refusal, pinned by the message of every issue in full (one per path when they differ): the path never stands in for it. */ +interface Case { refused: string; protection: Record; name?: string; paths: string[]; - /** The message of every issue in full (one per path when they differ): the path never stands in for it. */ fix: string | string[]; -}> = [ +} + +const cases: Case[] = [ { refused: "a status-check requirement without strict (the PUT 422s on it)", protection: { required_status_checks: { contexts: ["ci"] } }, diff --git a/test/sections/branches/snapshot-row.ts b/test/sections/branches/snapshot-row.ts index 5bda1af19..94f624b26 100644 --- a/test/sections/branches/snapshot-row.ts +++ b/test/sections/branches/snapshot-row.ts @@ -1,9 +1,7 @@ import { branchesSection } from "../../../src/sections/branches/index.js"; import type { Row } from "../snapshot-roundtrip.js"; -// The GET shape: url keys, {enabled} wrappers, actor objects, the checks list spelled twice. The -// rule surface beside it: main's GraphQL-only fields, and a wildcard rule that alone protects -// release/1.0, which the snapshot must therefore not write as a literal entry. +/** The GET shape (url keys, {enabled} wrappers, actor objects, the checks list spelled twice) beside main's GraphQL-only fields and a wildcard rule that alone protects release/1.0, which the snapshot must not write as a literal entry. */ export const row: Row = { section: branchesSection, live: { diff --git a/test/sections/contract/contract.test.ts b/test/sections/contract/contract.test.ts index 0ab64c4eb..a2b149042 100644 --- a/test/sections/contract/contract.test.ts +++ b/test/sections/contract/contract.test.ts @@ -865,26 +865,26 @@ describe("planContext read port", () => { // The same at the type level, on the section declaring every kind of role. const ctx = planContext(environmentsSection, new MockApi({}), REPO); // @ts-expect-error a REST write role is not on the port - ctx.read.update; + void ctx.read.update; // @ts-expect-error nor a GraphQL mutation - ctx.read.pin; + void ctx.read.pin; // @ts-expect-error nor a role the section never declared - ctx.read.typo; + void ctx.read.typo; // @ts-expect-error nor the raw client: every request goes through a bound read - ctx.api; + void ctx.api; // A posture narrows the helpers: an "absent" primary read offers no must-succeed call and neither list, // @ts-expect-error - ctx.read.probe.call; + void ctx.read.probe.call; // @ts-expect-error - ctx.read.probe.listAll; + void ctx.read.probe.listAll; // @ts-expect-error - ctx.read.probe.listAllEnveloped; + void ctx.read.probe.listAllEnveloped; // a "denied" one no 404-tolerant helper. const denied = planContext(rulesetsSection, new MockApi({}), REPO); // @ts-expect-error - denied.read.list.probeAbsent; + void denied.read.list.probeAbsent; // @ts-expect-error - denied.read.list.tryCall; + void denied.read.list.tryCall; expect([typeof ctx.read.probe.probeAbsent, typeof denied.read.list.listAll]).toEqual([ "function", "function", @@ -919,13 +919,13 @@ describe("planContext read port", () => { }); const ctx = planContext(advisory, api, REPO); // @ts-expect-error an advisory read offers no must-succeed call - ctx.read.probe.call; + void ctx.read.probe.call; // @ts-expect-error nor an absence probe - ctx.read.probe.probeAbsent; + void ctx.read.probe.probeAbsent; // @ts-expect-error nor a list - ctx.read.probe.listAll; + void ctx.read.probe.listAll; // @ts-expect-error nor an enveloped list - ctx.read.probe.listAllEnveloped; + void ctx.read.probe.listAllEnveloped; expect(await ctx.read.probe.tryCall(z.unknown(), { params: { branch: "main" } })).toEqual( ok({ error: { status: 500, message: "Internal Server Error", body: "" } }), ); @@ -956,7 +956,7 @@ describe("planContext read port", () => { } as const satisfies SectionMeta; const ctx = planContext(both, new MockApi({}), REPO); // @ts-expect-error the denied posture's call is not offered under advisory - ctx.read.probe.call; + void ctx.read.probe.call; }); test("an execution-phase read demands the ExecTools token only a thunk holds, REST and GraphQL alike", async () => { @@ -1269,9 +1269,15 @@ describe("a marked request's failure is rebuilt on the engine's side of the clie ).then(unwrap); test.each<{ + /** The wire and the client's answer, as the case title. */ wire: string; + + /** The call under test. */ run: () => Promise; + + /** The whole message the thrown error carries. */ thrown: string; + /** The rebuilt failure's kind; absent where the client's own throw passes through untouched. */ kind?: SectionFailure["kind"]; }>([ diff --git a/test/sections/contract/owner-gate.test.ts b/test/sections/contract/owner-gate.test.ts index 7e7b0400c..b158b1944 100644 --- a/test/sections/contract/owner-gate.test.ts +++ b/test/sections/contract/owner-gate.test.ts @@ -13,8 +13,10 @@ import { MockApi } from "../../mock-api.js"; import { failureOf, REPO, unwrap } from "../section-run.js"; import { validatedInput } from "../validated-input.js"; +/** The sections the owner gate guards. */ const gated = SECTIONS.filter((section) => section.ownerSensitivity === "org"); +/** The owner probe the gate issues. */ const ORG = "GET /orgs/o"; /** Each gated section's primary read over o/r, empty, so a plan of nothing converges after the probe. */ @@ -23,8 +25,10 @@ const LISTS = { "GET /repos/o/r/properties/values": { data: [] }, }; +/** The requests a client saw, as "METHOD path". */ const requests = (api: MockApi) => api.calls.map((c) => `${c.method} ${c.path}`); +/** Every gated section's plan of nothing over `repo`, as the notes they return. */ const planAll = async (api: GitHubClient, repo: RepoRef = REPO): Promise => { const notes: string[] = []; for (const section of gated) { diff --git a/test/sections/contract/section-failure.test.ts b/test/sections/contract/section-failure.test.ts index 60f421116..3ff6d6264 100644 --- a/test/sections/contract/section-failure.test.ts +++ b/test/sections/contract/section-failure.test.ts @@ -1,3 +1,8 @@ +/** + * failureFor's ladder: its order decides the kind (a rate-limited 403 is never a denial), and the line it renders is + * the one the loops report. + */ + import { describe, expect, test } from "bun:test"; import { z } from "zod"; import type { EndpointDecl } from "../../../src/sections/contract/endpoints.js"; @@ -7,7 +12,6 @@ import type { SectionContext, SectionMeta } from "../../../src/sections/contract import { listAll } from "../../../src/sections/contract/requests.js"; import { MockApi } from "../../mock-api.js"; -/** The ladder's order decides the kind (a rate-limited 403 is never a denial); the line is the one the loops report. */ const section: SectionMeta = { key: "rulesets", permission: { repo: ["administration"] }, diff --git a/test/sections/contract/validate-hook.test.ts b/test/sections/contract/validate-hook.test.ts index aa28283a8..e2a1f9f7d 100644 --- a/test/sections/contract/validate-hook.test.ts +++ b/test/sections/contract/validate-hook.test.ts @@ -21,9 +21,11 @@ import { repositorySection } from "../../../src/sections/repository/index.js"; type EntryBearing = { [K in SectionKey]: [EntryOf>] extends [never] ? never : K; }[SectionKey]; -// Each direction fails naming the section: an entry-bearing section outside the list has nowhere to go in -// `Record`, and a listed section without entries leaves its table slot `never`. + +// Each direction fails naming the section: an entry-bearing section outside the list has nowhere to go in `Record`, and a listed section without entries leaves its table slot `never`. const _everyEntryBearingSectionIsListed: Record, never> = {}; + +/** One valid entry per list section; doubled, it is the duplicate every validate hook must refuse. */ const ONE_ENTRY: { readonly [K in ListSection]: [EntryOf>] extends [never] ? never @@ -72,15 +74,18 @@ describe("every list section's validate hook reaches the engine", () => { ); }); -// The contract makes validate mandatory on a list section and optional on a mapping section; the census above pins -// the runtime half, these declarations the compile-time half. The erased roster keeps it optional, so every module -// erases into it. +// The contract makes validate mandatory on a list section and optional on a mapping section; the census above pins the runtime half, these declarations the compile-time half. const { validate: _dropped, ...withoutValidate } = labelsSection; + // @ts-expect-error labels is a list section, so registering it without validate is flagged by name const _list: SectionModule<"labels", typeof labelsSection.endpoints> = withoutValidate; + +/** A mapping section registers without validate. */ const _mapping: SectionModule< "repository", typeof repositorySection.endpoints, typeof repositorySection.graphql > = repositorySection; + +/** The erased roster keeps validate optional, so every module erases into it. */ const _erased: SectionModule = labelsSection; diff --git a/test/sections/deploy_keys/deploy_keys.test.ts b/test/sections/deploy_keys/deploy_keys.test.ts index e76952363..0d731c932 100644 --- a/test/sections/deploy_keys/deploy_keys.test.ts +++ b/test/sections/deploy_keys/deploy_keys.test.ts @@ -23,23 +23,39 @@ function liveKey(id: number, title: string, key: string, read_only = false) { return { id, title, key, read_only, verified: true, created_at: "2026-01-01T00:00:00Z" }; } +/** An ed25519 key, the one most cases declare. */ const BOT_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBotBotBotBotBotBotBotBotBotBotBotBotBotBotB"; + +/** A second ed25519 key, the material the mirror-pull title declares. */ const MIRROR_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMirrorMirrorMirrorMirrorMirrorMirrorMirrorM"; + +/** The ed25519 key the live mirror-pull entry holds before the declared one replaces it. */ const STALE_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIStaleStaleStaleStaleStaleStaleStaleStaleSta"; + +/** An RSA key, the material of the undeclared retired-service entry. */ const RETIRED_KEY = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCRetiredRetiredRetiredRetiredRetiredRetir="; + // The REAL OpenSSH private-key framing, assembled at runtime so no source line carries the string a secret scanner flags. const PRIVATE_KEY_HEADER = ["-----BEGIN", "OPENSSH PRIVATE", "KEY-----"].join(" "); + +/** The closing half of the same framing. */ const PRIVATE_KEY_FOOTER = ["-----END", "OPENSSH PRIVATE", "KEY-----"].join(" "); + +/** A whole private key in that framing, over a synthetic body. */ const PRIVATE_KEY = `${PRIVATE_KEY_HEADER}\nU3ludGhldGljRml4dHVyZUJvZHk=\n${PRIVATE_KEY_FOOTER}`; + // A PEM-framed PUBLIC key (ssh-keygen -e -m PKCS8): not a private key, and not the form GitHub takes either. const PEM_PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAU3ludGhldGljRml4dHVyZUJvZHlQdWJsaWM=\n-----END PUBLIC KEY-----"; + // A key under an algorithm this list lacks, in the two-field shape GitHub stores. const ED448_KEY = "ssh-ed448 AAAACXNzaC1lZDQ0OAAAADlFZDQ0OEZ1dHVyZUFsZ29yaXRobUZ1dHVyZUFsZ29yaXRobUZ1dHVyZUE="; + +/** The section's plan over `desired`, failures unwrapped. */ const plan = async (api: MockApi, desired: SectionInput<"deploy_keys">) => unwrap( await deployKeysSection.plan( diff --git a/test/sections/deploy_keys/generators.ts b/test/sections/deploy_keys/generators.ts index 1e131c805..74ed0e9b7 100644 --- a/test/sections/deploy_keys/generators.ts +++ b/test/sections/deploy_keys/generators.ts @@ -1,5 +1,6 @@ /** - * The deploy_keys fuzz generator fragment. + * The deploy_keys fuzz generator fragment. The pool's key comments are load-bearing: the mock strips them on storage + * the way GitHub does, so a converging apply proves the section compares algorithm + blob, not the string. */ import { deployKeysSection } from "../../../src/sections/deploy_keys/index.js"; @@ -14,12 +15,7 @@ import { } from "../../e2e/generators/gen-support.js"; import type { Rng } from "../../e2e/generators/prng.js"; -/** - * Each title owns one blob, DISTINCT from the others (GitHub rejects a reused public key with a 422, and the section - * refuses the pair at validation), so two layers naming one title agree on its material and two titles never share - * one. The comments are load-bearing: the mock strips them on storage the way GitHub does, so a converging apply - * proves the section compares algorithm + blob, not the string. - */ +/** Each title owns one blob, distinct from the others (GitHub 422s a reused public key and the section refuses the pair), so two layers naming one title agree on its material. */ const DEPLOY_KEY_POOL = { bot: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE2e2eFuzzAlphaAlphaAlphaAlphaAlphaAlphaAlph deploy@alpha", ci: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE2e2eFuzzBravoBravoBravoBravoBravoBravoBrav deploy@bravo", @@ -29,8 +25,10 @@ const DEPLOY_KEY_POOL = { "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTZAAAAIbmlzdHAyNTYAAABBBe2e deploy@delta", } as const; +/** The pool's titles in declaration order; genDeployKeys walks a run of them from a random start. */ const DEPLOY_KEY_TITLES = Object.keys(DEPLOY_KEY_POOL) as (keyof typeof DEPLOY_KEY_POOL)[]; +/** The generator for one title, its key pinned to that title's blob. */ const genDeployKey = (title: keyof typeof DEPLOY_KEY_POOL) => generatorFromSlice(DeployKeyConfig, { fields: { title: () => `deploy-${title}`, key: () => DEPLOY_KEY_POOL[title] }, diff --git a/test/sections/deploy_keys/mock.ts b/test/sections/deploy_keys/mock.ts index 3931904a3..d2681d3dc 100644 --- a/test/sections/deploy_keys/mock.ts +++ b/test/sections/deploy_keys/mock.ts @@ -7,10 +7,7 @@ import { deployKeysSection } from "../../../src/sections/deploy_keys/index.js"; import { type ListMockSpec, mockFragmentFor } from "../../e2e/mock/list-fragment.js"; import { type SectionRestHandlers, storedKeyMaterial } from "../../e2e/mock/support.js"; -/** - * The material is stored comment-free the way GitHub normalizes it, the audit fields are fixed so a - * repeat apply stays byte-stable, and uniqueness is by stored material: GitHub allows repeated titles. - */ +/** The material is stored comment-free the way GitHub normalizes it, the audit fields are fixed so a repeat apply stays byte-stable, and uniqueness is by stored material, since GitHub allows repeated titles. */ export const DEPLOY_KEYS_MOCK: ListMockSpec = { collection: (state) => state.deploy_keys, defaults: { read_only: false }, @@ -24,6 +21,7 @@ export const DEPLOY_KEYS_MOCK: ListMockSpec = { unique: (key) => storedKeyMaterial(String(key.key ?? "")), }; +/** The handlers derived from the section's declaration over the spec above. */ export const deployKeysMockHandlers: SectionRestHandlers<"deploy_keys"> = mockFragmentFor( deployKeysSection, DEPLOY_KEYS_MOCK, diff --git a/test/sections/deploy_keys/schema.test.ts b/test/sections/deploy_keys/schema.test.ts index 3481807d0..ce153ab2e 100644 --- a/test/sections/deploy_keys/schema.test.ts +++ b/test/sections/deploy_keys/schema.test.ts @@ -15,6 +15,7 @@ function issues(entries: unknown[]): readonly string[] | null { ); } +/** A well-formed ed25519 key: the control each refusal is measured against, and the material two entries share. */ const BOT_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBotBotBotBotBotBotBotBotBotBotBotBotBotBotB"; /** Assembled at runtime so no source line holds the header a secret scanner flags. */ diff --git a/test/sections/docs-registry.test.ts b/test/sections/docs-registry.test.ts index 3d388c207..c28809a04 100644 --- a/test/sections/docs-registry.test.ts +++ b/test/sections/docs-registry.test.ts @@ -37,6 +37,7 @@ function isDocsFile(path: string): boolean { // Bun's own scanner, so no import form slips past a regex; type-only imports are erased and carry no prose. const transpiler = new Bun.Transpiler({ loader: "ts" }); + function importSpecifiers(source: string): string[] { return transpiler.scanImports(source).map((entry) => entry.path); } @@ -193,6 +194,23 @@ describe("section docs completeness", () => { })); }); +/** The policy a Notes-cell claim asserts once its negation is resolved, and whether a negation flipped it; a double negation is refused, since it is not resolved. */ +function claimedPolicy( + key: SectionKey, + notes: string, + claim: RegExpMatchArray, +): { effective: "delete" | "keep"; negated: boolean } { + const family = CLAIM_FAMILY.delete.test(claim[1] ?? "") ? "delete" : "keep"; + const negation = stemNegation(notes.slice(0, claim.index)); + if ("doubleNegation" in negation) { + throw new Error( + `the ${key} Notes cell: a double negation governs "${negation.doubleNegation} ${claim[1]}"; reword it - double negatives are not resolved`, + ); + } + const flipped = family === "delete" ? "keep" : "delete"; + return { effective: negation.negated ? flipped : family, negated: negation.negated }; +} + describe("Notes cells vs undeclaredDefault", () => { test("a knobbed section's Notes cell never claims the opposite of its undeclaredDefault", () => { // A claim is a claim-family word joined to "default"; a cell that mentions a default without a parseable claim fails loudly rather than leaving @@ -216,18 +234,10 @@ describe("Notes cells vs undeclaredDefault", () => { ).toBeGreaterThan(0); } for (const claim of claims) { - const family = CLAIM_FAMILY.delete.test(claim[1] ?? "") ? "delete" : "keep"; - const negation = stemNegation(notes.slice(0, claim.index)); - if ("doubleNegation" in negation) { - throw new Error( - `the ${section.key} Notes cell: a double negation governs "${negation.doubleNegation} ${claim[1]}"; reword it - double negatives are not resolved`, - ); - } - const flipped = family === "delete" ? "keep" : "delete"; - const effective = negation.negated ? flipped : family; + const { effective, negated } = claimedPolicy(section.key, notes, claim); expect( effective, - `the ${section.key} Notes cell claims "${claim[0]}"${negation.negated ? " (negated)" : ""}, contradicting its "${section.undeclaredDefault}" undeclaredDefault`, + `the ${section.key} Notes cell claims "${claim[0]}"${negated ? " (negated)" : ""}, contradicting its "${section.undeclaredDefault}" undeclaredDefault`, ).toBe(section.undeclaredDefault); } } diff --git a/test/sections/duplicate-live.test.ts b/test/sections/duplicate-live.test.ts index 173e85d8e..c88acbc7e 100644 --- a/test/sections/duplicate-live.test.ts +++ b/test/sections/duplicate-live.test.ts @@ -24,19 +24,17 @@ import { validatedInput } from "./validated-input.js"; interface Seed { /** The list the pair sits in (`labels`, `environments[prod].variables`). */ readonly list: string; + + /** The live state the fake is seeded with, the pair inside it. */ readonly live: LiveState; - /** - * The declared value plan() runs against; absent when plan() has no list read over this identity - * (branches probes by name; the environment listing is snapshot's alone), so only snapshot() is proven. - */ + + /** The declared value plan() runs against; absent when plan() has no list read over this identity (branches probes by name; the environment listing is snapshot's alone), so only snapshot() is proven. */ readonly declared?: unknown; + /** The refusal, as liveByIdentity renders it: the noun, then each pair through liveIdentity. */ readonly refusal: string; - /** - * A response the mock's state cannot hold (a record keyed by the identity itself, node ids minted - * from the identity): the pair is served in place of the mock's answer to every GET on this path - * (any page), or to the named GraphQL operations. - */ + + /** A response the mock's state cannot hold (a record keyed by the identity itself, node ids minted from the identity), served in place of the mock's answer to every GET on this path (any page) or to the named GraphQL operations. */ readonly served?: | { readonly path: string; readonly body: unknown; readonly ops?: never } | { @@ -52,7 +50,9 @@ interface Seed { * the port, a GraphQL connection) fails the section's exemption. */ interface NoLiveList { + /** Why the section reads no live list. */ readonly noLiveList: string; + /** The declared value plan() runs against for the check. */ readonly declared: unknown; } diff --git a/test/sections/environments/generators.ts b/test/sections/environments/generators.ts index 9739fbbf9..b1cddf7d2 100644 --- a/test/sections/environments/generators.ts +++ b/test/sections/environments/generators.ts @@ -6,6 +6,102 @@ import { E2E_SECRET_ENV, type Json } from "../../e2e/generators/gen-support.js"; import type { Rng } from "../../e2e/generators/prng.js"; import { PROTECTION_RULE_APPS } from "../../e2e/mock/state.js"; +/** The review settings, on the main stream: a wait timer on half the draws, the self-review flag on another half. */ +function addReviewSettings(rng: Rng, env: Json): void { + if (rng.bool()) { + env.wait_timer = rng.int(30); + } + if (rng.bool()) { + // The flag rides the required-reviewers rule, so a true draw brings a reviewer with it; the + // parse rules refuse the flag on its own. No extra main-stream draw, so recorded seeds reproduce. + env.prevent_self_review = rng.bool(); + if (env.prevent_self_review === true) { + env.reviewers = [{ type: "User", id: 101 }]; + } + } +} + +/** + * A variables list on a third of the draws. Mixed-case picks exercise the case-insensitive match; the + * suffix keeps names unique after it. Over the empty live baseline an explicit `_undeclared` would + * change no outcome, so the wrapped draw omits it (curated scenarios pin the keep-note and delete paths). + */ +function addVariables(variablesRng: Rng, env: Json): void { + if (!variablesRng.bool(0.35)) { + return; + } + const entries: Json[] = Array.from({ length: variablesRng.int(2) + 1 }, (_, j) => ({ + name: `${variablesRng.pick(["DEPLOY_REGION", "log_level", "Retries"])}_${j}`, + value: variablesRng.pick(["eu-west-1", "debug", "3"]), + })); + env.variables = variablesRng.bool(0.25) ? { entries } : entries; +} + +/** + * A secrets list on a third of the draws. References come from the fixed pool so scenarioSecretEnv can + * wire the child env. Same-named secrets across sibling environments are deliberately common: + * per-environment scope resolution is exactly what that exercises. + */ +function addSecrets(secretsRng: Rng, env: Json): void { + if (!secretsRng.bool(0.3)) { + return; + } + const names = Object.keys(E2E_SECRET_ENV); + const count = secretsRng.int(names.length) + 1; + const entries: Json[] = names.slice(0, count).map((name) => ({ + name, + value: `$${name}`, + })); + env.secrets = secretsRng.bool(0.25) ? { entries } : entries; +} + +/** + * A branch policy list on a third of the draws. The suffix keeps names unique (the natural key is the + * exact pattern string). The list is ALWAYS paired with custom_branch_policies: true, so the oracle never + * sees the validation-error path. + */ +function addBranchPolicies(policiesRng: Rng, env: Json): void { + if (!policiesRng.bool(0.3)) { + return; + } + const entries: Json[] = Array.from({ length: policiesRng.int(2) + 1 }, (_, j) => { + const entry: Json = { name: `${policiesRng.pick(["release/*", "hotfix/*", "v*"])}-${j}` }; + if (policiesRng.bool(0.4)) { + entry.type = policiesRng.pick(["branch", "tag"]); + } + return entry; + }); + env.deployment_branch_policies = policiesRng.bool(0.25) ? { entries } : entries; + env.deployment_branch_policy = { protected_branches: false, custom_branch_policies: true }; +} + +/** + * A protection rule list on a third of the draws. Slugs come ONLY from PROTECTION_RULE_APPS (the mock's + * available-Apps listing serves the same objects), so every declared rule resolves. The wrapped draw + * omits `_undeclared` for the same reason as the variables. + */ +function addProtectionRules(rulesRng: Rng, env: Json): void { + if (!rulesRng.bool(0.3)) { + return; + } + const slugs = PROTECTION_RULE_APPS.map((app) => String(app.slug)); + const count = rulesRng.int(slugs.length) + 1; + const entries: Json[] = slugs.slice(0, count).map((slug) => ({ app: slug })); + env.deployment_protection_rules = rulesRng.bool(0.25) ? { entries } : entries; +} + +/** + * The pin on a quarter of the draws, so the GraphQL pins read runs on a subset of iterations; curated + * scenarios pin the interleaving and cap paths. + * pinned: true -> at most 3 entries, so the count never approaches GitHub's cap + * pinned: false -> a no-op unpin over the empty baseline: the read runs, no mutation + */ +function addPin(pinnedRng: Rng, env: Json): void { + if (pinnedRng.bool(0.25)) { + env.pinned = pinnedRng.bool(0.7); + } +} + export function genEnvironments(rng: Rng): Json[] { // Each family's draws fork their own stream, so adding one never disturbs the main stream and // recorded seeds keep reproducing. @@ -16,67 +112,12 @@ export function genEnvironments(rng: Rng): Json[] { const pinnedRng = rng.fork("pinned"); return Array.from({ length: rng.int(2) + 1 }, (_, i) => { const env: Json = { name: `${rng.pick(["staging", "prod", "qa"])}-${i}` }; - if (rng.bool()) { - env.wait_timer = rng.int(30); - } - if (rng.bool()) { - // The flag rides the required-reviewers rule, so a true draw brings a reviewer with it; the - // parse rules refuse the flag on its own. No extra main-stream draw, so recorded seeds reproduce. - env.prevent_self_review = rng.bool(); - if (env.prevent_self_review === true) { - env.reviewers = [{ type: "User", id: 101 }]; - } - } - if (variablesRng.bool(0.35)) { - // Mixed-case picks exercise the case-insensitive match; the suffix keeps names unique after it. - // Over the empty live baseline an explicit `_undeclared` would change no outcome, so the wrapped - // draw omits it (curated scenarios pin the keep-note and delete paths). - const entries: Json[] = Array.from({ length: variablesRng.int(2) + 1 }, (_, j) => ({ - name: `${variablesRng.pick(["DEPLOY_REGION", "log_level", "Retries"])}_${j}`, - value: variablesRng.pick(["eu-west-1", "debug", "3"]), - })); - env.variables = variablesRng.bool(0.25) ? { entries } : entries; - } - if (secretsRng.bool(0.3)) { - // References come from the fixed pool so scenarioSecretEnv can wire the child env. Same-named - // secrets across sibling environments are deliberately common: per-environment scope - // resolution is exactly what that exercises. - const names = Object.keys(E2E_SECRET_ENV); - const count = secretsRng.int(names.length) + 1; - const entries: Json[] = names.slice(0, count).map((name) => ({ - name, - value: `$${name}`, - })); - env.secrets = secretsRng.bool(0.25) ? { entries } : entries; - } - if (policiesRng.bool(0.3)) { - // The suffix keeps names unique (the natural key is the exact pattern string). The list is - // ALWAYS paired with custom_branch_policies: true, so the oracle never sees the validation-error path. - const entries: Json[] = Array.from({ length: policiesRng.int(2) + 1 }, (_, j) => { - const entry: Json = { name: `${policiesRng.pick(["release/*", "hotfix/*", "v*"])}-${j}` }; - if (policiesRng.bool(0.4)) { - entry.type = policiesRng.pick(["branch", "tag"]); - } - return entry; - }); - env.deployment_branch_policies = policiesRng.bool(0.25) ? { entries } : entries; - env.deployment_branch_policy = { protected_branches: false, custom_branch_policies: true }; - } - if (rulesRng.bool(0.3)) { - // Slugs come ONLY from PROTECTION_RULE_APPS (the mock's available-Apps listing serves the same - // objects), so every declared rule resolves. The wrapped draw omits `_undeclared` for the same - // reason as the variables above. - const slugs = PROTECTION_RULE_APPS.map((app) => String(app.slug)); - const count = rulesRng.int(slugs.length) + 1; - const entries: Json[] = slugs.slice(0, count).map((slug) => ({ app: slug })); - env.deployment_protection_rules = rulesRng.bool(0.25) ? { entries } : entries; - } - if (pinnedRng.bool(0.25)) { - // Gates the GraphQL pins read onto a subset of iterations; curated scenarios pin the interleaving and cap paths. - // pinned: true -> at most 3 entries, so the count never approaches GitHub's cap - // pinned: false -> a no-op unpin over the empty baseline: the read runs, no mutation - env.pinned = pinnedRng.bool(0.7); - } + addReviewSettings(rng, env); + addVariables(variablesRng, env); + addSecrets(secretsRng, env); + addBranchPolicies(policiesRng, env); + addProtectionRules(rulesRng, env); + addPin(pinnedRng, env); return env; }); } diff --git a/test/sections/environments/snapshot-row.ts b/test/sections/environments/snapshot-row.ts index 3b3d76a43..e2b59ba40 100644 --- a/test/sections/environments/snapshot-row.ts +++ b/test/sections/environments/snapshot-row.ts @@ -2,9 +2,7 @@ import { environmentsSection } from "../../../src/sections/environments/index.js import type { Row } from "../snapshot-roundtrip.js"; import { STAMPS } from "../snapshot-row-families.js"; -// staging is the one pinned environment, so it LEADS the snapshot with pinned: true (the planner -// reads declaration order as pin order) and production follows without the key. staging has no -// protection rules, which the snapshot writes out as the three disabled values. +/** staging is the one pinned environment, so it LEADS the snapshot with pinned: true (declaration order is pin order) and production follows without the key; staging's empty protection rules write out as the three disabled values. */ export const row: Row = { section: environmentsSection, live: { diff --git a/test/sections/fragment-fake.ts b/test/sections/fragment-fake.ts index db719c764..f29c6a0db 100644 --- a/test/sections/fragment-fake.ts +++ b/test/sections/fragment-fake.ts @@ -19,6 +19,7 @@ import { endpointRequirement, gradeRequirement, graphqlDenialErrors, + type Requirement, SECTION_BY_KEY, } from "../e2e/mock/grading.js"; import { GRAPHQL_HANDLERS, HANDLERS } from "../e2e/mock/handlers.js"; @@ -29,7 +30,9 @@ import type { DenialStyle, PermissionMask } from "../e2e/scenario.js"; import { REPO } from "./section-run.js"; export interface FragmentFake extends GitHubClient { + /** The mock state the handlers read and mutate. */ readonly state: MockState; + /** Every write past routing and the permission grade, a closed body's 422 included: "METHOD /path", or "GRAPHQL " for a mutation. */ readonly writes: string[]; } @@ -40,6 +43,30 @@ export interface FakeToken { readonly denialStyle: DenialStyle; } +/** A routed REST request: its URL, the matched declaration, and the handler the table holds for it. */ +interface Routed { + url: URL; + matched: NonNullable>; + handler: Handler; +} + +/** A dispatched GraphQL operation: the declaration the body names and the handler the table holds for it. */ +interface GraphqlRouted { + dispatched: NonNullable>; + handler: NonNullable<(typeof GRAPHQL_HANDLERS)[string]>; +} + +/** The client's status fold (apiErrorFromGraphqlErrors): FORBIDDEN 403, NOT_FOUND 404, else 422. */ +export function graphqlErrorStatus(types: readonly string[]): 403 | 404 | 422 { + if (types.includes("FORBIDDEN")) { + return 403; + } + if (types.includes("NOT_FOUND")) { + return 404; + } + return 422; +} + export function fragmentFake( section: SectionMeta, handlers: Readonly>, @@ -53,6 +80,61 @@ export function registryFake(live: LiveState, token?: FakeToken): FragmentFake { return handlerFake(HANDLERS, live, null, token); } +/** The request's route and handler, or the 404 the fake answers outside the section's (or the registry's) handlers. */ +function routeOf( + handlers: Readonly>, + only: SectionKey | null, + method: string, + path: string, +): Routed | { error: ApiError } { + const url = new URL(path, "https://api.github.com"); + const matched = matchEndpoint(method, url.pathname); + const handler = matched === null ? undefined : handlers[matched.key]; + if ( + matched === null || + (only !== null && matched.endpoint.section !== only) || + handler === undefined + ) { + return { error: { status: 404, message: `unexpected ${method} ${path}`, body: "" } }; + } + return { url, matched, handler }; +} + +/** The GraphQL operation the body names and its handler, or the 404 the fake answers outside the section's (or the registry's) handlers. */ +function graphqlRouteOf( + only: SectionKey | null, + name: string, +): GraphqlRouted | { error: ApiError } { + const dispatched = graphqlOpForBody({ operationName: name }, allGraphqlOps()); + const handler = dispatched === null ? undefined : GRAPHQL_HANDLERS[dispatched.key]; + if ( + dispatched === null || + (only !== null && dispatched.op.section !== only) || + handler === undefined + ) { + return { error: { status: 404, message: `unexpected GRAPHQL ${name}`, body: "" } }; + } + return { dispatched, handler }; +} + +/** The mock's own denial of a request the token's grants do not cover, so a section meets exactly what the e2e pipeline answers; null when it is allowed. */ +function denialOf( + token: FakeToken | undefined, + requirement: Requirement, +): { error: ApiError } | null { + if (token === undefined || gradeRequirement(token.mask, requirement).allowed) { + return null; + } + const denied = denialResponse(token.denialStyle, requirement.kind); + return { + error: { + status: denied.status, + message: String((denied.body as { message?: unknown }).message ?? ""), + body: JSON.stringify(denied.body), + }, + }; +} + function handlerFake( handlers: Readonly>, live: LiveState, @@ -67,27 +149,15 @@ function handlerFake( payload: unknown, accept: string | undefined, ): { response: MockResponse } | { error: ApiError } => { - const url = new URL(path, "https://api.github.com"); - const matched = matchEndpoint(method, url.pathname); - const handler = matched === null ? undefined : handlers[matched.key]; - if ( - matched === null || - (only !== null && matched.endpoint.section !== only) || - handler === undefined - ) { - return { error: { status: 404, message: `unexpected ${method} ${path}`, body: "" } }; + const routed = routeOf(handlers, only, method, path); + if ("error" in routed) { + return routed; } + const { url, matched, handler } = routed; const requirement = endpointRequirement(matched.endpoint); - if (token !== undefined && !gradeRequirement(token.mask, requirement).allowed) { - // The mock's own denial, so a section meets exactly what the e2e pipeline answers. - const denied = denialResponse(token.denialStyle, requirement.kind); - return { - error: { - status: denied.status, - message: String((denied.body as { message?: unknown }).message ?? ""), - body: JSON.stringify(denied.body), - }, - }; + const denied = denialOf(token, requirement); + if (denied !== null) { + return denied; } if (method !== "GET") { writes.push(`${method} ${url.pathname}`); @@ -95,29 +165,27 @@ function handlerFake( // The stage the wire pipeline runs before its handler (routes.ts): the handler keeps only the body fields GitHub // documents, so a section proof cannot converge on a key GitHub never stores. const accepted = acceptedBody(matched.endpoint.route, payload); - const response = - "rejected" in accepted - ? accepted.rejected - : handler({ - state, - endpoint: matched.endpoint, - param: (name) => { - const value = matched.params[name]; - if (value === undefined) { - throw new Error( - `fragmentFake: ${matched.endpoint.route} declares no "${name}" param`, - ); - } - return value; - }, - query: Object.fromEntries(url.searchParams), - body: accepted.body, - // The section's media type reaches the handler, so a probe whose reply GitHub shapes by Accept is proven here too. - headers: requestHeaders(accept === undefined ? {} : { accept }), - grants: (kind) => - token === undefined || - gradeRequirement(token.mask, { permission: requirement.permission, kind }).allowed, - }); + if ("rejected" in accepted) { + return { response: accepted.rejected }; + } + const response = handler({ + state, + endpoint: matched.endpoint, + param: (name) => { + const value = matched.params[name]; + if (value === undefined) { + throw new Error(`fragmentFake: ${matched.endpoint.route} declares no "${name}" param`); + } + return value; + }, + query: Object.fromEntries(url.searchParams), + body: accepted.body, + // The section's media type reaches the handler, so a probe whose reply GitHub shapes by Accept is proven here too. + headers: requestHeaders(accept === undefined ? {} : { accept }), + grants: (kind) => + token === undefined || + gradeRequirement(token.mask, { permission: requirement.permission, kind }).allowed, + }); return { response }; }; const answerOf = (response: MockResponse): ClientAnswer => { @@ -165,15 +233,11 @@ function handlerFake( } }, async tryGraphql(op, variables) { - const dispatched = graphqlOpForBody({ operationName: op.name }, allGraphqlOps()); - const handler = dispatched === null ? undefined : GRAPHQL_HANDLERS[dispatched.key]; - if ( - dispatched === null || - (only !== null && dispatched.op.section !== only) || - handler === undefined - ) { - return { error: { status: 404, message: `unexpected GRAPHQL ${op.name}`, body: "" } }; + const routed = graphqlRouteOf(only, op.name); + if ("error" in routed) { + return routed; } + const { dispatched, handler } = routed; const owner = SECTION_BY_KEY.get(dispatched.op.section); if (owner === undefined) { throw new Error( @@ -193,12 +257,10 @@ function handlerFake( ? handler({ state, op: dispatched.op, variables: variables as Json }) : { data: null, errors }; if (result.errors !== undefined) { - // The client's status fold (apiErrorFromGraphqlErrors): FORBIDDEN 403, NOT_FOUND 404, else 422. const types = result.errors.map((entry) => entry.type); - const status = types.includes("FORBIDDEN") ? 403 : types.includes("NOT_FOUND") ? 404 : 422; return { error: { - status, + status: graphqlErrorStatus(types), message: result.errors.map((entry) => entry.message).join("; "), body: JSON.stringify(result.errors), graphqlTypes: [...types].sort(), diff --git a/test/sections/graphql-queries.test.ts b/test/sections/graphql-queries.test.ts index 285f1b5b1..c631b2208 100644 --- a/test/sections/graphql-queries.test.ts +++ b/test/sections/graphql-queries.test.ts @@ -10,6 +10,7 @@ import { schema as published } from "@octokit/graphql-schema"; import { buildSchema, extendSchema, + type FieldNode, GraphQLSchema, type OperationDefinitionNode, parse, @@ -63,6 +64,22 @@ function operationOf(key: string, query: string): OperationDefinitionNode { return operations[0] as OperationDefinitionNode; } +/** The fields selected directly on the rule nodes of a `branchProtectionRules` field: where the engine reads node[twin]. */ +function ruleNodeFields(connection: FieldNode): FieldNode[] { + const fields: FieldNode[] = []; + for (const selection of connection.selectionSet?.selections ?? []) { + if (selection.kind !== "Field" || selection.name.value !== "nodes") { + continue; + } + for (const field of selection.selectionSet?.selections ?? []) { + if (field.kind === "Field") { + fields.push(field); + } + } + } + return fields; +} + describe("declared GraphQL queries", () => { test("every query is one named operation matching its declaration", () => { for (const [key, op] of Object.entries(allGraphqlOps())) { @@ -122,19 +139,12 @@ describe("declared GraphQL queries", () => { if (node.name.value !== "branchProtectionRules") { return; } - for (const selection of node.selectionSet?.selections ?? []) { - if (selection.kind !== "Field" || selection.name.value !== "nodes") { - continue; - } - for (const field of selection.selectionSet?.selections ?? []) { - if (field.kind === "Field") { - expect( - field.alias, - `${key} must not alias "${field.name.value}": the engine reads rule fields by their twin name`, - ).toBeUndefined(); - selected.add(field.name.value); - } - } + for (const field of ruleNodeFields(node)) { + expect( + field.alias, + `${key} must not alias "${field.name.value}": the engine reads rule fields by their twin name`, + ).toBeUndefined(); + selected.add(field.name.value); } }, }); diff --git a/test/sections/milestones/generators.ts b/test/sections/milestones/generators.ts index e2e352949..1750dff94 100644 --- a/test/sections/milestones/generators.ts +++ b/test/sections/milestones/generators.ts @@ -20,6 +20,7 @@ import { githubStoresDueOn } from "./mock.js"; /** A fixed pool, never Date.now, so generation stays deterministic; one day per DST state and one at the year's edge. */ const DUE_DATES = ["2026-01-15", "2026-06-30", "2026-12-31"] as const; +/** One milestone over the slice; due_on is present on 40% of draws, so the dated and the undated compare both run. */ const genMilestone = generatorFromSlice(MilestoneConfig, { fields: { title: (rng) => rng.pick(["v1", "v2", "backlog"]), diff --git a/test/sections/milestones/milestones.test.ts b/test/sections/milestones/milestones.test.ts index ba6804d18..080b26e4c 100644 --- a/test/sections/milestones/milestones.test.ts +++ b/test/sections/milestones/milestones.test.ts @@ -9,7 +9,6 @@ import { REPO, unwrap } from "../section-run.js"; import { validatedInput } from "../validated-input.js"; import { githubStoresDueOn, milestonesMockHandlers } from "./mock.js"; -/** Closed milestones are listed too: the read asks for state=all. */ const LIST = "GET /repos/o/r/milestones?state=all&per_page=100&page=1"; const liveMilestones = [ { number: 1, title: "v1", description: null, state: "open", due_on: null }, @@ -61,6 +60,7 @@ describe("milestones", () => { notes: [KEEP_NOTE], drift: [], }); + // state=all: closed milestones are listed too. expect(api.calls.map((c) => `${c.method} ${c.path}`)).toEqual([LIST]); }); diff --git a/test/sections/milestones/mock.ts b/test/sections/milestones/mock.ts index 98f94c346..8603b2f30 100644 --- a/test/sections/milestones/mock.ts +++ b/test/sections/milestones/mock.ts @@ -46,6 +46,7 @@ export const MILESTONES_MOCK: ListMockSpec = { unique: "identity", }; +/** The handlers derived from the section's declaration over the spec above. */ export const milestonesMockHandlers: SectionRestHandlers<"milestones"> = mockFragmentFor( milestonesSection, MILESTONES_MOCK, diff --git a/test/sections/pages/mock.ts b/test/sections/pages/mock.ts index 8e4ac3552..b45f93ae5 100644 --- a/test/sections/pages/mock.ts +++ b/test/sections/pages/mock.ts @@ -11,11 +11,10 @@ import { type SectionRestHandlers, } from "../../e2e/mock/support.js"; -/** - * The create and update bodies GitHub documents; anything else on the wire is dropped, as github.com - * drops it (`public` included: it is settable only on Enterprise Cloud). - */ +/** The create body GitHub documents; anything else on the wire is dropped, as github.com drops it (`public` included: it is settable only on Enterprise Cloud). */ const CREATE_BODY_FIELDS = ["build_type", "source"] as const; + +/** The update body GitHub documents, dropped past the same line. */ const UPDATE_BODY_FIELDS = ["cname", "https_enforced", "build_type", "source"] as const; function accepted(body: unknown, fields: readonly string[]): Json { diff --git a/test/sections/plan-idempotence.test.ts b/test/sections/plan-idempotence.test.ts index 8dffb293e..09bbe219c 100644 --- a/test/sections/plan-idempotence.test.ts +++ b/test/sections/plan-idempotence.test.ts @@ -24,22 +24,29 @@ interface Secret { readonly value: string; } +/** The list read every synthetic section shares. */ const LIST = { route: "GET /repos/{owner}/{repo}/actions/secrets", statuses: { 200: "the secrets" }, primaryRead: { notFound: "denied" }, } as const satisfies EndpointDecl; +/** The write the postures differ on. */ const PUT = { route: "PUT /repos/{owner}/{repo}/actions/secrets/{secret_name}", statuses: { 201: "created", 204: "updated" }, } as const satisfies EndpointDecl; -/** The dictionaries differ in ONE declaration each: whether the PUT recurs, or may carry the facet. */ +/** The PUT recurs by declaration. */ const SEALED_ENDPOINTS = { list: LIST, put: { ...PUT, alwaysRewrite: true } } as const; + +/** The PUT is planned on drift alone. */ const CONDITIONAL_ENDPOINTS = { list: LIST, put: PUT } as const; + +/** The PUT may carry the unverifiable facet. */ const UNVERIFIABLE_ENDPOINTS = { list: LIST, put: { ...PUT, unverifiable: true } } as const; +/** What every synthetic module shares; plan() alone differs between them. */ const META = { key: "actions_secrets", permission: { repo: ["secrets"] }, @@ -57,10 +64,7 @@ function entriesOf( return declaredEntries(declared).entries; } -/** - * The sealed posture: the PUT recurs by declaration with no drift to report (GitHub cannot echo a secret back), and its change line names the live - * state (created, then updated), so it may differ between passes while the write recurs. - */ +/** The sealed posture: the PUT recurs by declaration with no drift to report (GitHub cannot echo a secret back), and its change line names the live state (created, then updated), so it may differ between passes while the write recurs. */ const sealed = { ...META, endpoints: SEALED_ENDPOINTS, @@ -80,10 +84,7 @@ const sealed = { }, } satisfies SectionModule<"actions_secrets", typeof SEALED_ENDPOINTS>; -/** - * The non-converging posture: a conditional write whose live read never shows the result, so every pass re-plans a drift-bearing write; this is what - * a section comparing the wrong live field looks like. - */ +/** The non-converging posture: a conditional write whose live read never shows the result, so every pass re-plans a drift-bearing write, as a section comparing the wrong live field would. */ const stuck = { ...META, endpoints: CONDITIONAL_ENDPOINTS, @@ -104,7 +105,10 @@ const stuck = { }, } satisfies SectionModule<"actions_secrets", typeof CONDITIONAL_ENDPOINTS>; +/** The one declared secret. */ const DESIRED = [{ name: "DEPLOY_TOKEN", value: "$DEPLOY_TOKEN" }]; + +/** The execution tools: every reference resolves to one sealed stand-in. */ const TOOLS: ExecTools = { resolveSecret: () => "sealed" }; /** A client whose secrets list never reflects the PUTs it accepts. */ diff --git a/test/sections/raw-sibling.test.ts b/test/sections/raw-sibling.test.ts index 299af3261..f910ca556 100644 --- a/test/sections/raw-sibling.test.ts +++ b/test/sections/raw-sibling.test.ts @@ -169,35 +169,27 @@ function replacedAt(value: unknown, path: readonly Step[], replacement: unknown) /** A YAML alias to an ancestor: the raw walk in engine/validate.ts passes a cycle on to the shape, so a rule meets it. */ const CYCLE: Record = {}; + CYCLE.self = CYCLE; -/** - * What a failed sibling leaves in a rule's hands: one value of each JSON kind (the string is one a table lookup finds - * on Object.prototype), the empty and false primitives (each satisfies a guard written for the other kind: a - * `.length === 0` read, a falsy test, `[0]` of a string), a number that is not one, a mapping no string conversion - * survives and a cycle JSON.stringify refuses (a bare rendering into a message throws on either), a mapping whose - * length is such a value (a length check compares it), and absence (a valid one at a node changes nothing). - */ +/** What a failed sibling leaves in a rule's hands: one value of each JSON kind, and the ones a guard written for another kind lets through. */ const RAW_VALUES: readonly unknown[] = [ 1, - 0, - Number.NaN, + 0, // the empty and false primitives each satisfy a guard written for the other kind: a `.length === 0` read, a falsy test, `[0]` of a string + Number.NaN, // a number that is not one null, false, - "toString", + "toString", // a string a table lookup finds on Object.prototype "", {}, - { toString: null }, - { length: { toString: null } }, - CYCLE, + { toString: null }, // a mapping no string conversion survives: a bare rendering into a message throws + { length: { toString: null } }, // a mapping whose length is such a value: a length check compares it + CYCLE, // a cycle JSON.stringify refuses [], - ABSENT, + ABSENT, // absence: a valid value at a node changes nothing ]; -/** - * A rule that runs only for a specific string (the wildcard sweep of branches) needs a base holding it, which the - * generated bases cannot know; these are walked the same way, without variants. - */ +/** Bases a rule that runs only for a specific string (the wildcard sweep of branches) needs, which the generated bases cannot know; walked the same way, without variants. */ const SEEDED_BASES: Partial> = { branches: [ [ diff --git a/test/sections/refusal-messages.test.ts b/test/sections/refusal-messages.test.ts index 92d4922c0..8b0eb80e1 100644 --- a/test/sections/refusal-messages.test.ts +++ b/test/sections/refusal-messages.test.ts @@ -56,6 +56,7 @@ interface Fragment { /** Prose: long enough to be a clause and holding a space. A shorter whole literal is a terse message, pinned whole. */ const MIN_PROSE_LENGTH = 16; +/** The opening of a thrown invariant's message, which no user reads, so it is never pinned. */ const INVARIANT_PREFIX = "BUG:"; /** Every position exclusion() names has one planted literal in plantedSlice below, so a new exclusion plants its own. */ @@ -74,6 +75,7 @@ const VOCABULARY_CALLS: ReadonlySet = new Set([ "endsWith", ]); +/** The methods and constructors whose string receiver or argument is data (a separator, a pattern, a lookup key), the Error constructor's thrown text included. */ const VALUE_METHODS: ReadonlySet = new Set([ "split", "join", @@ -126,13 +128,7 @@ const SCHEMA_TWIN_CALLS: ReadonlySet = new Set(["meta", "conditional"]); /** The functions of compilable-form.ts that assemble regex source: every literal in them is a pattern piece. */ const DATA_FUNCTIONS: ReadonlySet = new Set(["render", "codePointEscape"]); -/** - * The local factories and helpers whose string arguments are data (a key name, a rule type, a wrapper kind, a - * failure code, a size measure, a definition id, a noun), by argument index, and those whose string arguments are - * pieces a longer message is composed from (pinned by containment). A key or a noun that renders inside a message - * is data too: its pin by containment would hold wherever a test spells the key. Auditable by name; a control below - * fails naming an entry no source declares or imports. - */ +/** Helpers whose string arguments at these indexes are data (a key name, a rule type, a wrapper kind, a noun); a key that renders inside a message is data too, since a containment pin would hold wherever a test spells it. */ const DATA_ARGUMENTS: Readonly> = { actorList: [0, 1], reviewActorHolder: [0], @@ -147,6 +143,7 @@ const DATA_ARGUMENTS: Readonly> = { githubName: [0], }; +/** The helpers whose string arguments at these indexes are pieces a longer message is composed from, pinned by containment; a control below fails naming an entry here or above that no source declares or imports. */ const PIECE_ARGUMENTS: Readonly> = { closedKeyError: [0], identifiedBy: [2], @@ -157,11 +154,7 @@ const PIECE_ARGUMENTS: Readonly> = { renamedKeyError: [0, 1, 2, 3], }; -/** - * The named constants of the literal-first sources whose string literals are data, not messages: key names, - * vocabularies, and pattern pieces. A prose-length literal inside one is still a message. Auditable: every entry is - * a name a reader can open, and a control below fails naming one no source declares. - */ +/** The named constants of the literal-first sources whose string literals are data (key names, vocabularies, pattern pieces), a prose-length literal inside one still a message; a control below fails naming one no source declares. */ const DATA_CONSTANTS: ReadonlySet = new Set([ // shared/schema-helpers.ts "UNDECLARED_POLICIES", @@ -212,6 +205,7 @@ const MESSAGE_KEYS: ReadonlySet = new Set(["message", "error", "conseque /** The issue builders of src/problem.ts, by name; their bodies are message positions. */ const ISSUE_BUILDER = /Issue$/; +/** The node types that open a function, so a top-level `noun:` is told from a nested one. */ const FUNCTION_TYPES: ReadonlySet = new Set([ "FunctionDeclaration", "FunctionExpression", @@ -221,6 +215,7 @@ const FUNCTION_TYPES: ReadonlySet = new Set([ /** The test functions whose first argument is a title, not a string a user reads. */ const TEST_FUNCTIONS: ReadonlySet = new Set(["test", "describe", "it"]); +/** The shared modules read like a slice: every literal in them is a message. */ const SHARED_REFUSAL_HELPERS = [ "schema-helpers", "setup-schema", @@ -306,17 +301,8 @@ function declaredName(declarator: Node): string | undefined { return id.type === "Identifier" ? field(id, "name") : undefined; } -/** - * Why a literal at this step is not a message, or undefined when it is one. `ancestors` is the chain from the root - * down to the literal's parent. - */ -function exclusion(step: Step, ancestors: readonly Step[]): string | undefined { - const { parent, key } = step; - if (parent === null) { - return undefined; - } - let twin = false; - let constVocabulary = false; +/** Why the literal's syntactic position under its parent makes it data, or undefined: a property key, a module specifier, a regex source, a case label. */ +function syntaxExclusion(parent: Node, key: string | undefined): string | undefined { if (parent.type === "Property" && key === "key") { return "property key"; } @@ -329,6 +315,11 @@ function exclusion(step: Step, ancestors: readonly Step[]): string | undefined { if (parent.type === "SwitchCase") { return "case label"; } + return undefined; +} + +/** Why what the literal is compared with, called on, or assigned to makes it data, or undefined: a value method's receiver, a comparison operand, a data field's value. */ +function receiverExclusion(parent: Node, key: string | undefined): string | undefined { // The receiver of a value method (`"(|^$".includes(x)`, `["a", "b"].join(", ")`) is data, whatever the argument. if (parent.type === "MemberExpression" && key === "object") { const method = field(parent, "property"); @@ -346,34 +337,42 @@ function exclusion(step: Step, ancestors: readonly Step[]): string | undefined { if (parent.type === "Property" && key === "value" && DATA_FIELDS.has(keyName(parent) ?? "")) { return `data field ${keyName(parent)}`; } - ancestors.forEach(({ node }, index) => { + return undefined; +} + +/** Whether the literal is an argument, at any depth, of a schema twin call, whose object argument carries keys and enum values. */ +function isSchemaTwinArgument(step: Step, ancestors: readonly Step[]): boolean { + return ancestors.some(({ node }, index) => { // The step below this ancestor: the literal's own step when the ancestor is the parent. const below = ancestors[index + 1] ?? step; - if ( + return ( (node.type === "CallExpression" || node.type === "NewExpression") && SCHEMA_TWIN_CALLS.has(calledName(node) ?? "") && below.key === "arguments" - ) { - twin = true; + ); + }); +} + +/** Whether the literal sits under an `as const` through arrays, objects, and properties alone and holds no prose: a vocabulary, not a message. */ +function isConstVocabulary(step: Step, ancestors: readonly Step[]): boolean { + return ancestors.some(({ node }, index) => { + if (node.type !== "TSAsExpression" || !isConstAssertion(node)) { + return false; } + const between = ancestors.slice(index + 1).map(({ node: inner }) => inner.type); + return ( + between.every((type) => ["ArrayExpression", "ObjectExpression", "Property"].includes(type)) && + !holdsProse(step.node) + ); }); - if (twin) { +} + +/** Why an ancestor of the literal makes it data, or undefined: a schema twin call, an `as const` vocabulary, a type position, a data constant, a data function. */ +function ancestorExclusion(step: Step, ancestors: readonly Step[]): string | undefined { + if (isSchemaTwinArgument(step, ancestors)) { return "schema twin"; } - ancestors.forEach(({ node }, index) => { - if (node.type === "TSAsExpression" && isConstAssertion(node)) { - const between = ancestors.slice(index + 1).map(({ node: inner }) => inner.type); - if ( - between.every((type) => - ["ArrayExpression", "ObjectExpression", "Property"].includes(type), - ) && - !holdsProse(step.node) - ) { - constVocabulary = true; - } - } - }); - if (constVocabulary) { + if (isConstVocabulary(step, ancestors)) { return "as const vocabulary"; } for (const { node } of ancestors) { @@ -399,8 +398,30 @@ function exclusion(step: Step, ancestors: readonly Step[]): string | undefined { return `data function ${functionName(node)}`; } } - // The literal's holder past any array nesting: a data field, or the call it is an argument of (directly, or as - // the receiver of a value method such as `["a", "b"].join(" ")`). + return undefined; +} + +/** Why being this call's argument makes the literal data, or undefined: the first argument of a vocabulary call, any argument of a value method, a registered data argument. */ +function argumentExclusion(call: Node, argument: Node): string | undefined { + const name = calledName(call) ?? ""; + const index = field(call, "arguments").indexOf(argument); + if (VOCABULARY_CALLS.has(name) && index === 0) { + return `vocabulary call ${name}`; + } + if (VALUE_METHODS.has(name)) { + return `value method ${name}`; + } + if (DATA_ARGUMENTS[name]?.includes(index)) { + return `data argument ${name}[${index}]`; + } + return undefined; +} + +/** + * Why the literal's holder past any array nesting makes it data, or undefined: a data field, or the call it is an + * argument of (directly, or as the receiver of a value method such as `["a", "b"].join(" ")`). + */ +function holderExclusion(step: Step, ancestors: readonly Step[]): string | undefined { let depth = ancestors.length - 1; while (depth >= 0 && ancestors[depth]?.node.type === "ArrayExpression") { depth -= 1; @@ -419,19 +440,24 @@ function exclusion(step: Step, ancestors: readonly Step[]): string | undefined { if (call === undefined || (call.type !== "CallExpression" && call.type !== "NewExpression")) { return undefined; } - const name = calledName(call) ?? ""; - const argument = ancestors[depth + 1]?.node ?? step.node; - const index = field(call, "arguments").indexOf(argument); - if (VOCABULARY_CALLS.has(name) && index === 0) { - return `vocabulary call ${name}`; - } - if (VALUE_METHODS.has(name)) { - return `value method ${name}`; - } - if (DATA_ARGUMENTS[name]?.includes(index)) { - return `data argument ${name}[${index}]`; + return argumentExclusion(call, ancestors[depth + 1]?.node ?? step.node); +} + +/** + * Why a literal at this step is not a message, or undefined when it is one. `ancestors` is the chain from the root + * down to the literal's parent. + */ +function exclusion(step: Step, ancestors: readonly Step[]): string | undefined { + const { parent, key } = step; + if (parent === null) { + return undefined; } - return undefined; + return ( + syntaxExclusion(parent, key) ?? + receiverExclusion(parent, key) ?? + ancestorExclusion(step, ancestors) ?? + holderExclusion(step, ancestors) + ); } /** Whether a literal (or any piece of a template) is prose: a vocabulary word never is, so prose in a table is a message. */ @@ -498,6 +524,41 @@ function textsOf(node: Node): { start: number; text: string; whole: boolean }[] .map((quasi) => ({ start: quasi.start, text: cooked(quasi), whole: false })); } +/** Whether the literal at this step is a piece of a longer message, pinned by containment: an operand of a `+` chain, a literal inside a template hole, an arm of a conditional, a helper's return, a registered piece argument. */ +function isPiece(step: Step, ancestors: readonly Step[]): boolean { + return ( + ancestors.some( + ({ node: ancestor }) => isConcatenation(ancestor) || ancestor.type === "TemplateLiteral", + ) || + step.parent?.type === "ConditionalExpression" || + step.parent?.type === "ReturnStatement" || + isPieceArgument(step) + ); +} + +/** Records the literal's message texts into `into` by offset, the first sighting of an offset winning; a piece that is punctuation alone is skipped. */ +function recordTexts( + node: Node, + piece: boolean, + path: string, + text: string, + into: Map, +): void { + for (const { start, text: value, whole } of textsOf(node)) { + if (piece && !/[^\s()[\]{}.,;:]/.test(value)) { + continue; + } + if (!into.has(start)) { + into.set(start, { + path, + line: lineOf(text, start), + text: value, + whole: whole && !piece, + }); + } + } +} + /** Every message literal under `root`, the exclusions applied at each. */ function literalsUnder( root: Node, @@ -510,44 +571,20 @@ function literalsUnder( walk( root, (step) => { - const { node, parent } = step; - if (isStringLiteral(node) || node.type === "TemplateLiteral") { - const why = exclusion(step, ancestors); - if (why === undefined) { - // A piece of a longer message, pinned by containment: an operand of a `+` chain, a literal inside a - // template hole, an arm of a conditional, a helper's return, a registered piece argument. - const piece = - asPiece || - ancestors.some( - ({ node: ancestor }) => - isConcatenation(ancestor) || ancestor.type === "TemplateLiteral", - ) || - parent?.type === "ConditionalExpression" || - parent?.type === "ReturnStatement" || - isPieceArgument(step); - for (const { start, text: value, whole } of textsOf(node)) { - if (piece && !/[^\s()[\]{}.,;:]/.test(value)) { - continue; - } - if (!into.has(start)) { - into.set(start, { - path, - line: lineOf(text, start), - text: value, - whole: whole && !piece, - }); - } - } - } - if (node.type === "TemplateLiteral") { - // The holes are read too: a literal inside one is a piece of this message. - ancestors.push(step); - return undefined; - } - return false; + const { node } = step; + if (!(isStringLiteral(node) || node.type === "TemplateLiteral")) { + ancestors.push(step); + return undefined; } - ancestors.push(step); - return undefined; + if (exclusion(step, ancestors) === undefined) { + recordTexts(node, asPiece || isPiece(step, ancestors), path, text, into); + } + if (node.type === "TemplateLiteral") { + // The holes are read too: a literal inside one is a piece of this message. + ancestors.push(step); + return undefined; + } + return false; }, (node) => { if (ancestors[ancestors.length - 1]?.node === node) { @@ -557,6 +594,76 @@ function literalsUnder( ); } +/** A module statement's declaration: the statement itself, or the one its `export` wraps. */ +function declarationOf(statement: Node): Node | null { + return statement.type === "ExportNamedDeclaration" + ? field(statement, "declaration") + : statement; +} + +/** Every identifier name under the node, in walk order. */ +function identifiersUnder(root: Node): string[] { + const names: string[] = []; + walk(root, ({ node }) => { + if (node.type === "Identifier") { + names.push(field(node, "name")); + } + return undefined; + }); + return names; +} + +/** Whether the node is a `.transform` call inside a live shape, which reports a response outside the documented API shape, never a settings-file refusal. */ +function isLiveTransform(node: Node, liveSpans: readonly Node[]): boolean { + return ( + node.type === "CallExpression" && + calledName(node) === "transform" && + liveSpans.some((span) => span.start <= node.start && node.end <= span.end) + ); +} + +/** A registered helper's piece arguments (`identifiedBy(key, field, "workflow")`), in index order; none for any other call. */ +function pieceArgumentsOf(call: Node): Node[] { + const indexes = PIECE_ARGUMENTS[calledName(call) ?? ""] ?? []; + const args = field(call, "arguments"); + return indexes.flatMap((index) => { + const argument = args[index]; + return argument === undefined ? [] : [argument]; + }); +} + +/** What the message walk gathers: the positions, the pieces, the names messages reach, every declarator, and the function depth of the node under visit. */ +interface Census { + positions: Node[]; + pieces: Node[]; + named: Set; + locals: Node[]; + functionDepth: number; +} + +/** One node's contribution to the census on entry: a registered helper's piece arguments, a top-level `noun:`, a function opening, a declarator, a message property's value and the names it reaches. */ +function censusEnter(node: Node, census: Census): void { + if (node.type === "CallExpression") { + census.pieces.push(...pieceArgumentsOf(node)); + } + if (node.type === "Property" && keyName(node) === MODULE_NOUN_KEY && census.functionDepth === 0) { + census.pieces.push(field(node, "value")); + } + if (FUNCTION_TYPES.has(node.type)) { + census.functionDepth += 1; + } + if (node.type === "VariableDeclarator") { + census.locals.push(node); + } + if (node.type === "Property" && MESSAGE_KEYS.has(keyName(node) ?? "")) { + const value = field(node, "value"); + census.positions.push(value); + for (const name of identifiersUnder(value)) { + census.named.add(name); + } + } +} + /** * A module's message positions: an object literal carrying a `message` property (an issue), an `error` or * `consequence` property's value, and the value of a top-level constant an `error`/`message` property names (a @@ -564,11 +671,13 @@ function literalsUnder( * census by the owner's ruling (parse refusals only). */ function messagePositions(program: Node): { positions: Node[]; pieces: Node[] } { - const positions: Node[] = []; - const pieces: Node[] = []; - const named = new Set(); - const locals: Node[] = []; - let functionDepth = 0; + const census: Census = { + positions: [], + pieces: [], + named: new Set(), + locals: [], + functionDepth: 0, + }; // A live shape (`LiveDeployKey`, the repository's naming for a GET body) reports a response outside the documented // API shape, never a settings-file refusal; a transform anywhere else (the routed list shape) is a parse position. const liveSpans: Node[] = []; @@ -581,62 +690,27 @@ function messagePositions(program: Node): { positions: Node[]; pieces: Node[] } walk( program, ({ node }) => { - if ( - node.type === "CallExpression" && - calledName(node) === "transform" && - liveSpans.some((span) => span.start <= node.start && node.end <= span.end) - ) { + if (isLiveTransform(node, liveSpans)) { return false; } - // A registered helper's piece argument (`identifiedBy(key, field, "workflow")`), and a top-level `noun:`. - if (node.type === "CallExpression") { - const indexes = PIECE_ARGUMENTS[calledName(node) ?? ""] ?? []; - const args = field(node, "arguments"); - for (const index of indexes) { - const argument = args[index]; - if (argument !== undefined) { - pieces.push(argument); - } - } - } - if (node.type === "Property" && keyName(node) === MODULE_NOUN_KEY && functionDepth === 0) { - pieces.push(field(node, "value")); - } - if (FUNCTION_TYPES.has(node.type)) { - functionDepth += 1; - } - if (node.type === "VariableDeclarator") { - locals.push(node); - } - if (node.type === "Property" && MESSAGE_KEYS.has(keyName(node) ?? "")) { - const value = field(node, "value"); - positions.push(value); - walk(value, ({ node: inner }) => { - if (inner.type === "Identifier") { - named.add(field(inner, "name")); - } - return undefined; - }); - } + censusEnter(node, census); return undefined; }, (node) => { if (FUNCTION_TYPES.has(node.type)) { - functionDepth -= 1; + census.functionDepth -= 1; } }, ); // The constants a message names, at the top level or local to the enclosing function (`beside`), one hop. - for (const declarator of locals) { + const { positions, pieces, named } = census; + for (const declarator of census.locals) { if (named.has(declaredName(declarator) ?? "")) { positions.push(declarator); } } for (const statement of field(program, "body")) { - const declaration = - statement.type === "ExportNamedDeclaration" - ? field(statement, "declaration") - : statement; + const declaration = declarationOf(statement); if (declaration?.type === "FunctionDeclaration") { const name = field(field(declaration, "id"), "name"); if (ISSUE_BUILDER.test(name) || named.has(name)) { @@ -647,39 +721,39 @@ function messagePositions(program: Node): { positions: Node[]; pieces: Node[] } return { positions, pieces }; } +/** The declarations of one module statement that a builder names, the builders themselves aside: each such declarator of a variable statement, or the function. */ +function namedDeclarations( + statement: Node, + named: ReadonlySet, + builders: readonly Node[], +): Node[] { + const declaration = declarationOf(statement); + if (declaration?.type === "VariableDeclaration") { + return field(declaration, "declarations").filter( + (declarator) => named.has(declaredName(declarator) ?? "") && !builders.includes(declarator), + ); + } + if ( + declaration?.type === "FunctionDeclaration" && + named.has(functionName(declaration) ?? "") && + !builders.includes(declaration) + ) { + return [declaration]; + } + return []; +} + /** A message position reaches the top-level constants and functions it names, one hop and no further. */ function builderConstants(program: Node, builders: readonly Node[]): Node[] { const named = new Set(); for (const builder of builders) { - walk(builder, ({ node }) => { - if (node.type === "Identifier") { - named.add(field(node, "name")); - } - return undefined; - }); - } - const constants: Node[] = []; - for (const statement of field(program, "body")) { - const declaration = - statement.type === "ExportNamedDeclaration" - ? field(statement, "declaration") - : statement; - if (declaration?.type === "VariableDeclaration") { - for (const declarator of field(declaration, "declarations")) { - if (named.has(declaredName(declarator) ?? "") && !builders.includes(declarator)) { - constants.push(declarator); - } - } - } - if ( - declaration?.type === "FunctionDeclaration" && - named.has(functionName(declaration) ?? "") && - !builders.includes(declaration) - ) { - constants.push(declaration); + for (const name of identifiersUnder(builder)) { + named.add(name); } } - return constants; + return field(program, "body").flatMap((statement) => + namedDeclarations(statement, named, builders), + ); } function parse(path: string, text: string): Node { diff --git a/test/sections/registry.test.ts b/test/sections/registry.test.ts index 50fe184e9..03f23b803 100644 --- a/test/sections/registry.test.ts +++ b/test/sections/registry.test.ts @@ -175,26 +175,14 @@ describe("section permissions", () => { test("sections declaring the same route agree on its contract", () => { // The mock resolves a request to the FIRST matching declaration, so a sibling that diverged (GET /orgs/{org} is declared by two sections, teams // and custom_properties) would be validated against another section's contract unnoticed. - const byRoute = new Map>(); + const byRoute = new Map>(); const sectionPermission = new Map(SECTIONS.map((section) => [section.key, section.permission])); - // A JSON.stringify replacer ARRAY would filter nested keys (statuses' "200"), so canonicalize recursively. - const canonical = (value: unknown): unknown => - Array.isArray(value) - ? value.map(canonical) - : value !== null && typeof value === "object" - ? Object.fromEntries( - Object.entries(value) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([k, v]) => [k, canonical(v)]), - ) - : value; for (const [key, endpoint] of Object.entries(allEndpoints())) { // The WHOLE wire declaration is compared, so a later EndpointDecl field cannot diverge uncovered; only the // 404 posture stays out, since it is the SECTION's (denialPosture reads it per section, never per route). // The effective permission rides along: two declarations can both omit an override while inheriting different section permissions. const { route: _route, section, role: _role, primaryRead: _posture, ...rest } = endpoint; - const projected = { ...rest, effective: rest.permission ?? sectionPermission.get(section) }; - const contract = JSON.stringify(canonical(projected)); + const contract = { ...rest, effective: rest.permission ?? sectionPermission.get(section) }; const group = byRoute.get(endpoint.route) ?? []; group.push({ key, contract }); byRoute.set(endpoint.route, group); @@ -208,7 +196,7 @@ describe("section permissions", () => { expect( sibling.contract, `${sibling.key} and ${first?.key} both declare "${route}" but disagree on its contract; the mock resolves the first match, so they must stay identical`, - ).toBe(first?.contract ?? ""); + ).toEqual(first?.contract); } } }); @@ -241,6 +229,25 @@ describe("grantFor", () => { }); }); +/** What is malformed about an endpoint's statuses record, each line tagged, so one sweep reports every problem at once. */ +function statusProblems(tag: string, statuses: EndpointDecl["statuses"]): string[] { + const problems: string[] = []; + const statusEntries = Object.entries(statuses); + if (statusEntries.length === 0) { + problems.push(`${tag}: declares no statuses`); + } + for (const [statusKey, meaning] of statusEntries) { + const status = Number(statusKey); + if (!Number.isInteger(status) || status < 100 || status >= 600) { + problems.push(`${tag}: status "${statusKey}" is not an integer in 100-599`); + } + if (typeof meaning !== "string" || meaning.length === 0) { + problems.push(`${tag}: status ${statusKey} carries no prose meaning`); + } + } + return problems; +} + describe("section endpoints", () => { test("every declared endpoint names at least one status, each an HTTP status with a prose meaning", () => { // The route itself is compile-checked against the OpenAPI-derived Route union; the statuses are a plain record, so only this sweep sees them. @@ -248,30 +255,102 @@ describe("section endpoints", () => { for (const module of SECTIONS) { for (const [role, endpoint] of Object.entries(module.endpoints)) { const tag = `${module.key}.${role} ("${endpoint.route}")`; - const statusEntries = Object.entries(endpoint.statuses); - if (statusEntries.length === 0) { - problems.push(`${tag}: declares no statuses`); - } - for (const [statusKey, meaning] of statusEntries) { - const status = Number(statusKey); - if (!Number.isInteger(status) || status < 100 || status >= 600) { - problems.push(`${tag}: status "${statusKey}" is not an integer in 100-599`); - } - if (typeof meaning !== "string" || meaning.length === 0) { - problems.push(`${tag}: status ${statusKey} carries no prose meaning`); - } - } + problems.push(...statusProblems(tag, endpoint.statuses)); } } expect(problems, `malformed endpoint declaration(s):\n ${problems.join("\n ")}`).toEqual([]); }); }); +/** One assignment a frozen declaration must refuse: what it would change, and the mutation. */ +type Attempt = [what: string, mutate: () => void]; + +/** The assignments that would corrupt one endpoint declaration: its slot in the dictionary, its route, a status, and, where declared, a hint, a permission list, and a rejection. */ +function endpointAttempts( + section: SectionModule, + role: string, + endpoint: SectionModule["endpoints"][string], +): Attempt[] { + const tag = `${section.key}.${role}`; + const attempts: Attempt[] = [ + [ + `${tag} replace`, + () => { + (section.endpoints as Record)[role] = {}; + }, + ], + [ + `${tag} route`, + () => { + (endpoint as { route: string }).route = "DELETE /repos/{owner}/{repo}"; + }, + ], + [ + `${tag} statuses`, + () => { + (endpoint.statuses as Record)[299] = "hacked"; + }, + ], + ]; + if (endpoint.hints !== undefined) { + attempts.push([ + `${tag} hints`, + () => { + (endpoint.hints as Record)[422] = "hacked"; + }, + ]); + } + if (typeof endpoint.permission === "object") { + attempts.push([ + `${tag} permission`, + () => { + (endpoint.permission as unknown as { repo: string[] }).repo.push("hacked"); + }, + ]); + } + if (endpoint.rejections !== undefined) { + attempts.push([ + `${tag} rejections`, + () => { + (endpoint.rejections as unknown as object[]).push({}); + }, + ]); + } + return attempts; +} + +/** The assignments that would corrupt one GraphQL operation declaration: an outcome, its kind, and, where declared, its connection path. */ +function graphqlAttempts(tag: string, op: GraphqlDict[string]): Attempt[] { + const attempts: Attempt[] = [ + [ + `${tag} outcomes`, + () => { + (op.outcomes as Record).ok = "hacked"; + }, + ], + [ + `${tag} kind`, + () => { + (op as { kind: string }).kind = "write"; + }, + ], + ]; + if (op.connection !== undefined) { + attempts.push([ + `${tag} connection`, + () => { + (op.connection as unknown as { path: string[] }).path.push("hacked"); + }, + ]); + } + return attempts; +} + describe("declarations are frozen at registration", () => { test("mutating any registered section's declaration throws, with no view called first", () => { // The module, endpoint, and op objects themselves are frozen by no view (the views freeze only the tagged copies they build), so a // frozen source here is freezeDeclarations' work alone. ES module test files are strict, so an assignment on a frozen object throws. - const attempts: [string, () => void][] = []; + const attempts: Attempt[] = []; let endpointCount = 0; for (const section of SECTIONS) { expect(Object.isFrozen(section), `${section.key} module`).toBe(true); @@ -283,52 +362,8 @@ describe("declarations are frozen at registration", () => { ]); for (const [role, endpoint] of Object.entries(section.endpoints)) { endpointCount++; - const tag = `${section.key}.${role}`; - expect(Object.isFrozen(endpoint), tag).toBe(true); - attempts.push( - [ - `${tag} replace`, - () => { - (section.endpoints as Record)[role] = {}; - }, - ], - [ - `${tag} route`, - () => { - (endpoint as { route: string }).route = "DELETE /repos/{owner}/{repo}"; - }, - ], - [ - `${tag} statuses`, - () => { - (endpoint.statuses as Record)[299] = "hacked"; - }, - ], - ); - if (endpoint.hints !== undefined) { - attempts.push([ - `${tag} hints`, - () => { - (endpoint.hints as Record)[422] = "hacked"; - }, - ]); - } - if (typeof endpoint.permission === "object") { - attempts.push([ - `${tag} permission`, - () => { - (endpoint.permission as unknown as { repo: string[] }).repo.push("hacked"); - }, - ]); - } - if (endpoint.rejections !== undefined) { - attempts.push([ - `${tag} rejections`, - () => { - (endpoint.rejections as unknown as object[]).push({}); - }, - ]); - } + expect(Object.isFrozen(endpoint), `${section.key}.${role}`).toBe(true); + attempts.push(...endpointAttempts(section, role, endpoint)); } if (section.closedSurface !== undefined) { attempts.push([ @@ -341,28 +376,7 @@ describe("declarations are frozen at registration", () => { for (const [role, op] of Object.entries(section.graphql ?? {})) { const tag = `${section.key}.${role}`; expect(Object.isFrozen(op), tag).toBe(true); - attempts.push( - [ - `${tag} outcomes`, - () => { - (op.outcomes as Record).ok = "hacked"; - }, - ], - [ - `${tag} kind`, - () => { - (op as { kind: string }).kind = "write"; - }, - ], - ); - if (op.connection !== undefined) { - attempts.push([ - `${tag} connection`, - () => { - (op.connection as unknown as { path: string[] }).path.push("hacked"); - }, - ]); - } + attempts.push(...graphqlAttempts(tag, op)); } } expect(attempts.length).toBeGreaterThanOrEqual(SECTIONS.length + endpointCount * 3); diff --git a/test/sections/repository/mock.ts b/test/sections/repository/mock.ts index 5005d07fd..6abb5792a 100644 --- a/test/sections/repository/mock.ts +++ b/test/sections/repository/mock.ts @@ -16,8 +16,7 @@ import { type SectionRestHandlers, } from "../../e2e/mock/support.js"; -// Every repo body a REST handler serves, and the PATCH body it accepts, goes through -// restRepoSurface, which strips the GraphQL-only fields, so the REST paths stay as blind to them as GitHub's. +// Every repo body a REST handler serves, and the PATCH body it accepts, goes through restRepoSurface, which strips the GraphQL-only fields, so the REST paths stay as blind to them as GitHub's. export const repositoryMockHandlers: SectionRestHandlers<"repository"> = { "repository.get": ({ state }) => ok(restRepoSurface(state.repo)), "repository.update": ({ state, body }) => { diff --git a/test/sections/repository/repository.test.ts b/test/sections/repository/repository.test.ts index d30855c23..8c9414120 100644 --- a/test/sections/repository/repository.test.ts +++ b/test/sections/repository/repository.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { z } from "zod"; import { executePlan } from "../../../src/engine/execute.js"; import { validateSectionShapes } from "../../../src/engine/validate.js"; -import type { GitHubClient } from "../../../src/github/api.js"; +import type { ClientAnswer, GitHubClient } from "../../../src/github/api.js"; import type { SectionInput } from "../../../src/sections/contract/declared.js"; import type { SectionFailure } from "../../../src/sections/contract/errors.js"; import { sectionGrant } from "../../../src/sections/contract/operations.js"; @@ -75,6 +75,23 @@ const echo = (fields: Record) => ({ "GRAPHQL UpdateRepositoryFeatures": { data: { updateRepository: { repository: fields } } }, }); +/** What a toggle's GET answers, as GitHub does: an off toggle 404s, except private vulnerability reporting, which answers `{enabled}` either way. */ +function toggleRead(feature: string, enabled: boolean): ClientAnswer { + const off = { error: { status: 404, message: "Not Found", body: "" } } as const; + switch (feature) { + case "vulnerability-alerts": + return enabled ? { data: null } : off; + case "automated-security-fixes": + return enabled ? { data: { enabled: true, paused: false } } : off; + case "private-vulnerability-reporting": + return { data: { enabled } }; + case "immutable-releases": + return enabled ? { data: { enabled: true, enforced_by_owner: false } } : off; + default: + return off; + } +} + /** A stateful fake of the repository API: each toggle GET answers as GitHub does, and the write-only LFS pair is stored nowhere. */ function liveRepo(seed: { repo?: Record; @@ -84,7 +101,6 @@ function liveRepo(seed: { const repo: Record = { topics: [], ...seed.repo }; const toggles: Record = { ...seed.toggles }; const feature = seed.features ?? { hasSponsorshipsEnabled: false, issueCreationPolicy: "ALL" }; - const off = { error: { status: 404, message: "Not Found", body: "" } } as const; return withListing({ writes: [], async tryRequest(method, path, payload) { @@ -107,19 +123,7 @@ function liveRepo(seed: { toggles[feature] = method === "PUT"; return { data: null }; } - const enabled = toggles[feature] === true; - switch (feature) { - case "vulnerability-alerts": - return enabled ? { data: null } : off; - case "automated-security-fixes": - return enabled ? { data: { enabled: true, paused: false } } : off; - case "private-vulnerability-reporting": - return { data: { enabled } }; - case "immutable-releases": - return enabled ? { data: { enabled: true, enforced_by_owner: false } } : off; - default: - return off; - } + return toggleRead(feature, toggles[feature] === true); }, async tryGraphql(op, variables) { if (op.name === "RepositoryFeatures") { diff --git a/test/sections/repository/snapshot-row.ts b/test/sections/repository/snapshot-row.ts index 03bbd7c8b..0fbbe32f1 100644 --- a/test/sections/repository/snapshot-row.ts +++ b/test/sections/repository/snapshot-row.ts @@ -1,8 +1,7 @@ import { repositorySection } from "../../../src/sections/repository/index.js"; import type { Row } from "../snapshot-roundtrip.js"; -// Over the repo fixture (ids, urls, counts, owner, license, has_downloads: none PATCHable); the -// overlay adds a null homepage, a passthrough field, two toggles, and the GraphQL fields. +/** Over the repo fixture (ids, urls, counts, owner, license, has_downloads: none PATCHable), the overlay adds a null homepage, a passthrough field, two toggles, and the GraphQL fields. */ export const row: Row = { section: repositorySection, live: { diff --git a/test/sections/rulesets/generators.ts b/test/sections/rulesets/generators.ts index 63e886965..9851a7b29 100644 --- a/test/sections/rulesets/generators.ts +++ b/test/sections/rulesets/generators.ts @@ -5,10 +5,7 @@ import { type EntriesForm, maybeWrapUndeclared } from "../../e2e/generators/gen-support.js"; import type { Rng } from "../../e2e/generators/prng.js"; -/** - * The five parameters the spec requires of a pull_request rule, so a drawn rule parses and passes the mock's - * request-body check; the invalid catalog (test/e2e/generators/invalid-settings.ts) mis-cases one field of this set. - */ +/** The five parameters the spec requires of a pull_request rule, so a drawn rule parses and passes the mock's request-body check; the invalid catalog (test/e2e/generators/invalid-settings.ts) mis-cases one field of this set. */ export const PULL_REQUEST_PARAMETERS = { dismiss_stale_reviews_on_push: true, require_code_owner_review: false, diff --git a/test/sections/rulesets/mock.ts b/test/sections/rulesets/mock.ts index ba87fa121..5c58d6357 100644 --- a/test/sections/rulesets/mock.ts +++ b/test/sections/rulesets/mock.ts @@ -13,11 +13,7 @@ import { type SectionRestHandlers, } from "../../e2e/mock/support.js"; -/** - * The seed completion buildState applies (test/e2e/mock/state.ts LIST_MOCKS), and the create handler below spreads - * the same defaults, so a seed is served as a created ruleset would be. GitHub documents "branch" as the target a - * create without one gets; enforcement has no default there (the create requires it), so none is minted. - */ +/** The seed completion buildState applies (test/e2e/mock/state.ts LIST_MOCKS) and the create handler spreads too; GitHub defaults a create's target to "branch" and requires enforcement, so no enforcement is minted. */ export const RULESETS_MOCK: ListMockSpec = { collection: (state) => state.rulesets, defaults: { source_type: "Repository", target: "branch" }, diff --git a/test/sections/rulesets/schema.test.ts b/test/sections/rulesets/schema.test.ts index 105423e74..83a0eb695 100644 --- a/test/sections/rulesets/schema.test.ts +++ b/test/sections/rulesets/schema.test.ts @@ -19,6 +19,7 @@ function verdict(...rulesets: Record[]): Verdict { ); } +/** A pattern parameter with every field, shared by the three message and email pattern rules. */ const pattern = { operator: "regex", pattern: "^v\\d+", negate: false, name: "shown" }; /** Every rule type the spec knows with parameters it accepts, plus one it does not know; a passthrough key on each kind. */ diff --git a/test/sections/rulesets/snapshot-row.ts b/test/sections/rulesets/snapshot-row.ts index 1f7f24c75..25c7279d8 100644 --- a/test/sections/rulesets/snapshot-row.ts +++ b/test/sections/rulesets/snapshot-row.ts @@ -1,9 +1,7 @@ import { rulesetsSection } from "../../../src/sections/rulesets/index.js"; import type { Row } from "../snapshot-roundtrip.js"; -// The all-grades token sees bypass_actors; the server fields fall away; the organization's ruleset -// is noted, not declared. A field GitHub adds to a rule's parameters or a bypass actor (the two -// `future_*` keys) survives the projection, or the next full-payload update would clear it. +/** The all-grades token sees bypass_actors, the server fields fall away, the organization's ruleset is noted, not declared, and the two `future_*` keys GitHub may add survive the projection, or the next full-payload update would clear them. */ export const row: Row = { section: rulesetsSection, live: { diff --git a/test/sections/secret_scanning_custom_patterns/generators.ts b/test/sections/secret_scanning_custom_patterns/generators.ts index 175958b42..e4511a1bc 100644 --- a/test/sections/secret_scanning_custom_patterns/generators.ts +++ b/test/sections/secret_scanning_custom_patterns/generators.ts @@ -11,10 +11,10 @@ import { } from "../../e2e/generators/gen-support.js"; import type { Rng } from "../../e2e/generators/prng.js"; +/** The name pool; an index suffix inside the pool keeps the exact-name natural key unique. */ const NAMES = ["internal-api-token", "staging-key", "vendor-secret", "license-key"] as const; -// Every pool value passes the syntax check (generatorFromSlice validates each draw against the slice), and -// three spell Hyperscan-only forms the check translates; the refused draw lives in the invalid-settings -// catalog (test/e2e/generators/invalid-settings.ts), predicted by the same check. + +/** Every pool value passes the syntax check (generatorFromSlice validates each draw against the slice), three in Hyperscan-only forms the check translates; the refused draw lives in test/e2e/generators/invalid-settings.ts, predicted by the same check. */ const PATTERNS = [ "int_[a-z0-9]{8}", "key-[0-9]{6}", diff --git a/test/sections/shared/list-section.test.ts b/test/sections/shared/list-section.test.ts index 6446893a8..1898bdfa4 100644 --- a/test/sections/shared/list-section.test.ts +++ b/test/sections/shared/list-section.test.ts @@ -27,11 +27,16 @@ import { provePlanIdempotent } from "../plan-idempotence.js"; import { failureOf, REPO, unwrap } from "../section-run.js"; import { validatedInput } from "../validated-input.js"; +/** The labels declaration every variant module here is built from. */ const base = labelsSection.decl; + +/** The labels list, one page. */ const LIST = "GET /repos/o/r/labels?per_page=100&page=1"; /** The labels dictionary without its update role: a resource GitHub could not edit. */ const { update: _update, ...IMMUTABLE_ENDPOINTS } = base.endpoints; + +/** The module over that dictionary. */ const immutable = listSection({ ...base, endpoints: IMMUTABLE_ENDPOINTS }); /** The derived mock fake over a variant module, seeded with `live`. */ @@ -497,7 +502,7 @@ describe("listSection without an update role", () => { const fragment = mockFragmentFor(immutable, LABELS_MOCK); expect(Object.keys(fragment).sort()).toEqual(["labels.create", "labels.list", "labels.remove"]); // @ts-expect-error no update role, no update handler key - fragment["labels.update"]; + void fragment["labels.update"]; expect(Object.keys(mockFragmentFor(labelsSection, LABELS_MOCK)).sort()).toEqual([ "labels.create", "labels.list", diff --git a/test/sections/shared/sealed-box.test.ts b/test/sections/shared/sealed-box.test.ts index 94964b572..34e0a93ce 100644 --- a/test/sections/shared/sealed-box.test.ts +++ b/test/sections/shared/sealed-box.test.ts @@ -11,8 +11,13 @@ import { await sodium.ready; +/** A string's UTF-8 bytes. */ const encode = (text: string) => new TextEncoder().encode(text); + +/** Bytes as lowercase hex, for a readable compare. */ const hex = (bytes: Uint8Array) => Buffer.from(bytes).toString("hex"); + +/** The bytes a hex string spells. */ const fromHex = (text: string) => new Uint8Array(Buffer.from(text, "hex")); // The empty message (a tag-only box), a message spanning several cipher blocks, and multi-byte UTF-8. diff --git a/test/sections/shared/secret-variable-schema.test.ts b/test/sections/shared/secret-variable-schema.test.ts index 576a10a27..001021c2d 100644 --- a/test/sections/shared/secret-variable-schema.test.ts +++ b/test/sections/shared/secret-variable-schema.test.ts @@ -42,6 +42,7 @@ const FAMILIES: Array<{ }, ]; +/** The families a variable value's size rule applies to. */ const VARIABLE_FAMILIES = FAMILIES.filter((family) => family.noun === "variable"); function issuesOf(doc: Record): readonly string[] | "accepted" { diff --git a/test/sections/shared/secrets-engine.test.ts b/test/sections/shared/secrets-engine.test.ts index 5f5e3b5d0..44753cf68 100644 --- a/test/sections/shared/secrets-engine.test.ts +++ b/test/sections/shared/secrets-engine.test.ts @@ -220,10 +220,10 @@ describe("planSecrets and the execution-time resolver", () => { drift: [], change: deletion.change, }); - ({ + void ({ // @ts-expect-error the remove builder must return the remove-role operation remove: swapped, - }) satisfies Pick, "remove">; + } satisfies Pick, "remove">); }); test("a builder cannot demand a facet the engine never supplies", () => { @@ -235,10 +235,10 @@ describe("planSecrets and the execution-time resolver", () => { drift: [], change: `${write.change} ${write.keyId}`, }); - ({ + void ({ // @ts-expect-error the engine supplies no keyId facet put: demanding, - }) satisfies Pick, "put">; + } satisfies Pick, "put">); }); test("an empty declaration plans nothing and never reads the sealing key", async () => { @@ -259,12 +259,12 @@ describe("the section context arms", () => { // Reference VALIDATION lives in the engine (src/engine/secrets.ts + secret-refs.ts) and runs before any section; the context ARMS are // compiler-enforced. test("a check-mode context carrying a resolver does not compile", () => { - ({ + void ({ api: new MockApi({}), repo: { owner: "o", name: "r", slug: "o/r" }, check: true, resolveSecret: (reference: string): string => reference, // @ts-expect-error the check arm pins resolveSecret to never - }) satisfies SectionContext; + } satisfies SectionContext); }); }); diff --git a/test/sections/shared/setup-section.test.ts b/test/sections/shared/setup-section.test.ts index a5086dcff..80b52caa5 100644 --- a/test/sections/shared/setup-section.test.ts +++ b/test/sections/shared/setup-section.test.ts @@ -30,28 +30,46 @@ function reversed(names: T): T { /** The lockstep tuple on which the minted sections differ, typed by the same key throughout. */ interface SetupFacts { + /** The minted section under test. */ section: SetupSectionModule; + /** The expanded endpoint path ("/repos/o/r/code-quality/setup"). */ path: string; + /** A live GET body; must carry a `languages` list for the set compare, and lack the key `knownAbsent` declares. */ live: Record & { languages: Languages }; - /** A declared document that drifts from `live`, and the exact drift line. */ + + /** A declared document that drifts from `live`. */ driftDeclared: Declared; + + /** The exact drift line that document earns. */ driftLine: string; + /** A slice key `live` lacks: drift the PATCH resolves, never a phantom. */ knownAbsent: Declared; + /** A language only the GET reports, and the exact refusal it earns in a settings file. */ getOnlyLanguage: [string, string]; - /** The mock's seeded setup whose `languages` carry the GET's spellings, and what they read back as. */ + + /** The mock's seeded setup, its `languages` in the GET's spellings. */ seeded: LiveState; + + /** What the seeded languages read back as. */ languagesRead: Languages; + /** The GET-only names with no declarable form, left out of the compare and the snapshot with a note. */ undeclarable: string[]; + /** A declared document for the verbatim-PATCH case. */ applyPayload: Declared; + + /** The change line that PATCH reports. */ changeLine: string; - /** The whole failure messages a 409 and a 403 on the PATCH produce. */ + + /** The whole failure message a 409 on the PATCH produces. */ conflict409: string; + + /** The whole failure message a 403 on the PATCH produces. */ denied403: string; } @@ -131,12 +149,12 @@ type _DeniedReadHasNoProbe = MustBeNever< >; type DeclaredOf = SectionInput; -({ query_suite: "extended" }) satisfies DeclaredOf<"code_scanning_default_setup">; +void ({ query_suite: "extended" } satisfies DeclaredOf<"code_scanning_default_setup">); // @ts-expect-error ai_findings_option belongs to code_quality_setup alone -({ ai_findings_option: "disabled" }) satisfies DeclaredOf<"code_scanning_default_setup">; -({ ai_findings_option: "disabled" }) satisfies DeclaredOf<"code_quality_setup">; +void ({ ai_findings_option: "disabled" } satisfies DeclaredOf<"code_scanning_default_setup">); +void ({ ai_findings_option: "disabled" } satisfies DeclaredOf<"code_quality_setup">); // @ts-expect-error query_suite belongs to code_scanning_default_setup alone -({ query_suite: "extended" }) satisfies DeclaredOf<"code_quality_setup">; +void ({ query_suite: "extended" } satisfies DeclaredOf<"code_quality_setup">); /** * A stateful fake: the GET serves what the PATCH last merged over the seeded body, and the PATCH answers the spec's plain 200, an EMPTY object, so diff --git a/test/sections/snapshot-denial.test.ts b/test/sections/snapshot-denial.test.ts index b8485a177..7887770b4 100644 --- a/test/sections/snapshot-denial.test.ts +++ b/test/sections/snapshot-denial.test.ts @@ -27,8 +27,10 @@ const DENIED_403: FakeToken = { mask: NO_GRANTS, denialStyle: 403 }; /** The fine-grained style: a denied read answers 404, which an absent posture reads as nothing to snapshot. */ const DENIED_404: FakeToken = { mask: NO_GRANTS, denialStyle: "fine_grained" }; +/** What every denial failure's detail carries: the denial, then the grant to make. */ const GRANT_ADVICE = /the token was denied .*To fix, grant/; +/** The sections that declare snapshot(). */ const reading = SECTIONS.filter((section) => section.snapshot !== undefined); /** Whether any planning read needs a grant; a section reading only public endpoints is never denied. */ diff --git a/test/sections/snapshot-roundtrip.ts b/test/sections/snapshot-roundtrip.ts index 42f7a047f..fc4cfdc7d 100644 --- a/test/sections/snapshot-roundtrip.ts +++ b/test/sections/snapshot-roundtrip.ts @@ -19,9 +19,12 @@ export type SnapshotSection = SectionModule & Required/snapshot-row.ts exporting `row`. */ export interface Row { + /** The section under proof. */ readonly section: SnapshotSection; + /** The live state seeded into the mock; must hold at least one resource of the section's. */ readonly live: LiveState; + /** The whole snapshot the seeded state reads back as. */ readonly expected: { value: unknown; notes: string[] }; } diff --git a/test/sections/teams/snapshot-row.ts b/test/sections/teams/snapshot-row.ts index e7c34452e..e5d9274a1 100644 --- a/test/sections/teams/snapshot-row.ts +++ b/test/sections/teams/snapshot-row.ts @@ -1,8 +1,7 @@ import { teamsSection } from "../../../src/sections/teams/index.js"; import type { Row } from "../snapshot-roundtrip.js"; -// A null access entry is a team without access, which the listing omits; a custom role named -// "push" has no declaration that plans as itself. The wrapper spells the section's keep default out. +/** A null access entry is a team without access, which the listing omits; a custom role named "push" has no declaration that plans as itself; the wrapper spells the section's keep default out. */ export const row: Row = { section: teamsSection, live: { diff --git a/test/sections/teams/teams.test.ts b/test/sections/teams/teams.test.ts index 2df761238..0db70ce6b 100644 --- a/test/sections/teams/teams.test.ts +++ b/test/sections/teams/teams.test.ts @@ -1,3 +1,8 @@ +/** + * The teams section through its registered module, since the owner gate the registry composes is part of the + * behavior under test. + */ + import { describe, expect, test } from "bun:test"; import type { SectionInput } from "../../../src/sections/contract/declared.js"; import type { SectionModule } from "../../../src/sections/contract/module.js"; @@ -11,7 +16,6 @@ import { REPO, unwrap } from "../section-run.js"; import { validatedInput } from "../validated-input.js"; import { teamsMockHandlers } from "./mock.js"; -/** The registered module: the owner gate the registry composes is part of the behavior under test. */ const gated = sectionModule("teams") as SectionModule<"teams"> & Required, "snapshot">>; diff --git a/test/sections/webhooks/events.test.ts b/test/sections/webhooks/events.test.ts index ab39ba239..ea0b6e085 100644 --- a/test/sections/webhooks/events.test.ts +++ b/test/sections/webhooks/events.test.ts @@ -31,10 +31,11 @@ interface WebhooksDescriptor { interface WebhookVocabulary { /** Sorted wire event names, without the "*" wildcard (the schema adds it). */ readonly events: readonly string[]; + /** The reference pages the entries' externalDocs point at, minus their anchors; GitHub keeps them on one page. */ readonly references: readonly string[]; - /** Entries lacking a field the derivation reads, as `hook.path`. A missing field is reported, not skipped: a - * skipped entry would drop an event GitHub still delivers and the parser would start refusing a valid file. */ + + /** Entries lacking a field the derivation reads, as `hook.path`; reported, never skipped, since a skipped entry would drop an event GitHub still delivers and the parser would start refusing a valid file. */ readonly missing: readonly string[]; } diff --git a/test/sections/webhooks/mock.ts b/test/sections/webhooks/mock.ts index 165e9465f..eea983923 100644 --- a/test/sections/webhooks/mock.ts +++ b/test/sections/webhooks/mock.ts @@ -17,11 +17,7 @@ import { storedHookConfig, } from "../../e2e/mock/support.js"; -/** - * The seed completion buildState applies (test/e2e/mock/state.ts LIST_MOCKS). A hook's server - * fields are minted by completeHook, which buildState runs over every seed and the create handler - * below runs over every body, so this spec adds only the id it is handed. - */ +/** The seed completion buildState applies (test/e2e/mock/state.ts LIST_MOCKS): completeHook mints a hook's server fields over every seed and every created body, so this spec adds only the id it is handed. */ export const WEBHOOKS_MOCK: ListMockSpec = { collection: (state) => state.hooks, defaults: {}, @@ -29,6 +25,7 @@ export const WEBHOOKS_MOCK: ListMockSpec = { unique: "identity", }; +/** The hand-written handlers: every echo masks config.secret while the stored hook keeps the real one. */ export const webhooksMockHandlers: SectionRestHandlers<"webhooks"> = { "webhooks.list": ({ state, query }) => paged(state.hooks.map(maskHookSecret), query), "webhooks.create": ({ state, body }) => { diff --git a/test/sections/webhooks/snapshot-row.ts b/test/sections/webhooks/snapshot-row.ts index c659e53b0..628f79c7b 100644 --- a/test/sections/webhooks/snapshot-row.ts +++ b/test/sections/webhooks/snapshot-row.ts @@ -1,8 +1,7 @@ import { webhooksSection } from "../../../src/sections/webhooks/index.js"; import type { Row } from "../snapshot-roundtrip.js"; -// The service hook is outside the section and noted; the set secret reads back as a reference keyed -// by the hook's id, so a reordering never rebinds it. +/** The service hook is outside the section and noted; the set secret reads back as a reference keyed by the hook's id, so a reordering never rebinds it. */ export const row: Row = { section: webhooksSection, live: { From e21e81296e5852dbeafcec15ea74e38e36ab7276 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:41:56 -0400 Subject: [PATCH 07/11] refactor(test): bring the remaining test files under the lint floor and the member-shape rules The 51 test files outside test/e2e and test/sections now clear the fleet's lint floor (36 findings) and the member-shape check (298 findings) with no suppressions and no behavior change: every assertion, every early return, and the order of every pushed problem line are as before. Functions too branched to read as a list of steps were split into named steps that sit above their callers: the claim reader in docs/claims.ts, the label and demonstration checks in docs/diagrams.test.ts, the refusal-table and link helpers in docs/guides.test.ts, the fenced-line generator in library/public-surface.test.ts, the schema verdict in schema-corpus.test.ts, the publisher, wiring, and push-boundary relations in the workflow tests, the repeated-flag and case-pair test bodies, and the Tree.mentions branches in engine/brand-mint.test.ts. Nested ternaries became if chains or a keyed table; a guard's else was unwrapped; the two test.each tables whose callbacks took more than six parameters now pass one object per row. Half-commented records and constant blocks are now commented throughout or bare; stacked and multi-line field comments became one line, the facts that needed more moving to the record's own doc comment or the file header; commented constants stand alone. Three route-factory arrow constants became function declarations, which is what ends a constant block for the check. --- test/action/inputs.test.ts | 2 + test/cli/execution.test.ts | 67 +++-- test/cli/inputs.test.ts | 111 +++++--- test/config/package-json.test.ts | 77 +++-- test/config/package-pins.test.ts | 9 +- test/count-nouns.test.ts | 12 +- test/discovery/exclude-pattern.test.ts | 64 +++-- test/docs/claims.ts | 61 ++-- test/docs/coverage.test.ts | 43 +-- test/docs/diagrams.test.ts | 134 +++++---- test/docs/guides.test.ts | 283 +++++++++++-------- test/docs/library-examples.test.ts | 41 +-- test/docs/readme.test.ts | 29 +- test/engine/brand-mint.test.ts | 111 +++++--- test/engine/canonical.test.ts | 32 ++- test/engine/check-purity.test.ts | 5 +- test/engine/layers.test.ts | 13 +- test/fixtures/environment-parse-rules.ts | 10 + test/flows/merge-parity.test.ts | 13 +- test/flows/snapshot.test.ts | 97 ++++--- test/github/api.test.ts | 114 +++++--- test/github/list.test.ts | 15 +- test/github/stub.ts | 17 +- test/io/capture.ts | 9 + test/library/public-surface.test.ts | 110 ++++--- test/lint/never-throw.test.ts | 6 + test/lint/rule-gate.test.ts | 4 + test/mock-api.ts | 10 + test/private.test.ts | 9 +- test/problem.test.ts | 5 + test/published-schema.test.ts | 49 ++-- test/report/issue-report.test.ts | 15 + test/schema-corpus.test.ts | 76 +++-- test/scripts/auto-fix-steps.test.ts | 6 + test/scripts/auto-format-steps.test.ts | 1 + test/scripts/gen-action-docs.test.ts | 121 ++++---- test/scripts/gen-docs.test.ts | 8 +- test/scripts/gen-inputs-table.test.ts | 8 + test/scripts/generated-regions.test.ts | 11 + test/scripts/generated.test.ts | 9 +- test/scripts/nightly-steps.test.ts | 30 +- test/scripts/release-pipeline-build.test.ts | 1 + test/scripts/release-pipeline-fixture.ts | 58 +++- test/scripts/release-pipeline-npm.test.ts | 87 +++--- test/scripts/relocated-region.ts | 3 +- test/scripts/step-fixture.ts | 6 +- test/workflows/nightly-workflows.test.ts | 10 +- test/workflows/npm-publish-workflows.test.ts | 89 +++++- test/workflows/post-green-workflow.test.ts | 152 +++++----- test/workflows/release-gate-workflow.test.ts | 4 + test/workflows/repo-owned-workflows.test.ts | 98 +++++-- 51 files changed, 1495 insertions(+), 860 deletions(-) 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/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 `