diff --git a/.github/scripts/gen-action-docs.ts b/.github/scripts/gen-action-docs.ts index 24fdc12c..630808a5 100644 --- a/.github/scripts/gen-action-docs.ts +++ b/.github/scripts/gen-action-docs.ts @@ -20,7 +20,7 @@ import { import { SECTIONS } from "../../src/sections/registry.js"; import { agree } from "../../src/text.js"; import { countWord } from "./lib/count-word.js"; -import { escapeRe, type GeneratedRegion, regenerateRegions } from "./lib/generated-regions.js"; +import { type GeneratedRegion, regenerateRegions } from "./lib/generated-regions.js"; const ROOT = join(import.meta.dir, "..", ".."); @@ -338,7 +338,7 @@ function blockShape(lines: string): RegExp { } function tableShape(header: string, cells: string): RegExp { - return blockShape(String.raw`${escapeRe(header)}\n(?:\| ${cells} \|\n)*`); + return blockShape(String.raw`${RegExp.escape(header)}\n(?:\| ${cells} \|\n)*`); } /** A JSON string literal as JSON.stringify() emits it: its own escapes only, bare quotes never. */ @@ -398,7 +398,7 @@ export const GENERATED_REGIONS: Readonly renderPolicyCountSentence(knobbedSections())), }, { @@ -415,7 +415,7 @@ export const GENERATED_REGIONS: Readonly renderGrantSentence(SECTIONS)), }, { @@ -433,7 +433,7 @@ export const GENERATED_REGIONS: Readonly renderCheckModeGatedReads(SECTIONS)), }, diff --git a/.github/scripts/gen-docs.ts b/.github/scripts/gen-docs.ts index 9d35ce6b..df6c1998 100644 --- a/.github/scripts/gen-docs.ts +++ b/.github/scripts/gen-docs.ts @@ -15,12 +15,7 @@ import type { UndeclaredPolicy } from "../../src/types.js"; import { readArchitecture, renderArchitectureMermaid } from "./arch-lint.js"; import { COVERAGE_DATA, type CoverageData } from "./coverage-data.js"; import { ENDPOINT_ANCHORS, type EndpointAnchors } from "./endpoint-docs.js"; -import { - escapeRe, - type GeneratedRegion, - regenerateRegions, - regionBounds, -} from "./lib/generated-regions.js"; +import { type GeneratedRegion, regenerateRegions, regionBounds } from "./lib/generated-regions.js"; const ROOT = join(import.meta.dir, "..", ".."); export const COVERAGE_PATH = "docs/reference/coverage.md"; @@ -428,7 +423,7 @@ function sectionsTableRegion(name: string, heading: string): GeneratedRegion { name, placement: { kind: "under-heading", heading }, body: new RegExp( - String.raw`^\n(?:${escapeRe(TABLE_HEADER)}\n(?:\| \x60[a-z_]+\x60 \| [^\n]* \|\n)*)?$`, + String.raw`^\n(?:${RegExp.escape(TABLE_HEADER)}\n(?:\| \x60[a-z_]+\x60 \| [^\n]* \|\n)*)?$`, ), render: () => `\n${renderSectionsTable(SECTIONS, DOCS)}\n`, }; @@ -439,7 +434,7 @@ function outputsListRegion(name: string, heading: string): GeneratedRegion { name, placement: { kind: "under-heading", heading }, body: new RegExp( - String.raw`^(?:\x60[a-z]+\x60(?: / \x60[a-z]+\x60)*${escapeRe(RESULT_TAIL)})?$`, + String.raw`^(?:\x60[a-z]+\x60(?: / \x60[a-z]+\x60)*${RegExp.escape(RESULT_TAIL)})?$`, ), render: () => renderOutputsList(RUN_RESULTS), }; @@ -450,7 +445,7 @@ function patUrlRegion(name: string): GeneratedRegion { return { name, placement: { kind: "tail" }, - body: new RegExp(String.raw`^\n(?:\[${escapeRe(PAT_FORM_LABEL)}\]: \S+\n)?$`), + body: new RegExp(String.raw`^\n(?:\[${RegExp.escape(PAT_FORM_LABEL)}\]: \S+\n)?$`), render: () => `\n[${PAT_FORM_LABEL}]: ${patFormUrl()}\n`, }; } @@ -505,10 +500,10 @@ const nonBlank = (excluded: string): string => String.raw`[ \t]*[^${excluded}\s][^${excluded}\r\n]*`; const PROSE_LINE = `${nonBlank("")}\n`; const CELL = nonBlank("|"); -const KEY_CELL = String.raw`\[\x60[a-z_]+\x60\]\(${escapeRe(SECTIONS_PAGE)}\)(?: \(\x60${nonBlank("|\x60")}\x60\))?`; +const KEY_CELL = String.raw`\[\x60[a-z_]+\x60\]\(${RegExp.escape(SECTIONS_PAGE)}\)(?: \(\x60${nonBlank("|\x60")}\x60\))?`; const SUPPORTED_ROWS = String.raw`(?:\| ${CELL} \| ${KEY_CELL} \| ${CELL} \|\n)+`; const GAP_ROWS = String.raw`(?:\| ${CELL} \| ${CELL} \| ${CELL} \|\n)+`; -const GAPS_BODY = String.raw`(?:${PROSE_LINE}\n${escapeRe(GAPS_HEADER)}\n|${escapeRe(GAPS_HEADER)}\n${GAP_ROWS})`; +const GAPS_BODY = String.raw`(?:${PROSE_LINE}\n${RegExp.escape(GAPS_HEADER)}\n|${RegExp.escape(GAPS_HEADER)}\n${GAP_ROWS})`; const BULLETS = `(?:- ${PROSE_LINE})+`; const NOTE_GROUPS = String.raw`(?:\*\*${nonBlank("*")}\*\* \(\x60[a-z_]+\x60\)\n\n${BULLETS}\n)+`; @@ -519,10 +514,10 @@ const COVERAGE_REGIONS: readonly GeneratedRegion[] = [ name: "coverage", placement: { kind: "tail" }, body: new RegExp( - String.raw`^\n(?:(?:${PROSE_LINE}\n)+${escapeRe(SUPPORTED_HEADING)}\n\n${escapeRe(SUPPORTED_HEADER)}\n` + - String.raw`${SUPPORTED_ROWS}\n${escapeRe(NOTES_HEADING)}\n\n${NOTE_GROUPS}` + - String.raw`${escapeRe(GAPS_HEADING)}\n\n${GAPS_BODY}\n${escapeRe(NO_API_HEADING)}\n\n` + - String.raw`${PROSE_LINE}\n${BULLETS}\n${escapeRe(OUT_OF_SCOPE_HEADING)}\n\n${BULLETS})?$`, + String.raw`^\n(?:(?:${PROSE_LINE}\n)+${RegExp.escape(SUPPORTED_HEADING)}\n\n${RegExp.escape(SUPPORTED_HEADER)}\n` + + String.raw`${SUPPORTED_ROWS}\n${RegExp.escape(NOTES_HEADING)}\n\n${NOTE_GROUPS}` + + String.raw`${RegExp.escape(GAPS_HEADING)}\n\n${GAPS_BODY}\n${RegExp.escape(NO_API_HEADING)}\n\n` + + String.raw`${PROSE_LINE}\n${BULLETS}\n${RegExp.escape(OUT_OF_SCOPE_HEADING)}\n\n${BULLETS})?$`, ), render: () => `\n${renderCoverage(SECTIONS, DOCS, COVERAGE_DATA, ENDPOINT_ANCHORS)}\n`, }, diff --git a/.github/scripts/lib/generated-regions.ts b/.github/scripts/lib/generated-regions.ts index fc67162a..8e9f1605 100644 --- a/.github/scripts/lib/generated-regions.ts +++ b/.github/scripts/lib/generated-regions.ts @@ -8,11 +8,6 @@ import { Parser } from "yaml"; /** Which comment syntax a file's markers use: a complete `` comment, or a whole-line YAML `#` comment. */ export type MarkerSyntax = "html" | "yaml"; -/** `text` as a regex source matching itself literally; body shapes splice renderer constants through it. */ -export function escapeRe(text: string): string { - return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - /** Character offsets of one marker: `[start, end)`. */ export type MarkerSpan = readonly [start: number, end: number]; diff --git a/src/discovery/central.ts b/src/discovery/central.ts index 14e62e49..d7f6293d 100644 --- a/src/discovery/central.ts +++ b/src/discovery/central.ts @@ -4,13 +4,13 @@ */ import { existsSync, readdirSync, statSync } from "node:fs"; -import { join } from "node:path"; +import { extname, join, parse } from "node:path"; import { err, ok, type Result } from "neverthrow"; import { type SlugKey, slugKey } from "../github/slug.js"; import type { CentralFileProblem, ProblemOf } from "../problem.js"; import { type CentralTarget, SLUG_RE } from "./targets.js"; -const YAML_EXT = /\.ya?ml$/; +const YAML_EXTENSIONS = new Set([".yml", ".yaml"]); export function resolveCentralTargets( reposDir: string, @@ -52,13 +52,13 @@ export function resolveCentralTargets( ); continue; } - if (!YAML_EXT.test(inner)) { + if (!YAML_EXTENSIONS.has(extname(inner))) { warnings.push( `ignoring ${innerPath}: not a .yml/.yaml file, so it defines no target repository`, ); continue; } - addTarget(`${owner}/${inner.replace(YAML_EXT, "")}`, innerPath); + addTarget(`${owner}/${parse(inner).name}`, innerPath); } }; @@ -71,7 +71,7 @@ export function resolveCentralTargets( scanOwnerDir(entryPath, entry); continue; } - if (!YAML_EXT.test(entry)) { + if (!YAML_EXTENSIONS.has(extname(entry))) { warnings.push( `ignoring ${entryPath}: not a .yml/.yaml file, so it defines no target repository`, ); @@ -81,7 +81,7 @@ export function resolveCentralTargets( ownerlessFiles.push(entryPath); continue; } - addTarget(`${adminOwner}/${entry.replace(YAML_EXT, "")}`, entryPath); + addTarget(`${adminOwner}/${parse(entry).name}`, entryPath); } if (ownerlessFiles.length > 0) { errors.push({ kind: "ownerless", files: ownerlessFiles }); diff --git a/src/engine/validate.ts b/src/engine/validate.ts index 46ff02d3..ffe62af3 100644 --- a/src/engine/validate.ts +++ b/src/engine/validate.ts @@ -1,6 +1,7 @@ /** Shape validation against each section's loose zod shape; the parsed output, not the input, is what the engine applies. */ import { err, ok, type Result } from "neverthrow"; +import { z } from "zod"; import { nonPlainKind } from "../plain-data.js"; import type { ProblemOf } from "../problem.js"; import { LIST_SECTIONS, type ListSection, SECTION_KEYS, type SettingsFile } from "../schema.js"; @@ -10,6 +11,7 @@ import { type DeclaredSecretValue, } from "../sections/contract/module.js"; import { listLayering, sectionModule, sectionShape } from "../sections/registry.js"; +import { valueAt } from "../sections/shared/list-section.js"; import { agree, countNoun } from "../text.js"; import { type SettingsSource, validateSecretRef } from "./secret-refs.js"; @@ -19,18 +21,6 @@ function isListSection(key: string): key is ListSection { return LIST_KEYS.has(key); } -/** The value at an issue's path, so a null the author wrote can be told from a type the author got wrong. */ -function valueAt(root: unknown, path: readonly PropertyKey[]): unknown { - let node: unknown = root; - for (const step of path) { - if (typeof node !== "object" || node === null) { - return undefined; - } - node = (node as Record)[step]; - } - return node; -} - /** zod's issue fields this module reads; `legal` is this action's own, set where a shape refuses null itself. */ interface NullIssue { code: string; @@ -230,18 +220,14 @@ export function validateSectionShapes( if (!parsed.success) { const issues = parsed.error.issues; for (const issue of issues.slice(0, 5)) { - const path = issue.path - .map((p) => (typeof p === "number" ? `[${p}]` : `.${String(p)}`)) - .join(""); + const path = z.core.toDotPath([key, ...issue.path]); // A null the shape refused is the author saying "empty" where GitHub has no empty state: name the values that // exist. A shape's own diagnostic (a custom issue) already names the fix, unless it supplies the legal values itself. if (valueAt(declared, issue.path) === null && rewritesForNull(issue as NullIssue)) { - problems.push( - `${key}${path} has no empty state; write ${legalValues(issue as NullIssue)}`, - ); + problems.push(`${path} has no empty state; write ${legalValues(issue as NullIssue)}`); continue; } - problems.push(`${key}${path}: ${issue.message}`); + problems.push(`${path}: ${issue.message}`); } if (issues.length > 5) { // A silently truncated list costs one fix-and-rerun cycle per hidden offender. diff --git a/src/sections/contract/endpoints.ts b/src/sections/contract/endpoints.ts index 7055856e..90f00624 100644 --- a/src/sections/contract/endpoints.ts +++ b/src/sections/contract/endpoints.ts @@ -243,10 +243,7 @@ export function expand( ); } if (query && Object.keys(query).length > 0) { - const qs = Object.entries(query) - .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) - .join("&"); - return `${path}?${qs}`; + return `${path}?${new URLSearchParams(query)}`; } return path; } diff --git a/src/sections/contract/live.ts b/src/sections/contract/live.ts index e733fb85..d334049e 100644 --- a/src/sections/contract/live.ts +++ b/src/sections/contract/live.ts @@ -5,7 +5,7 @@ */ import { err, ok, type Result } from "neverthrow"; -import type { z } from "zod"; +import { z } from "zod"; import { countNoun } from "../../text.js"; import { endpointMethod, endpointPath } from "./endpoints.js"; import type { SectionFailure } from "./errors.js"; @@ -85,10 +85,7 @@ export function parseLive( } const issues = parsed.error.issues; const shown = issues.slice(0, 3).map((issue) => { - const path = issue.path - .map((part) => (typeof part === "number" ? `[${part}]` : `.${String(part)}`)) - .join(""); - return `${path.replace(/^\./, "") || "(body)"}: ${issue.message}`; + return `${z.core.toDotPath(issue.path) || "(body)"}: ${issue.message}`; }); const more = issues.length > 3 ? `; and ${countNoun(issues.length - 3, "more issue", "more issues")}` : ""; diff --git a/src/sections/contract/permissions.ts b/src/sections/contract/permissions.ts index 8f66964a..32e1789d 100644 --- a/src/sections/contract/permissions.ts +++ b/src/sections/contract/permissions.ts @@ -35,11 +35,7 @@ export function samePermission( } const resources = new Set(a.repo); const others = new Set(b.repo); - return ( - a.org === b.org && - resources.size === others.size && - [...resources].every((resource) => others.has(resource)) - ); + return a.org === b.org && resources.size === others.size && resources.isSubsetOf(others); } /** Human-facing label for each PAT resource, as shown in the token UI. */ diff --git a/src/sections/custom_properties/index.ts b/src/sections/custom_properties/index.ts index 212d648e..002f91a4 100644 --- a/src/sections/custom_properties/index.ts +++ b/src/sections/custom_properties/index.ts @@ -54,7 +54,7 @@ function sameValue(a: WireValue, b: WireValue): boolean { if (Array.isArray(a) && Array.isArray(b)) { const setA = new Set(a); const setB = new Set(b); - return setA.size === setB.size && [...setA].every((element) => setB.has(element)); + return setA.size === setB.size && setA.isSubsetOf(setB); } return a === b; } diff --git a/src/sections/environments/branch-policies.ts b/src/sections/environments/branch-policies.ts index 4ba2b90c..9765d041 100644 --- a/src/sections/environments/branch-policies.ts +++ b/src/sections/environments/branch-policies.ts @@ -7,7 +7,7 @@ import { err, ok, Result, safeTry } from "neverthrow"; import { z } from "zod"; import { subsetDiff } from "../../engine/diff.js"; import type { UndeclaredPolicy } from "../../types.js"; -import { type SectionFailure, sectionFailure } from "../contract/errors.js"; +import type { SectionFailure } from "../contract/errors.js"; import { liveByIdentity, liveIdentity } from "../contract/live.js"; import { type DeclaredIssue, @@ -18,7 +18,11 @@ import { undeclaredNote, } from "../contract/module.js"; import { hasDrift, plainData, type Read } from "../contract/plan.js"; -import type { EnvironmentRestOp, EnvironmentsRestContext } from "./endpoints.js"; +import { + type EnvironmentRestOp, + type EnvironmentsRestContext, + unreconcilable, +} from "./endpoints.js"; import type { LiveEnvironmentBody } from "./index.js"; import type { NestedPlan } from "./nested.js"; import type { DeploymentBranchPolicyConfig } from "./schema.js"; @@ -46,23 +50,18 @@ function livePolicyType(policy: LiveBranchPolicy): string { return typeof policy.type === "string" ? policy.type : "branch"; } -function unreconcilable(envName: string, what: string): SectionFailure { - return sectionFailure( - "live-shape", - `environments: the deployment branch-policy list for environment "${envName}" returned a policy without ${what}, so it cannot be reconciled. Check the "api-version" input against the GitHub REST docs for this endpoint`, - ); -} +const POLICY = { list: "deployment branch-policy", entry: "policy" }; function livePolicyId(policy: LiveBranchPolicy, envName: string): Result { if (policy.id === undefined) { - return err(unreconcilable(envName, "an id")); + return err(unreconcilable(POLICY, envName, "an id")); } return ok(String(policy.id)); } function livePolicyName(policy: LiveBranchPolicy, envName: string): Result { if (typeof policy.name !== "string") { - return err(unreconcilable(envName, "a name")); + return err(unreconcilable(POLICY, envName, "a name")); } return ok(policy.name); } diff --git a/src/sections/environments/endpoints.ts b/src/sections/environments/endpoints.ts index 9316ecfc..2edf0e63 100644 --- a/src/sections/environments/endpoints.ts +++ b/src/sections/environments/endpoints.ts @@ -4,6 +4,7 @@ */ import type { EndpointDecl } from "../contract/endpoints.js"; +import { type SectionFailure, sectionFailure } from "../contract/errors.js"; import type { PlanContext, PlannedOp } from "../contract/plan.js"; const BRANCH_POLICIES_DENIAL_HINT = @@ -133,3 +134,15 @@ export const ENDPOINTS = { export type EnvironmentsRestContext = PlanContext; export type EnvironmentRestOp = PlannedOp; + +/** A live entry missing the field its reconcile keys on has no identity to match; `noun` names the list and one entry. */ +export function unreconcilable( + noun: { list: string; entry: string }, + envName: string, + what: string, +): SectionFailure { + return sectionFailure( + "live-shape", + `environments: the ${noun.list} list for environment "${envName}" returned a ${noun.entry} without ${what}, so it cannot be reconciled. Check the "api-version" input against the GitHub REST docs for this endpoint`, + ); +} diff --git a/src/sections/environments/protection-rules.ts b/src/sections/environments/protection-rules.ts index 230c816b..b262d228 100644 --- a/src/sections/environments/protection-rules.ts +++ b/src/sections/environments/protection-rules.ts @@ -12,7 +12,7 @@ import { undeclaredNote, } from "../contract/module.js"; import type { Read } from "../contract/plan.js"; -import type { EnvironmentsRestContext } from "./endpoints.js"; +import { type EnvironmentsRestContext, unreconcilable } from "./endpoints.js"; import type { NestedPlan } from "./nested.js"; import type { DeploymentProtectionRuleConfig } from "./schema.js"; @@ -32,17 +32,12 @@ const LiveProtectionRule = z.looseObject({ }); type LiveProtectionRule = z.infer; -function unreconcilable(envName: string, what: string): SectionFailure { - return sectionFailure( - "live-shape", - `environments: the deployment protection rule list for environment "${envName}" returned a rule without ${what}, so it cannot be reconciled. Check the "api-version" input against the GitHub REST docs for this endpoint`, - ); -} +const RULE = { list: "deployment protection rule", entry: "rule" }; function liveRuleSlug(rule: LiveProtectionRule, envName: string): Result { const slug = rule.app?.slug; if (typeof slug !== "string") { - return err(unreconcilable(envName, "an app slug")); + return err(unreconcilable(RULE, envName, "an app slug")); } return ok(slug); } @@ -50,7 +45,7 @@ function liveRuleSlug(rule: LiveProtectionRule, envName: string): Result { // A null or string id would serialize into the DELETE path (".../deployment_protection_rules/null"). if (typeof rule.id !== "number") { - return err(unreconcilable(envName, "a numeric id")); + return err(unreconcilable(RULE, envName, "a numeric id")); } return ok(String(rule.id)); } diff --git a/src/sections/rulesets/schema.ts b/src/sections/rulesets/schema.ts index 09cbc3cc..7065bcc5 100644 --- a/src/sections/rulesets/schema.ts +++ b/src/sections/rulesets/schema.ts @@ -276,14 +276,6 @@ const UnknownRule = z }) .meta({ id: "UnknownRule" }); -function renderPath(path: readonly PropertyKey[]): string { - return path - .map((step, i) => - typeof step === "number" ? `[${step}]` : `${i === 0 ? "" : "."}${String(step)}`, - ) - .join(""); -} - /** * zod reports a failed union as "Invalid input" unless exactly one branch failed on non-aborting checks alone, and * here every branch aborts, so the report is built from the branch the rule's type selects. @@ -296,7 +288,9 @@ function ruleUnionError(issue: z.core.$ZodRawIssue): string | undefined { const type = (issue.input as { type?: unknown } | null)?.type; const own = typeof type === "string" && KNOWN_RULE_TYPES.includes(type) ? known : unknown; return own - .map((sub) => (sub.path.length === 0 ? sub.message : `${renderPath(sub.path)}: ${sub.message}`)) + .map((sub) => + sub.path.length === 0 ? sub.message : `${z.core.toDotPath(sub.path)}: ${sub.message}`, + ) .join("; "); } diff --git a/src/sections/secret_scanning_custom_patterns/index.ts b/src/sections/secret_scanning_custom_patterns/index.ts index dbec6822..9c1da5ed 100644 --- a/src/sections/secret_scanning_custom_patterns/index.ts +++ b/src/sections/secret_scanning_custom_patterns/index.ts @@ -8,6 +8,7 @@ * PATCH and DELETE -> carry custom_pattern_version when GitHub supplies one; a pattern edited between read and write answers 412 */ +import { isDeepStrictEqual } from "node:util"; import { ok, type Result } from "neverthrow"; import { z } from "zod"; import { agree } from "../../text.js"; @@ -141,7 +142,7 @@ function matches(declaredValue: string | string[], liveValue: unknown): boolean Array.isArray(declaredValue) && (liveValue === undefined || liveValue === null) ? [] : liveValue; - return JSON.stringify(liveComparable) === JSON.stringify(declaredValue); + return isDeepStrictEqual(liveComparable, declaredValue); } function patternsByName( diff --git a/src/sections/secret_scanning_custom_patterns/schema.ts b/src/sections/secret_scanning_custom_patterns/schema.ts index 1e9ee3e3..cfdc1d2f 100644 --- a/src/sections/secret_scanning_custom_patterns/schema.ts +++ b/src/sections/secret_scanning_custom_patterns/schema.ts @@ -48,8 +48,7 @@ export function unverifiableRegexFields(entry: unknown): string[] { if (reason === undefined) { return []; } - const path = issue.path.map((step) => (typeof step === "number" ? `[${step}]` : step)).join(""); - labels.push(`${path} (${reason})`); + labels.push(`${z.core.toDotPath(issue.path)} (${reason})`); } return labels; } diff --git a/src/sections/shared/list-section.ts b/src/sections/shared/list-section.ts index bf2397ea..012b693c 100644 --- a/src/sections/shared/list-section.ts +++ b/src/sections/shared/list-section.ts @@ -517,13 +517,14 @@ function pathOf(field: string): string[] { return field.split("."); } -function valueAt(record: unknown, path: readonly string[]): unknown { +/** The own value at `path`; undefined once a step is missing, so a null the author wrote reads as null, not as absent. */ +export function valueAt(record: unknown, path: readonly PropertyKey[]): unknown { let node: unknown = record; for (const step of path) { if (typeof node !== "object" || node === null || !Object.hasOwn(node, step)) { return undefined; } - node = (node as Fields)[step]; + node = (node as Record)[step]; } return node; } diff --git a/test/docs/markdown.ts b/test/docs/markdown.ts index 3b763d5f..f71f000b 100644 --- a/test/docs/markdown.ts +++ b/test/docs/markdown.ts @@ -1,9 +1,7 @@ -import { escapeRe } from "../../.github/scripts/lib/generated-regions.js"; - /** The contents of every fenced code block whose info string is exactly `info`. */ export function fencedBlocks(markdown: string, info: string): string[] { const blocks: string[] = []; - const escaped = escapeRe(info); + const escaped = RegExp.escape(info); // Leading whitespace is stripped (the README nests fences inside list items) and longer fences close per CommonMark; the guides additionally pin // column-zero triple backticks, so this extractor cannot miss a docs/ block. const re = new RegExp( diff --git a/test/e2e/foundation.test.ts b/test/e2e/foundation.test.ts index 5055cdce..e6d80e37 100644 --- a/test/e2e/foundation.test.ts +++ b/test/e2e/foundation.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"; import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, join } from "node:path"; import { parse as parseYaml } from "yaml"; -import { escapeRe } from "../../.github/scripts/lib/generated-regions.js"; import { MARKER_LABEL, MARKER_LABEL_CONFIG } from "../../src/report/issue-report.js"; import { SECTION_KEYS } from "../../src/schema.js"; import { ROOT } from "../root.js"; @@ -293,7 +292,9 @@ describe("scenario corpus loader (collectYmlFiles)", () => { // the read silently succeeding. writeFileSync(join(root, "one.yml"), "name: one\n"); chmodSync(root, 0o000); - const named = new RegExp(`^cannot read the scenario directory ${escapeRe(root)}: .*EACCES`); + const named = new RegExp( + `^cannot read the scenario directory ${RegExp.escape(root)}: .*EACCES`, + ); expect(() => collectYmlFiles(root)).toThrow(named); // loadScenarios is what run.ts and the coverage tripwire call, so the // failure must reach them through it. @@ -318,7 +319,9 @@ describe("scenario corpus loader (collectYmlFiles)", () => { expect(roots[0]).toBe(join(import.meta.dir, "scenarios")); expect(roots).toContain(unreadable); expect(() => loadScenarios(roots.slice(1))).toThrow( - new RegExp(`^cannot read the scenario directory ${escapeRe(unreadable)}: .*EACCES`), + new RegExp( + `^cannot read the scenario directory ${RegExp.escape(unreadable)}: .*EACCES`, + ), ); } finally { // withTempRoot restores only the top of the tree; this nested diff --git a/test/flows/snapshot.test.ts b/test/flows/snapshot.test.ts index 7d5f7f2a..5a555473 100644 --- a/test/flows/snapshot.test.ts +++ b/test/flows/snapshot.test.ts @@ -22,7 +22,6 @@ import { import { tmpdir } from "node:os"; import { basename, dirname, join, sep } from "node:path"; import { parse as parseYaml } from "yaml"; -import { escapeRe } from "../../.github/scripts/lib/generated-regions.js"; import { parseRepoSlug } from "../../src/discovery/targets.js"; import { SectionSelection } from "../../src/engine/section-selection.js"; import { failRun } from "../../src/flows/deliver.js"; @@ -370,13 +369,13 @@ describe("runSnapshot writes through a staging file", () => { ); // The staging name carries the pid and random bytes, so the line is matched with that piece wild. const [head = "", tail = ""] = line.split(""); - const stagingRe = `${escapeRe(join(cfg.snapshotDir, "o"))}/\\.gsac-\\d+-[0-9a-f]{8}\\.tmp`; + const stagingRe = `${RegExp.escape(join(cfg.snapshotDir, "o"))}/\\.gsac-\\d+-[0-9a-f]{8}\\.tmp`; expect(collected.lines).toEqual([ TAKEN, { level, line: expect.stringMatching( - new RegExp(`^o/a: ${escapeRe(head)}${stagingRe}${escapeRe(tail)}$`), + new RegExp(`^o/a: ${RegExp.escape(head)}${stagingRe}${RegExp.escape(tail)}$`), ), }, { line: `o/b: snapshot written to ${fileB}` }, @@ -907,7 +906,7 @@ describe("runSnapshot, dir form", () => { level: "error", line: expect.stringMatching( new RegExp( - `^${escapeRe(second)}: cannot write the snapshot to .*: the filesystem carries it to the file this run already claimed for ${escapeRe(first)}\\. `, + `^${RegExp.escape(second)}: cannot write the snapshot to .*: the filesystem carries it to the file this run already claimed for ${RegExp.escape(first)}\\. `, ), ), }); diff --git a/test/scripts/release-pipeline.test.ts b/test/scripts/release-pipeline.test.ts index 994a4c52..b0b8cb63 100644 --- a/test/scripts/release-pipeline.test.ts +++ b/test/scripts/release-pipeline.test.ts @@ -7,7 +7,6 @@ import { describe, expect, setDefaultTimeout, test } from "bun:test"; import { execFileSync } from "node:child_process"; import { mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs"; import { basename, join } from "node:path"; -import { escapeRe } from "../../.github/scripts/lib/generated-regions.js"; import { anchorCheck, anchorReleasePr, @@ -343,7 +342,7 @@ describe("packageRelease", () => { drift(rerun); expect(() => packageRelease({ cwd: rerun, tag: "v2.1.0", sourceSha: fx.mergeSha })).toThrow( new RegExp( - `^${TAG} \\(${before}\\) packages ${fx.mergeSha}, but its tree [0-9a-f]{40} is not the tree [0-9a-f]{40} this checkout's build packages, .*Diff the two trees by hand; ${escapeRe(FROZEN)}$`, + `^${TAG} \\(${before}\\) packages ${fx.mergeSha}, but its tree [0-9a-f]{40} is not the tree [0-9a-f]{40} this checkout's build packages, .*Diff the two trees by hand; ${RegExp.escape(FROZEN)}$`, ), ); expect(git(fx.origin, "rev-parse", `${TAG}^{}`)).toBe(before); @@ -355,7 +354,7 @@ describe("packageRelease", () => { const fx = seedFixture(); const { from, sha, error } = plant(fx); git(from, "push", "--quiet", "origin", `${sha}:${TAG}`); - const frozen = new RegExp(`${escapeRe(FROZEN)}$`); + const frozen = new RegExp(`${RegExp.escape(FROZEN)}$`); const pushes = withPushPlans(fx, [], () => { for (const path of [ () => packageRelease({ cwd: fx.work, tag: "v2.1.0", sourceSha: fx.mergeSha }), @@ -437,7 +436,7 @@ describe("packageRelease", () => { const fx = seedFixture(); spoil(fx.work); const message = new RegExp( - `does not carry a non-empty regular-file ${escapeRe(file)} \\(${entry}\\); refusing to point a consumable ref at an unpackaged commit; run the build before packaging\\.$`, + `does not carry a non-empty regular-file ${RegExp.escape(file)} \\(${entry}\\); refusing to point a consumable ref at an unpackaged commit; run the build before packaging\\.$`, ); expect(() => packageCommit({ cwd: fx.work, sourceSha: fx.mergeSha })).toThrow(message); expect(() => packageRelease({ cwd: fx.work, tag: "v2.1.0", sourceSha: fx.mergeSha })).toThrow( diff --git a/test/sections/docs-registry.test.ts b/test/sections/docs-registry.test.ts index 79144b87..3d388c20 100644 --- a/test/sections/docs-registry.test.ts +++ b/test/sections/docs-registry.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"; import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { dirname, join, relative } from "node:path"; import { ok } from "neverthrow"; -import { escapeRe } from "../../.github/scripts/lib/generated-regions.js"; import { SECTION_KEYS, type SectionKey } from "../../src/schema.js"; import { readDocsYaml, SectionDocs } from "../../src/sections/contract/docs.js"; import { endpointPath, type Route } from "../../src/sections/contract/endpoints.js"; @@ -14,7 +13,7 @@ import { withTempDir } from "../temp-dir.js"; // Whole-identifier, case-insensitive: "the pinEnvironment mutation" names PinEnvironment, "DocumentPinEnvironmentAudit" does not. function namesOperation(prose: string, name: string): boolean { - return new RegExp(`(? { "at coverage[0]", ]) { expect(readDocsYaml(malformed, SectionDocs)._unsafeUnwrapErr()).toMatch( - new RegExp(escapeRe(issue)), + new RegExp(RegExp.escape(issue)), ); } // The tail of a missing-file error is the runtime's ENOENT prose, so only our prefix is pinned. const absent = join(dir, "absent.yml"); expect(readDocsYaml(absent, SectionDocs)._unsafeUnwrapErr()).toMatch( - new RegExp(`^${escapeRe(`${absent} is not valid YAML: `)}`), + new RegExp(`^${RegExp.escape(`${absent} is not valid YAML: `)}`), ); // YAML that does not even parse (a duplicated key, which the loader refuses) names the file too. writeFileSync(malformed, ["sections_table:", " endpoints: a", " endpoints: b"].join("\n")); expect(readDocsYaml(malformed, SectionDocs)._unsafeUnwrapErr()).toMatch( - new RegExp(`${escapeRe(malformed)} is not valid YAML: .*unique`), + new RegExp(`${RegExp.escape(malformed)} is not valid YAML: .*unique`), ); // Control: the same reader accepts a well-formed document. writeFileSync(malformed, WELL_FORMED_DOCS.join("\n")); @@ -264,7 +263,7 @@ describe("Endpoints cells vs declared operations", () => { ]; const cell = normalize(DOCS[endpoint.section].sections_table.endpoints); expect( - variants.some((variant) => new RegExp(`\\b${escapeRe(variant)}\\b`).test(cell)), + variants.some((variant) => new RegExp(`\\b${RegExp.escape(variant)}\\b`).test(cell)), `the ${endpoint.section} Endpoints cell never mentions "${needle}" from endpoint ${endpoint.route}`, ).toBe(true); } diff --git a/tsconfig.json b/tsconfig.json index 2c25838e..39b30b0d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2022"], + "lib": ["ES2025"], "types": ["node", "bun"], "strict": true, "noUncheckedIndexedAccess": true,