From ad63d731a72b40596ca89619a079441b5ae1389d Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Mon, 23 Mar 2026 15:12:20 -0700 Subject: [PATCH] Add shared validation runner package (#258768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #256599 — extracts the shared validation runner infrastructure into its own package to land incrementally. --------- Signed-off-by: Tyler Smalley (cherry picked from commit edea0805670d85d180e4f72a0481adaa68ff909b) # Conflicts: # .github/CODEOWNERS --- .github/CODEOWNERS | 1 + package.json | 1 + packages/kbn-moon/moon.yml | 1 + packages/kbn-moon/src/index.ts | 26 +- packages/kbn-moon/src/query_changed_files.ts | 78 ++++ packages/kbn-moon/src/query_projects.ts | 180 +++++++++ packages/kbn-moon/tsconfig.json | 1 + .../packages/shared/kbn-dev-utils/index.ts | 9 + .../kbn-dev-validation-runner/README.md | 3 + .../shared/kbn-dev-validation-runner/index.ts | 28 ++ .../kbn-dev-validation-runner/jest.config.js | 14 + .../kbn-dev-validation-runner/kibana.jsonc | 10 + .../shared/kbn-dev-validation-runner/moon.yml | 56 +++ .../kbn-dev-validation-runner/package.json | 6 + .../resolve_validation_run_context.test.ts | 341 ++++++++++++++++++ .../src/resolve_validation_run_context.ts | 201 +++++++++++ .../src/run_validation_command.test.ts | 153 ++++++++ .../src/run_validation_command.ts | 165 +++++++++ .../src/validation_run_cli.test.ts | 172 +++++++++ .../src/validation_run_cli.ts | 139 +++++++ .../kbn-dev-validation-runner/tsconfig.json | 21 ++ tsconfig.base.json | 2 + yarn.lock | 4 + 23 files changed, 1611 insertions(+), 1 deletion(-) create mode 100644 packages/kbn-moon/src/query_changed_files.ts create mode 100644 packages/kbn-moon/src/query_projects.ts create mode 100644 src/platform/packages/shared/kbn-dev-validation-runner/README.md create mode 100644 src/platform/packages/shared/kbn-dev-validation-runner/index.ts create mode 100644 src/platform/packages/shared/kbn-dev-validation-runner/jest.config.js create mode 100644 src/platform/packages/shared/kbn-dev-validation-runner/kibana.jsonc create mode 100644 src/platform/packages/shared/kbn-dev-validation-runner/moon.yml create mode 100644 src/platform/packages/shared/kbn-dev-validation-runner/package.json create mode 100644 src/platform/packages/shared/kbn-dev-validation-runner/src/resolve_validation_run_context.test.ts create mode 100644 src/platform/packages/shared/kbn-dev-validation-runner/src/resolve_validation_run_context.ts create mode 100644 src/platform/packages/shared/kbn-dev-validation-runner/src/run_validation_command.test.ts create mode 100644 src/platform/packages/shared/kbn-dev-validation-runner/src/run_validation_command.ts create mode 100644 src/platform/packages/shared/kbn-dev-validation-runner/src/validation_run_cli.test.ts create mode 100644 src/platform/packages/shared/kbn-dev-validation-runner/src/validation_run_cli.ts create mode 100644 src/platform/packages/shared/kbn-dev-validation-runner/tsconfig.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7d01ffcb73bc6..f688d053625ba 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -483,6 +483,7 @@ src/platform/packages/shared/kbn-dev-cli-errors @elastic/kibana-operations src/platform/packages/shared/kbn-dev-cli-runner @elastic/kibana-operations src/platform/packages/shared/kbn-dev-proc-runner @elastic/kibana-operations src/platform/packages/shared/kbn-dev-utils @elastic/kibana-operations +src/platform/packages/shared/kbn-dev-validation-runner @elastic/kibana-operations src/platform/packages/shared/kbn-discover-contextual-components @elastic/obs-exploration-team @elastic/kibana-data-discovery src/platform/packages/shared/kbn-discover-utils @elastic/kibana-data-discovery src/platform/packages/shared/kbn-doc-links @elastic/docs diff --git a/package.json b/package.json index ad6b45dd62007..6a0fbed8bfb7d 100644 --- a/package.json +++ b/package.json @@ -1577,6 +1577,7 @@ "@kbn/dev-cli-runner": "link:src/platform/packages/shared/kbn-dev-cli-runner", "@kbn/dev-proc-runner": "link:src/platform/packages/shared/kbn-dev-proc-runner", "@kbn/dev-utils": "link:src/platform/packages/shared/kbn-dev-utils", + "@kbn/dev-validation-runner": "link:src/platform/packages/shared/kbn-dev-validation-runner", "@kbn/docs-utils": "link:packages/kbn-docs-utils", "@kbn/dot-text": "link:src/platform/packages/private/kbn-dot-text", "@kbn/dot-text-loader": "link:src/platform/packages/private/kbn-dot-text-loader", diff --git a/packages/kbn-moon/moon.yml b/packages/kbn-moon/moon.yml index 48929fa56c479..d924270f188ba 100644 --- a/packages/kbn-moon/moon.yml +++ b/packages/kbn-moon/moon.yml @@ -19,6 +19,7 @@ project: sourceRoot: packages/kbn-moon dependsOn: - '@kbn/repo-info' + - '@kbn/dev-utils' - '@kbn/dev-cli-runner' - '@kbn/repo-packages' - '@kbn/tooling-log' diff --git a/packages/kbn-moon/src/index.ts b/packages/kbn-moon/src/index.ts index 50873477b1a15..18a0abd5e3363 100644 --- a/packages/kbn-moon/src/index.ts +++ b/packages/kbn-moon/src/index.ts @@ -8,5 +8,29 @@ */ import { regenerateMoonProjects } from './cli/regenerate_moon_projects'; +import { + getAffectedMoonProjectsFromChangedFiles, + getMoonExecutablePath, + normalizeRepoRelativePath, + resolveMoonAffectedBase, + ROOT_MOON_PROJECT_ID, + summarizeAffectedMoonProjects, +} from './query_projects'; +import { getMoonChangedFiles } from './query_changed_files'; -export { regenerateMoonProjects }; +export { + regenerateMoonProjects, + getAffectedMoonProjectsFromChangedFiles, + getMoonChangedFiles, + getMoonExecutablePath, + normalizeRepoRelativePath, + resolveMoonAffectedBase, + ROOT_MOON_PROJECT_ID, + summarizeAffectedMoonProjects, +}; +export type { + MoonProject, + MoonDownstreamMode, + MoonAffectedBase, + MoonAffectedProjectSummary, +} from './query_projects'; diff --git a/packages/kbn-moon/src/query_changed_files.ts b/packages/kbn-moon/src/query_changed_files.ts new file mode 100644 index 0000000000000..89f8639283e2e --- /dev/null +++ b/packages/kbn-moon/src/query_changed_files.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { existsSync } from 'fs'; +import Path from 'path'; + +import { REPO_ROOT } from '@kbn/repo-info'; + +import { getMoonExecutablePath, normalizeRepoRelativePath } from './query_projects'; + +type ChangedFilesScope = 'local' | 'staged' | 'branch'; + +export interface GetMoonChangedFilesOptions { + scope: ChangedFilesScope; + base?: string; + head?: string; +} + +interface MoonChangedFilesResponse { + files: string[]; +} + +/** Builds CLI args for `moon query changed-files` based on scope. */ +export const buildChangedFilesArgs = ({ scope, base, head }: GetMoonChangedFilesOptions) => { + const args = ['query', 'changed-files']; + + switch (scope) { + case 'local': + args.push('--local'); + break; + case 'staged': + args.push('--local', '--status', 'staged'); + break; + case 'branch': + if (base) args.push('--base', base); + if (head) args.push('--head', head); + break; + } + + return args; +}; + +/** + * Queries Moon for changed files in the given scope. + * + * Returns repo-relative paths of files that exist on disk (deleted files are excluded). + */ +export const getMoonChangedFiles = async ({ + scope, + base, + head, +}: GetMoonChangedFilesOptions): Promise => { + const execa = (await import('execa')).default; + const moonExec = await getMoonExecutablePath(); + const args = buildChangedFilesArgs({ scope, base, head }); + + const { stdout } = await execa(moonExec, args, { + cwd: REPO_ROOT, + stdin: 'ignore', + env: { + ...process.env, + CI_STATS_DISABLED: 'true', + }, + }); + + const { files } = JSON.parse(stdout) as MoonChangedFilesResponse; + + return files + .map(normalizeRepoRelativePath) + .filter((file) => existsSync(Path.resolve(REPO_ROOT, file))) + .sort((a, b) => a.localeCompare(b)); +}; diff --git a/packages/kbn-moon/src/query_projects.ts b/packages/kbn-moon/src/query_projects.ts new file mode 100644 index 0000000000000..6f17546514aa8 --- /dev/null +++ b/packages/kbn-moon/src/query_projects.ts @@ -0,0 +1,180 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import Path from 'path'; +import { existsSync } from 'fs'; + +import { + getRemoteDefaultBranchRefs, + resolveNearestMergeBase, + type ValidationDownstreamMode, +} from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/repo-info'; + +export type MoonDownstreamMode = ValidationDownstreamMode; + +/** Minimal Moon project metadata needed for affected-source resolution. */ +export interface MoonProject { + id: string; + sourceRoot: string; +} + +/** Derived summary of affected projects for root-project escalation handling. */ +export interface MoonAffectedProjectSummary { + sourceRoots: string[]; + isRootProjectAffected: boolean; +} + +/** Resolved base revision metadata for Moon affected queries. */ +export interface MoonAffectedBase { + base: string; + baseRef: string; +} + +interface MoonQueryProjectsResponse { + projects: Array<{ + id: string; + source: string; + config?: { + project?: { + metadata?: { + sourceRoot?: string; + }; + }; + }; + }>; +} + +/** Options for resolving the affected base revision from git state. */ +export interface ResolveMoonAffectedBaseOptions { + headRef?: string; +} + +export const ROOT_MOON_PROJECT_ID = 'kibana'; + +let moonExecutablePath: string | undefined; + +/** Normalizes repository-relative paths to POSIX separators for stable matching. */ +export const normalizeRepoRelativePath = (pathValue: string) => + Path.normalize(pathValue).split(Path.sep).join('/'); + +/** Resolves the path to the Moon executable. */ +export const getMoonExecutablePath = async () => { + if (moonExecutablePath) { + return moonExecutablePath; + } + + const moonBinPath = Path.resolve(REPO_ROOT, 'node_modules/.bin/moon'); + if (existsSync(moonBinPath)) { + moonExecutablePath = moonBinPath; + return moonExecutablePath; + } + + const execa = (await import('execa')).default; + const { stdout } = await execa('yarn', ['--silent', 'which', 'moon'], { + cwd: REPO_ROOT, + stdin: 'ignore', + }); + + moonExecutablePath = stdout.trim(); + return moonExecutablePath; +}; + +/** Resolves the base revision used for Moon affected comparisons. */ +export const resolveMoonAffectedBase = async ({ + headRef = 'HEAD', +}: ResolveMoonAffectedBaseOptions = {}): Promise => { + const envBase = process.env.GITHUB_PR_MERGE_BASE?.trim(); + if (envBase) { + return { + base: envBase, + baseRef: 'GITHUB_PR_MERGE_BASE', + }; + } + + const baseRefs = await getRemoteDefaultBranchRefs(); + if (baseRefs.length === 0) { + throw new Error( + 'Unable to resolve a remote default branch for affected type check. Set GITHUB_PR_MERGE_BASE to override.' + ); + } + + const bestCandidate = await resolveNearestMergeBase({ + baseRefs, + headRef, + }); + if (!bestCandidate) { + throw new Error( + `Unable to resolve merge-base for affected type check from remote default branches: ${baseRefs.join( + ', ' + )}.` + ); + } + + return { + base: bestCandidate.mergeBase, + baseRef: bestCandidate.baseRef, + }; +}; + +const parseMoonProjectsResponse = (stdout: string): MoonProject[] => { + const response = JSON.parse(stdout) as MoonQueryProjectsResponse; + return response.projects.map((project) => { + const sourceRoot = project.config?.project?.metadata?.sourceRoot ?? project.source; + return { + id: project.id, + sourceRoot: normalizeRepoRelativePath(sourceRoot), + }; + }); +}; + +/** + * Queries Moon for affected projects by piping pre-resolved changed files JSON + * into `moon query projects --affected`. + * + * Use this when changed files have already been resolved to avoid duplicate Moon queries. + */ +export const getAffectedMoonProjectsFromChangedFiles = async ({ + changedFilesJson, + downstream = 'none', +}: { + changedFilesJson: string; + downstream?: MoonDownstreamMode; +}): Promise => { + const execa = (await import('execa')).default; + const moonExec = await getMoonExecutablePath(); + + const projectArgs = ['query', 'projects', '--affected']; + if (downstream !== 'none') { + projectArgs.push('--downstream', downstream); + } + + const { stdout } = await execa(moonExec, projectArgs, { + cwd: REPO_ROOT, + input: changedFilesJson, + env: { + ...process.env, + CI_STATS_DISABLED: 'true', + }, + }); + + return parseMoonProjectsResponse(stdout); +}; + +/** Summarizes affected Moon projects into non-root source roots and root-project flag. */ +export const summarizeAffectedMoonProjects = ( + projects: MoonProject[] +): MoonAffectedProjectSummary => { + const nonRootProjects = projects.filter((project) => project.id !== ROOT_MOON_PROJECT_ID); + + return { + sourceRoots: nonRootProjects.map((project) => project.sourceRoot), + isRootProjectAffected: projects.some((project) => project.id === ROOT_MOON_PROJECT_ID), + }; +}; diff --git a/packages/kbn-moon/tsconfig.json b/packages/kbn-moon/tsconfig.json index 2ad3847fd6001..ced992318adf5 100644 --- a/packages/kbn-moon/tsconfig.json +++ b/packages/kbn-moon/tsconfig.json @@ -17,6 +17,7 @@ ], "kbn_references": [ "@kbn/repo-info", + "@kbn/dev-utils", "@kbn/dev-cli-runner", "@kbn/repo-packages", "@kbn/tooling-log" diff --git a/src/platform/packages/shared/kbn-dev-utils/index.ts b/src/platform/packages/shared/kbn-dev-utils/index.ts index 61044ae4b7a9d..90a57b375905f 100644 --- a/src/platform/packages/shared/kbn-dev-utils/index.ts +++ b/src/platform/packages/shared/kbn-dev-utils/index.ts @@ -32,4 +32,13 @@ export * from './src/streams'; export * from './src/extract'; export * from './src/diff_strings'; export * from './src/git'; +export { + parseAndResolveValidationContract, + VALIDATION_PROFILE_DEFAULTS, + type ValidationDownstreamMode, + type ValidationProfile, + type ResolvedValidationContract, + type ValidationScope, + type ValidationTestMode, +} from './src/validation_run_contract'; export * from './src/worker'; diff --git a/src/platform/packages/shared/kbn-dev-validation-runner/README.md b/src/platform/packages/shared/kbn-dev-validation-runner/README.md new file mode 100644 index 0000000000000..cc0f3100a6add --- /dev/null +++ b/src/platform/packages/shared/kbn-dev-validation-runner/README.md @@ -0,0 +1,3 @@ +# @kbn/dev-validation-runner + +Shared orchestration helpers for validation-style developer CLIs. diff --git a/src/platform/packages/shared/kbn-dev-validation-runner/index.ts b/src/platform/packages/shared/kbn-dev-validation-runner/index.ts new file mode 100644 index 0000000000000..706670d529703 --- /dev/null +++ b/src/platform/packages/shared/kbn-dev-validation-runner/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { + describeValidationScope, + describeValidationNoTargetsScope, + describeValidationScoping, + resolveValidationBaseContext, +} from './src/run_validation_command'; +export type { ValidationBaseContext } from './src/run_validation_command'; +export { + resolveValidationAffectedProjects, + type ValidationAffectedProjectsContext, +} from './src/resolve_validation_run_context'; +export { + buildValidationCliArgs, + formatReproductionCommand, + hasValidationRunFlags, + readValidationRunFlags, + VALIDATION_RUN_HELP, + VALIDATION_RUN_STRING_FLAGS, +} from './src/validation_run_cli'; diff --git a/src/platform/packages/shared/kbn-dev-validation-runner/jest.config.js b/src/platform/packages/shared/kbn-dev-validation-runner/jest.config.js new file mode 100644 index 0000000000000..963b95ed6d567 --- /dev/null +++ b/src/platform/packages/shared/kbn-dev-validation-runner/jest.config.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../..', + roots: ['/src/platform/packages/shared/kbn-dev-validation-runner'], +}; diff --git a/src/platform/packages/shared/kbn-dev-validation-runner/kibana.jsonc b/src/platform/packages/shared/kbn-dev-validation-runner/kibana.jsonc new file mode 100644 index 0000000000000..3338083afc4b2 --- /dev/null +++ b/src/platform/packages/shared/kbn-dev-validation-runner/kibana.jsonc @@ -0,0 +1,10 @@ +{ + "type": "shared-common", + "id": "@kbn/dev-validation-runner", + "owner": [ + "@elastic/kibana-operations" + ], + "group": "platform", + "visibility": "shared", + "devOnly": true +} diff --git a/src/platform/packages/shared/kbn-dev-validation-runner/moon.yml b/src/platform/packages/shared/kbn-dev-validation-runner/moon.yml new file mode 100644 index 0000000000000..bb42823e9bece --- /dev/null +++ b/src/platform/packages/shared/kbn-dev-validation-runner/moon.yml @@ -0,0 +1,56 @@ +# This file is generated by the @kbn/moon package. Any manual edits will be erased! +# To extend this, write your extensions/overrides to 'moon.extend.yml' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/dev-validation-runner' + +$schema: https://moonrepo.dev/schemas/project.json +id: '@kbn/dev-validation-runner' +layer: unknown +owners: + defaultOwner: '@elastic/kibana-operations' +toolchains: + default: node +language: typescript +project: + title: '@kbn/dev-validation-runner' + description: Moon project for @kbn/dev-validation-runner + channel: '' + owner: '@elastic/kibana-operations' + sourceRoot: src/platform/packages/shared/kbn-dev-validation-runner +dependsOn: + - '@kbn/dev-cli-errors' + - '@kbn/dev-utils' + - '@kbn/moon' +tags: + - shared-common + - package + - dev + - group-platform + - shared + - jest-unit-tests +fileGroups: + src: + - '**/*.ts' + - '!target/**/*' +tasks: + jest: + command: node + args: + - '--no-experimental-require-module' + - $workspaceRoot/scripts/jest + - '--config' + - $projectRoot/jest.config.js + options: + runFromWorkspaceRoot: true + inputs: + - '@group(src)' + jestCI: + command: node + args: + - '--no-experimental-require-module' + - $workspaceRoot/scripts/jest + - '--config' + - $projectRoot/jest.config.js + options: + runFromWorkspaceRoot: true + inputs: + - '@group(src)' diff --git a/src/platform/packages/shared/kbn-dev-validation-runner/package.json b/src/platform/packages/shared/kbn-dev-validation-runner/package.json new file mode 100644 index 0000000000000..cac6c211e77ee --- /dev/null +++ b/src/platform/packages/shared/kbn-dev-validation-runner/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/dev-validation-runner", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} diff --git a/src/platform/packages/shared/kbn-dev-validation-runner/src/resolve_validation_run_context.test.ts b/src/platform/packages/shared/kbn-dev-validation-runner/src/resolve_validation_run_context.test.ts new file mode 100644 index 0000000000000..5c4cff83e02ea --- /dev/null +++ b/src/platform/packages/shared/kbn-dev-validation-runner/src/resolve_validation_run_context.test.ts @@ -0,0 +1,341 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { + countCommitsBetweenRefs, + hasStagedChanges, + isShallowRepository, + parseAndResolveValidationContract, +} from '@kbn/dev-utils'; +import { + getAffectedMoonProjectsFromChangedFiles, + getMoonChangedFiles, + resolveMoonAffectedBase, + summarizeAffectedMoonProjects, +} from '@kbn/moon'; + +import { + assertNoValidationRunFlagsForDirectTarget, + resolveValidationAffectedProjects, + resolveValidationRunContext, +} from './resolve_validation_run_context'; + +jest.mock('@kbn/dev-cli-errors', () => ({ + createFailError: (message: string) => new Error(message), +})); + +jest.mock('@kbn/dev-utils', () => ({ + countCommitsBetweenRefs: jest.fn(), + hasStagedChanges: jest.fn(), + isShallowRepository: jest.fn(), + parseAndResolveValidationContract: jest.fn(), + VALIDATION_PROFILE_DEFAULTS: { + branch: { + scope: 'branch', + testMode: 'affected', + downstream: 'none', + }, + }, +})); + +jest.mock('@kbn/moon', () => ({ + getAffectedMoonProjectsFromChangedFiles: jest.fn(), + getMoonChangedFiles: jest.fn(), + resolveMoonAffectedBase: jest.fn(), + summarizeAffectedMoonProjects: jest.fn(), +})); + +const mockParseAndResolveValidationContract = parseAndResolveValidationContract as jest.Mock; +const mockHasStagedChanges = hasStagedChanges as jest.Mock; +const mockIsShallowRepository = isShallowRepository as jest.Mock; +const mockResolveMoonAffectedBase = resolveMoonAffectedBase as jest.Mock; +const mockCountCommitsBetweenRefs = countCommitsBetweenRefs as jest.Mock; +const mockGetAffectedMoonProjectsFromChangedFiles = + getAffectedMoonProjectsFromChangedFiles as jest.Mock; +const mockSummarizeAffectedMoonProjects = summarizeAffectedMoonProjects as jest.Mock; +const mockGetMoonChangedFiles = getMoonChangedFiles as jest.Mock; + +describe('assertNoValidationRunFlagsForDirectTarget', () => { + it('throws when direct target is combined with run flags', () => { + expect(() => assertNoValidationRunFlagsForDirectTarget({ profile: 'quick' })).toThrow( + 'Cannot combine direct target mode with validation contract flags (--profile, --scope, --test-mode, --downstream, --base-ref, --head-ref).' + ); + }); + + it('allows direct target mode with no run flags', () => { + expect(() => assertNoValidationRunFlagsForDirectTarget({})).not.toThrow(); + }); +}); + +describe('resolveValidationRunContext', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetMoonChangedFiles.mockResolvedValue(['packages/foo/src/index.ts']); + mockIsShallowRepository.mockResolvedValue(false); + }); + + it('returns full context for full scope', async () => { + mockParseAndResolveValidationContract.mockReturnValue({ + profile: 'full', + scope: 'full', + testMode: 'all', + downstream: 'none', + }); + + await expect(resolveValidationRunContext({ flags: {} })).resolves.toEqual({ + kind: 'full', + contract: { + profile: 'full', + scope: 'full', + testMode: 'all', + downstream: 'none', + }, + }); + expect(mockGetAffectedMoonProjectsFromChangedFiles).not.toHaveBeenCalled(); + expect(mockGetMoonChangedFiles).not.toHaveBeenCalled(); + }); + + it('returns skip context for staged scope when no staged changes exist', async () => { + mockParseAndResolveValidationContract.mockReturnValue({ + profile: 'precommit', + scope: 'staged', + testMode: 'related', + downstream: 'none', + }); + mockHasStagedChanges.mockResolvedValue(false); + + await expect(resolveValidationRunContext({ flags: {} })).resolves.toEqual({ + kind: 'skip', + reason: 'no_staged_changes', + contract: { + profile: 'precommit', + scope: 'staged', + testMode: 'related', + downstream: 'none', + }, + }); + expect(mockResolveMoonAffectedBase).not.toHaveBeenCalled(); + expect(mockGetMoonChangedFiles).not.toHaveBeenCalled(); + }); + + it('falls back to full context and emits warning when branch base resolution fails', async () => { + mockParseAndResolveValidationContract.mockReturnValue({ + profile: 'branch', + scope: 'branch', + testMode: 'affected', + downstream: 'none', + baseRef: undefined, + headRef: undefined, + }); + mockResolveMoonAffectedBase.mockRejectedValue(new Error('merge-base failed')); + const warning = jest.fn(); + + await expect( + resolveValidationRunContext({ + flags: {}, + runnerDescription: 'type check', + onWarning: warning, + }) + ).resolves.toEqual({ + kind: 'full', + reason: 'resolve_branch_scope_failed', + contract: { + profile: 'branch', + scope: 'branch', + testMode: 'affected', + downstream: 'none', + baseRef: undefined, + headRef: undefined, + }, + }); + expect(warning).toHaveBeenCalledWith( + 'Failed to resolve merge-base for affected type check (merge-base failed). Falling back to full type check.' + ); + expect(mockGetMoonChangedFiles).not.toHaveBeenCalled(); + }); + + it('returns affected context with branch change count and Moon-sourced changed files', async () => { + mockParseAndResolveValidationContract.mockReturnValue({ + profile: 'branch', + scope: 'branch', + testMode: 'affected', + downstream: 'none', + baseRef: undefined, + headRef: undefined, + }); + mockResolveMoonAffectedBase.mockResolvedValue({ + base: 'base-sha', + baseRef: 'upstream/main', + }); + mockCountCommitsBetweenRefs.mockResolvedValue(4); + mockGetMoonChangedFiles.mockResolvedValue(['packages/foo/src/bar.ts']); + + const result = await resolveValidationRunContext({ flags: {} }); + + expect(result).toEqual({ + kind: 'affected', + contract: { + profile: 'branch', + scope: 'branch', + testMode: 'affected', + downstream: 'none', + baseRef: undefined, + headRef: undefined, + }, + resolvedBase: { + base: 'base-sha', + baseRef: 'upstream/main', + }, + branchCommitCount: 4, + changedFiles: ['packages/foo/src/bar.ts'], + }); + + expect(mockGetAffectedMoonProjectsFromChangedFiles).not.toHaveBeenCalled(); + expect(mockGetMoonChangedFiles).toHaveBeenCalledWith({ + scope: 'branch', + base: 'base-sha', + head: undefined, + }); + }); + + it('keeps branch change count undefined when count lookup fails', async () => { + mockParseAndResolveValidationContract.mockReturnValue({ + profile: 'branch', + scope: 'branch', + testMode: 'affected', + downstream: 'none', + }); + mockResolveMoonAffectedBase.mockResolvedValue({ + base: 'base-sha', + baseRef: 'upstream/main', + }); + mockCountCommitsBetweenRefs.mockRejectedValue(new Error('count failed')); + + const context = await resolveValidationRunContext({ flags: {} }); + expect(context).toMatchObject({ + kind: 'affected', + branchCommitCount: undefined, + }); + }); + + it('includes changed files for local scope without resolving Moon projects', async () => { + mockParseAndResolveValidationContract.mockReturnValue({ + profile: 'branch', + scope: 'local', + testMode: 'related', + downstream: 'none', + }); + + await expect(resolveValidationRunContext({ flags: {} })).resolves.toEqual({ + kind: 'affected', + contract: { + profile: 'branch', + scope: 'local', + testMode: 'related', + downstream: 'none', + }, + resolvedBase: undefined, + branchCommitCount: undefined, + changedFiles: ['packages/foo/src/index.ts'], + }); + + expect(mockGetAffectedMoonProjectsFromChangedFiles).not.toHaveBeenCalled(); + expect(mockGetMoonChangedFiles).toHaveBeenCalledWith({ + scope: 'local', + base: undefined, + head: undefined, + }); + }); + + it('emits a warning when Moon reports no changed files in a shallow repository', async () => { + mockParseAndResolveValidationContract.mockReturnValue({ + profile: 'quick', + scope: 'local', + testMode: 'related', + downstream: 'none', + }); + mockGetMoonChangedFiles.mockResolvedValue([]); + mockIsShallowRepository.mockResolvedValue(true); + const warning = jest.fn(); + + await expect(resolveValidationRunContext({ flags: {}, onWarning: warning })).resolves.toEqual({ + kind: 'affected', + contract: { + profile: 'quick', + scope: 'local', + testMode: 'related', + downstream: 'none', + }, + resolvedBase: undefined, + branchCommitCount: undefined, + changedFiles: [], + }); + + expect(warning).toHaveBeenCalledWith( + 'Moon reported no changed files for scope=local, but this repository is shallow. A full Git history is required for affected validation; run `git fetch --unshallow`.' + ); + }); +}); + +describe('resolveValidationAffectedProjects', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('summarizes Moon-affected projects from pre-resolved changed files', async () => { + mockGetAffectedMoonProjectsFromChangedFiles.mockResolvedValue([ + { id: 'foo', sourceRoot: 'packages/foo' }, + { id: 'kibana', sourceRoot: '.' }, + ]); + mockSummarizeAffectedMoonProjects.mockReturnValue({ + sourceRoots: ['packages/foo'], + isRootProjectAffected: false, + }); + + const changedFilesJson = JSON.stringify({ files: ['packages/foo/src/bar.ts'] }); + + await expect( + resolveValidationAffectedProjects({ + changedFilesJson, + downstream: 'none', + }) + ).resolves.toEqual({ + affectedSourceRoots: ['packages/foo'], + isRootProjectAffected: false, + }); + + expect(mockGetAffectedMoonProjectsFromChangedFiles).toHaveBeenCalledWith({ + changedFilesJson, + downstream: 'none', + }); + expect(mockSummarizeAffectedMoonProjects).toHaveBeenCalledWith([ + { id: 'foo', sourceRoot: 'packages/foo' }, + { id: 'kibana', sourceRoot: '.' }, + ]); + }); + + it('preserves root-only affected results for wide-scope fallback decisions', async () => { + mockGetAffectedMoonProjectsFromChangedFiles.mockResolvedValue([ + { id: 'kibana', sourceRoot: '.' }, + ]); + mockSummarizeAffectedMoonProjects.mockReturnValue({ + sourceRoots: [], + isRootProjectAffected: true, + }); + + await expect( + resolveValidationAffectedProjects({ + changedFilesJson: JSON.stringify({ files: ['tsconfig.json'] }), + }) + ).resolves.toEqual({ + affectedSourceRoots: [], + isRootProjectAffected: true, + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-dev-validation-runner/src/resolve_validation_run_context.ts b/src/platform/packages/shared/kbn-dev-validation-runner/src/resolve_validation_run_context.ts new file mode 100644 index 0000000000000..b3d11fc6cbc3a --- /dev/null +++ b/src/platform/packages/shared/kbn-dev-validation-runner/src/resolve_validation_run_context.ts @@ -0,0 +1,201 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { createFailError } from '@kbn/dev-cli-errors'; +import { + countCommitsBetweenRefs, + hasStagedChanges, + isShallowRepository, + parseAndResolveValidationContract, + type ResolvedValidationContract, +} from '@kbn/dev-utils'; +import { + getAffectedMoonProjectsFromChangedFiles, + getMoonChangedFiles, + resolveMoonAffectedBase, + summarizeAffectedMoonProjects, + type MoonAffectedBase, +} from '@kbn/moon'; + +/** Raw CLI flag inputs used to resolve a validation run contract. */ +export interface ValidationRunFlagsInput { + profile?: string; + scope?: string; + testMode?: string; + downstream?: string; + baseRef?: string; + headRef?: string; +} + +interface ValidationRunContextBase { + contract: ResolvedValidationContract; +} + +type ValidationRunSkipContext = ValidationRunContextBase & { + kind: 'skip'; + reason: 'no_staged_changes'; +}; + +type ValidationRunFullContext = ValidationRunContextBase & { + kind: 'full'; + reason?: 'resolve_branch_scope_failed'; +}; + +type ValidationRunAffectedContext = ValidationRunContextBase & { + kind: 'affected'; + resolvedBase?: MoonAffectedBase; + branchCommitCount?: number; + changedFiles: string[]; +}; + +export type ValidationRunContext = + | ValidationRunSkipContext + | ValidationRunFullContext + | ValidationRunAffectedContext; + +export interface ValidationAffectedProjectsContext { + affectedSourceRoots: string[]; + isRootProjectAffected: boolean; +} + +/** Inputs for resolving a contract-driven validation run context. */ +export interface ResolveValidationRunContextOptions { + flags: ValidationRunFlagsInput; + runnerDescription?: string; + onWarning?: (message: string) => void; +} + +export interface ResolveValidationAffectedProjectsOptions { + changedFilesJson: string; + downstream?: 'none' | 'direct' | 'deep'; +} + +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + + return String(error); +}; + +/** Rejects validation-contract flags when a command is running in direct-target mode. */ +export const assertNoValidationRunFlagsForDirectTarget = (flags: ValidationRunFlagsInput) => { + if (Object.values(flags).some((v) => v !== undefined)) { + throw createFailError( + 'Cannot combine direct target mode with validation contract flags (--profile, --scope, --test-mode, --downstream, --base-ref, --head-ref).' + ); + } +}; + +/** Resolves a concrete validation run context from CLI flags, including changed-file scope data. */ +export const resolveValidationRunContext = async ({ + flags, + runnerDescription = 'validation run', + onWarning, +}: ResolveValidationRunContextOptions): Promise => { + const contract = parseAndResolveValidationContract(flags); + + if (contract.scope === 'full') { + return { + kind: 'full', + contract, + }; + } + + if (contract.scope === 'staged') { + const stagedChangesDetected = await hasStagedChanges(); + if (!stagedChangesDetected) { + return { + kind: 'skip', + reason: 'no_staged_changes', + contract, + }; + } + } + + // Resolve base for branch scope (for logging, reproduction, and Moon queries). + let resolvedBase: MoonAffectedBase | undefined; + if (contract.scope === 'branch') { + try { + const normalizedBaseRef = contract.baseRef?.trim() || undefined; + if (normalizedBaseRef) { + resolvedBase = { base: normalizedBaseRef, baseRef: normalizedBaseRef }; + } else { + resolvedBase = await resolveMoonAffectedBase({ + headRef: contract.headRef?.trim() || 'HEAD', + }); + } + } catch (error) { + onWarning?.( + `Failed to resolve merge-base for affected ${runnerDescription} (${getErrorMessage( + error + )}). Falling back to full ${runnerDescription}.` + ); + return { + kind: 'full', + reason: 'resolve_branch_scope_failed', + contract, + }; + } + } + + let branchCommitCount: number | undefined; + if (contract.scope === 'branch' && resolvedBase) { + try { + branchCommitCount = await countCommitsBetweenRefs({ + base: resolvedBase.base, + head: contract.headRef?.trim() || 'HEAD', + }); + } catch { + // Branch change count is a logging enhancement only. + } + } + + const changedFiles = await getMoonChangedFiles({ + scope: contract.scope as Exclude, + base: resolvedBase?.base, + head: contract.headRef?.trim() || undefined, + }); + + if (changedFiles.length === 0 && (await isShallowRepository())) { + onWarning?.( + `Moon reported no changed files for scope=${contract.scope}, but this repository is shallow. A full Git history is required for affected validation; run \`git fetch --unshallow\`.` + ); + } + + return { + kind: 'affected', + contract, + resolvedBase, + branchCommitCount, + changedFiles, + }; +}; + +/** + * Resolves Moon-affected project roots from pre-resolved changed files. + * + * Accepts `changedFilesJson` (Moon JSON format) to pipe directly into + * `moon query projects --affected`, avoiding duplicate Moon queries. + */ +export const resolveValidationAffectedProjects = async ({ + changedFilesJson, + downstream = 'none', +}: ResolveValidationAffectedProjectsOptions): Promise => { + const affectedMoonProjects = await getAffectedMoonProjectsFromChangedFiles({ + changedFilesJson, + downstream, + }); + const affectedProjectSummary = summarizeAffectedMoonProjects(affectedMoonProjects); + + return { + affectedSourceRoots: affectedProjectSummary.sourceRoots, + isRootProjectAffected: affectedProjectSummary.isRootProjectAffected, + }; +}; diff --git a/src/platform/packages/shared/kbn-dev-validation-runner/src/run_validation_command.test.ts b/src/platform/packages/shared/kbn-dev-validation-runner/src/run_validation_command.test.ts new file mode 100644 index 0000000000000..1fcc25bdf8071 --- /dev/null +++ b/src/platform/packages/shared/kbn-dev-validation-runner/src/run_validation_command.test.ts @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { + assertNoValidationRunFlagsForDirectTarget, + resolveValidationRunContext, +} from './resolve_validation_run_context'; +import { + describeValidationScoping, + resolveValidationBaseContext, + type ValidationBaseContext, +} from './run_validation_command'; + +jest.mock('@kbn/dev-utils', () => ({ + parseAndResolveValidationContract: jest.fn(), +})); + +jest.mock('./resolve_validation_run_context', () => ({ + assertNoValidationRunFlagsForDirectTarget: jest.fn(), + resolveValidationRunContext: jest.fn(), +})); + +const mockAssertNoValidationRunFlagsForDirectTarget = + assertNoValidationRunFlagsForDirectTarget as jest.Mock; +const mockResolveValidationRunContext = resolveValidationRunContext as jest.Mock; + +const directTargetContext: ValidationBaseContext = { + mode: 'direct_target', + directTarget: '/repo/packages/foo/tsconfig.json', +}; + +describe('resolveValidationBaseContext', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockResolveValidationRunContext.mockResolvedValue({ + kind: 'affected', + contract: { + profile: 'branch', + scope: 'branch', + testMode: 'affected', + downstream: 'none', + }, + resolvedBase: { + base: 'base-sha', + baseRef: 'upstream/main', + }, + changedFiles: ['packages/foo/src/index.ts'], + }); + }); + + it('uses direct-target mode without resolving Moon/Git context', async () => { + await expect( + resolveValidationBaseContext({ + flags: {}, + directTarget: '/repo/packages/foo/tsconfig.json', + }) + ).resolves.toEqual(directTargetContext); + + expect(mockAssertNoValidationRunFlagsForDirectTarget).toHaveBeenCalledWith({}); + expect(mockResolveValidationRunContext).not.toHaveBeenCalled(); + }); + + it('uses contract mode when no direct target is provided', async () => { + await expect( + resolveValidationBaseContext({ + flags: { profile: 'quick' }, + runnerDescription: 'type check', + }) + ).resolves.toEqual({ + mode: 'contract', + contract: { + profile: 'branch', + scope: 'branch', + testMode: 'affected', + downstream: 'none', + }, + runContext: { + kind: 'affected', + contract: { + profile: 'branch', + scope: 'branch', + testMode: 'affected', + downstream: 'none', + }, + resolvedBase: { + base: 'base-sha', + baseRef: 'upstream/main', + }, + changedFiles: ['packages/foo/src/index.ts'], + }, + }); + + expect(mockResolveValidationRunContext).toHaveBeenCalledWith({ + flags: { profile: 'quick' }, + runnerDescription: 'type check', + onWarning: undefined, + }); + }); +}); + +describe('describeValidationScoping', () => { + it('formats branch-scoped moon-limited summaries', () => { + expect( + describeValidationScoping({ + baseContext: { + mode: 'contract', + contract: { + profile: 'branch', + scope: 'branch', + testMode: 'affected', + downstream: 'none', + }, + runContext: { + kind: 'affected', + contract: { + profile: 'branch', + scope: 'branch', + testMode: 'affected', + downstream: 'none', + }, + resolvedBase: { + base: '2365cc0e7c29d5cc2324cd078a9854866e01e007', + baseRef: 'upstream/main', + }, + branchCommitCount: 1, + changedFiles: [], + }, + }, + targetCount: 3, + }) + ).toBe( + 'Checking 3 projects affected by 1 commit between upstream/main (2365cc0e7c29) and HEAD (scope=branch, test-mode=affected, downstream=none).' + ); + }); + + it('omits contract summary for direct targets', () => { + expect( + describeValidationScoping({ + baseContext: { + mode: 'direct_target', + directTarget: '/repo/packages/foo/tsconfig.json', + }, + targetCount: 1, + }) + ).toBe('Checking 1 project from direct target /repo/packages/foo/tsconfig.json.'); + }); +}); diff --git a/src/platform/packages/shared/kbn-dev-validation-runner/src/run_validation_command.ts b/src/platform/packages/shared/kbn-dev-validation-runner/src/run_validation_command.ts new file mode 100644 index 0000000000000..0a94124db072c --- /dev/null +++ b/src/platform/packages/shared/kbn-dev-validation-runner/src/run_validation_command.ts @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { + assertNoValidationRunFlagsForDirectTarget, + resolveValidationRunContext, + type ValidationRunContext, + type ValidationRunFlagsInput, +} from './resolve_validation_run_context'; + +/** Context for direct-target execution where contract-driven Moon/Git resolution is skipped. */ +export interface ValidationDirectTargetContext { + mode: 'direct_target'; + directTarget: string; +} + +/** Context for contract-driven execution where change-file scope has been resolved. */ +export interface ValidationContractContext { + mode: 'contract'; + contract: ValidationRunContext['contract']; + runContext: ValidationRunContext; +} + +export type ValidationBaseContext = ValidationDirectTargetContext | ValidationContractContext; + +/** Options for resolving a reusable validation base context from CLI-facing flags. */ +export interface ResolveValidationBaseContextOptions { + flags: ValidationRunFlagsInput; + directTarget?: string; + runnerDescription?: string; + onWarning?: (message: string) => void; +} + +const formatShortRevision = (revision: string) => revision.slice(0, 12); +const pluralize = (count: number, singular: string) => + `${count} ${singular}${count === 1 ? '' : 's'}`; + +/** + * Describes what revisions/change-scope a resolved validation context represents. + * Consumers can log this once and keep command-level logs focused on tool-specific execution. + */ +export const describeValidationScope = (baseContext: ValidationBaseContext): string => { + if (baseContext.mode === 'direct_target') { + return `direct target=${baseContext.directTarget}`; + } + + const { contract, runContext } = baseContext; + const parts = [ + `profile=${contract.profile}`, + `scope=${contract.scope}`, + `test-mode=${contract.testMode}`, + `downstream=${contract.downstream}`, + ]; + + if (runContext.kind === 'affected' && runContext.resolvedBase) { + parts.push( + `base=${runContext.resolvedBase.baseRef} (${formatShortRevision( + runContext.resolvedBase.base + )})`, + `head=HEAD` + ); + + if (contract.scope === 'branch') { + parts.push(`commits=${runContext.branchCommitCount ?? 'unknown'}`); + } + } else if (runContext.kind === 'full' && runContext.reason) { + parts.push(`reason=${runContext.reason}`); + } + + return parts.join(', '); +}; + +/** Describes where no affected targets were searched for the current validation contract. */ +export const describeValidationNoTargetsScope = (baseContext: ValidationBaseContext): string => { + if (baseContext.mode === 'direct_target') { + return 'for the direct target'; + } + + const { contract, runContext } = baseContext; + if (contract.scope === 'branch' && runContext.kind === 'affected' && runContext.resolvedBase) { + return `between ${runContext.resolvedBase.baseRef} (${formatShortRevision( + runContext.resolvedBase.base + )}) and HEAD`; + } + + if (contract.scope === 'staged') { + return 'in staged changes'; + } + + if (contract.scope === 'local') { + return 'in local changes'; + } + + return 'for the selected validation scope'; +}; + +/** Inputs for formatting a scoped-target summary from a resolved validation context. */ +export interface DescribeValidationScopingOptions { + baseContext: ValidationBaseContext; + targetCount: number; + targetNoun?: string; +} + +/** Formats a human-readable "Checking ..." message for tool-specific target sets. */ +export const describeValidationScoping = ({ + baseContext, + targetCount, + targetNoun = 'project', +}: DescribeValidationScopingOptions): string => { + const targetSummary = pluralize(targetCount, targetNoun); + + if (baseContext.mode === 'direct_target') { + return `Checking ${targetSummary} from direct target ${baseContext.directTarget}.`; + } + + const { contract, runContext } = baseContext; + const contractSummary = `scope=${contract.scope}, test-mode=${contract.testMode}, downstream=${contract.downstream}`; + + if (runContext.kind === 'affected' && runContext.resolvedBase && contract.scope === 'branch') { + const changeSummary = + runContext.branchCommitCount === undefined + ? 'commits' + : pluralize(runContext.branchCommitCount, 'commit'); + return `Checking ${targetSummary} affected by ${changeSummary} between ${ + runContext.resolvedBase.baseRef + } (${formatShortRevision(runContext.resolvedBase.base)}) and HEAD (${contractSummary}).`; + } + + if (runContext.kind === 'affected' && contract.scope === 'staged') { + return `Checking ${targetSummary} affected by staged changes (${contractSummary}).`; + } + + if (runContext.kind === 'affected' && contract.scope === 'local') { + return `Checking ${targetSummary} affected by local changes (${contractSummary}).`; + } + + return `Checking ${targetSummary} (${contractSummary}).`; +}; + +/** Resolves reusable validation context once for either direct-target or contract-driven execution. */ +export const resolveValidationBaseContext = async ({ + flags, + directTarget, + runnerDescription, + onWarning, +}: ResolveValidationBaseContextOptions): Promise => { + if (directTarget) { + assertNoValidationRunFlagsForDirectTarget(flags); + return { mode: 'direct_target', directTarget }; + } + + const runContext = await resolveValidationRunContext({ + flags, + runnerDescription, + onWarning, + }); + + return { mode: 'contract', contract: runContext.contract, runContext }; +}; diff --git a/src/platform/packages/shared/kbn-dev-validation-runner/src/validation_run_cli.test.ts b/src/platform/packages/shared/kbn-dev-validation-runner/src/validation_run_cli.test.ts new file mode 100644 index 0000000000000..99487c15c2ac7 --- /dev/null +++ b/src/platform/packages/shared/kbn-dev-validation-runner/src/validation_run_cli.test.ts @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { + buildValidationCliArgs, + formatReproductionCommand, + hasValidationRunFlags, + readValidationRunFlags, +} from './validation_run_cli'; + +describe('readValidationRunFlags', () => { + it('reads shared validation flags from flagsReader', () => { + const flags: Record = { + profile: 'pr', + scope: 'branch', + 'test-mode': 'affected', + downstream: 'deep', + 'base-ref': 'origin/main', + 'head-ref': 'HEAD', + }; + + expect( + readValidationRunFlags({ + string: (name) => flags[name], + }) + ).toEqual({ + profile: 'pr', + scope: 'branch', + testMode: 'affected', + downstream: 'deep', + baseRef: 'origin/main', + headRef: 'HEAD', + }); + }); +}); + +describe('buildValidationCliArgs', () => { + it('returns direct-target args when direct target is provided', () => { + expect( + buildValidationCliArgs({ + directTarget: { + flag: '--project', + value: 'packages/foo/tsconfig.json', + }, + }) + ).toEqual({ + logArgs: ['--project', 'packages/foo/tsconfig.json'], + reproductionArgs: ['--project', 'packages/foo/tsconfig.json'], + }); + }); + + it('returns full-profile args when forced to full mode', () => { + expect( + buildValidationCliArgs({ + contract: { + profile: 'branch', + scope: 'branch', + testMode: 'affected', + downstream: 'none', + }, + forceFullProfile: true, + }) + ).toEqual({ + logArgs: ['--profile', 'full'], + reproductionArgs: ['--profile', 'full'], + }); + }); + + it('uses env var reference in log args but resolved SHA in reproduction args for GITHUB_PR_MERGE_BASE', () => { + expect( + buildValidationCliArgs({ + contract: { + profile: 'branch', + scope: 'branch', + testMode: 'affected', + downstream: 'none', + }, + resolvedBase: { + base: '2365cc0e7c29d5cc2324cd078a9854866e01e007', + baseRef: 'GITHUB_PR_MERGE_BASE', + }, + }) + ).toEqual({ + logArgs: [ + '--profile', + 'branch', + '--scope', + 'branch', + '--test-mode', + 'affected', + '--downstream', + 'none', + '--base-ref', + '$GITHUB_PR_MERGE_BASE', + ], + reproductionArgs: [ + '--scope', + 'branch', + '--test-mode', + 'affected', + '--base-ref', + '2365cc0e7c29d5cc2324cd078a9854866e01e007', + ], + }); + }); + + it('includes explicit base for non-default branch inputs', () => { + expect( + buildValidationCliArgs({ + contract: { + profile: 'pr', + scope: 'branch', + testMode: 'affected', + downstream: 'deep', + }, + resolvedBase: { + base: 'base-sha', + baseRef: 'upstream/main', + }, + }) + ).toEqual({ + logArgs: [ + '--profile', + 'pr', + '--scope', + 'branch', + '--test-mode', + 'affected', + '--downstream', + 'deep', + '--base-ref', + 'base-sha', + ], + reproductionArgs: [ + '--scope', + 'branch', + '--test-mode', + 'affected', + '--downstream', + 'deep', + '--base-ref', + 'base-sha', + ], + }); + }); +}); + +describe('hasValidationRunFlags', () => { + it('detects shared validation flags in raw argv', () => { + expect(hasValidationRunFlags(['--profile', 'quick'])).toBe(true); + expect(hasValidationRunFlags(['--scope=branch'])).toBe(true); + expect(hasValidationRunFlags(['--fix', 'src/foo.ts'])).toBe(false); + }); +}); + +describe('formatReproductionCommand', () => { + it('formats a script name with args', () => { + expect( + formatReproductionCommand('type_check', ['--scope', 'branch', '--base-ref', 'abc']) + ).toBe('node scripts/type_check --scope branch --base-ref abc'); + }); + + it('formats a script name with no args', () => { + expect(formatReproductionCommand('type_check', [])).toBe('node scripts/type_check'); + }); +}); diff --git a/src/platform/packages/shared/kbn-dev-validation-runner/src/validation_run_cli.ts b/src/platform/packages/shared/kbn-dev-validation-runner/src/validation_run_cli.ts new file mode 100644 index 0000000000000..d1110718d8570 --- /dev/null +++ b/src/platform/packages/shared/kbn-dev-validation-runner/src/validation_run_cli.ts @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ResolvedValidationContract } from '@kbn/dev-utils'; +import type { MoonAffectedBase } from '@kbn/moon'; +import type { ValidationRunFlagsInput } from './resolve_validation_run_context'; + +/** Shared validation flag names used by validation-style CLIs. */ +export const VALIDATION_RUN_STRING_FLAGS = [ + 'profile', + 'scope', + 'test-mode', + 'base-ref', + 'head-ref', + 'downstream', +] as const; + +const VALIDATION_RUN_FLAGS = new Set(VALIDATION_RUN_STRING_FLAGS.map((flag) => `--${flag}`)); + +/** Shared validation help text block for CLI usage messages. */ +export const VALIDATION_RUN_HELP = ` + --profile [name] Validation profile: precommit, quick, agent, branch, pr, full (default: branch) + --scope [scope] Scope: staged, local, branch, full + --test-mode [mode] Test selection mode: related, affected, all + --base-ref [git-ref] Base revision for branch scope + --head-ref [git-ref] Head revision for branch scope (default: HEAD) + --downstream [mode] Downstream mode for related/affected: none, direct, deep +`; + +/** Minimal flags reader contract needed to parse shared validation flags. */ +export interface ValidationRunFlagsReader { + string: (name: string) => string | undefined; +} + +/** Inputs for building consistent log/reproduction args from a resolved validation contract. */ +export interface BuildValidationCliArgsOptions { + contract?: ResolvedValidationContract; + resolvedBase?: MoonAffectedBase; + directTarget?: { + flag: string; + value: string; + }; + forceFullProfile?: boolean; +} + +/** Generated args for command logs and CI/local reproduction output. */ +export interface ValidationCliArgs { + logArgs: string[]; + reproductionArgs: string[]; +} + +/** Returns true when any shared validation-contract flag is present in raw CLI args. */ +export const hasValidationRunFlags = (args: string[]) => + args.some((arg) => VALIDATION_RUN_FLAGS.has(arg.split('=')[0])); + +/** Formats a reproduction command from a script name and CLI args. */ +export const formatReproductionCommand = (scriptName: string, args: string[]) => { + const argsSuffix = args.length > 0 ? ` ${args.join(' ')}` : ''; + return `node scripts/${scriptName}${argsSuffix}`; +}; + +/** Reads shared validation flags from a command flags reader. */ +export const readValidationRunFlags = ( + flagsReader: ValidationRunFlagsReader +): ValidationRunFlagsInput => ({ + profile: flagsReader.string('profile'), + scope: flagsReader.string('scope'), + testMode: flagsReader.string('test-mode'), + downstream: flagsReader.string('downstream'), + baseRef: flagsReader.string('base-ref'), + headRef: flagsReader.string('head-ref'), +}); + +/** Builds shared validation CLI args for command logs and reproduction commands. */ +export const buildValidationCliArgs = ({ + contract, + resolvedBase, + directTarget, + forceFullProfile = false, +}: BuildValidationCliArgsOptions): ValidationCliArgs => { + if (directTarget) { + const args = [directTarget.flag, directTarget.value]; + return { + logArgs: args, + reproductionArgs: args, + }; + } + + if (forceFullProfile) { + const args = ['--profile', 'full']; + return { + logArgs: args, + reproductionArgs: args, + }; + } + + if (!contract) { + throw new Error( + 'contract is required when neither directTarget nor forceFullProfile is provided.' + ); + } + + const logArgs: string[] = [ + '--profile', + contract.profile, + '--scope', + contract.scope, + '--test-mode', + contract.testMode, + '--downstream', + contract.downstream, + ]; + const reproductionArgs: string[] = ['--scope', contract.scope, '--test-mode', contract.testMode]; + if (contract.downstream !== 'none') { + reproductionArgs.push('--downstream', contract.downstream); + } + + if (contract.scope === 'branch' && resolvedBase) { + if (resolvedBase.baseRef === 'GITHUB_PR_MERGE_BASE') { + logArgs.push('--base-ref', '$GITHUB_PR_MERGE_BASE'); + } else { + logArgs.push('--base-ref', resolvedBase.base); + } + + // Reproduction always uses the resolved SHA so copy/paste works in any shell. + reproductionArgs.push('--base-ref', resolvedBase.base); + } + + return { + logArgs, + reproductionArgs, + }; +}; diff --git a/src/platform/packages/shared/kbn-dev-validation-runner/tsconfig.json b/src/platform/packages/shared/kbn-dev-validation-runner/tsconfig.json new file mode 100644 index 0000000000000..d793cdfb05865 --- /dev/null +++ b/src/platform/packages/shared/kbn-dev-validation-runner/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/dev-cli-errors", + "@kbn/dev-utils", + "@kbn/moon" + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index b545732d604c1..92e5d932c6e5e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -882,6 +882,8 @@ "@kbn/dev-tools-plugin/*": ["src/platform/plugins/shared/dev_tools/*"], "@kbn/dev-utils": ["src/platform/packages/shared/kbn-dev-utils"], "@kbn/dev-utils/*": ["src/platform/packages/shared/kbn-dev-utils/*"], + "@kbn/dev-validation-runner": ["src/platform/packages/shared/kbn-dev-validation-runner"], + "@kbn/dev-validation-runner/*": ["src/platform/packages/shared/kbn-dev-validation-runner/*"], "@kbn/developer-examples-plugin": ["examples/developer_examples"], "@kbn/developer-examples-plugin/*": ["examples/developer_examples/*"], "@kbn/discover-contextual-components": ["src/platform/packages/shared/kbn-discover-contextual-components"], diff --git a/yarn.lock b/yarn.lock index 42e540d345ce4..21e1349dbd6dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5882,6 +5882,10 @@ version "0.0.0" uid "" +"@kbn/dev-validation-runner@link:src/platform/packages/shared/kbn-dev-validation-runner": + version "0.0.0" + uid "" + "@kbn/developer-examples-plugin@link:examples/developer_examples": version "0.0.0" uid ""