From ec6d9edfb7ff8339edb23dc076ffeab5a242fdbc Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Tue, 22 Sep 2026 05:26:27 -0400 Subject: [PATCH] refactor: replace hand-rolled helpers with the library calls that already ship zod issue paths render through z.core.toDotPath at the four sites that each spelled their own join, so a key with a non-word character reads as ["my-key"] instead of .my-key. The secret scanning pattern comparison uses isDeepStrictEqual from node:util instead of JSON.stringify equality. The two set-equality helpers use Set.prototype.isSubsetOf, and the tsconfig lib moves from ES2022 to ES2025 for its types while the target stays ES2022. The REST query string is built by URLSearchParams, which writes a space as + where encodeURIComponent wrote %20; GitHub decodes both. Central-mode file names go through extname and parse from node:path instead of a YAML extension regex. valueAt has one home in the shared list-section module, the two environments modules share one unreconcilable message builder, and the generator scripts call RegExp.escape instead of a local escapeRe. --- .github/scripts/gen-action-docs.ts | 10 ++++---- .github/scripts/gen-docs.ts | 25 ++++++++----------- .github/scripts/lib/generated-regions.ts | 5 ---- src/discovery/central.ts | 12 ++++----- src/engine/validate.ts | 24 ++++-------------- src/sections/contract/endpoints.ts | 5 +--- src/sections/contract/live.ts | 7 ++---- src/sections/contract/permissions.ts | 6 +---- src/sections/custom_properties/index.ts | 2 +- src/sections/environments/branch-policies.ts | 19 +++++++------- src/sections/environments/endpoints.ts | 13 ++++++++++ src/sections/environments/protection-rules.ts | 13 +++------- src/sections/rulesets/schema.ts | 12 +++------ .../secret_scanning_custom_patterns/index.ts | 3 ++- .../secret_scanning_custom_patterns/schema.ts | 3 +-- src/sections/shared/list-section.ts | 5 ++-- test/docs/markdown.ts | 4 +-- test/e2e/foundation.test.ts | 9 ++++--- test/flows/snapshot.test.ts | 7 +++--- test/scripts/release-pipeline.test.ts | 7 +++--- test/sections/docs-registry.test.ts | 11 ++++---- tsconfig.json | 2 +- 22 files changed, 85 insertions(+), 119 deletions(-) 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,