From 8b6dc123614979f256312cc9f9db893be8546103 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Wed, 23 Sep 2026 20:31:50 +0000 Subject: [PATCH 1/4] Consolidate regex escaping onto native RegExp.escape (#63227) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35abb1df-6285-443c-892a-30c594d2d64c --- package-lock.json | 1 - package.json | 1 - .../linting-rules/code-annotation-comment-spacing.ts | 2 +- .../lib/linting-rules/link-quotation.ts | 7 +++---- .../lib/linting-rules/rai-app-card-structure.ts | 12 +++++------- src/content-render/scripts/move-content.ts | 5 ++--- src/data-directory/lib/filename-to-key.ts | 7 +++---- .../scripts/update-data-and-image-paths.ts | 3 +-- src/fixtures/tests/playwright-rendering.spec.ts | 2 +- src/versions/scripts/use-short-versions.ts | 3 +-- 10 files changed, 17 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2408c2b61588..e0e3e72c6b1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -135,7 +135,6 @@ "cross-env": "^10.1.0", "csv-parse": "7.0.0", "domhandler": "^5.0.3", - "escape-string-regexp": "5.0.0", "eslint": "^9.39.3", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", diff --git a/package.json b/package.json index 4d3640682b4b..ae869e658c10 100644 --- a/package.json +++ b/package.json @@ -297,7 +297,6 @@ "cross-env": "^10.1.0", "csv-parse": "7.0.0", "domhandler": "^5.0.3", - "escape-string-regexp": "5.0.0", "eslint": "^9.39.3", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", diff --git a/src/content-linter/lib/linting-rules/code-annotation-comment-spacing.ts b/src/content-linter/lib/linting-rules/code-annotation-comment-spacing.ts index e0f5c8a89392..8a14cd9003fd 100644 --- a/src/content-linter/lib/linting-rules/code-annotation-comment-spacing.ts +++ b/src/content-linter/lib/linting-rules/code-annotation-comment-spacing.ts @@ -52,7 +52,7 @@ export const codeAnnotationCommentSpacing = { if (restOfLine.startsWith(' ') && restOfLine.length > 1 && restOfLine[1] === ' ') { const lineNumber: number = token.lineNumber + index + 1 const fixedLine: string = line.replace( - new RegExp(`^(\\s*${commentChar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})\\s+`), + new RegExp(`^(\\s*${RegExp.escape(commentChar)})\\s+`), `$1 `, ) diff --git a/src/content-linter/lib/linting-rules/link-quotation.ts b/src/content-linter/lib/linting-rules/link-quotation.ts index f9f01934f007..d7a8e9acf295 100644 --- a/src/content-linter/lib/linting-rules/link-quotation.ts +++ b/src/content-linter/lib/linting-rules/link-quotation.ts @@ -1,6 +1,5 @@ import { addError, filterTokens } from 'markdownlint-rule-helpers' import { getRange, quotePrecedesLinkOpen } from '../helpers/utils' -import { escapeRegExp } from 'lodash-es' import type { RuleParams, RuleErrorCallback, MarkdownToken, Rule } from '../../types' export const linkQuotation: Rule = { @@ -21,11 +20,11 @@ export const linkQuotation: Rule = { if (child.type === 'link_open' && quotePrecedesLinkOpen(previous_child.content || '')) { if (!child.attrs) continue inLinkWithPrecedingQuotes = true - linkUrl = escapeRegExp(child.attrs[0][1]) + linkUrl = RegExp.escape(child.attrs[0][1]) } else if (inLinkWithPrecedingQuotes && child.type === 'text') { - content.push(escapeRegExp((child.content || '').trim())) + content.push(RegExp.escape((child.content || '').trim())) } else if (inLinkWithPrecedingQuotes && child.type === 'code_inline') { - content.push(`\`${escapeRegExp((child.content || '').trim())}\``) + content.push(`\`${RegExp.escape((child.content || '').trim())}\``) } else if (child.type === 'link_close') { const title = content.join(' ') const regex = new RegExp(`"\\[${title}\\]\\(${linkUrl}\\)({%.*%})?(!|\\.|\\?|,)?"`) diff --git a/src/content-linter/lib/linting-rules/rai-app-card-structure.ts b/src/content-linter/lib/linting-rules/rai-app-card-structure.ts index 5d7dc3004f65..a519e62f5d59 100644 --- a/src/content-linter/lib/linting-rules/rai-app-card-structure.ts +++ b/src/content-linter/lib/linting-rules/rai-app-card-structure.ts @@ -63,13 +63,11 @@ function extractTemplateBlock(): string { // Headings containing the placeholder get a pattern that matches any text in // place of the placeholder. Fixed headings get an exact match. function headingToPattern(text: string): RegExp { - if (text.includes(PLACEHOLDER)) { - const escaped = text - .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - .replace(new RegExp(PLACEHOLDER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '.+') - return new RegExp(`^${escaped}$`, 'i') - } - return new RegExp(`^${text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') + const pattern = text + .split(PLACEHOLDER) + .map((part) => RegExp.escape(part)) + .join('.+') + return new RegExp(`^${pattern}$`, 'i') } // Replaces the placeholder with "..." to keep error messages concise. diff --git a/src/content-render/scripts/move-content.ts b/src/content-render/scripts/move-content.ts index 004cb880363d..d0f02a9e0f14 100755 --- a/src/content-render/scripts/move-content.ts +++ b/src/content-render/scripts/move-content.ts @@ -28,7 +28,6 @@ import { execFileSync } from 'child_process' import { program } from 'commander' import chalk from 'chalk' import walk from 'walk-sync' -import escapeStringRegexp from 'escape-string-regexp' import fm from '@/frame/lib/frontmatter' import readFrontmatter from '@/frame/lib/read-frontmatter' @@ -587,7 +586,7 @@ function changeHomepageLinks(oldHref: string, newHref: string, verbose: boolean) // Homepage childGroup links do not have a leading '/', so we need to remove that. const homepageOldHref = oldHref.replace('/', '') const homepageNewHref = newHref.replace('/', '') - const escapedHomepageOldHref = escapeStringRegexp(homepageOldHref) + const escapedHomepageOldHref = RegExp.escape(homepageOldHref) const regex = new RegExp(`- ${escapedHomepageOldHref}$`, 'gm') const homepage = path.join(CONTENT_ROOT, 'index.md') const oldContent = fs.readFileSync(homepage, 'utf-8') @@ -605,7 +604,7 @@ function changeFeaturedLinks(oldHref: string, newHref: string): void { directories: false, }).filter((file) => !file.includes('README.md')) - const regex = new RegExp(`(^|%} )${escapeStringRegexp(oldHref)}($| {%)`) + const regex = new RegExp(`(^|%} )${RegExp.escape(oldHref)}($| {%)`) for (const file of allFiles) { let changed = false diff --git a/src/data-directory/lib/filename-to-key.ts b/src/data-directory/lib/filename-to-key.ts index a13bf343f676..e46c27903709 100644 --- a/src/data-directory/lib/filename-to-key.ts +++ b/src/data-directory/lib/filename-to-key.ts @@ -1,11 +1,10 @@ import path from 'path' -import { escapeRegExp } from 'lodash-es' -const leadingPathSeparator = new RegExp(`^${escapeRegExp(path.sep)}`) +const leadingPathSeparator = new RegExp(`^${RegExp.escape(path.sep)}`) const windowsLeadingPathSeparator = new RegExp('^/') // all slashes in the filename. path.sep is OS agnostic (windows, mac, etc) -const pathSeparator = new RegExp(escapeRegExp(path.sep), 'g') +const pathSeparator = new RegExp(RegExp.escape(path.sep), 'g') const windowsPathSeparator = new RegExp('/', 'g') // handle MS Windows style double-backslashed filenames @@ -13,7 +12,7 @@ const windowsDoubleSlashSeparator = new RegExp('\\\\', 'g') // derive `foo.bar.baz` object key from `foo/bar/baz.yml` filename export default function filenameToKey(filename: string): string { - const extension = new RegExp(`${escapeRegExp(path.extname(filename))}$`) + const extension = new RegExp(`${RegExp.escape(path.extname(filename))}$`) const key = filename .replace(extension, '') .replace(leadingPathSeparator, '') diff --git a/src/early-access/scripts/update-data-and-image-paths.ts b/src/early-access/scripts/update-data-and-image-paths.ts index dc74cc0a69d9..f1823f9e1187 100644 --- a/src/early-access/scripts/update-data-and-image-paths.ts +++ b/src/early-access/scripts/update-data-and-image-paths.ts @@ -7,7 +7,6 @@ import fs from 'fs' import path from 'path' import { program } from 'commander' import walkFiles from '@/workflows/walk-files' -import { escapeRegExp } from 'lodash-es' import patterns from '@/frame/lib/patterns' interface ProgramOptions { @@ -114,7 +113,7 @@ for (const file of selectedFiles) { let newContents = oldContents for (const [oldRef, newRef] of Object.entries(replacements)) { - newContents = newContents.replace(new RegExp(escapeRegExp(oldRef), 'g'), newRef) + newContents = newContents.replace(new RegExp(RegExp.escape(oldRef), 'g'), newRef) } fs.writeFileSync(file, newContents) diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index 766f68394506..c005042e32df 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -1992,7 +1992,7 @@ test.describe('LandingArticleGridWithFilter component', () => { await firstCardLink.focus() await page.keyboard.press('Enter') - await expect(page).toHaveURL(new RegExp(href!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + await expect(page).toHaveURL(new RegExp(RegExp.escape(href!))) const stillClientSide = await page.evaluate( () => (window as unknown as { __spaMarker?: boolean }).__spaMarker === true, ) diff --git a/src/versions/scripts/use-short-versions.ts b/src/versions/scripts/use-short-versions.ts index 2f5e1f2a8ee8..f0847142db9b 100755 --- a/src/versions/scripts/use-short-versions.ts +++ b/src/versions/scripts/use-short-versions.ts @@ -1,7 +1,6 @@ import fs from 'fs' import walk from 'walk-sync' import path from 'path' -import { escapeRegExp } from 'lodash-es' import { Tokenizer, TypeGuards, type TopLevelToken, type TagToken } from 'liquidjs' import frontmatter from '@/frame/lib/read-frontmatter' import { allVersions } from '@/versions/lib/all-versions' @@ -147,7 +146,7 @@ function removeInputProps(arrayOfObjects: TopLevelToken[]): TopLevelToken[] { function makeLiquidReplacements(replacementsObj: ReplacementsMap, text: string): string { let newText = text for (const [oldCond, newCond] of Object.entries(replacementsObj)) { - const oldCondRegex = new RegExp(`({%-?)\\s*?${escapeRegExp(oldCond)}\\s*?(-?%})`, 'g') + const oldCondRegex = new RegExp(`({%-?)\\s*?${RegExp.escape(oldCond)}\\s*?(-?%})`, 'g') newText = newText .replace(oldCondRegex, `$1 ${newCond} $2`) // Content files use an old-school hack to ensure our old regex deprecation script DTRT, for example: From 2deb9658b7e74717778fa6872a282b3307090923 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Wed, 23 Sep 2026 21:17:35 +0000 Subject: [PATCH 2/4] Replace mkdirp and rimraf with node:fs built-ins (#63228) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35abb1df-6285-443c-892a-30c594d2d64c Copilot-Session: 946d101e-639b-48d2-a35c-b51badaa245a --- package-lock.json | 63 ------------------- package.json | 2 - src/audit-logs/lib/deduplicate.ts | 5 +- src/audit-logs/scripts/sync.ts | 5 +- .../lib/update-markdown.ts | 8 +-- .../tests/update-markdown.ts | 5 +- src/codeql-cli/scripts/sync.ts | 8 +-- .../scripts/symlink-from-local-repo.ts | 3 +- src/frame/tests/get-remote-json.ts | 3 +- .../scripts/deprecate/archive-version.ts | 7 ++- .../deprecate/update-automated-pipelines.ts | 12 ++-- src/github-apps/scripts/sync.ts | 7 +-- src/graphql/scripts/sync.ts | 3 +- .../scripts/utils/bucket-by-category.ts | 3 +- src/rest/scripts/update-files.ts | 16 +++-- src/rest/scripts/utils/sync.ts | 5 +- src/tests/scripts/copy-fixture-data.ts | 3 +- src/webhooks/scripts/sync.ts | 5 +- 18 files changed, 41 insertions(+), 122 deletions(-) diff --git a/package-lock.json b/package-lock.json index e0e3e72c6b1e..d85acffb4a6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -164,14 +164,12 @@ "markdownlint-rule-search-replace": "^1.2.0", "mdast-util-gfm": "^3.1.0", "micromark-extension-gfm": "^3.0.0", - "mkdirp": "^3.0.1", "mockdate": "^3.0.5", "nock": "^14.0.11", "nodemon": "3.1.10", "ora": "^9.3.0", "patch-package": "^8.0.1", "prettier": "^3.8.1", - "rimraf": "^6.1.3", "sass": "^1.97.3", "start-server-and-test": "^3.0.0", "typescript": "^6.0.2", @@ -12384,22 +12382,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mockdate": { "version": "3.0.5", "dev": true, @@ -12967,13 +12949,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -13957,44 +13932,6 @@ "dev": true, "license": "MIT" }, - "node_modules/rimraf": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", - "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "glob": "^13.0.3", - "package-json-from-dist": "^1.0.1" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/rollup": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", diff --git a/package.json b/package.json index ae869e658c10..f1934fbff9ef 100644 --- a/package.json +++ b/package.json @@ -326,14 +326,12 @@ "markdownlint-rule-search-replace": "^1.2.0", "mdast-util-gfm": "^3.1.0", "micromark-extension-gfm": "^3.0.0", - "mkdirp": "^3.0.1", "mockdate": "^3.0.5", "nock": "^14.0.11", "nodemon": "3.1.10", "ora": "^9.3.0", "patch-package": "^8.0.1", "prettier": "^3.8.1", - "rimraf": "^6.1.3", "sass": "^1.97.3", "start-server-and-test": "^3.0.0", "typescript": "^6.0.2", diff --git a/src/audit-logs/lib/deduplicate.ts b/src/audit-logs/lib/deduplicate.ts index f637fddb4a28..5b361c4314b6 100644 --- a/src/audit-logs/lib/deduplicate.ts +++ b/src/audit-logs/lib/deduplicate.ts @@ -1,6 +1,5 @@ import { existsSync } from 'fs' -import { writeFile } from 'fs/promises' -import { mkdirp } from 'mkdirp' +import { mkdir, writeFile } from 'fs/promises' import path from 'path' import type { @@ -79,7 +78,7 @@ export async function writeDeduplicatedAuditLogData( const sharedDir = path.join(AUDIT_LOG_DATA_DIR, 'shared') if (!existsSync(sharedDir)) { - await mkdirp(sharedDir) + await mkdir(sharedDir, { recursive: true }) } await writeFile(path.join(sharedDir, 'entries.json'), JSON.stringify(entriesPool)) diff --git a/src/audit-logs/scripts/sync.ts b/src/audit-logs/scripts/sync.ts index 1718d90416a4..de8dc4502c3a 100755 --- a/src/audit-logs/scripts/sync.ts +++ b/src/audit-logs/scripts/sync.ts @@ -3,8 +3,7 @@ // // Requires GITHUB_TOKEN. import { existsSync } from 'fs' -import { readFile, writeFile } from 'fs/promises' -import { mkdirp } from 'mkdirp' +import { mkdir, readFile, writeFile } from 'fs/promises' import path from 'path' import { filterByAllowlistValues, filterAndUpdateGhesDataByAllowlistValues } from '../lib/index' @@ -190,7 +189,7 @@ async function main() { const auditLogVersionDirPath = path.join(AUDIT_LOG_DATA_DIR, version) if (!existsSync(auditLogVersionDirPath)) { - await mkdirp(auditLogVersionDirPath) + await mkdir(auditLogVersionDirPath, { recursive: true }) } for (const page of Object.values(AUDIT_LOG_PAGES)) { diff --git a/src/automated-pipelines/lib/update-markdown.ts b/src/automated-pipelines/lib/update-markdown.ts index 964e0c78bce2..67ab4bd34183 100644 --- a/src/automated-pipelines/lib/update-markdown.ts +++ b/src/automated-pipelines/lib/update-markdown.ts @@ -1,10 +1,8 @@ import walk from 'walk-sync' import { existsSync, lstatSync, unlinkSync } from 'fs' import path from 'path' -import { readFile, writeFile, readdir } from 'fs/promises' +import { mkdir, rm, readFile, writeFile, readdir } from 'fs/promises' import matter from '@gr2m/gray-matter' -import { rimraf } from 'rimraf' -import { mkdirp } from 'mkdirp' import { difference, isEqual } from 'lodash-es' import { allVersions } from '@/versions/lib/all-versions' @@ -212,7 +210,7 @@ async function updateDirectory( const initialDirectoryListing = await getDirectoryInfo(directory) if (initialDirectoryListing.directoryContents.length === 0 && !rootDirectoryOnly) { logger.info('Removing empty directory', { directory }) - await rimraf(directory) + await rm(directory, { recursive: true, force: true }) return } @@ -526,7 +524,7 @@ function isRootIndexFile(indexFile: string): boolean { async function createDirectory(targetDirectory: string): Promise { if (!existsSync(targetDirectory)) { - await mkdirp(targetDirectory) + await mkdir(targetDirectory, { recursive: true }) } } diff --git a/src/automated-pipelines/tests/update-markdown.ts b/src/automated-pipelines/tests/update-markdown.ts index 3b2290b86864..a617287046ff 100644 --- a/src/automated-pipelines/tests/update-markdown.ts +++ b/src/automated-pipelines/tests/update-markdown.ts @@ -1,10 +1,9 @@ import { tmpdir } from 'os' import { cp, rm, readFile } from 'fs/promises' -import { existsSync } from 'fs' +import { existsSync, mkdirSync } from 'fs' import path from 'path' import { afterAll, beforeAll, describe, expect, test } from 'vitest' -import { mkdirp } from 'mkdirp' import matter from '@gr2m/gray-matter' import type { FrontmatterVersions } from '@/types' @@ -77,7 +76,7 @@ describe('automated content directory updates', () => { // structure and contents after running updateContentDirectory. beforeAll(async () => { process.env.TEST_OS_ROOT_DIR = tempDirectory - mkdirp.sync(`${tempContentDirectory}`) + mkdirSync(`${tempContentDirectory}`, { recursive: true }) await cp('src/automated-pipelines/tests/fixtures/content', tempContentDirectory, { recursive: true, }) diff --git a/src/codeql-cli/scripts/sync.ts b/src/codeql-cli/scripts/sync.ts index f675954b555a..479bd83de914 100755 --- a/src/codeql-cli/scripts/sync.ts +++ b/src/codeql-cli/scripts/sync.ts @@ -1,11 +1,9 @@ -import { readFile, writeFile, copyFile } from 'fs/promises' +import { mkdir, rm, readFile, writeFile, copyFile } from 'fs/promises' import { existsSync } from 'fs' import walk from 'walk-sync' -import { mkdirp } from 'mkdirp' import { execFileSync, execSync } from 'child_process' import path from 'path' import matter from '@gr2m/gray-matter' -import { rimraf } from 'rimraf' import { updateContentDirectory } from '../../automated-pipelines/lib/update-markdown' import { convertContentToDocs } from './convert-markdown-for-docs' @@ -72,8 +70,8 @@ async function setupEnvironment() { ) } - await rimraf(TEMP_DIRECTORY) - await mkdirp(TEMP_DIRECTORY) + await rm(TEMP_DIRECTORY, { recursive: true, force: true }) + await mkdir(TEMP_DIRECTORY, { recursive: true }) } async function rstToMarkdown(rstSourceDirectory: string) { diff --git a/src/early-access/scripts/symlink-from-local-repo.ts b/src/early-access/scripts/symlink-from-local-repo.ts index fcb2a70f9e61..43ef6423ec9c 100644 --- a/src/early-access/scripts/symlink-from-local-repo.ts +++ b/src/early-access/scripts/symlink-from-local-repo.ts @@ -3,7 +3,6 @@ * @description Create or destroy symlinks to your local docs-early-access checkout */ -import { rimraf } from 'rimraf' import fs from 'fs' import path from 'path' import { program } from 'commander' @@ -68,7 +67,7 @@ const destinationDirsMap: Record = destinationDirNames.reduce( // Remove all existing early access directories from this repo for (const dirName of destinationDirNames) { const destDir = destinationDirsMap[dirName] - rimraf.sync(destDir) + fs.rmSync(destDir, { recursive: true, force: true }) console.log(`- Removed symlink for early access directory '${dirName}' from this repo`) } diff --git a/src/frame/tests/get-remote-json.ts b/src/frame/tests/get-remote-json.ts index f7e3a33825f3..5f1270ef0b09 100644 --- a/src/frame/tests/get-remote-json.ts +++ b/src/frame/tests/get-remote-json.ts @@ -2,7 +2,6 @@ import fs from 'fs' import path from 'path' import os from 'os' -import { rimraf } from 'rimraf' import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest' import nock from 'nock' @@ -21,7 +20,7 @@ describe('getRemoteJSON', () => { afterAll(() => { process.env.GET_REMOTE_JSON_DISK_CACHE_ROOT = envVarValueBefore - rimraf.sync(tempDir) + fs.rmSync(tempDir, { recursive: true, force: true }) }) afterEach(() => { diff --git a/src/ghes-releases/scripts/deprecate/archive-version.ts b/src/ghes-releases/scripts/deprecate/archive-version.ts index 179e2decacca..9c1f807ba498 100755 --- a/src/ghes-releases/scripts/deprecate/archive-version.ts +++ b/src/ghes-releases/scripts/deprecate/archive-version.ts @@ -10,7 +10,6 @@ import path from 'path' import fs from 'fs' import scrape from 'website-scraper' import { program } from 'commander' -import { rimraf } from 'rimraf' import http from 'http' import createApp from '@/frame/lib/app' @@ -58,6 +57,10 @@ const localDev = program.opts().localDev const tmpArchivalDirectory = output ? path.join(process.cwd(), output) : path.join(process.cwd(), `tmpArchivalDir_${version}`) +// rimraf refused to remove a filesystem root. fs.rm does not. +if (path.resolve(tmpArchivalDirectory) === path.parse(path.resolve(tmpArchivalDirectory)).root) { + throw new Error(`Refusing to remove filesystem root: ${tmpArchivalDirectory}`) +} main() async function main() { @@ -88,7 +91,7 @@ async function main() { } // remove temp directory - await rimraf(tmpArchivalDirectory) + await fs.promises.rm(tmpArchivalDirectory, { recursive: true, force: true }) const app = createApp() const server = http.createServer(app) diff --git a/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts b/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts index d1240f2f5d2e..46c1f1a0ae04 100755 --- a/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts +++ b/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts @@ -9,11 +9,9 @@ // // [end-readme] -import { existsSync } from 'fs' -import { readFile, readdir, writeFile, cp } from 'fs/promises' -import { rimrafSync } from 'rimraf' +import { existsSync, rmSync } from 'fs' +import { mkdir, readFile, readdir, writeFile, cp } from 'fs/promises' import { difference, intersection } from 'lodash-es' -import { mkdirp } from 'mkdirp' import { deprecated, supported } from '@/versions/lib/enterprise-server-releases' @@ -120,7 +118,7 @@ export async function updateAutomatedPipelines() { const removeFiles = difference(existingDataDir, expectedDirectory) for (const directory of removeFiles) { console.log(`Removing src/${pipeline}/data/${directory}`) - rimrafSync(`src/${pipeline}/data/${directory}`) + rmSync(`src/${pipeline}/data/${directory}`, { recursive: true, force: true }) } // Get a list of data directories to create (release) and create them @@ -175,11 +173,11 @@ export async function updateAutomatedPipelines() { const removeRelNoteDirs = intersection(deprecatedHyphenated, ghesReleaseNotesDirs) for (const directory of removeRelNoteDirs) { console.log(`Removing data/release-notes/enterprise-server/${directory}`) - rimrafSync(`data/release-notes/enterprise-server/${directory}`) + rmSync(`data/release-notes/enterprise-server/${directory}`, { recursive: true, force: true }) } for (const directory of addRelNoteDirs) { console.log(`Create new directory data/release-notes/enterprise-server/${directory}`) - await mkdirp(`data/release-notes/enterprise-server/${directory}`) + await mkdir(`data/release-notes/enterprise-server/${directory}`, { recursive: true }) await cp( `data/release-notes/PLACEHOLDER-TEMPLATE.yml`, `data/release-notes/enterprise-server/${directory}/PLACEHOLDER.yml`, diff --git a/src/github-apps/scripts/sync.ts b/src/github-apps/scripts/sync.ts index 84fcfba10a66..b9c1f7d84ae7 100755 --- a/src/github-apps/scripts/sync.ts +++ b/src/github-apps/scripts/sync.ts @@ -1,6 +1,5 @@ import fs, { existsSync } from 'fs' -import { mkdirp } from 'mkdirp' -import { readFile, writeFile } from 'fs/promises' +import { mkdir, readFile, writeFile } from 'fs/promises' import path from 'path' import { slug } from 'github-slugger' import { load } from 'js-yaml' @@ -278,7 +277,7 @@ export async function syncGitHubAppsData( // When a new version is added, we need to create the directory for it if (!existsSync(targetDirectory)) { - await mkdirp(targetDirectory) + await mkdir(targetDirectory, { recursive: true }) } for (const pageType of Object.keys(githubAppsData)) { @@ -377,7 +376,7 @@ async function writeDeduplicatedAppsFormat() { const sharedDir = path.join(ENABLED_APPS_DIR, 'shared') if (!existsSync(sharedDir)) { - await mkdirp(sharedDir) + await mkdir(sharedDir, { recursive: true }) } await writeFile(path.join(sharedDir, 'entries.json'), JSON.stringify(entriesPool)) diff --git a/src/graphql/scripts/sync.ts b/src/graphql/scripts/sync.ts index 6293964ded4f..9bcc3257bb38 100755 --- a/src/graphql/scripts/sync.ts +++ b/src/graphql/scripts/sync.ts @@ -1,7 +1,6 @@ import fs from 'fs/promises' import { appendFileSync } from 'fs' import path from 'path' -import { mkdirp } from 'mkdirp' import { load } from 'js-yaml' import { execSync } from 'child_process' import { getContents, hasMatchingRef } from '@/workflows/git-utils' @@ -293,7 +292,7 @@ function getVersionName(graphqlVersion: string) { async function updateFile(filepath: string, content: string) { console.log(`Updating file ${filepath}`) - await mkdirp(path.dirname(filepath)) + await fs.mkdir(path.dirname(filepath), { recursive: true }) return fs.writeFile(filepath, content, 'utf8') } diff --git a/src/graphql/scripts/utils/bucket-by-category.ts b/src/graphql/scripts/utils/bucket-by-category.ts index 6af2e3b3a709..794f1b85ea73 100644 --- a/src/graphql/scripts/utils/bucket-by-category.ts +++ b/src/graphql/scripts/utils/bucket-by-category.ts @@ -1,6 +1,5 @@ import fs from 'fs/promises' import path from 'path' -import { mkdirp } from 'mkdirp' import { ALL_KIND_KEYS, CATEGORIES, @@ -108,7 +107,7 @@ export function bucketSchemaByCategory( // for this version get an empty file so the loader has a deterministic file // to consume (rather than relying on filesystem stat). export async function writeCategoryFiles(dir: string, buckets: CategoryBuckets): Promise { - await mkdirp(dir) + await fs.mkdir(dir, { recursive: true }) // First, delete any stale schema-*.json files so a category that becomes // empty in a new sync doesn't leave behind a stale file. let existing: string[] = [] diff --git a/src/rest/scripts/update-files.ts b/src/rest/scripts/update-files.ts index ea40a5f10057..5b4256945ec2 100755 --- a/src/rest/scripts/update-files.ts +++ b/src/rest/scripts/update-files.ts @@ -5,12 +5,10 @@ // // [end-readme] -import { readdir, copyFile, readFile, writeFile, rename } from 'fs/promises' +import { mkdir, rm, readdir, copyFile, readFile, writeFile, rename } from 'fs/promises' import path from 'path' import { program, Option } from 'commander' import { execSync } from 'child_process' -import { rimraf } from 'rimraf' -import { mkdirp } from 'mkdirp' import { fileURLToPath } from 'url' import walk from 'walk-sync' import { existsSync } from 'fs' @@ -76,8 +74,8 @@ main() async function main() { const pipelines = Array.isArray(output) ? output : [output] await validateInputParameters() - await rimraf(TEMP_OPENAPI_DIR) - await mkdirp(TEMP_OPENAPI_DIR) + await rm(TEMP_OPENAPI_DIR, { recursive: true, force: true }) + await mkdir(TEMP_OPENAPI_DIR, { recursive: true }) // If the source repo is github, this is the local development workflow // and the files in github must be bundled and dereferenced first. @@ -106,7 +104,7 @@ async function main() { await copyFile(file, path.join(TEMP_OPENAPI_DIR, baseName)) } - await rimraf(TEMP_BUNDLED_OPENAPI_DIR) + await rm(TEMP_BUNDLED_OPENAPI_DIR, { recursive: true, force: true }) await normalizeDataVersionNames(TEMP_OPENAPI_DIR) // The REST_API_DESCRIPTION_ROOT repo contains all current and @@ -119,7 +117,7 @@ async function main() { for (const schema of derefDir) { // if the schema does not start with a current version name, delete it if (!currentOpenApiVersions.find((version) => schema.startsWith(version))) { - await rimraf(path.join(TEMP_OPENAPI_DIR, schema)) + await rm(path.join(TEMP_OPENAPI_DIR, schema), { recursive: true, force: true }) } } } @@ -185,8 +183,8 @@ async function getBundledFiles(): Promise { execSync('git pull', { cwd: GITHUB_REP_DIR }) } - await rimraf(TEMP_OPENAPI_DIR) - await mkdirp(TEMP_BUNDLED_OPENAPI_DIR) + await rm(TEMP_OPENAPI_DIR, { recursive: true, force: true }) + await mkdir(TEMP_BUNDLED_OPENAPI_DIR, { recursive: true }) console.log( `\nπŸƒβ€β™€οΈπŸƒπŸƒβ€β™€οΈRunning \`bin/openapi bundle\` in branch '${githubBranch}' of your github/github checkout to generate the dereferenced OpenAPI schema files.\n`, diff --git a/src/rest/scripts/utils/sync.ts b/src/rest/scripts/utils/sync.ts index bb8de5013a53..9e8ceb073207 100644 --- a/src/rest/scripts/utils/sync.ts +++ b/src/rest/scripts/utils/sync.ts @@ -1,7 +1,6 @@ -import { readFile, writeFile, readdir, unlink } from 'fs/promises' +import { mkdir, readFile, writeFile, readdir, unlink } from 'fs/promises' import { existsSync } from 'fs' import path from 'path' -import { mkdirp } from 'mkdirp' import { updateRestFiles } from './update-markdown' import { allVersions } from '@/versions/lib/all-versions' @@ -73,7 +72,7 @@ export async function syncRestData( ) } if (!existsSync(targetDirectoryPath)) { - await mkdirp(targetDirectoryPath) + await mkdir(targetDirectoryPath, { recursive: true }) } const writtenFiles = new Set() diff --git a/src/tests/scripts/copy-fixture-data.ts b/src/tests/scripts/copy-fixture-data.ts index a8edfdbdcf72..cc2ad6437747 100755 --- a/src/tests/scripts/copy-fixture-data.ts +++ b/src/tests/scripts/copy-fixture-data.ts @@ -15,7 +15,6 @@ import path from 'path' import { program } from 'commander' import chalk from 'chalk' -import { mkdirp } from 'mkdirp' // Here, write down all the files that are actually part of the rendering // functionality yet live in data. @@ -75,7 +74,7 @@ async function main(opts: { check?: boolean; dryRun?: boolean; verbose?: boolean if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } if (!opts.dryRun) { - await mkdirp(path.dirname(destination)) + await fs.promises.mkdir(path.dirname(destination), { recursive: true }) fs.writeFileSync(destination, source, 'utf-8') if (opts.verbose) { console.log(`Copied latest ${chalk.green(file)} to ${chalk.bold(destination)} πŸ‘πŸΌ`) diff --git a/src/webhooks/scripts/sync.ts b/src/webhooks/scripts/sync.ts index a192cb4a6ee1..2a909e72635d 100644 --- a/src/webhooks/scripts/sync.ts +++ b/src/webhooks/scripts/sync.ts @@ -1,7 +1,6 @@ -import { readFile, writeFile, unlink } from 'fs/promises' +import { mkdir, readFile, writeFile, unlink } from 'fs/promises' import { existsSync } from 'fs' import path from 'path' -import { mkdirp } from 'mkdirp' import { WEBHOOK_DATA_DIR } from '../lib/index' import Webhook, { WebhookSchema } from '@/webhooks/scripts/webhook' @@ -49,7 +48,7 @@ export async function syncWebhookData( const targetDirectory = path.join(WEBHOOK_DATA_DIR, versionName) if (!existsSync(targetDirectory)) { - await mkdirp(targetDirectory) + await mkdir(targetDirectory, { recursive: true }) } // Write one JSON file per webhook category (e.g. check_run.json) instead From 6d475c0aeb113de9d686f7cc5a6b01af8f3b4a66 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Wed, 23 Sep 2026 21:20:17 +0000 Subject: [PATCH 3/4] Replace json-schema-merge-allof with a local helper (#63246) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 35abb1df-6285-443c-892a-30c594d2d64c Copilot-Session: 946d101e-639b-48d2-a35c-b51badaa245a --- package-lock.json | 84 ---------- package.json | 1 - src/rest/scripts/utils/merge-all-of.ts | 193 +++++++++++++++++++++++ src/rest/scripts/utils/operation.ts | 4 +- src/rest/tests/merge-all-of.ts | 210 +++++++++++++++++++++++++ src/types/json-schema-merge-allof.d.ts | 27 ---- 6 files changed, 405 insertions(+), 114 deletions(-) create mode 100644 src/rest/scripts/utils/merge-all-of.ts create mode 100644 src/rest/tests/merge-all-of.ts delete mode 100644 src/types/json-schema-merge-allof.d.ts diff --git a/package-lock.json b/package-lock.json index d85acffb4a6a..46353e74eb1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -156,7 +156,6 @@ "husky": "^9.1.7", "is-svg": "6.0.0", "jiti": "^2.6.1", - "json-schema-merge-allof": "^0.8.1", "lint-staged": "^17.0.4", "lowdb": "7.0.1", "markdownlint": "^0.34.0", @@ -6845,29 +6844,6 @@ "node": ">=22.12.0" } }, - "node_modules/compute-gcd": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/compute-gcd/-/compute-gcd-1.2.1.tgz", - "integrity": "sha512-TwMbxBNz0l71+8Sc4czv13h4kEqnchV9igQZBi6QUaz09dnz13juGnnaWWJTRsP3brxOoxeB4SA2WELLw1hCtg==", - "dev": true, - "dependencies": { - "validate.io-array": "^1.0.3", - "validate.io-function": "^1.0.2", - "validate.io-integer-array": "^1.0.0" - } - }, - "node_modules/compute-lcm": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/compute-lcm/-/compute-lcm-1.1.2.tgz", - "integrity": "sha512-OFNPdQAXnQhDSKioX8/XYT6sdUlXwpeMjfd6ApxMJfyZ4GxmLR1xvMERctlYhlHwIiz6CSpBc2+qYKjHGZw4TQ==", - "dev": true, - "dependencies": { - "compute-gcd": "^1.2.1", - "validate.io-array": "^1.0.3", - "validate.io-function": "^1.0.2", - "validate.io-integer-array": "^1.0.0" - } - }, "node_modules/concat-map": { "version": "0.0.1", "license": "MIT" @@ -10627,29 +10603,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-compare": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/json-schema-compare/-/json-schema-compare-0.2.2.tgz", - "integrity": "sha512-c4WYmDKyJXhs7WWvAWm3uIYnfyWFoIp+JEoX34rctVvEkMYCPGhXtvmFFXiffBbxfZsvQ0RNnV5H7GvDF5HCqQ==", - "dev": true, - "dependencies": { - "lodash": "^4.17.4" - } - }, - "node_modules/json-schema-merge-allof": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/json-schema-merge-allof/-/json-schema-merge-allof-0.8.1.tgz", - "integrity": "sha512-CTUKmIlPJbsWfzRRnOXz+0MjIqvnleIXwFTzz+t9T86HnYX/Rozria6ZVGLktAU9e+NygNljveP+yxqtQp/Q4w==", - "dev": true, - "dependencies": { - "compute-lcm": "^1.1.2", - "json-schema-compare": "^0.2.2", - "lodash": "^4.17.20" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "license": "MIT" @@ -15885,43 +15838,6 @@ "uuid": "dist-node/bin/uuid" } }, - "node_modules/validate.io-array": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/validate.io-array/-/validate.io-array-1.0.6.tgz", - "integrity": "sha512-DeOy7CnPEziggrOO5CZhVKJw6S3Yi7e9e65R1Nl/RTN1vTQKnzjfvks0/8kQ40FP/dsjRAOd4hxmJ7uLa6vxkg==", - "dev": true - }, - "node_modules/validate.io-function": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/validate.io-function/-/validate.io-function-1.0.2.tgz", - "integrity": "sha512-LlFybRJEriSuBnUhQyG5bwglhh50EpTL2ul23MPIuR1odjO7XaMLFV8vHGwp7AZciFxtYOeiSCT5st+XSPONiQ==", - "dev": true - }, - "node_modules/validate.io-integer": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/validate.io-integer/-/validate.io-integer-1.0.5.tgz", - "integrity": "sha512-22izsYSLojN/P6bppBqhgUDjCkr5RY2jd+N2a3DCAUey8ydvrZ/OkGvFPR7qfOpwR2LC5p4Ngzxz36g5Vgr/hQ==", - "dev": true, - "dependencies": { - "validate.io-number": "^1.0.3" - } - }, - "node_modules/validate.io-integer-array": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/validate.io-integer-array/-/validate.io-integer-array-1.0.0.tgz", - "integrity": "sha512-mTrMk/1ytQHtCY0oNO3dztafHYyGU88KL+jRxWuzfOmQb+4qqnWmI+gykvGp8usKZOM0H7keJHEbRaFiYA0VrA==", - "dev": true, - "dependencies": { - "validate.io-array": "^1.0.3", - "validate.io-integer": "^1.0.4" - } - }, - "node_modules/validate.io-number": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/validate.io-number/-/validate.io-number-1.0.3.tgz", - "integrity": "sha512-kRAyotcbNaSYoDnXvb4MHg/0a1egJdLwS6oJ38TJY7aw9n93Fl/3blIXdyYvPOp55CNxywooG/3BcrwNrBpcSg==", - "dev": true - }, "node_modules/vary": { "version": "1.1.2", "license": "MIT", diff --git a/package.json b/package.json index f1934fbff9ef..4bf3b4e51425 100644 --- a/package.json +++ b/package.json @@ -318,7 +318,6 @@ "husky": "^9.1.7", "is-svg": "6.0.0", "jiti": "^2.6.1", - "json-schema-merge-allof": "^0.8.1", "lint-staged": "^17.0.4", "lowdb": "7.0.1", "markdownlint": "^0.34.0", diff --git a/src/rest/scripts/utils/merge-all-of.ts b/src/rest/scripts/utils/merge-all-of.ts new file mode 100644 index 000000000000..c027285e46a5 --- /dev/null +++ b/src/rest/scripts/utils/merge-all-of.ts @@ -0,0 +1,193 @@ +type Schema = Record + +// Keywords whose value is a map of name to schema. +const SCHEMA_MAP_KEYWORDS = new Set([ + 'properties', + 'patternProperties', + 'definitions', + '$defs', + 'dependentSchemas', +]) + +// Keywords whose value is a single schema. +const SINGLE_SCHEMA_KEYWORDS = new Set([ + 'additionalProperties', + 'additionalItems', + 'unevaluatedItems', + 'unevaluatedProperties', + 'contains', + 'propertyNames', + 'not', + 'if', + 'then', + 'else', +]) + +// Keywords whose value is an array of schemas. +const SCHEMA_ARRAY_KEYWORDS = new Set(['anyOf', 'oneOf', 'prefixItems']) + +// Keywords that only describe a schema. When two `allOf` members disagree on +// one of these, the first definition wins instead of being treated as a +// conflict, because the choice cannot make the rendered docs wrong. +const ANNOTATION_KEYWORDS = new Set([ + 'title', + 'description', + '$comment', + 'example', + 'examples', + 'default', + 'deprecated', + 'readOnly', + 'writeOnly', +]) + +function isSchemaObject(value: unknown): value is Schema { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +// Plain assignment would treat a key like `__proto__` as the prototype rather +// than a property, so keys that come from the schema are defined explicitly. +function setOwn(target: Schema, key: string, value: unknown): void { + Object.defineProperty(target, key, { + value, + enumerable: true, + writable: true, + configurable: true, + }) +} + +function isDeepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((item, index) => isDeepEqual(item, b[index])) + } + if (isSchemaObject(a) && isSchemaObject(b)) { + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) + return ( + aKeys.length === bKeys.length && + aKeys.every((key) => Object.hasOwn(b, key) && isDeepEqual(a[key], b[key])) + ) + } + return false +} + +/** + * Combines `source` into `target`, treating the two as an intersection of + * constraints. Keywords already on `target` win, so the schema that owns the + * `allOf` takes precedence over its members and earlier members take + * precedence over later ones. + * + * Only the cases the GitHub OpenAPI descriptions actually use are merged: + * identical values, `properties`, `required`, and annotations. Anything else + * throws rather than guessing, so a future description that needs real + * conflict resolution fails the build loudly instead of quietly publishing the + * wrong request body parameters. + */ +function mergeInto(target: Schema, source: Schema, path: string): void { + for (const [key, value] of Object.entries(source)) { + if (!Object.hasOwn(target, key)) { + setOwn(target, key, value) + continue + } + + const existing = target[key] + if (isDeepEqual(existing, value)) continue + + if (key === 'properties' && isSchemaObject(existing) && isSchemaObject(value)) { + for (const [name, propertySchema] of Object.entries(value)) { + if (!Object.hasOwn(existing, name)) { + setOwn(existing, name, propertySchema) + continue + } + const existingProperty = existing[name] + if (isSchemaObject(existingProperty) && isSchemaObject(propertySchema)) { + mergeInto(existingProperty, propertySchema, `${path}/properties/${name}`) + } else if (!isDeepEqual(existingProperty, propertySchema)) { + throw new Error( + `Cannot merge allOf: conflicting definitions of property "${name}" at ${path}/properties`, + ) + } + } + continue + } + + if (key === 'required' && Array.isArray(existing) && Array.isArray(value)) { + target[key] = [...new Set([...existing, ...value])] + continue + } + + if (ANNOTATION_KEYWORDS.has(key)) continue + + throw new Error( + `Cannot merge allOf: conflicting "${key}" keyword at ${path}. ` + + `This schema needs a merge strategy for "${key}" adding to merge-all-of.ts.`, + ) + } +} + +function resolveKeyword(key: string, value: unknown, path: string): unknown { + if (SCHEMA_MAP_KEYWORDS.has(key) && isSchemaObject(value)) { + const resolved: Schema = {} + for (const [name, subSchema] of Object.entries(value)) { + setOwn(resolved, name, resolveSchema(subSchema, `${path}/${name}`)) + } + return resolved + } + + if (SCHEMA_ARRAY_KEYWORDS.has(key) && Array.isArray(value)) { + return value.map((item, index) => resolveSchema(item, `${path}/${index}`)) + } + + // `items` is a single schema in current drafts and an array in draft-04. + if (key === 'items') { + if (Array.isArray(value)) { + return value.map((item, index) => resolveSchema(item, `${path}/${index}`)) + } + return resolveSchema(value, path) + } + + if (SINGLE_SCHEMA_KEYWORDS.has(key)) return resolveSchema(value, path) + + // Anything else holds instance data rather than a schema, such as `enum`, + // `const`, or `default`. It is copied through untouched so that a value or a + // property that happens to be named `allOf` survives. + return value +} + +function resolveSchema(schema: unknown, path: string): unknown { + if (!isSchemaObject(schema)) return schema + + const resolved: Schema = {} + for (const [key, value] of Object.entries(schema)) { + if (key !== 'allOf') setOwn(resolved, key, resolveKeyword(key, value, `${path}/${key}`)) + } + + if (Object.hasOwn(schema, 'allOf')) { + const members = schema.allOf + if (!Array.isArray(members)) { + throw new Error(`Cannot merge allOf: "allOf" at ${path} is not an array`) + } + for (const [index, member] of members.entries()) { + const memberPath = `${path}/allOf/${index}` + const resolvedMember = resolveSchema(member, memberPath) + if (!isSchemaObject(resolvedMember)) { + throw new Error(`Cannot merge allOf: member at ${memberPath} is not an object schema`) + } + mergeInto(resolved, resolvedMember, path) + } + } + + return resolved +} + +/** + * Flattens every `allOf` in a JSON schema so that consumers only have to walk + * `properties`. Replaces the unmaintained `json-schema-merge-allof` package. + * + * The returned schema is a deep copy, so callers are free to mutate it without + * touching the OpenAPI operation it came from. + */ +export function mergeAllOf(schema: unknown): unknown { + return resolveSchema(structuredClone(schema), '#') +} diff --git a/src/rest/scripts/utils/operation.ts b/src/rest/scripts/utils/operation.ts index 0dbe7a2a087a..d3a0595bbed4 100644 --- a/src/rest/scripts/utils/operation.ts +++ b/src/rest/scripts/utils/operation.ts @@ -2,8 +2,8 @@ import { STATUS_CODES } from 'node:http' import { get, isPlainObject } from 'lodash-es' import { parseTemplate } from 'url-template' -import mergeAllOf from 'json-schema-merge-allof' +import { mergeAllOf } from '@/rest/scripts/utils/merge-all-of' import { renderContent } from './render-content' import getCodeSamples from './create-rest-examples' import operationSchema from './operation-schema' @@ -188,7 +188,7 @@ export default class Operation { // Operation Id: markdown/render-raw const contentType = Object.keys(this.#operation.requestBody.content)[0] const schema = get(this.#operation, `requestBody.content.${contentType}.schema`, {}) - const mergedAllofSchema = mergeAllOf(schema as Parameters[0]) + const mergedAllofSchema = mergeAllOf(schema) try { this.bodyParameters = isPlainObject(schema) ? await getBodyParams(mergedAllofSchema as Parameters[0], true) diff --git a/src/rest/tests/merge-all-of.ts b/src/rest/tests/merge-all-of.ts new file mode 100644 index 000000000000..14dd31e8708e --- /dev/null +++ b/src/rest/tests/merge-all-of.ts @@ -0,0 +1,210 @@ +import { describe, expect, test } from 'vitest' + +import { mergeAllOf } from '@/rest/scripts/utils/merge-all-of' + +describe('mergeAllOf', () => { + test('leaves a schema without allOf untouched', () => { + const schema = { + type: 'object', + properties: { name: { type: 'string' }, age: { type: 'integer' } }, + required: ['name'], + } + expect(mergeAllOf(schema)).toEqual(schema) + }) + + test('unions properties and required across allOf members', () => { + expect( + mergeAllOf({ + allOf: [ + { type: 'object', properties: { a: { type: 'string' } }, required: ['a'] }, + { type: 'object', properties: { b: { type: 'integer' } }, required: ['b'] }, + ], + }), + ).toEqual({ + type: 'object', + properties: { a: { type: 'string' }, b: { type: 'integer' } }, + required: ['a', 'b'], + }) + }) + + test('annotations declared alongside allOf win over the members', () => { + expect( + mergeAllOf({ + type: 'object', + title: 'outer', + description: 'from the parent', + allOf: [{ title: 'inner', description: 'from the member', properties: { a: {} } }], + }), + ).toEqual({ + type: 'object', + title: 'outer', + description: 'from the parent', + properties: { a: {} }, + }) + }) + + test('deduplicates required entries', () => { + expect( + mergeAllOf({ required: ['a'], allOf: [{ required: ['a', 'b'] }, { required: ['b', 'c'] }] }), + ).toEqual({ required: ['a', 'b', 'c'] }) + }) + + test('merges nested allOf inside a shared property', () => { + expect( + mergeAllOf({ + allOf: [ + { properties: { nested: { type: 'object', properties: { a: { type: 'string' } } } } }, + { properties: { nested: { type: 'object', properties: { b: { type: 'string' } } } } }, + ], + }), + ).toEqual({ + properties: { + nested: { type: 'object', properties: { a: { type: 'string' }, b: { type: 'string' } } }, + }, + }) + }) + + test('resolves allOf nested inside oneOf, as the ruleset schemas do', () => { + expect( + mergeAllOf({ + type: 'object', + properties: { + conditions: { + oneOf: [ + { + type: 'object', + title: 'org ruleset conditions', + allOf: [ + { type: 'object', properties: { ref_name: { type: 'object' } } }, + { + type: 'object', + properties: { repository_name: { type: 'object' } }, + required: ['repository_name'], + }, + ], + }, + ], + }, + }, + }), + ).toEqual({ + type: 'object', + properties: { + conditions: { + oneOf: [ + { + type: 'object', + title: 'org ruleset conditions', + properties: { ref_name: { type: 'object' }, repository_name: { type: 'object' } }, + required: ['repository_name'], + }, + ], + }, + }, + }) + }) + + test('resolves allOf inside items', () => { + expect( + mergeAllOf({ + type: 'array', + items: { allOf: [{ properties: { a: {} } }, { properties: { b: {} } }] }, + }), + ).toEqual({ type: 'array', items: { properties: { a: {}, b: {} } } }) + }) + + test('keeps a property that is literally named allOf', () => { + const schema = { type: 'object', properties: { allOf: { type: 'string' } } } + expect(mergeAllOf(schema)).toEqual(schema) + }) + + test('merges a property named after an Object.prototype member', () => { + const merged = mergeAllOf({ + allOf: [ + { properties: { a: { type: 'string' } } }, + { properties: { constructor: { type: 'string' }, toString: { type: 'string' } } }, + ], + }) as { properties: Record } + expect(merged.properties).toEqual({ + a: { type: 'string' }, + constructor: { type: 'string' }, + toString: { type: 'string' }, + }) + expect(Object.hasOwn(merged.properties, 'constructor')).toBe(true) + }) + + test('keeps a property named __proto__ as an own key', () => { + const schema = JSON.parse( + '{"allOf":[{"properties":{"a":{}}},{"properties":{"__proto__":{"type":"string"}}}]}', + ) + const merged = mergeAllOf(schema) as { properties: Record } + expect(Object.hasOwn(merged.properties, '__proto__')).toBe(true) + expect(Object.getOwnPropertyDescriptor(merged.properties, '__proto__')?.value).toEqual({ + type: 'string', + }) + expect(Object.getPrototypeOf(merged.properties)).toBe(Object.prototype) + }) + + test('keeps instance data that contains an allOf key', () => { + const schema = { + type: 'object', + properties: { config: { type: 'object', default: { allOf: 'literal user data' } } }, + enum: [{ allOf: 'still literal' }], + } + expect(mergeAllOf(schema)).toEqual(schema) + }) + + test('accepts members that agree on a keyword', () => { + expect( + mergeAllOf({ + allOf: [ + { type: 'object', enum: ['a'] }, + { type: 'object', enum: ['a'] }, + ], + }), + ).toEqual({ type: 'object', enum: ['a'] }) + }) + + test('throws on a conflicting keyword rather than guessing', () => { + expect(() => mergeAllOf({ allOf: [{ type: 'string' }, { type: 'number' }] })).toThrow( + /conflicting "type" keyword/, + ) + expect(() => mergeAllOf({ allOf: [{ enum: ['a'] }, { enum: ['b'] }] })).toThrow( + /conflicting "enum" keyword/, + ) + expect(() => mergeAllOf({ allOf: [{ minimum: 10 }, { minimum: 20 }] })).toThrow( + /conflicting "minimum" keyword/, + ) + }) + + test('throws when allOf is not an array of object schemas', () => { + expect(() => mergeAllOf({ allOf: { properties: {} } })).toThrow(/is not an array/) + expect(() => mergeAllOf({ allOf: [false] })).toThrow(/is not an object schema/) + }) + + test('reports the path of a conflict', () => { + expect(() => + mergeAllOf({ + properties: { outer: { items: { allOf: [{ type: 'string' }, { type: 'number' }] } } }, + }), + ).toThrow(/#\/properties\/outer\/items/) + }) + + test('returns a deep copy, so consumers can mutate the result safely', () => { + const schema = { + oneOf: [ + { type: 'object', properties: { a: {} } }, + { type: 'object', properties: { b: {} } }, + ], + } + const before = JSON.stringify(schema) + const merged = mergeAllOf(schema) as { oneOf: { properties: Record }[] } + + // get-body-params merges the oneOf members in place, so this must not + // reach back into the OpenAPI operation the schema came from. + Object.assign(merged.oneOf[0].properties, merged.oneOf[1].properties) + merged.oneOf[0].properties.injected = true + + expect(JSON.stringify(schema)).toBe(before) + }) +}) diff --git a/src/types/json-schema-merge-allof.d.ts b/src/types/json-schema-merge-allof.d.ts deleted file mode 100644 index 9cde0571cdcd..000000000000 --- a/src/types/json-schema-merge-allof.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -declare module 'json-schema-merge-allof' { - interface JSONSchema { - allOf?: JSONSchema[] - properties?: Record - required?: string[] - type?: string | string[] - items?: JSONSchema | JSONSchema[] - additionalProperties?: boolean | JSONSchema - [key: string]: unknown // JSON Schema allows arbitrary additional properties per spec - } - - interface MergeAllOfOptions { - // `unknown` because this library's schema structures vary at runtime. - resolvers?: Record< - string, - (values: unknown[], path: string[], mergeSchemas: unknown, options: unknown) => unknown - > - - ignoreAdditionalProperties?: boolean - - deep?: boolean - } - - function mergeAllOf(schema: JSONSchema, options?: MergeAllOfOptions): JSONSchema - - export default mergeAllOf -} From a1e26f195e07f0d6f7f5de9bd72f9689d5461ca4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:00:30 +0000 Subject: [PATCH 4/4] Clarify when to use GitHub Agentic Workflows (#63101) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Co-authored-by: Peli de Halleux Co-authored-by: Vanessa --- content/copilot/concepts/agents/cloud-agent/about-cloud-agent.md | 1 + 1 file changed, 1 insertion(+) diff --git a/content/copilot/concepts/agents/cloud-agent/about-cloud-agent.md b/content/copilot/concepts/agents/cloud-agent/about-cloud-agent.md index d130401f5417..b70db75f4735 100644 --- a/content/copilot/concepts/agents/cloud-agent/about-cloud-agent.md +++ b/content/copilot/concepts/agents/cloud-agent/about-cloud-agent.md @@ -171,3 +171,4 @@ Try the [Expand your team with {% data variables.copilot.copilot_cloud_agent %}] * [AUTOTITLE](/copilot/how-tos/use-copilot-agents/cloud-agent) how-to articles * [AUTOTITLE](/copilot/concepts/agents/cloud-agent/about-custom-agents) * [AUTOTITLE](/copilot/responsible-use/agents) +* [AUTOTITLE](/copilot/concepts/agents/about-github-agentic-workflows) for recurring repository automation that you want to version with your code and run in {% data variables.product.prodname_actions %}