diff --git a/.moon/tasks/tag-jest-unit-tests.yml b/.moon/tasks/tag-jest-unit-tests.yml index 0be7bc1d309e6..f3b5fe7de1c9c 100644 --- a/.moon/tasks/tag-jest-unit-tests.yml +++ b/.moon/tasks/tag-jest-unit-tests.yml @@ -9,7 +9,6 @@ tasks: command: node args: - scripts/jest.js - - '--runInBand' - '--passWithNoTests' - '--config' - '@files(jest-config)' diff --git a/packages/kbn-moon/jest.config.js b/packages/kbn-moon/jest.config.js new file mode 100644 index 0000000000000..0b45b5b38779b --- /dev/null +++ b/packages/kbn-moon/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/jest_node', + rootDir: '../..', + roots: ['/packages/kbn-moon'], +}; diff --git a/packages/kbn-moon/moon.yml b/packages/kbn-moon/moon.yml index 434f7850a81f6..7f819c04cf88a 100644 --- a/packages/kbn-moon/moon.yml +++ b/packages/kbn-moon/moon.yml @@ -27,9 +27,12 @@ tags: - package - dev - group-undefined + - jest-unit-tests fileGroups: src: - '**/*.ts' - '**/*.tsx' - '!target/**/*' + jest-config: + - jest.config.js tasks: {} diff --git a/packages/kbn-moon/src/query_projects.test.ts b/packages/kbn-moon/src/query_projects.test.ts new file mode 100644 index 0000000000000..bdc7a268279d4 --- /dev/null +++ b/packages/kbn-moon/src/query_projects.test.ts @@ -0,0 +1,128 @@ +/* + * 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". + */ + +const mockExeca = jest.fn(); + +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + existsSync: jest.fn().mockReturnValue(true), +})); + +jest.mock('@kbn/repo-info', () => ({ + REPO_ROOT: '/repo', +})); + +jest.mock('@kbn/dev-utils', () => ({ + getRemoteDefaultBranchRefs: jest.fn(), + resolveNearestMergeBase: jest.fn(), +})); + +jest.mock('execa', () => ({ + __esModule: true, + default: mockExeca, +})); + +const createMoonProjectsOutput = (projects: Array<{ id: string; sourceRoot: string }>) => + JSON.stringify({ + projects: projects.map((project) => ({ + id: project.id, + source: project.sourceRoot, + config: { + project: { + metadata: { + sourceRoot: project.sourceRoot, + }, + }, + }, + })), + }); + +describe('getAffectedMoonProjectsFromChangedFiles', () => { + beforeEach(() => { + jest.resetModules(); + mockExeca.mockReset(); + }); + + it('adds the root project for repo-root TypeScript inputs', async () => { + mockExeca.mockResolvedValueOnce({ + stdout: createMoonProjectsOutput([]), + }); + + const { getAffectedMoonProjectsFromChangedFiles } = await import('./query_projects'); + + await expect( + getAffectedMoonProjectsFromChangedFiles({ + changedFilesJson: JSON.stringify({ files: ['tsconfig.base.json'] }), + }) + ).resolves.toEqual([{ id: 'kibana', sourceRoot: '.' }]); + }); + + it('adds the root project alongside affected package projects for typings changes', async () => { + mockExeca.mockResolvedValueOnce({ + stdout: createMoonProjectsOutput([{ id: 'foo', sourceRoot: 'packages/foo' }]), + }); + + const { getAffectedMoonProjectsFromChangedFiles } = await import('./query_projects'); + + await expect( + getAffectedMoonProjectsFromChangedFiles({ + changedFilesJson: JSON.stringify({ + files: ['packages/foo/src/index.ts', 'typings/something.d.ts'], + }), + }) + ).resolves.toEqual([ + { id: 'foo', sourceRoot: 'packages/foo' }, + { id: 'kibana', sourceRoot: '.' }, + ]); + }); + + it('does not add the root project for package-owned files', async () => { + mockExeca.mockResolvedValueOnce({ + stdout: createMoonProjectsOutput([{ id: 'foo', sourceRoot: 'packages/foo' }]), + }); + + const { getAffectedMoonProjectsFromChangedFiles } = await import('./query_projects'); + + await expect( + getAffectedMoonProjectsFromChangedFiles({ + changedFilesJson: JSON.stringify({ files: ['packages/foo/src/index.ts'] }), + }) + ).resolves.toEqual([{ id: 'foo', sourceRoot: 'packages/foo' }]); + }); + + it('does not add the root project for unrelated repo-root files', async () => { + mockExeca.mockResolvedValueOnce({ + stdout: createMoonProjectsOutput([]), + }); + + const { getAffectedMoonProjectsFromChangedFiles } = await import('./query_projects'); + + await expect( + getAffectedMoonProjectsFromChangedFiles({ + changedFilesJson: JSON.stringify({ files: ['.github/CODEOWNERS'] }), + }) + ).resolves.toEqual([]); + }); + + it('keeps Moon-reported root project results without querying all projects again', async () => { + mockExeca.mockResolvedValueOnce({ + stdout: createMoonProjectsOutput([{ id: 'kibana', sourceRoot: '.' }]), + }); + + const { getAffectedMoonProjectsFromChangedFiles } = await import('./query_projects'); + + await expect( + getAffectedMoonProjectsFromChangedFiles({ + changedFilesJson: JSON.stringify({ files: ['tsconfig.base.json'] }), + }) + ).resolves.toEqual([{ id: 'kibana', sourceRoot: '.' }]); + + expect(mockExeca).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/kbn-moon/src/query_projects.ts b/packages/kbn-moon/src/query_projects.ts index 6f17546514aa8..05f95d3d0cf43 100644 --- a/packages/kbn-moon/src/query_projects.ts +++ b/packages/kbn-moon/src/query_projects.ts @@ -51,6 +51,10 @@ interface MoonQueryProjectsResponse { }>; } +interface MoonChangedFilesInput { + files?: string[]; +} + /** Options for resolving the affected base revision from git state. */ export interface ResolveMoonAffectedBaseOptions { headRef?: string; @@ -58,8 +62,18 @@ export interface ResolveMoonAffectedBaseOptions { export const ROOT_MOON_PROJECT_ID = 'kibana'; +// Module-level cache — acceptable for short-lived CLI processes, tests mock getMoonExecutablePath. let moonExecutablePath: string | undefined; +const ROOT_MOON_PROJECT_TRIGGER_FILES = new Set([ + 'tsconfig.json', + 'tsconfig.base.json', + 'tsconfig.base.type_check.json', + 'tsconfig.browser.json', + 'tsconfig.refs.json', + 'tsconfig.type_check.json', +]); + /** Normalizes repository-relative paths to POSIX separators for stable matching. */ export const normalizeRepoRelativePath = (pathValue: string) => Path.normalize(pathValue).split(Path.sep).join('/'); @@ -134,6 +148,39 @@ const parseMoonProjectsResponse = (stdout: string): MoonProject[] => { }); }; +const parseChangedFilesInput = (changedFilesJson: string): string[] => { + const payload = JSON.parse(changedFilesJson) as MoonChangedFilesInput; + + return (payload.files ?? []).map(normalizeRepoRelativePath); +}; + +const isRootMoonProjectTriggerFile = (repoRelPath: string) => { + if (repoRelPath.startsWith('typings/')) { + return true; + } + + return !repoRelPath.includes('/') && ROOT_MOON_PROJECT_TRIGGER_FILES.has(repoRelPath); +}; + +const shouldIncludeRootMoonProject = ({ + changedFilesJson, + projects, +}: { + changedFilesJson: string; + projects: MoonProject[]; +}): boolean => { + if (projects.some((project) => project.id === ROOT_MOON_PROJECT_ID)) { + return false; + } + + const changedFiles = parseChangedFilesInput(changedFilesJson); + if (changedFiles.length === 0) { + return false; + } + + return changedFiles.some(isRootMoonProjectTriggerFile); +}; + /** * Queries Moon for affected projects by piping pre-resolved changed files JSON * into `moon query projects --affected`. @@ -164,7 +211,15 @@ export const getAffectedMoonProjectsFromChangedFiles = async ({ }, }); - return parseMoonProjectsResponse(stdout); + const projects = parseMoonProjectsResponse(stdout); + + // Moon currently omits the root `kibana` project for global TypeScript inputs owned by + // sourceRoot `.`, for example repo-root tsconfig files and `typings/**`. + if (shouldIncludeRootMoonProject({ changedFilesJson, projects })) { + return [...projects, { id: ROOT_MOON_PROJECT_ID, sourceRoot: '.' }]; + } + + return projects; }; /** Summarizes affected Moon projects into non-root source roots and root-project flag. */ diff --git a/packages/kbn-ts-type-check-cli/execute_type_check_validation.ts b/packages/kbn-ts-type-check-cli/execute_type_check_validation.ts new file mode 100644 index 0000000000000..7e56742421286 --- /dev/null +++ b/packages/kbn-ts-type-check-cli/execute_type_check_validation.ts @@ -0,0 +1,373 @@ +/* + * 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 Fsp from 'fs/promises'; + +import { createFailError } from '@kbn/dev-cli-errors'; +import type { ProcRunner } from '@kbn/dev-proc-runner'; +import { + buildValidationCliArgs, + describeValidationNoTargetsScope, + describeValidationScoping, + formatReproductionCommand, + resolveValidationAffectedProjects, + type ValidationBaseContext, +} from '@kbn/dev-validation-runner'; +import { normalizeRepoRelativePath } from '@kbn/moon'; +import { REPO_ROOT } from '@kbn/repo-info'; +import { asyncForEachWithLimit, asyncMapWithLimit } from '@kbn/std'; +import type { ToolingLog } from '@kbn/tooling-log'; +import type { TsProject } from '@kbn/ts-projects'; + +import { archiveTSBuildArtifacts } from './src/archive/archive_ts_build_artifacts'; +import { restoreTSBuildArtifacts } from './src/archive/restore_ts_build_artifacts'; +import { LOCAL_CACHE_ROOT } from './src/archive/constants'; +import { isCiEnvironment } from './src/archive/utils'; +import { formatPathForLog } from './src/normalize_project_path'; + +export const TSC_LABEL = 'tsc'; + +const rel = (from: string, to: string) => { + const path = Path.relative(from, to); + return path.startsWith('.') ? path : `./${path}`; +}; + +const isTsProjectWithinMoonSourceRoots = ( + tsProject: TsProject, + moonSourceRoots: Set +): boolean => { + if (moonSourceRoots.has('.')) { + return true; + } + + const projectRepoRelPath = normalizeRepoRelativePath(tsProject.repoRel); + for (const sourceRoot of moonSourceRoots) { + if (projectRepoRelPath === sourceRoot || projectRepoRelPath.startsWith(`${sourceRoot}/`)) { + return true; + } + } + + return false; +}; + +/** + * Generates `tsconfig.type_check.json` files for the selected projects and any + * referenced dependencies they need for a composite build. + */ +export async function createTypeCheckConfigs( + log: ToolingLog, + projects: TsProject[], + allProjects: TsProject[] +) { + const writes: Array<[path: string, content: string]> = []; + + // write tsconfig.type_check.json files for each project that is not the root + const queue = new Set(projects); + for (const project of queue) { + const config = project.config; + const base = project.getBase(); + if (base) { + queue.add(base); + } + + const typeCheckConfig = { + ...config, + extends: base ? rel(project.directory, base.typeCheckConfigPath) : undefined, + compilerOptions: { + ...config.compilerOptions, + composite: true, + rootDir: '.', + noEmit: false, + emitDeclarationOnly: true, + paths: project.repoRel === 'tsconfig.base.json' ? config.compilerOptions?.paths : undefined, + }, + kbn_references: undefined, + references: project.getKbnRefs(allProjects).map((refd) => { + queue.add(refd); + + return { + path: rel(project.directory, refd.typeCheckConfigPath), + }; + }), + }; + + writes.push([project.typeCheckConfigPath, JSON.stringify(typeCheckConfig, null, 2)]); + } + + return new Set( + await asyncMapWithLimit(writes, 50, async ([path, content]) => { + try { + const existing = await Fsp.readFile(path, 'utf8'); + if (existing === content) { + return path; + } + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + + log.verbose('updating', path); + await Fsp.writeFile(path, content, 'utf8'); + return path; + }) + ); +} + +/** Detects uncommitted working-tree changes after a type-check run. */ +export async function detectLocalChanges(): Promise { + const execa = (await import('execa')).default; + const { stdout } = await execa( + 'git', + // Some CI environments change these files dynamically, like FIPS but it shouldn't invalidate the cache + ['status', '--porcelain', '--', '.', ':!:config/node.options', ':!config/kibana.yml'], + { + cwd: REPO_ROOT, + } + ); + + return stdout + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0); +} + +/** Removes generated type-check outputs and the local type-check archive cache. */ +export async function cleanTypeCheckCaches(log: ToolingLog, projects: TsProject[]) { + await asyncForEachWithLimit(projects, 10, async (proj) => { + await Fsp.rm(Path.resolve(proj.directory, 'target/types'), { + force: true, + recursive: true, + }); + }); + await Fsp.rm(LOCAL_CACHE_ROOT, { + force: true, + recursive: true, + }); + log.warning('Deleted all TypeScript caches'); +} + +type ProcRunnerLike = Pick; + +export interface TscValidationResult { + projectCount: number; +} + +export interface ExecuteTypeCheckValidationOptions { + baseContext: ValidationBaseContext; + log: ToolingLog; + procRunner: ProcRunnerLike; + cleanup?: boolean; + extendedDiagnostics?: boolean; + pretty?: boolean; + verbose?: boolean; + withArchive?: boolean; +} + +/** + * Resolves the type-check scope from the validation contract, prepares the + * generated configs, and runs `tsc -b` for the selected projects. + */ +export const executeTypeCheckValidation = async ({ + baseContext, + log, + procRunner, + cleanup = false, + extendedDiagnostics = false, + pretty = true, + verbose = false, + withArchive = false, +}: ExecuteTypeCheckValidationOptions): Promise => { + // Lazy-load so reusable consumers can avoid TS project metadata work until needed. + const { TS_PROJECTS } = await import('@kbn/ts-projects'); + + const allTypeCheckProjects = TS_PROJECTS.filter((project) => !project.isTypeCheckDisabled()); + + let selectedProjects = allTypeCheckProjects; + let shouldRunAllProjects = false; + let reproductionCommand: string; + + if (baseContext.mode === 'direct_target') { + selectedProjects = allTypeCheckProjects.filter( + (project) => project.path === baseContext.directTarget + ); + + if (selectedProjects.length === 0) { + throw createFailError( + `Could not find a TypeScript project at '${baseContext.directTarget}'.` + ); + } + + const directTargetForLog = formatPathForLog(baseContext.directTarget); + const cliArgs = buildValidationCliArgs({ + directTarget: { flag: '--project', value: directTargetForLog }, + }); + reproductionCommand = formatReproductionCommand('type_check', cliArgs.reproductionArgs); + log.info(`Running \`${formatReproductionCommand('type_check', cliArgs.logArgs)}\``); + } else { + const { runContext } = baseContext; + + if (runContext.kind === 'skip') { + selectedProjects = []; + } else if (runContext.kind === 'full' || baseContext.contract.testMode === 'all') { + shouldRunAllProjects = true; + selectedProjects = allTypeCheckProjects; + } else if (runContext.changedFiles.length === 0) { + selectedProjects = []; + } else { + const changedFilesJson = JSON.stringify({ files: runContext.changedFiles }); + const affectedProjectsContext = await resolveValidationAffectedProjects({ + changedFilesJson, + downstream: baseContext.contract.downstream, + }); + + if (affectedProjectsContext.isRootProjectAffected) { + log.info('Root TypeScript inputs changed; escalating to full type check of all projects.'); + shouldRunAllProjects = true; + selectedProjects = allTypeCheckProjects; + } else { + const affectedMoonSourceRoots = new Set( + affectedProjectsContext.affectedSourceRoots.map((sourceRoot) => + normalizeRepoRelativePath(sourceRoot) + ) + ); + + selectedProjects = allTypeCheckProjects.filter((project) => + isTsProjectWithinMoonSourceRoots(project, affectedMoonSourceRoots) + ); + } + } + + if (selectedProjects.length === 0) { + log.info( + `No affected TypeScript projects found ${describeValidationNoTargetsScope( + baseContext + )}; skipping type check.` + ); + return null; + } + + log.info( + describeValidationScoping({ + baseContext, + targetCount: shouldRunAllProjects ? allTypeCheckProjects.length : selectedProjects.length, + }) + ); + + const resolvedBase = runContext.kind === 'affected' ? runContext.resolvedBase : undefined; + const cliArgs = buildValidationCliArgs({ + contract: baseContext.contract, + resolvedBase, + forceFullProfile: shouldRunAllProjects, + }); + + reproductionCommand = formatReproductionCommand('type_check', cliArgs.reproductionArgs); + log.info(`Running \`${formatReproductionCommand('type_check', cliArgs.logArgs)}\``); + } + + // Setup + execute wrapped so cleanup always runs even if setup partially fails + const { updateRootRefsConfig, ROOT_REFS_CONFIG_PATH } = await import('./root_refs_config'); + + let rootRefsConfigCreated = false; + let createdConfigs = new Set(); + let tscFailed = false; + + try { + if (shouldRunAllProjects) { + await updateRootRefsConfig(log); + rootRefsConfigCreated = true; + } + + if (withArchive) { + await restoreTSBuildArtifacts(log); + } else { + log.verbose('Skipping TypeScript cache restore because --with-archive was not provided.'); + } + + createdConfigs = await createTypeCheckConfigs(log, selectedProjects, TS_PROJECTS); + + const buildTargets = shouldRunAllProjects + ? [Path.relative(REPO_ROOT, ROOT_REFS_CONFIG_PATH)] + : [ + ...new Set( + selectedProjects.map((project) => Path.relative(REPO_ROOT, project.typeCheckConfigPath)) + ), + ].sort((left, right) => left.localeCompare(right)); + + if (buildTargets.length > 0) { + await procRunner.run(TSC_LABEL, { + cmd: Path.relative(REPO_ROOT, require.resolve('typescript/bin/tsc')), + args: [ + '-b', + ...buildTargets, + ...(pretty ? ['--pretty'] : []), + ...(verbose ? ['--verbose'] : []), + ...(extendedDiagnostics ? ['--extendedDiagnostics'] : []), + ], + env: { + NODE_OPTIONS: '--max-old-space-size=12288', + }, + cwd: REPO_ROOT, + wait: true, + }); + } + } catch (_error) { + // Error details are surfaced via captured ToolingLog output from procRunner + tscFailed = true; + } + + // Cleanup always runs, even if setup or tsc failed partway through + try { + // Archive artifacts (only after successful tsc) + if (withArchive && !tscFailed) { + const localChanges = await detectLocalChanges(); + const hasLocalChanges = localChanges.length > 0; + if (hasLocalChanges) { + const changedFiles = localChanges.join('\n'); + const message = `uncommitted changes were detected after the TypeScript build. TypeScript cache artifacts must be generated from a clean working tree.\nChanged files:\n${changedFiles}`; + + if (isCiEnvironment()) { + throw new Error(`Cancelling TypeScript cache archive because ${message}`); + } + + log.info(`Skipping TypeScript cache archive because ${message}`); + } else { + await archiveTSBuildArtifacts(log); + } + } else if (!withArchive) { + log.verbose('Skipping TypeScript cache archive because --with-archive was not provided.'); + } + } finally { + if (cleanup) { + log.verbose('cleaning up'); + + if (rootRefsConfigCreated) { + const { cleanupRootRefsConfig } = await import('./root_refs_config'); + await cleanupRootRefsConfig(); + } + + await asyncForEachWithLimit(createdConfigs, 40, async (path) => { + await Fsp.unlink(path); + }); + } + } + + if (tscFailed) { + if (isCiEnvironment()) { + throw createFailError( + `${TSC_LABEL} failed. Reproduce this run locally with:\n ${reproductionCommand}` + ); + } + + throw createFailError('Unable to build TS project refs'); + } + + return { projectCount: selectedProjects.length }; +}; diff --git a/packages/kbn-ts-type-check-cli/index.ts b/packages/kbn-ts-type-check-cli/index.ts new file mode 100644 index 0000000000000..5b0f9659aa906 --- /dev/null +++ b/packages/kbn-ts-type-check-cli/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { + executeTypeCheckValidation, + runLegacyTypeCheckCli, + runTypeCheckContractCli, + TSC_LABEL, + type ExecuteTypeCheckValidationOptions, + type TscValidationResult, +} from './run_type_check_cli'; diff --git a/packages/kbn-ts-type-check-cli/moon.yml b/packages/kbn-ts-type-check-cli/moon.yml index 1ab35533f359f..d89e8c96d7684 100644 --- a/packages/kbn-ts-type-check-cli/moon.yml +++ b/packages/kbn-ts-type-check-cli/moon.yml @@ -20,6 +20,10 @@ dependsOn: - '@kbn/tooling-log' - '@kbn/repo-info' - '@kbn/dev-cli-errors' + - '@kbn/dev-proc-runner' + - '@kbn/dev-validation-runner' + - '@kbn/dev-utils' + - '@kbn/moon' - '@kbn/ts-projects' - '@kbn/dev-cli-runner' - '@kbn/std' diff --git a/packages/kbn-ts-type-check-cli/package.json b/packages/kbn-ts-type-check-cli/package.json index 66b0d33281ece..480fdb8b385bb 100644 --- a/packages/kbn-ts-type-check-cli/package.json +++ b/packages/kbn-ts-type-check-cli/package.json @@ -3,5 +3,5 @@ "private": true, "version": "1.0.0", "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0", - "main": "./run_type_check_cli" + "main": "./index" } diff --git a/packages/kbn-ts-type-check-cli/run_type_check_cli.test.ts b/packages/kbn-ts-type-check-cli/run_type_check_cli.test.ts new file mode 100644 index 0000000000000..810262058c4c0 --- /dev/null +++ b/packages/kbn-ts-type-check-cli/run_type_check_cli.test.ts @@ -0,0 +1,507 @@ +/* + * 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 { + getAffectedMoonProjectsFromChangedFiles, + getMoonChangedFiles, + resolveMoonAffectedBase, + ROOT_MOON_PROJECT_ID, + summarizeAffectedMoonProjects, +} from '@kbn/moon'; +import { countCommitsBetweenRefs, hasStagedChanges } from '@kbn/dev-utils'; +import { cleanupRootRefsConfig, updateRootRefsConfig } from './root_refs_config'; +import { isCiEnvironment } from './src/archive/utils'; + +const tsProjectsState: { projects: any[] } = { + projects: [], +}; + +jest.mock('@kbn/dev-cli-runner', () => ({ + run: jest.fn(), +})); + +jest.mock('@kbn/dev-cli-errors', () => ({ + createFailError: (message: string) => new Error(message), +})); + +jest.mock('@kbn/repo-info', () => ({ + REPO_ROOT: '/repo', +})); + +jest.mock('@kbn/moon', () => ({ + getAffectedMoonProjectsFromChangedFiles: jest.fn(), + getMoonChangedFiles: jest.fn(), + resolveMoonAffectedBase: jest.fn(), + ROOT_MOON_PROJECT_ID: 'kibana', + summarizeAffectedMoonProjects: jest.fn(), + normalizeRepoRelativePath: (pathValue: string) => pathValue.replace(/\\/g, '/'), +})); + +jest.mock('@kbn/dev-utils', () => { + const actual = jest.requireActual('@kbn/dev-utils'); + return { + ...actual, + countCommitsBetweenRefs: jest.fn().mockResolvedValue(3), + hasStagedChanges: jest.fn().mockResolvedValue(true), + }; +}); + +jest.mock('@kbn/std', () => ({ + asyncForEachWithLimit: async ( + items: any[], + _limit: number, + iterator: (item: any) => Promise + ) => { + for (const item of items) { + await iterator(item); + } + }, + asyncMapWithLimit: async ( + items: any[], + _limit: number, + iterator: (item: any) => Promise + ) => { + return await Promise.all(items.map((item) => iterator(item))); + }, +})); + +jest.mock('@kbn/ts-projects', () => ({ + get TS_PROJECTS() { + return tsProjectsState.projects; + }, +})); + +jest.mock('./root_refs_config', () => ({ + ROOT_REFS_CONFIG_PATH: '/repo/tsconfig.refs.json', + updateRootRefsConfig: jest.fn(), + cleanupRootRefsConfig: jest.fn(), +})); + +jest.mock('./src/archive/archive_ts_build_artifacts', () => ({ + archiveTSBuildArtifacts: jest.fn(), +})); + +jest.mock('./src/archive/restore_ts_build_artifacts', () => ({ + restoreTSBuildArtifacts: jest.fn(), +})); + +jest.mock('./src/archive/utils', () => ({ + isCiEnvironment: jest.fn(), +})); + +jest.mock('execa', () => { + const mockExecaFn = jest.fn(); + return { + __esModule: true, + default: mockExecaFn, + __mock: { mockExecaFn }, + }; +}); + +jest.mock('fs/promises', () => ({ + readFile: jest.fn(), + writeFile: jest.fn(), + rm: jest.fn(), + unlink: jest.fn(), +})); + +const mockRun = jest.requireMock('@kbn/dev-cli-runner').run as jest.Mock; +const fsPromises = jest.requireMock('fs/promises') as { + readFile: jest.Mock; + writeFile: jest.Mock; + rm: jest.Mock; + unlink: jest.Mock; +}; +const mockGetAffectedMoonProjectsFromChangedFiles = + getAffectedMoonProjectsFromChangedFiles as unknown as jest.Mock; +const mockGetMoonChangedFiles = getMoonChangedFiles as unknown as jest.Mock; +const mockResolveMoonAffectedBase = resolveMoonAffectedBase as unknown as jest.Mock; +const mockSummarizeAffectedMoonProjects = summarizeAffectedMoonProjects as unknown as jest.Mock; +const mockCountCommitsBetweenRefs = countCommitsBetweenRefs as unknown as jest.Mock; +const mockHasStagedChanges = hasStagedChanges as unknown as jest.Mock; +const mockUpdateRootRefsConfig = updateRootRefsConfig as unknown as jest.Mock; +const mockCleanupRootRefsConfig = cleanupRootRefsConfig as unknown as jest.Mock; +const mockIsCiEnvironment = isCiEnvironment as unknown as jest.Mock; +const mockArchiveTSBuildArtifacts = jest.requireMock('./src/archive/archive_ts_build_artifacts') + .archiveTSBuildArtifacts as jest.Mock; +const mockRestoreTSBuildArtifacts = jest.requireMock('./src/archive/restore_ts_build_artifacts') + .restoreTSBuildArtifacts as jest.Mock; +const mockExeca = (jest.requireMock('execa') as { __mock: { mockExecaFn: jest.Mock } }).__mock + .mockExecaFn; + +let contractHandler: (args: { + log: { info: jest.Mock; warning: jest.Mock; verbose: jest.Mock }; + flagsReader: { + boolean: (name: string) => boolean; + string: (name: string) => string | undefined; + path: (name: string) => string | undefined; + }; + procRunner: { run: jest.Mock }; +}) => Promise; + +let legacyHandler: (args: { + log: { info: jest.Mock; warning: jest.Mock; verbose: jest.Mock }; + flagsReader: { + boolean: (name: string) => boolean; + string: (name: string) => string | undefined; + path: (name: string) => string | undefined; + }; + procRunner: { run: jest.Mock }; +}) => Promise; + +const createProject = (overrides: Record = {}) => ({ + isTypeCheckDisabled: () => false, + path: '/repo/packages/foo/tsconfig.json', + repoRel: 'packages/foo', + config: { + compilerOptions: {}, + }, + getBase: () => undefined, + getKbnRefs: () => [], + directory: '/repo/packages/foo', + typeCheckConfigPath: '/repo/packages/foo/tsconfig.type_check.json', + ...overrides, +}); + +const createFlagsReader = (flags: Record) => ({ + boolean(name: string) { + return flags[name] === true; + }, + string(name: string) { + const value = flags[name]; + return typeof value === 'string' ? value : undefined; + }, + path(name: string) { + const value = flags[name]; + return typeof value === 'string' ? value : undefined; + }, +}); + +const createContext = (flags: Record) => { + const log = { + info: jest.fn(), + warning: jest.fn(), + verbose: jest.fn(), + }; + + const procRunner = { + run: jest.fn().mockResolvedValue(undefined), + }; + + return { + log, + procRunner, + flagsReader: createFlagsReader(flags), + }; +}; + +const configureBranchBase = (baseSha = '2365cc0e7c29d5cc2324cd078a9854866e01e007') => { + mockResolveMoonAffectedBase.mockResolvedValue({ + base: baseSha, + baseRef: 'upstream/main', + }); +}; + +describe('run_type_check_cli', () => { + beforeAll(async () => { + mockRun.mockClear(); + const { runLegacyTypeCheckCli, runTypeCheckContractCli } = await import('./run_type_check_cli'); + runLegacyTypeCheckCli(); + legacyHandler = mockRun.mock.calls[0][0]; + runTypeCheckContractCli(); + contractHandler = mockRun.mock.calls[1][0]; + }); + + beforeEach(() => { + jest.clearAllMocks(); + + tsProjectsState.projects = []; + + const notFoundError = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + fsPromises.readFile.mockRejectedValue(notFoundError); + fsPromises.writeFile.mockResolvedValue(undefined); + fsPromises.rm.mockResolvedValue(undefined); + fsPromises.unlink.mockResolvedValue(undefined); + mockExeca.mockResolvedValue({ stdout: '' }); + + mockIsCiEnvironment.mockReturnValue(false); + mockCountCommitsBetweenRefs.mockResolvedValue(3); + mockGetMoonChangedFiles.mockResolvedValue([]); + mockHasStagedChanges.mockResolvedValue(true); + mockSummarizeAffectedMoonProjects.mockImplementation( + (projects: Array<{ id: string; sourceRoot: string }>) => { + 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), + }; + } + ); + }); + + it('legacy CLI runs the default type check path when no validation flags are provided', async () => { + tsProjectsState.projects = [createProject()]; + + const ctx = createContext({}); + await legacyHandler(ctx); + + expect(mockResolveMoonAffectedBase).not.toHaveBeenCalled(); + expect(mockGetAffectedMoonProjectsFromChangedFiles).not.toHaveBeenCalled(); + expect(mockUpdateRootRefsConfig).toHaveBeenCalledTimes(1); + expect(ctx.procRunner.run).toHaveBeenCalledWith( + 'tsc', + expect.objectContaining({ + args: expect.arrayContaining(['-b', 'packages/foo/tsconfig.type_check.json', '--pretty']), + }) + ); + }); + + it('legacy CLI cleans up generated configs before failing CI archive validation', async () => { + tsProjectsState.projects = [createProject()]; + mockIsCiEnvironment.mockReturnValue(true); + mockExeca.mockResolvedValue({ + stdout: ' M packages/foo/tsconfig.type_check.json', + }); + + const ctx = createContext({ cleanup: true, 'with-archive': true }); + + await expect(legacyHandler(ctx)).rejects.toThrow( + 'Cancelling TypeScript cache archive because uncommitted changes were detected after the TypeScript build.' + ); + + expect(mockCleanupRootRefsConfig).toHaveBeenCalledTimes(1); + expect(fsPromises.unlink).toHaveBeenCalledWith('/repo/packages/foo/tsconfig.type_check.json'); + expect(mockArchiveTSBuildArtifacts).not.toHaveBeenCalled(); + }); + + it('uses branch profile affected selection when validation flags are provided', async () => { + const baseSha = '2365cc0e7c29d5cc2324cd078a9854866e01e007'; + tsProjectsState.projects = [createProject()]; + configureBranchBase(baseSha); + mockGetMoonChangedFiles.mockResolvedValue(['packages/foo/src/index.ts']); + mockGetAffectedMoonProjectsFromChangedFiles.mockResolvedValue([]); + + const ctx = createContext({ profile: 'branch' }); + await contractHandler(ctx); + + expect(mockResolveMoonAffectedBase).toHaveBeenCalledWith({ + headRef: 'HEAD', + }); + expect(mockGetAffectedMoonProjectsFromChangedFiles).toHaveBeenCalledWith({ + changedFilesJson: JSON.stringify({ files: ['packages/foo/src/index.ts'] }), + downstream: 'none', + }); + expect(ctx.procRunner.run).not.toHaveBeenCalled(); + expect(ctx.log.info).toHaveBeenCalledWith( + `No affected TypeScript projects found between upstream/main (${baseSha.slice( + 0, + 12 + )}) and HEAD; skipping type check.` + ); + }); + + it('supports quick profile (local related mode) without branch base resolution', async () => { + tsProjectsState.projects = [createProject()]; + mockGetMoonChangedFiles.mockResolvedValue(['packages/foo/src/index.ts']); + mockGetAffectedMoonProjectsFromChangedFiles.mockResolvedValue([]); + + const ctx = createContext({ profile: 'quick' }); + await contractHandler(ctx); + + expect(mockResolveMoonAffectedBase).not.toHaveBeenCalled(); + expect(mockGetAffectedMoonProjectsFromChangedFiles).toHaveBeenCalledWith({ + changedFilesJson: JSON.stringify({ files: ['packages/foo/src/index.ts'] }), + downstream: 'none', + }); + expect(ctx.log.info).toHaveBeenCalledWith( + 'No affected TypeScript projects found in local changes; skipping type check.' + ); + }); + + it('supports staged scope via Moon changed-files piping', async () => { + tsProjectsState.projects = [createProject()]; + mockGetMoonChangedFiles.mockResolvedValue(['packages/foo/src/index.ts']); + mockGetAffectedMoonProjectsFromChangedFiles.mockResolvedValue([]); + + const ctx = createContext({ scope: 'staged', downstream: 'deep' }); + await contractHandler(ctx); + + expect(mockResolveMoonAffectedBase).not.toHaveBeenCalled(); + expect(mockGetAffectedMoonProjectsFromChangedFiles).toHaveBeenCalledWith({ + changedFilesJson: JSON.stringify({ files: ['packages/foo/src/index.ts'] }), + downstream: 'deep', + }); + expect(ctx.log.info).toHaveBeenCalledWith( + 'No affected TypeScript projects found in staged changes; skipping type check.' + ); + }); + + it('supports --clean-cache on the contract path', async () => { + tsProjectsState.projects = [createProject()]; + + const ctx = createContext({ profile: 'quick', 'clean-cache': true }); + await contractHandler(ctx); + + expect(fsPromises.rm).toHaveBeenCalledWith('/repo/packages/foo/target/types', { + force: true, + recursive: true, + }); + expect(fsPromises.rm).toHaveBeenCalledWith(expect.stringContaining('/archives'), { + force: true, + recursive: true, + }); + expect(mockGetMoonChangedFiles).not.toHaveBeenCalled(); + expect(ctx.procRunner.run).not.toHaveBeenCalled(); + }); + + it('restores and archives caches when --with-archive is used on the contract path', async () => { + tsProjectsState.projects = [createProject()]; + mockGetMoonChangedFiles.mockResolvedValue(['packages/foo/src/index.ts']); + mockGetAffectedMoonProjectsFromChangedFiles.mockResolvedValue([ + { id: 'foo', sourceRoot: 'packages/foo' }, + ]); + + const ctx = createContext({ profile: 'quick', 'with-archive': true }); + await contractHandler(ctx); + + expect(mockRestoreTSBuildArtifacts).toHaveBeenCalledTimes(1); + expect(ctx.procRunner.run).toHaveBeenCalledTimes(1); + expect(mockArchiveTSBuildArtifacts).toHaveBeenCalledTimes(1); + }); + + it('skips staged scope immediately when nothing is staged', async () => { + tsProjectsState.projects = [createProject()]; + mockHasStagedChanges.mockResolvedValue(false); + + const ctx = createContext({ scope: 'staged' }); + await contractHandler(ctx); + + expect(mockResolveMoonAffectedBase).not.toHaveBeenCalled(); + expect(mockGetAffectedMoonProjectsFromChangedFiles).not.toHaveBeenCalled(); + expect(ctx.procRunner.run).not.toHaveBeenCalled(); + expect(ctx.log.info).toHaveBeenCalledWith( + 'No affected TypeScript projects found in staged changes; skipping type check.' + ); + }); + + it('skips Moon affected selection when there are no changed files', async () => { + tsProjectsState.projects = [createProject()]; + + const ctx = createContext({ profile: 'quick' }); + await contractHandler(ctx); + + expect(mockResolveMoonAffectedBase).not.toHaveBeenCalled(); + expect(mockGetAffectedMoonProjectsFromChangedFiles).not.toHaveBeenCalled(); + expect(ctx.log.info).toHaveBeenCalledWith( + 'No affected TypeScript projects found in local changes; skipping type check.' + ); + }); + + it('rejects --base-ref outside branch scope', async () => { + const ctx = createContext({ scope: 'local', 'base-ref': 'origin/main' }); + + await expect(contractHandler(ctx)).rejects.toThrow( + '--base-ref can only be used when --scope is branch.' + ); + expect(mockGetAffectedMoonProjectsFromChangedFiles).not.toHaveBeenCalled(); + }); + + it('falls back to full type-check when root TypeScript inputs are affected', async () => { + const baseSha = '2365cc0e7c29d5cc2324cd078a9854866e01e007'; + tsProjectsState.projects = [createProject()]; + configureBranchBase(baseSha); + mockGetAffectedMoonProjectsFromChangedFiles.mockResolvedValue([ + { id: ROOT_MOON_PROJECT_ID, sourceRoot: '.' }, + ]); + mockGetMoonChangedFiles.mockResolvedValue(['tsconfig.base.json']); + + const ctx = createContext({ profile: 'branch' }); + await contractHandler(ctx); + + expect(ctx.log.info).toHaveBeenCalledWith( + 'Root TypeScript inputs changed; escalating to full type check of all projects.' + ); + expect(mockUpdateRootRefsConfig).toHaveBeenCalledTimes(1); + expect(ctx.procRunner.run).toHaveBeenCalledWith( + 'tsc', + expect.objectContaining({ + args: expect.arrayContaining(['-b', 'tsconfig.refs.json', '--pretty']), + }) + ); + expect(ctx.log.info).toHaveBeenCalledWith( + expect.stringContaining('Running `node scripts/type_check --profile full`') + ); + }); + + it('ignores non-TypeScript root changes when selecting affected projects', async () => { + const baseSha = '2365cc0e7c29d5cc2324cd078a9854866e01e007'; + tsProjectsState.projects = [createProject()]; + configureBranchBase(baseSha); + mockGetAffectedMoonProjectsFromChangedFiles.mockResolvedValue([]); + mockGetMoonChangedFiles.mockResolvedValue(['.github/CODEOWNERS']); + + const ctx = createContext({ profile: 'branch' }); + await contractHandler(ctx); + + expect(mockUpdateRootRefsConfig).not.toHaveBeenCalled(); + expect(ctx.procRunner.run).not.toHaveBeenCalled(); + expect(ctx.log.info).toHaveBeenCalledWith( + `No affected TypeScript projects found between upstream/main (${baseSha.slice( + 0, + 12 + )}) and HEAD; skipping type check.` + ); + }); + + it('omits commit count in branch summary when commit counting fails', async () => { + const baseSha = '2365cc0e7c29d5cc2324cd078a9854866e01e007'; + tsProjectsState.projects = [createProject()]; + configureBranchBase(baseSha); + mockCountCommitsBetweenRefs.mockRejectedValue(new Error('count failed')); + mockGetAffectedMoonProjectsFromChangedFiles.mockResolvedValue([ + { id: ROOT_MOON_PROJECT_ID, sourceRoot: '.' }, + ]); + mockGetMoonChangedFiles.mockResolvedValue(['tsconfig.base.json']); + + const ctx = createContext({ profile: 'branch' }); + await contractHandler(ctx); + + const scopeMessage = ctx.log.info.mock.calls + .map((call) => call[0]) + .find((message) => message.startsWith('Checking ')); + + expect(scopeMessage).toEqual(expect.any(String)); + expect(scopeMessage as string).toContain( + `between upstream/main (${baseSha.slice(0, 12)}) and HEAD` + ); + expect(scopeMessage as string).toContain('affected by commits'); + }); + + it('prints CI reproduction command with resolved base SHA for copy/paste', async () => { + const baseSha = '2365cc0e7c29d5cc2324cd078a9854866e01e007'; + tsProjectsState.projects = [createProject()]; + mockResolveMoonAffectedBase.mockResolvedValue({ + base: baseSha, + baseRef: 'GITHUB_PR_MERGE_BASE', + }); + mockGetMoonChangedFiles.mockResolvedValue(['packages/foo/src/index.ts']); + mockGetAffectedMoonProjectsFromChangedFiles.mockResolvedValue([ + { id: 'foo', sourceRoot: 'packages/foo' }, + ]); + mockIsCiEnvironment.mockReturnValue(true); + + const ctx = createContext({ profile: 'branch' }); + ctx.procRunner.run.mockRejectedValue(new Error('tsc failed')); + + await expect(contractHandler(ctx)).rejects.toThrow( + `tsc failed. Reproduce this run locally with:\n node scripts/type_check --scope branch --test-mode affected --base-ref ${baseSha}` + ); + }); +}); diff --git a/packages/kbn-ts-type-check-cli/run_type_check_cli.ts b/packages/kbn-ts-type-check-cli/run_type_check_cli.ts index 9869e882a1c92..19c56f0b97a8d 100644 --- a/packages/kbn-ts-type-check-cli/run_type_check_cli.ts +++ b/packages/kbn-ts-type-check-cli/run_type_check_cli.ts @@ -7,238 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import Path from 'path'; -import Fsp from 'fs/promises'; -import { run } from '@kbn/dev-cli-runner'; -import { createFailError } from '@kbn/dev-cli-errors'; -import { REPO_ROOT } from '@kbn/repo-info'; -import { asyncForEachWithLimit, asyncMapWithLimit } from '@kbn/std'; -import type { SomeDevLog } from '@kbn/some-dev-log'; -import type { TsProject } from '@kbn/ts-projects'; -import execa from 'execa'; - -import { archiveTSBuildArtifacts } from './src/archive/archive_ts_build_artifacts'; -import { restoreTSBuildArtifacts } from './src/archive/restore_ts_build_artifacts'; -import { LOCAL_CACHE_ROOT } from './src/archive/constants'; -import { isCiEnvironment } from './src/archive/utils'; -import { normalizeProjectPath } from './src/normalize_project_path'; - -const rel = (from: string, to: string) => { - const path = Path.relative(from, to); - return path.startsWith('.') ? path : `./${path}`; -}; - -async function createTypeCheckConfigs( - log: SomeDevLog, - projects: TsProject[], - allProjects: TsProject[] -) { - const writes: Array<[path: string, content: string]> = []; - - // write tsconfig.type_check.json files for each project that is not the root - const queue = new Set(projects); - for (const project of queue) { - const config = project.config; - const base = project.getBase(); - if (base) { - queue.add(base); - } - - const typeCheckConfig = { - ...config, - extends: base ? rel(project.directory, base.typeCheckConfigPath) : undefined, - compilerOptions: { - ...config.compilerOptions, - composite: true, - rootDir: '.', - noEmit: false, - emitDeclarationOnly: true, - paths: project.repoRel === 'tsconfig.base.json' ? config.compilerOptions?.paths : undefined, - }, - kbn_references: undefined, - references: project.getKbnRefs(allProjects).map((refd) => { - queue.add(refd); - - return { - path: rel(project.directory, refd.typeCheckConfigPath), - }; - }), - }; - - writes.push([project.typeCheckConfigPath, JSON.stringify(typeCheckConfig, null, 2)]); - } - - return new Set( - await asyncMapWithLimit(writes, 50, async ([path, content]) => { - try { - const existing = await Fsp.readFile(path, 'utf8'); - if (existing === content) { - return path; - } - } catch (err) { - if (err.code !== 'ENOENT') { - throw err; - } - } - - log.verbose('updating', path); - await Fsp.writeFile(path, content, 'utf8'); - return path; - }) - ); -} - -async function detectLocalChanges(): Promise { - const { stdout } = await execa( - 'git', - // Some CI environments change these files dynamically, like FIPS but it shouldn't invalidate the cache - ['status', '--porcelain', '--', '.', ':!:config/node.options', ':!config/kibana.yml'], - { - cwd: REPO_ROOT, - } - ); - - return stdout - .split('\n') - .map((line) => line.trimEnd()) - .filter((line) => line.length > 0); -} - -run( - async ({ log, flagsReader, procRunner }) => { - // Lazy-load so --help can run before TS project metadata is available. - const { TS_PROJECTS } = await import('@kbn/ts-projects'); - const shouldCleanCache = flagsReader.boolean('clean-cache'); - const shouldUseArchive = flagsReader.boolean('with-archive'); - - if (shouldCleanCache) { - await asyncForEachWithLimit(TS_PROJECTS, 10, async (proj) => { - await Fsp.rm(Path.resolve(proj.directory, 'target/types'), { - force: true, - recursive: true, - }); - }); - await Fsp.rm(LOCAL_CACHE_ROOT, { - force: true, - recursive: true, - }); - log.warning('Deleted all TypeScript caches'); - return; - } - - const { updateRootRefsConfig, cleanupRootRefsConfig, ROOT_REFS_CONFIG_PATH } = await import( - './root_refs_config' - ); - - // if the tsconfig.refs.json file is not self-managed then make sure it has - // a reference to every composite project in the repo - await updateRootRefsConfig(log); - - if (shouldUseArchive && !shouldCleanCache) { - await restoreTSBuildArtifacts(log); - } else if (shouldCleanCache && shouldUseArchive) { - log.info('Skipping TypeScript cache restore because --clean-cache was provided.'); - } else { - log.verbose('Skipping TypeScript cache restore because --with-archive was not provided.'); - } - - const projectFilter = normalizeProjectPath(flagsReader.path('project'), log); - - const projects = TS_PROJECTS.filter( - (p) => !p.isTypeCheckDisabled() && (!projectFilter || p.path === projectFilter) - ); - - const created = await createTypeCheckConfigs(log, projects, TS_PROJECTS); - - let didTypeCheckFail = false; - try { - log.info( - `Building TypeScript projects to check types (For visible, though excessive, progress info you can pass --verbose)` - ); - - const relative = Path.relative( - REPO_ROOT, - projects.length === 1 ? projects[0].typeCheckConfigPath : ROOT_REFS_CONFIG_PATH - ); - - await procRunner.run('tsc', { - cmd: Path.relative(REPO_ROOT, require.resolve('typescript/bin/tsc')), - args: [ - '-b', - relative, - '--pretty', - ...(flagsReader.boolean('verbose') ? ['--verbose'] : []), - ...(flagsReader.boolean('extended-diagnostics') ? ['--extendedDiagnostics'] : []), - ], - env: { - NODE_OPTIONS: '--max-old-space-size=12288', - }, - cwd: REPO_ROOT, - wait: true, - }); - } catch (error) { - didTypeCheckFail = true; - } - - const localChanges = shouldUseArchive ? await detectLocalChanges() : []; - const hasLocalChanges = localChanges.length > 0; - - if (shouldUseArchive) { - if (hasLocalChanges) { - const changedFiles = localChanges.join('\n'); - const message = `uncommitted changes were detected after the TypeScript build. TypeScript cache artifacts must be generated from a clean working tree.\nChanged files:\n${changedFiles}`; - - if (isCiEnvironment()) { - throw new Error(`Cancelling TypeScript cache archive because ${message}`); - } - - log.info(`Skipping TypeScript cache archive because ${message}`); - } else { - await archiveTSBuildArtifacts(log); - } - } else { - log.verbose('Skipping TypeScript cache archive because --with-archive was not provided.'); - } - - // cleanup if requested - if (flagsReader.boolean('cleanup')) { - log.verbose('cleaning up'); - await cleanupRootRefsConfig(); - - await asyncForEachWithLimit(created, 40, async (path) => { - await Fsp.unlink(path); - }); - } - - if (didTypeCheckFail) { - throw createFailError('Unable to build TS project refs'); - } - }, - { - description: ` - Run the TypeScript compiler without emitting files so that it can check types during development. - - Examples: - # check types in all projects - node scripts/type_check - - # check types in a single project - node scripts/type_check --project packages/kbn-pm/tsconfig.json - `, - flags: { - string: ['project'], - boolean: ['clean-cache', 'cleanup', 'extended-diagnostics', 'with-archive'], - help: ` - --project [path] Path to a tsconfig.json file determines the project to check - --help Show this message - --clean-cache Delete any existing TypeScript caches before running type check - --cleanup Pass to avoid leaving temporary tsconfig files on disk. Leaving these - files in place makes subsequent executions faster because ts can - identify that none of the imports have changed (it uses creation/update - times) but cleaning them prevents leaving garbage around the repo. - --extended-diagnostics Turn on extended diagnostics in the TypeScript compiler - --with-archive Restore cached artifacts before running and archive results afterwards - `, - }, - } -); +export { + executeTypeCheckValidation, + TSC_LABEL, + type ExecuteTypeCheckValidationOptions, + type TscValidationResult, +} from './execute_type_check_validation'; +export { runLegacyTypeCheckCli } from './run_type_check_legacy_cli'; +export { runTypeCheckContractCli } from './run_type_check_contract_cli'; diff --git a/packages/kbn-ts-type-check-cli/run_type_check_contract_cli.ts b/packages/kbn-ts-type-check-cli/run_type_check_contract_cli.ts new file mode 100644 index 0000000000000..0477b3dfa4d7f --- /dev/null +++ b/packages/kbn-ts-type-check-cli/run_type_check_contract_cli.ts @@ -0,0 +1,88 @@ +/* + * 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 { run } from '@kbn/dev-cli-runner'; +import { + readValidationRunFlags, + resolveValidationBaseContext, + VALIDATION_RUN_HELP, + VALIDATION_RUN_STRING_FLAGS, +} from '@kbn/dev-validation-runner'; + +import { cleanTypeCheckCaches, executeTypeCheckValidation } from './execute_type_check_validation'; +import { normalizeProjectPath } from './src/normalize_project_path'; + +/** Runs the validation-contract-aware `scripts/type_check` CLI entrypoint. */ +export const runTypeCheckContractCli = () => { + run( + async ({ log, flagsReader, procRunner }) => { + if (flagsReader.boolean('clean-cache')) { + const { TS_PROJECTS } = await import('@kbn/ts-projects'); + await cleanTypeCheckCaches(log, TS_PROJECTS); + return; + } + + const projectFilter = normalizeProjectPath(flagsReader.path('project'), log); + const validationRunFlags = readValidationRunFlags(flagsReader); + const baseContext = await resolveValidationBaseContext({ + flags: validationRunFlags, + directTarget: projectFilter, + runnerDescription: 'type check', + onWarning: (message) => log.warning(message), + }); + + await executeTypeCheckValidation({ + baseContext, + log, + procRunner, + cleanup: flagsReader.boolean('cleanup'), + extendedDiagnostics: flagsReader.boolean('extended-diagnostics'), + verbose: flagsReader.boolean('verbose'), + withArchive: flagsReader.boolean('with-archive'), + }); + }, + { + description: ` + Run the TypeScript compiler using the shared validation contract to select scoped projects. + + Examples: + # run the quick validation profile + node scripts/type_check --profile quick + + # run the agent validation profile + node scripts/type_check --profile agent + + # run PR-equivalent affected selection + node scripts/type_check --profile pr + + # check all TypeScript projects + node scripts/type_check --profile full + + # branch scope with explicit refs + node scripts/type_check --scope branch --base-ref origin/main --head-ref HEAD + `, + flags: { + string: ['project', ...VALIDATION_RUN_STRING_FLAGS], + boolean: ['clean-cache', 'cleanup', 'extended-diagnostics', 'with-archive'], + help: ` + --project [path] Path to a tsconfig.json file for direct-target execution +${VALIDATION_RUN_HELP} + --help Show this message + --clean-cache Delete any existing TypeScript caches before running type check + --cleanup Pass to avoid leaving temporary tsconfig files on disk. Leaving these + files in place makes subsequent executions faster because ts can + identify that none of the imports have changed (it uses creation/update + times) but cleaning them prevents leaving garbage around the repo. + --extended-diagnostics Turn on extended diagnostics in the TypeScript compiler + --with-archive Restore cached artifacts before running and archive results afterwards + `, + }, + } + ); +}; diff --git a/packages/kbn-ts-type-check-cli/run_type_check_legacy_cli.ts b/packages/kbn-ts-type-check-cli/run_type_check_legacy_cli.ts new file mode 100644 index 0000000000000..af0ffb38a70af --- /dev/null +++ b/packages/kbn-ts-type-check-cli/run_type_check_legacy_cli.ts @@ -0,0 +1,156 @@ +/* + * 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 Fsp from 'fs/promises'; + +import { run } from '@kbn/dev-cli-runner'; +import { createFailError } from '@kbn/dev-cli-errors'; +import { REPO_ROOT } from '@kbn/repo-info'; +import { asyncForEachWithLimit } from '@kbn/std'; + +import { + cleanTypeCheckCaches, + createTypeCheckConfigs, + detectLocalChanges, + TSC_LABEL, +} from './execute_type_check_validation'; +import { archiveTSBuildArtifacts } from './src/archive/archive_ts_build_artifacts'; +import { restoreTSBuildArtifacts } from './src/archive/restore_ts_build_artifacts'; +import { isCiEnvironment } from './src/archive/utils'; +import { normalizeProjectPath } from './src/normalize_project_path'; + +/** Runs the legacy direct-target `scripts/type_check` CLI flow. */ +export const runLegacyTypeCheckCli = () => { + run( + async ({ log, flagsReader, procRunner }) => { + const { TS_PROJECTS } = await import('@kbn/ts-projects'); + const shouldCleanCache = flagsReader.boolean('clean-cache'); + const shouldUseArchive = flagsReader.boolean('with-archive'); + + if (shouldCleanCache) { + await cleanTypeCheckCaches(log, TS_PROJECTS); + return; + } + + const { updateRootRefsConfig, cleanupRootRefsConfig, ROOT_REFS_CONFIG_PATH } = await import( + './root_refs_config' + ); + + // If the tsconfig.refs.json file is not self-managed then make sure it has + // a reference to every composite project in the repo. + await updateRootRefsConfig(log); + + if (shouldUseArchive) { + await restoreTSBuildArtifacts(log); + } else { + log.verbose('Skipping TypeScript cache restore because --with-archive was not provided.'); + } + + const projectFilter = normalizeProjectPath(flagsReader.path('project'), log); + const projects = TS_PROJECTS.filter( + (project) => + !project.isTypeCheckDisabled() && (!projectFilter || project.path === projectFilter) + ); + + const createdConfigs = await createTypeCheckConfigs(log, projects, TS_PROJECTS); + let tscFailed = false; + try { + log.info( + 'Building TypeScript projects to check types (For visible, though excessive, progress info you can pass --verbose)' + ); + + const buildTarget = Path.relative( + REPO_ROOT, + projects.length === 1 ? projects[0].typeCheckConfigPath : ROOT_REFS_CONFIG_PATH + ); + + await procRunner.run(TSC_LABEL, { + cmd: Path.relative(REPO_ROOT, require.resolve('typescript/bin/tsc')), + args: [ + '-b', + buildTarget, + '--pretty', + ...(flagsReader.boolean('verbose') ? ['--verbose'] : []), + ...(flagsReader.boolean('extended-diagnostics') ? ['--extendedDiagnostics'] : []), + ], + env: { + NODE_OPTIONS: '--max-old-space-size=12288', + }, + cwd: REPO_ROOT, + wait: true, + }); + } catch { + tscFailed = true; + } + + try { + const localChanges = shouldUseArchive ? await detectLocalChanges() : []; + const hasLocalChanges = localChanges.length > 0; + + if (shouldUseArchive) { + if (hasLocalChanges) { + const changedFiles = localChanges.join('\n'); + const message = `uncommitted changes were detected after the TypeScript build. TypeScript cache artifacts must be generated from a clean working tree.\nChanged files:\n${changedFiles}`; + + if (isCiEnvironment()) { + throw new Error(`Cancelling TypeScript cache archive because ${message}`); + } + + log.info(`Skipping TypeScript cache archive because ${message}`); + } else { + await archiveTSBuildArtifacts(log); + } + } else { + log.verbose('Skipping TypeScript cache archive because --with-archive was not provided.'); + } + } finally { + if (flagsReader.boolean('cleanup')) { + log.verbose('cleaning up'); + await cleanupRootRefsConfig(); + + await asyncForEachWithLimit(createdConfigs, 40, async (path) => { + await Fsp.unlink(path); + }); + } + } + + if (tscFailed) { + throw createFailError('Unable to build TS project refs'); + } + }, + { + description: ` + Run the TypeScript compiler without emitting files so that it can check types during development. + + Examples: + # check types in all projects + node scripts/type_check + + # check types in a single project + node scripts/type_check --project packages/kbn-pm/tsconfig.json + `, + flags: { + string: ['project'], + boolean: ['clean-cache', 'cleanup', 'extended-diagnostics', 'with-archive'], + help: ` + --project [path] Path to a tsconfig.json file determines the project to check + --help Show this message + --clean-cache Delete any existing TypeScript caches before running type check + --cleanup Pass to avoid leaving temporary tsconfig files on disk. Leaving these + files in place makes subsequent executions faster because ts can + identify that none of the imports have changed (it uses creation/update + times) but cleaning them prevents leaving garbage around the repo. + --extended-diagnostics Turn on extended diagnostics in the TypeScript compiler + --with-archive Restore cached artifacts before running and archive results afterwards + `, + }, + } + ); +}; diff --git a/packages/kbn-ts-type-check-cli/src/normalize_project_path.ts b/packages/kbn-ts-type-check-cli/src/normalize_project_path.ts index c7ceab0338f78..76d8099c738cb 100644 --- a/packages/kbn-ts-type-check-cli/src/normalize_project_path.ts +++ b/packages/kbn-ts-type-check-cli/src/normalize_project_path.ts @@ -14,7 +14,8 @@ import type { SomeDevLog } from '@kbn/some-dev-log'; const TYPE_CHECK_CONFIG_FILENAME = 'tsconfig.type_check.json'; const PROJECT_CONFIG_FILENAME = 'tsconfig.json'; -const formatPathForLog = (path: string) => { +/** Formats absolute paths relative to the repo root when possible for cleaner logs. */ +export const formatPathForLog = (path: string) => { if (!Path.isAbsolute(path)) { return path; } diff --git a/packages/kbn-ts-type-check-cli/tsconfig.json b/packages/kbn-ts-type-check-cli/tsconfig.json index baead4859f5b7..933a9b70360a0 100644 --- a/packages/kbn-ts-type-check-cli/tsconfig.json +++ b/packages/kbn-ts-type-check-cli/tsconfig.json @@ -17,6 +17,10 @@ "@kbn/tooling-log", "@kbn/repo-info", "@kbn/dev-cli-errors", + "@kbn/dev-proc-runner", + "@kbn/dev-validation-runner", + "@kbn/dev-utils", + "@kbn/moon", "@kbn/ts-projects", "@kbn/dev-cli-runner", "@kbn/std", diff --git a/scripts/check.js b/scripts/check.js new file mode 100755 index 0000000000000..832e6e4b19945 --- /dev/null +++ b/scripts/check.js @@ -0,0 +1,12 @@ +#!/usr/bin/env node +/* + * 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". + */ + +require('@kbn/setup-node-env'); +require('../src/dev/run_check'); diff --git a/scripts/jest.js b/scripts/jest.js index 3afc13d30cdb9..4f7fef063a8fc 100755 --- a/scripts/jest.js +++ b/scripts/jest.js @@ -8,4 +8,9 @@ */ require('@kbn/setup-node-env'); -require('@kbn/test').runJest(); + +if (require('@kbn/dev-validation-runner').hasValidationRunFlags(process.argv.slice(2))) { + require('@kbn/test').runJestContract(); +} else { + require('@kbn/test').runJest(); +} diff --git a/scripts/type_check.js b/scripts/type_check.js index 157d4b6d780ca..5749aaafc7290 100644 --- a/scripts/type_check.js +++ b/scripts/type_check.js @@ -8,4 +8,9 @@ */ require('@kbn/setup-node-env'); -require('@kbn/ts-type-check-cli'); + +if (require('@kbn/dev-validation-runner').hasValidationRunFlags(process.argv.slice(2))) { + require('@kbn/ts-type-check-cli').runTypeCheckContractCli(); +} else { + require('@kbn/ts-type-check-cli').runLegacyTypeCheckCli(); +} diff --git a/src/dev/eslint/constants.ts b/src/dev/eslint/constants.ts new file mode 100644 index 0000000000000..38760701a271e --- /dev/null +++ b/src/dev/eslint/constants.ts @@ -0,0 +1,11 @@ +/* + * 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 const LINT_LABEL = 'lint'; +export const LINT_LOG_PREFIX = `[${LINT_LABEL}]`; diff --git a/src/dev/eslint/lint_files.test.ts b/src/dev/eslint/lint_files.test.ts new file mode 100644 index 0000000000000..e2a068a9a7a91 --- /dev/null +++ b/src/dev/eslint/lint_files.test.ts @@ -0,0 +1,167 @@ +/* + * 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 { REPO_ROOT } from '@kbn/repo-info'; +import { ToolingLog } from '@kbn/tooling-log'; + +import { File } from '../file'; + +jest.mock('eslint', () => { + const mockConstructor = jest.fn(); + const mockLintFiles = jest.fn(); + const mockLoadFormatter = jest.fn(); + const mockOutputFixes = jest.fn(); + + return { + ESLint: class ESLint { + static outputFixes = mockOutputFixes; + + constructor(options: unknown) { + mockConstructor(options); + } + + public lintFiles = mockLintFiles; + public loadFormatter = mockLoadFormatter; + }, + __mock: { + mockConstructor, + mockLintFiles, + mockLoadFormatter, + mockOutputFixes, + }, + }; +}); + +import { lintFiles } from './lint_files'; + +const { __mock } = jest.requireMock('eslint') as { + __mock: { + mockConstructor: jest.Mock; + mockLintFiles: jest.Mock; + mockLoadFormatter: jest.Mock; + mockOutputFixes: jest.Mock; + }; +}; +const { mockConstructor, mockLintFiles, mockLoadFormatter, mockOutputFixes } = __mock; + +describe('lintFiles', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockLintFiles.mockResolvedValue([ + { + errorCount: 0, + filePath: `${REPO_ROOT}/src/foo.ts`, + output: 'fixed foo', + warningCount: 0, + }, + { + errorCount: 0, + filePath: `${REPO_ROOT}/src/bar.ts`, + output: undefined, + warningCount: 0, + }, + { + errorCount: 0, + filePath: `${REPO_ROOT}/src/baz.ts`, + output: 'fixed baz', + warningCount: 0, + }, + ]); + mockLoadFormatter.mockResolvedValue({ + format: jest.fn(), + }); + }); + + it('returns and logs the files updated by eslint --fix', async () => { + const log = new ToolingLog(); + jest.spyOn(log, 'info').mockImplementation(() => undefined); + jest.spyOn(log, 'success').mockImplementation(() => undefined); + + const result = await lintFiles( + log, + [ + new File(`${REPO_ROOT}/src/foo.ts`), + new File(`${REPO_ROOT}/src/bar.ts`), + new File(`${REPO_ROOT}/src/baz.ts`), + ], + { fix: true } + ); + + expect(mockConstructor).toHaveBeenCalledWith({ + cache: true, + cwd: REPO_ROOT, + fix: true, + }); + expect(mockOutputFixes).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + fixedFiles: ['src/baz.ts', 'src/foo.ts'], + failedFiles: [], + lintedFileCount: 3, + warningCount: 0, + }); + }); + + it('returns failedFiles when eslint reports errors', async () => { + mockLintFiles.mockResolvedValue([ + { + errorCount: 2, + filePath: `${REPO_ROOT}/src/broken.ts`, + output: undefined, + warningCount: 0, + }, + { + errorCount: 0, + filePath: `${REPO_ROOT}/src/ok.ts`, + output: undefined, + warningCount: 0, + }, + ]); + mockLoadFormatter.mockResolvedValue({ + format: jest.fn().mockReturnValue('error output'), + }); + + const log = new ToolingLog(); + jest.spyOn(log, 'error').mockImplementation(() => undefined); + + const result = await lintFiles( + log, + [new File(`${REPO_ROOT}/src/broken.ts`), new File(`${REPO_ROOT}/src/ok.ts`)], + { fix: false } + ); + + expect(result.failedFiles).toEqual(['src/broken.ts']); + expect(result.lintedFileCount).toBe(2); + }); + + it('counts warnings for files that also have errors', async () => { + mockLintFiles.mockResolvedValue([ + { + errorCount: 1, + filePath: `${REPO_ROOT}/src/mixed.ts`, + output: undefined, + warningCount: 2, + }, + ]); + mockLoadFormatter.mockResolvedValue({ + format: jest.fn().mockReturnValue('mixed output'), + }); + + const log = new ToolingLog(); + jest.spyOn(log, 'error').mockImplementation(() => undefined); + + const result = await lintFiles(log, [new File(`${REPO_ROOT}/src/mixed.ts`)], { fix: false }); + + expect(result).toEqual({ + failedFiles: ['src/mixed.ts'], + fixedFiles: [], + lintedFileCount: 1, + warningCount: 2, + }); + }); +}); diff --git a/src/dev/eslint/lint_files.ts b/src/dev/eslint/lint_files.ts index 88f68cb81a678..67a1933decc7b 100644 --- a/src/dev/eslint/lint_files.ts +++ b/src/dev/eslint/lint_files.ts @@ -7,22 +7,31 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import Path from 'path'; + import { ESLint } from 'eslint'; import { REPO_ROOT } from '@kbn/repo-info'; -import { createFailError } from '@kbn/dev-cli-errors'; import type { ToolingLog } from '@kbn/tooling-log'; import type { File } from '../file'; +import { LINT_LOG_PREFIX } from './constants'; + +export interface LintFilesResult { + fixedFiles: string[]; + failedFiles: string[]; + lintedFileCount: number; + warningCount: number; +} /** - * Lints a list of files with eslint. eslint reports are written to the log - * and a FailError is thrown when linting errors occur. - * - * @param {ToolingLog} log - * @param {Array} files - * @return {undefined} + * Lints a list of files with eslint. Reports are written to the log. + * Returns a result with `failedFiles` populated when errors are found. */ -export async function lintFiles(log: ToolingLog, files: File[], { fix }: { fix?: boolean } = {}) { +export async function lintFiles( + log: ToolingLog, + files: File[], + { fix }: { fix?: boolean } = {} +): Promise { const eslint = new ESLint({ cache: true, cwd: REPO_ROOT, @@ -36,16 +45,29 @@ export async function lintFiles(log: ToolingLog, files: File[], { fix }: { fix?: await ESLint.outputFixes(reports); } + const fixedFiles = fix + ? reports + .filter((report) => report.output !== undefined) + .map((report) => report.filePath) + .map((filePath) => Path.relative(REPO_ROOT, filePath)) + .sort((left, right) => left.localeCompare(right)) + : []; + let foundError = false; let foundWarning = false; - reports.some((report) => { + let warningCount = 0; + const failedFiles: string[] = []; + for (const report of reports) { if (report.errorCount !== 0) { foundError = true; - return true; - } else if (report.warningCount !== 0) { + failedFiles.push(Path.relative(REPO_ROOT, report.filePath)); + } + + if (report.warningCount !== 0) { + warningCount += report.warningCount; foundWarning = true; } - }); + } if (foundError || foundWarning) { const formatter = await eslint.loadFormatter(); @@ -53,9 +75,25 @@ export async function lintFiles(log: ToolingLog, files: File[], { fix }: { fix?: log[foundError ? 'error' : 'warning'](msg); if (foundError) { - throw createFailError(`[eslint] errors`); + log.error(`${LINT_LOG_PREFIX} errors in ${failedFiles.length} file(s)`); + } + } + + if (!foundError) { + log.success(`${LINT_LOG_PREFIX} %d files linted successfully`, files.length); + } + + if (fixedFiles.length > 0) { + log.info(`${LINT_LOG_PREFIX} auto-fixed %d file(s):`, fixedFiles.length); + for (const fixedFile of fixedFiles) { + log.info(' %s', fixedFile); } } - log.success('[eslint] %d files linted successfully', files.length); + return { + fixedFiles, + failedFiles, + lintedFileCount: files.length, + warningCount, + }; } diff --git a/src/dev/eslint/pick_files_to_lint.ts b/src/dev/eslint/pick_files_to_lint.ts index eba942ece3daa..a13ebdd8b3509 100644 --- a/src/dev/eslint/pick_files_to_lint.ts +++ b/src/dev/eslint/pick_files_to_lint.ts @@ -11,6 +11,7 @@ import { ESLint } from 'eslint'; import type { ToolingLog } from '@kbn/tooling-log'; import type { File } from '../file'; +import { LINT_LOG_PREFIX } from './constants'; /** * Filters a list of files to only include lintable files. @@ -29,11 +30,11 @@ export async function pickFilesToLint(log: ToolingLog, files: File[]) { const path = file.getRelativePath(); if (await eslint.isPathIgnored(path)) { - log.warning(`[eslint] %j ignored by .eslintignore`, file); + log.warning(`${LINT_LOG_PREFIX} %j ignored by .eslintignore`, file); continue; } - log.debug('[eslint] linting %j', file); + log.debug(`${LINT_LOG_PREFIX} linting %j`, file); filesToLint.push(file); } diff --git a/src/dev/eslint/run_eslint_contract.ts b/src/dev/eslint/run_eslint_contract.ts new file mode 100644 index 0000000000000..fadd95e1b3297 --- /dev/null +++ b/src/dev/eslint/run_eslint_contract.ts @@ -0,0 +1,184 @@ +/* + * 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 { run } from '@kbn/dev-cli-runner'; +import { createFailError } from '@kbn/dev-cli-errors'; +import { + buildValidationCliArgs, + describeValidationNoTargetsScope, + formatReproductionCommand, + readValidationRunFlags, + resolveValidationBaseContext, + type ValidationBaseContext, + VALIDATION_RUN_HELP, + VALIDATION_RUN_STRING_FLAGS, +} from '@kbn/dev-validation-runner'; +import { getRepoFiles } from '@kbn/get-repo-files'; +import { REPO_ROOT } from '@kbn/repo-info'; +import type { ToolingLog } from '@kbn/tooling-log'; + +import { File } from '../file'; +import { LINT_LABEL } from './constants'; +import { lintFiles, pickFilesToLint } from '.'; + +export interface ExecuteEslintValidationOptions { + baseContext: ValidationBaseContext; + log: ToolingLog; + fix?: boolean; +} + +const isFullEslintRun = (baseContext: ValidationBaseContext) => { + return ( + baseContext.mode === 'contract' && + (baseContext.runContext.kind === 'full' || baseContext.contract.testMode === 'all') + ); +}; + +export interface EslintValidationResult { + fileCount: number; + fixedFiles: string[]; + failedFiles: string[]; + warningCount: number; +} + +/** + * Resolves the ESLint file scope from the shared validation contract and runs + * linting on the selected files. + */ +export const executeEslintValidation = async ({ + baseContext, + log, + fix = false, +}: ExecuteEslintValidationOptions): Promise => { + if (baseContext.mode === 'direct_target') { + throw createFailError( + 'scripts/eslint only supports validation-contract execution. Remove explicit file paths and use --profile/--scope instead.' + ); + } + + const resolvedBase = + baseContext.runContext.kind === 'affected' ? baseContext.runContext.resolvedBase : undefined; + const shouldRunFullRepo = isFullEslintRun(baseContext); + const cliArgs = buildValidationCliArgs({ + contract: baseContext.contract, + resolvedBase, + forceFullProfile: shouldRunFullRepo, + }); + log.info(`Running \`${formatReproductionCommand('eslint', cliArgs.logArgs)}\``); + + if (baseContext.runContext.kind === 'skip') { + log.info( + `No changed files found ${describeValidationNoTargetsScope(baseContext)}; skipping eslint.` + ); + return null; + } + + let changedFiles: string[]; + if (shouldRunFullRepo) { + changedFiles = (await getRepoFiles()).map((file) => file.repoRel); + } else if (baseContext.runContext.kind === 'affected') { + changedFiles = baseContext.runContext.changedFiles; + } else { + changedFiles = []; + } + + if (changedFiles.length === 0) { + log.info( + `No changed files found ${describeValidationNoTargetsScope(baseContext)}; skipping eslint.` + ); + return null; + } + + const filesToLint = await pickFilesToLint( + log, + changedFiles.map((pathValue) => new File(Path.resolve(REPO_ROOT, pathValue))) + ); + + if (filesToLint.length === 0) { + log.info( + `No JS/TS files selected for eslint ${describeValidationNoTargetsScope( + baseContext + )}; skipping eslint.` + ); + return null; + } + + log.info( + `Selected ${filesToLint.length} lintable file(s) from ${changedFiles.length} candidate file(s).` + ); + + const result = await lintFiles(log, filesToLint, { fix }); + return { + fileCount: result.lintedFileCount, + fixedFiles: result.fixedFiles, + failedFiles: result.failedFiles, + warningCount: result.warningCount, + }; +}; + +/** Runs the validation-contract-aware `scripts/eslint` CLI entrypoint. */ +export const runEslintContract = () => { + run( + async ({ log, flags, flagsReader }) => { + if (flags._.length > 0) { + throw createFailError( + 'scripts/eslint only supports validation-contract execution. Remove explicit file paths and use --profile/--scope instead.' + ); + } + + const validationFlags = readValidationRunFlags(flagsReader); + const baseContext = await resolveValidationBaseContext({ + flags: validationFlags, + runnerDescription: 'eslint', + onWarning: (message) => log.warning(message), + }); + + const result = await executeEslintValidation({ + baseContext, + log, + fix: flagsReader.boolean('fix'), + }); + + if (result && result.failedFiles.length > 0) { + throw createFailError(`${LINT_LABEL} errors`); + } + }, + { + description: ` + Run ESLint using the shared validation contract to select scoped files. + + Examples: + # quick local profile + node scripts/eslint --profile quick + + # agent local profile + node scripts/eslint --profile agent + + # PR-equivalent branch scope + node scripts/eslint --profile pr + + # full repository lint + node scripts/eslint --profile full + `, + flags: { + string: [...VALIDATION_RUN_STRING_FLAGS], + boolean: ['fix'], + default: { + fix: true, + }, + help: ` +${VALIDATION_RUN_HELP} + --no-fix Disable lint auto-fix + `, + }, + } + ); +}; diff --git a/src/dev/run_check.test.ts b/src/dev/run_check.test.ts new file mode 100644 index 0000000000000..eb5a3e38ebe41 --- /dev/null +++ b/src/dev/run_check.test.ts @@ -0,0 +1,442 @@ +/* + * 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 Os from 'os'; + +jest.mock('@kbn/dev-cli-runner', () => ({ + run: jest.fn(), +})); + +jest.mock('@kbn/dev-cli-errors', () => ({ + createFailError: (message: string) => new Error(message), +})); + +jest.mock('@kbn/dev-validation-runner', () => ({ + readValidationRunFlags: jest.fn(), + resolveValidationBaseContext: jest.fn(), + VALIDATION_RUN_HELP: '', + VALIDATION_RUN_STRING_FLAGS: [], +})); + +jest.mock('@kbn/moon', () => ({ + getMoonExecutablePath: jest.fn().mockResolvedValue('/mock/moon'), +})); + +jest.mock('@kbn/repo-info', () => ({ + REPO_ROOT: '/repo', +})); + +jest.mock('./type_check_validation_loader', () => ({ + executeTypeCheckValidation: jest.fn(), +})); + +jest.mock('./eslint/run_eslint_contract', () => ({ + executeEslintValidation: jest.fn(), +})); + +jest.mock('@kbn/dev-proc-runner', () => ({ + ProcRunner: jest.fn().mockImplementation(() => ({ + teardown: jest.fn(), + })), +})); + +jest.mock('@kbn/tooling-log', () => ({ + ToolingLog: jest.fn().mockImplementation(() => ({ + writers: [] as Array<{ write: (msg: { args: unknown[]; type: string }) => boolean }>, + setWriters: jest.fn(function (this: { writers: unknown[] }, writers: unknown[]) { + this.writers = writers; + }), + })), +})); + +jest.mock('@kbn/repo-packages', () => ({ + getPackages: jest.fn(), +})); + +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + existsSync: jest.fn(), +})); + +const mockExecaFn = jest.fn(); +jest.mock('execa', () => ({ __esModule: true, default: mockExecaFn })); + +const mockRun = jest.requireMock('@kbn/dev-cli-runner').run as jest.Mock; +const mockReadValidationRunFlags = jest.requireMock('@kbn/dev-validation-runner') + .readValidationRunFlags as jest.Mock; +const mockResolveValidationBaseContext = jest.requireMock('@kbn/dev-validation-runner') + .resolveValidationBaseContext as jest.Mock; +const mockExecuteTypeCheckValidation = jest.requireMock('./type_check_validation_loader') + .executeTypeCheckValidation as jest.Mock; +const mockExecuteEslintValidation = jest.requireMock('./eslint/run_eslint_contract') + .executeEslintValidation as jest.Mock; +const mockGetPackages = jest.requireMock('@kbn/repo-packages').getPackages as jest.Mock; +const mockExistsSync = jest.requireMock('fs').existsSync as jest.Mock; +const mockExeca = mockExecaFn; + +let handler: (args: { + flags: { + fix: boolean; + }; + flagsReader: { + boolean: (name: string) => boolean; + }; +}) => Promise; + +const createArgs = (overrides: { fix?: boolean; verbose?: boolean } = {}) => ({ + flags: { + fix: overrides.fix ?? true, + }, + flagsReader: { + boolean: (name: string) => { + if (name === 'fix') { + return overrides.fix ?? true; + } + + return name === 'verbose' ? overrides.verbose ?? false : false; + }, + }, +}); + +const baseContext = { + mode: 'contract' as const, + contract: { + profile: 'quick', + scope: 'local', + testMode: 'related', + downstream: 'none', + }, + runContext: { + kind: 'affected' as const, + contract: { + profile: 'quick', + scope: 'local', + testMode: 'related', + downstream: 'none', + }, + resolvedBase: undefined, + changedFiles: ['packages/foo/src/index.ts'], + }, +}; + +describe('run_check', () => { + let stdoutSpy: jest.SpyInstance; + let cpusSpy: jest.SpyInstance; + let previousExitCode: typeof process.exitCode; + + beforeAll(() => { + require('./run_check'); + handler = mockRun.mock.calls[0][0]; + }); + + beforeEach(() => { + jest.clearAllMocks(); + previousExitCode = process.exitCode; + process.exitCode = undefined; + stdoutSpy = jest.spyOn(process.stdout, 'write').mockReturnValue(true); + cpusSpy = jest.spyOn(Os, 'cpus').mockReturnValue(new Array(8).fill({}) as any); + mockReadValidationRunFlags.mockReturnValue({}); + mockResolveValidationBaseContext.mockResolvedValue(baseContext); + mockExistsSync.mockImplementation( + (p: string) => + p === '/repo/packages/foo/jest.config.js' || p === '/repo/packages/bar/jest.config.js' + ); + mockExecuteEslintValidation.mockResolvedValue({ + fileCount: 3, + fixedFiles: [], + failedFiles: [], + warningCount: 0, + }); + mockExecuteTypeCheckValidation.mockResolvedValue({ projectCount: 2 }); + mockGetPackages.mockReturnValue([ + { directory: '/repo/packages/foo' }, + { directory: '/repo/packages/bar' }, + ]); + + // Mock Moon jest run (success, 1 config ran, 5 tests via --json) + mockExeca.mockResolvedValue({ + exitCode: 0, + stdout: [ + 'pass RunTask(@kbn/foo:jest) (1s 200ms, abc123)', + '@kbn/foo:jest | {"success":true,"numTotalTests":5,"numPassedTests":5,"numFailedTests":0,"testResults":[]}', + ].join('\n'), + stderr: '', + }); + }); + + afterEach(() => { + process.exitCode = previousExitCode; + cpusSpy.mockRestore(); + stdoutSpy.mockRestore(); + }); + + it('prints compact output for successful checks', async () => { + await handler(createArgs()); + + const output = stdoutSpy.mock.calls.map(([text]: [string]) => text).join(''); + expect(output).toContain('check scope=local'); + expect(output).toContain('lint ✓ 3 files'); + expect(output).toContain('jest ✓ 1 config ran, 5 tests'); + expect(output).toContain('tsc ✓ 2 projects'); + }); + + it('prints skip lines when validators return null and no changed files', async () => { + mockExecuteEslintValidation.mockResolvedValue(null); + mockExecuteTypeCheckValidation.mockResolvedValue(null); + mockResolveValidationBaseContext.mockResolvedValue({ + ...baseContext, + runContext: { + ...baseContext.runContext, + changedFiles: [], + }, + }); + + await handler(createArgs()); + + const output = stdoutSpy.mock.calls.map(([text]: [string]) => text).join(''); + expect(output).toContain('lint — no files changed'); + expect(output).toContain('jest — no changed files'); + expect(output).toContain('tsc — no affected projects'); + }); + + it('prints validation warnings before check results', async () => { + mockResolveValidationBaseContext.mockImplementation(async ({ onWarning }) => { + onWarning?.('affected file detection is unavailable in a shallow repository'); + onWarning?.('run `git fetch --unshallow`'); + return { + ...baseContext, + runContext: { + ...baseContext.runContext, + changedFiles: [], + }, + }; + }); + mockExecuteEslintValidation.mockResolvedValue(null); + mockExecuteTypeCheckValidation.mockResolvedValue(null); + + await handler(createArgs()); + + const output = stdoutSpy.mock.calls.map(([text]: [string]) => text).join(''); + expect(output).toContain( + 'warn ! affected file detection is unavailable in a shallow repository' + ); + expect(output).toContain('warn ! run `git fetch --unshallow`'); + expect(output).toContain('jest — no changed files'); + }); + + it('continues running checks and reports all failures', async () => { + mockExecuteEslintValidation.mockResolvedValue({ + fileCount: 3, + fixedFiles: [], + failedFiles: ['src/foo.ts'], + warningCount: 0, + }); + mockExecuteTypeCheckValidation.mockRejectedValue(new Error('tsc error')); + + await handler(createArgs()); + + expect(process.exitCode).toBe(1); + const output = stdoutSpy.mock.calls.map(([text]: [string]) => text).join(''); + expect(output).toContain('lint ✗ failed'); + expect(output).toContain('node scripts/eslint src/foo.ts'); + expect(output).toContain('jest ✓ 1 config ran, 5 tests'); + expect(output).toContain('tsc ✗ failed'); + }); + + it('shows fixed file count when eslint auto-fixes', async () => { + mockExecuteEslintValidation.mockResolvedValue({ + fileCount: 10, + fixedFiles: ['a.ts', 'b.ts'], + failedFiles: [], + warningCount: 0, + }); + + await handler(createArgs()); + + const output = stdoutSpy.mock.calls.map(([text]: [string]) => text).join(''); + expect(output).toContain('lint ✓ 10 files (fixed 2 files)'); + }); + + it('uses test-only fast path when all changed files are test files', async () => { + mockResolveValidationBaseContext.mockResolvedValue({ + ...baseContext, + runContext: { + ...baseContext.runContext, + changedFiles: ['packages/foo/src/bar.test.ts', 'packages/foo/src/baz.test.ts'], + }, + }); + + mockExeca.mockResolvedValue({ + exitCode: 0, + stdout: 'Tests: 8 passed, 8 total\n', + stderr: '', + }); + + await handler(createArgs()); + + const output = stdoutSpy.mock.calls.map(([text]: [string]) => text).join(''); + expect(output).toContain('jest ✓ 2 test files · 8 tests'); + }); + + it('shows failing fast-path Jest output and a minimal rerun command', async () => { + mockResolveValidationBaseContext.mockResolvedValue({ + ...baseContext, + runContext: { + ...baseContext.runContext, + changedFiles: ['packages/foo/src/bar.test.ts'], + }, + }); + + mockExeca.mockResolvedValue({ + exitCode: 1, + stdout: '', + stderr: [ + 'FAIL packages/foo/src/bar.test.ts', + ' Example failure', + 'Tests: 1 failed, 1 total', + ].join('\n'), + }); + + await handler(createArgs()); + + expect(process.exitCode).toBe(1); + const output = stdoutSpy.mock.calls.map(([text]: [string]) => text).join(''); + expect(output).toContain('jest ✗ failed'); + expect(output).toContain('FAIL packages/foo/src/bar.test.ts'); + expect(output).toContain('node scripts/jest packages/foo/src/bar.test.ts'); + }); + + it('still invokes Moon for downstream Jest tasks when the changed package has no local config', async () => { + mockGetPackages.mockReturnValue([ + { directory: '/repo/packages/baz' }, + { directory: '/repo/packages/foo' }, + ]); + mockResolveValidationBaseContext.mockResolvedValue({ + ...baseContext, + contract: { + ...baseContext.contract, + downstream: 'deep', + }, + runContext: { + ...baseContext.runContext, + contract: { + ...baseContext.runContext.contract, + downstream: 'deep', + }, + changedFiles: ['packages/baz/src/index.ts'], + }, + }); + + await handler(createArgs()); + + expect(mockExeca).toHaveBeenCalledWith( + '/mock/moon', + expect.arrayContaining(['--affected', '--stdin', '--downstream', 'deep']), + expect.any(Object) + ); + + const output = stdoutSpy.mock.calls.map(([text]: [string]) => text).join(''); + expect(output).toContain('jest ✓ 1 config ran, 5 tests'); + }); + + it('caps Moon concurrency at two for cache-heavy affected Jest runs', async () => { + mockGetPackages.mockReturnValue([ + { directory: '/repo/packages/foo' }, + { directory: '/repo/packages/bar' }, + { directory: '/repo/packages/baz' }, + { directory: '/repo/packages/qux' }, + ]); + mockResolveValidationBaseContext.mockResolvedValue({ + ...baseContext, + runContext: { + ...baseContext.runContext, + changedFiles: [ + 'packages/foo/src/index.ts', + 'packages/bar/src/index.ts', + 'packages/baz/src/index.ts', + 'packages/qux/src/index.ts', + ], + }, + }); + + await handler(createArgs()); + + expect(mockExeca).toHaveBeenCalledWith( + '/mock/moon', + expect.arrayContaining(['--concurrency', '2', '--maxWorkers=4']), + expect.any(Object) + ); + }); + + it('reports Moon Jest startup failures instead of saying no affected configs', async () => { + mockExeca.mockResolvedValue({ + exitCode: 1, + stdout: '', + stderr: [ + '@kbn/foo:jest | Error: task_runner::run_failed', + '@kbn/foo:jest | Broken startup before Jest JSON output', + 'Error: task_runner::run_failed', + ].join('\n'), + }); + + await handler(createArgs()); + + expect(process.exitCode).toBe(1); + const output = stdoutSpy.mock.calls.map(([text]: [string]) => text).join(''); + expect(output).toContain('jest ✗ failed'); + expect(output).toContain('Broken startup before Jest JSON output'); + expect(output).not.toContain('jest — no affected configs'); + }); + + it('uses the repo root Jest config when no package-level config is found', async () => { + mockExistsSync.mockImplementation((p: string) => p === '/repo/jest.config.js'); + mockResolveValidationBaseContext.mockResolvedValue({ + ...baseContext, + runContext: { + ...baseContext.runContext, + changedFiles: ['packages/foo/src/index.ts'], + }, + }); + mockExeca.mockResolvedValue({ + exitCode: 0, + stdout: [ + 'fail RunTask(@kbn/foo:jest) (1s 200ms, abc123)', + '@kbn/foo:jest | {"success":false,"numTotalTests":1,"numPassedTests":0,"numFailedTests":1,"testResults":[{"name":"/repo/packages/foo/src/bar.test.ts","assertionResults":[{"status":"failed","fullName":"fails","failureMessages":["Error\\n at /repo/packages/foo/src/bar.test.ts:12:3"]}]}]}', + ].join('\n'), + stderr: '', + }); + + await handler(createArgs()); + + expect(process.exitCode).toBe(1); + const output = stdoutSpy.mock.calls.map(([text]: [string]) => text).join(''); + expect(output).toContain('node scripts/jest --config jest.config.js'); + }); + + it('uses the repo root tsconfig in the tsc rerun command when no nearer config exists', async () => { + mockExistsSync.mockImplementation( + (p: string) => p === '/repo/packages/foo/jest.config.js' || p === '/repo/tsconfig.json' + ); + mockExecuteTypeCheckValidation.mockImplementation(async ({ log }) => { + log.writers[0].write({ + args: ['proc [tsc] src/dev/run_check.ts(852,13): error TS1234: broken'], + type: 'error', + }); + throw new Error('tsc error'); + }); + + await handler(createArgs()); + + expect(process.exitCode).toBe(1); + const output = stdoutSpy.mock.calls.map(([text]: [string]) => text).join(''); + expect(output).toContain('src/dev/run_check.ts:852:13: error TS1234: broken'); + expect(output).toContain('node scripts/type_check --project tsconfig.json'); + expect(output).not.toContain('node scripts/type_check --profile quick'); + }); +}); diff --git a/src/dev/run_check.ts b/src/dev/run_check.ts new file mode 100644 index 0000000000000..da711a0bcd4c2 --- /dev/null +++ b/src/dev/run_check.ts @@ -0,0 +1,918 @@ +/* + * 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 Os from 'os'; +import Path from 'path'; + +import { run } from '@kbn/dev-cli-runner'; +import { ProcRunner } from '@kbn/dev-proc-runner'; +import { + readValidationRunFlags, + resolveValidationBaseContext, + type ValidationBaseContext, + VALIDATION_RUN_HELP, + VALIDATION_RUN_STRING_FLAGS, +} from '@kbn/dev-validation-runner'; +import { getMoonExecutablePath } from '@kbn/moon'; +import { REPO_ROOT } from '@kbn/repo-info'; +import { ToolingLog, type Message, type Writer } from '@kbn/tooling-log'; +import { executeTypeCheckValidation } from './type_check_validation_loader'; + +import { executeEslintValidation } from './eslint/run_eslint_contract'; + +// ── Output helpers ────────────────────────────────────────────────────────── + +const isTTY = process.stdout.isTTY === true; +const write = (text: string) => process.stdout.write(text); +const writeln = (text: string) => write(text + '\n'); +const clearLine = () => write('\r\x1B[2K'); + +const formatDuration = (ms: number) => + ms < 1_000 ? `${Math.round(ms)}ms` : `${(ms / 1_000).toFixed(1)}s`; + +const pluralize = (count: number, singular: string) => + `${count} ${singular}${count === 1 ? '' : 's'}`; + +const pad = (label: string) => label.padEnd(6); + +const line = (label: string, symbol: string, detail: string, duration?: string) => + ` ${pad(label)}${symbol} ${detail}${duration ? ` ${duration}` : ''}`; + +/** Start a ticking progress line. Returns { stop, writeResult } to finalize. */ +const startProgress = (label: string, detail?: string) => { + const start = Date.now(); + let msg = detail ? `${detail} ...` : '...'; + + const render = () => { + write(` ${pad(label)}${msg}`); + }; + + if (isTTY) { + render(); + } + + const timer = isTTY + ? setInterval(() => { + clearLine(); + render(); + write(` ${formatDuration(Date.now() - start)}`); + }, 1_000) + : undefined; + + return { + elapsed: () => formatDuration(Date.now() - start), + setDetail: (detailText?: string) => { + msg = detailText ? `${detailText} ...` : '...'; + if (isTTY) { + clearLine(); + render(); + } + }, + writeResult: (text: string) => { + if (timer) clearInterval(timer); + if (isTTY) clearLine(); + writeln(text); + }, + }; +}; + +// ── Silent logging ────────────────────────────────────────────────────────── + +const createCapturingWriter = (): { writer: Writer; captured: string[] } => { + const captured: string[] = []; + return { + captured, + writer: { + write(msg: Message) { + const text = msg.args.map(String).join(' ').trim(); + if (text && (msg.type === 'error' || msg.type === 'warning' || msg.type === 'write')) { + captured.push(text); + } + return true; + }, + }, + }; +}; + +const createSilentLog = () => { + const { writer, captured } = createCapturingWriter(); + const log = new ToolingLog(); + log.setWriters([writer]); + return { log, captured }; +}; + +// ── Header ────────────────────────────────────────────────────────────────── + +const formatHeader = (baseContext: ValidationBaseContext) => { + if (baseContext.mode === 'direct_target') { + return `check target=${baseContext.directTarget}`; + } + + const { contract, runContext } = baseContext; + + if (runContext.kind === 'affected' && runContext.resolvedBase && contract.scope === 'branch') { + const base = runContext.resolvedBase.baseRef; + const sha = runContext.resolvedBase.base.slice(0, 12); + const commits = + runContext.branchCommitCount !== undefined ? ` ${runContext.branchCommitCount} commits` : ''; + return `check branch ${base} (${sha})..HEAD${commits}`; + } + + return `check scope=${contract.scope}`; +}; + +// ── Jest via Moon ─────────────────────────────────────────────────────────── + +const JEST_CONFIG_NAMES = [ + 'jest.config.dev.js', + 'jest.config.js', + 'jest.config.cjs', + 'jest.config.mjs', + 'jest.config.ts', + 'jest.config.json', +] as const; + +const TEST_FILE_RE = /\.(?:test|spec)\.(js|mjs|ts|tsx)$/; + +const isTestFile = (filePath: string) => TEST_FILE_RE.test(filePath); + +interface JestFailedTest { + file: string; + line?: number; + name: string; + message: string; +} + +interface MoonJestTaskResult { + project: string; + configPath?: string; + cached: boolean; + passed: boolean; + testCount: number; + failures: JestFailedTest[]; +} + +const stripAnsi = (s: string) => s.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ''); + +const findJestConfig = (testFilePath: string): string | undefined => { + let dir = Path.dirname(Path.resolve(REPO_ROOT, testFilePath)); + while (true) { + for (const configName of JEST_CONFIG_NAMES) { + const configPath = Path.join(dir, configName); + if (existsSync(configPath)) { + return Path.relative(REPO_ROOT, configPath); + } + } + + if (dir === REPO_ROOT || dir === Path.dirname(dir)) { + break; + } + + dir = Path.dirname(dir); + } + return undefined; +}; + +interface MoonJestParseResult { + tasks: MoonJestTaskResult[]; + parseFailures: string[]; +} + +const parseMoonJestOutput = (output: string): MoonJestParseResult => { + const results = new Map(); + const cachedProjects = new Set(); + const parseFailures: string[] = []; + + for (const rawLine of output.split('\n')) { + const stripped = stripAnsi(rawLine).trim(); + + // "pass RunTask(@kbn/foo:jest) (cached, ...)" from --summary detailed + const cachedMatch = stripped.match(/^pass RunTask\((@[^:]+):jest\) \(cached/); + if (cachedMatch) { + cachedProjects.add(cachedMatch[1]); + continue; + } + + // Jest --json output: either prefixed "@kbn/foo:jest | {...}" or unprefixed "{...}" + const jsonPrefixMatch = stripped.match(/^(@[^:]+):jest \| \{/); + const isUnprefixedJson = !jsonPrefixMatch && stripped.startsWith('{"num'); + if (jsonPrefixMatch || isUnprefixedJson) { + const project = jsonPrefixMatch ? jsonPrefixMatch[1] : '_single'; + const jsonStr = stripped.replace(/^[^{]*/, ''); + try { + const json = JSON.parse(jsonStr); + const failures: JestFailedTest[] = []; + + for (const suite of json.testResults ?? []) { + const file = Path.relative(REPO_ROOT, suite.name); + for (const assertion of suite.assertionResults ?? []) { + if (assertion.status === 'failed') { + const message = (assertion.failureMessages ?? []).join('\n'); + const lineMatch = stripAnsi(message).match( + new RegExp(`${suite.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:(\\d+)`) + ); + failures.push({ + file, + line: lineMatch ? parseInt(lineMatch[1], 10) : undefined, + name: assertion.fullName ?? assertion.title ?? 'unknown', + message, + }); + } + } + } + + const firstTestFile = json.testResults?.[0]?.name; + results.set(project, { + project, + configPath: firstTestFile + ? findJestConfig(Path.relative(REPO_ROOT, firstTestFile)) + : undefined, + cached: false, + passed: json.success === true, + testCount: json.numTotalTests ?? 0, + failures, + }); + } catch (err) { + parseFailures.push( + `Failed to parse Jest JSON from project ${project}: ${ + err instanceof Error ? err.message : String(err) + }` + ); + } + } + } + + // Mark cached tasks (they replay JSON output, so they'll be in results already) + for (const project of cachedProjects) { + const existing = results.get(project); + if (existing) { + existing.cached = true; + } else { + results.set(project, { + project, + cached: true, + passed: true, + testCount: 0, + failures: [], + }); + } + } + + return { tasks: [...results.values()], parseFailures }; +}; + +interface AffectedPackageInfo { + dirs: string[]; + jestDirs: string[]; +} + +/** Find the owning package directory for a file (longest prefix match). */ +const findOwningPackage = (file: string, dirs: string[]): string | undefined => { + let best: string | undefined; + for (const dir of dirs) { + if (file.startsWith(dir + '/') && (!best || dir.length > best.length)) { + best = dir; + } + } + return best; +}; + +const resolveAffectedPackageInfo = async (files: string[]): Promise => { + const { getPackages } = await import('@kbn/repo-packages'); + const sortedDirs = getPackages(REPO_ROOT) + .map((pkg) => Path.relative(REPO_ROOT, pkg.directory)) + .sort(); + + const matched = new Set(); + for (const file of files) { + const owner = findOwningPackage(file, sortedDirs); + if (owner) { + matched.add(owner); + } + } + + const dirs = [...matched]; + const jestDirs = dirs.filter((dir) => + JEST_CONFIG_NAMES.some((name) => existsSync(Path.resolve(REPO_ROOT, dir, name))) + ); + + return { dirs, jestDirs }; +}; + +/** + * Keep Moon concurrency low so cache-heavy affected runs do not starve the real + * Jest work of CPU. Example: if 4 configs are scheduled and 3 are cached, we + * want the 1 uncached Jest process to keep most cores instead of reserving them + * for Moon slots that finish immediately. + * + * - maxWorkers never drops below 2 + * - Moon concurrency caps at 2 + */ +const computeJestParallelism = (estimatedTasks: number) => { + const cpus = Os.cpus().length; + const safeEstimatedTasks = Math.max(1, estimatedTasks); + const concurrency = Math.min(safeEstimatedTasks, 2); + const maxWorkers = Math.max(2, Math.floor(cpus / concurrency)); + return { concurrency, maxWorkers }; +}; + +interface JestPhaseResult { + taskCount: number; + cachedCount: number; + totalTests: number; + failed: MoonJestTaskResult[]; + exitCode: number; + verboseDetail?: string; + failureExcerpt?: string[]; + warnings?: string[]; +} + +interface JestPhaseProgress { + completedCount: number; +} + +const extractMoonFailureExcerpt = (output: string) => { + return output + .split('\n') + .map((lineText) => stripAnsi(lineText).trim()) + .filter(Boolean) + .filter((lineText) => !lineText.startsWith('{')) + .slice(-8); +}; + +const parseMoonJestProgressProject = (rawLine: string) => { + const stripped = stripAnsi(rawLine).trim(); + + const cachedSummaryMatch = stripped.match(/^pass RunTask\((@[^:]+):jest\) \(cached/); + if (cachedSummaryMatch) { + return cachedSummaryMatch[1]; + } + + const summaryMatch = stripped.match(/^(?:pass|fail) RunTask\((@[^:]+):jest\) \(/); + if (summaryMatch) { + return summaryMatch[1]; + } + + const jsonPrefixMatch = stripped.match(/^(@[^:]+):jest \| \{/); + if (jsonPrefixMatch) { + return jsonPrefixMatch[1]; + } + + return undefined; +}; + +const attachOutputLineListeners = ({ + stream, + onChunk, + onLine, +}: { + stream?: NodeJS.ReadableStream | null; + onChunk: (chunk: string) => void; + onLine: (line: string) => void; +}) => { + if (!stream) { + return; + } + + let pending = ''; + stream.on('data', (chunk) => { + const text = chunk.toString(); + onChunk(text); + pending += text; + + while (true) { + const newlineIndex = pending.indexOf('\n'); + if (newlineIndex === -1) { + break; + } + + onLine(pending.slice(0, newlineIndex)); + pending = pending.slice(newlineIndex + 1); + } + }); + + stream.on('end', () => { + if (pending.length > 0) { + onLine(pending); + } + }); +}; + +const runJestViaMoon = async ( + changedFiles: string[], + verbose: boolean, + downstream: string = 'none', + onProgress?: (progress: JestPhaseProgress) => void +): Promise => { + const packageInfo = await resolveAffectedPackageInfo(changedFiles); + const changedFilesJson = JSON.stringify({ files: changedFiles }); + + const execa = (await import('execa')).default; + const moonExec = await getMoonExecutablePath(); + const { concurrency, maxWorkers } = computeJestParallelism( + Math.max(packageInfo.jestDirs.length, packageInfo.dirs.length) + ); + const cpus = Os.cpus().length; + const completedProjects = new Set(); + + const subprocess = execa( + moonExec, + [ + 'run', + ':jest', + '--affected', + '--stdin', + ...(downstream !== 'none' ? ['--downstream', downstream] : []), + '--concurrency', + String(concurrency), + '--summary', + 'detailed', + '--', + `--maxWorkers=${maxWorkers}`, + '--json', + '--passWithNoTests', + ], + { + cwd: REPO_ROOT, + input: changedFilesJson, + reject: false, + env: { + ...process.env, + CI_STATS_DISABLED: 'true', + FORCE_COLOR: '1', + }, + } + ); + + let capturedStdout = ''; + let capturedStderr = ''; + + const handleOutputLine = (lineText: string) => { + const project = parseMoonJestProgressProject(lineText); + if (!project || completedProjects.has(project)) { + return; + } + + completedProjects.add(project); + onProgress?.({ + completedCount: completedProjects.size, + }); + }; + + attachOutputLineListeners({ + stream: 'stdout' in subprocess ? subprocess.stdout : undefined, + onChunk: (chunk) => { + capturedStdout += chunk; + }, + onLine: handleOutputLine, + }); + attachOutputLineListeners({ + stream: 'stderr' in subprocess ? subprocess.stderr : undefined, + onChunk: (chunk) => { + capturedStderr += chunk; + }, + onLine: handleOutputLine, + }); + + const result = await subprocess; + + const output = + (capturedStdout || result.stdout || '') + '\n' + (capturedStderr || result.stderr || ''); + const { tasks, parseFailures } = parseMoonJestOutput(output); + const failed = tasks.filter((t) => !t.passed && !t.cached); + if (tasks.length === 0 && result.exitCode !== 0) { + const warnings = [ + `Moon exited with code ${result.exitCode} but no Jest task output was parsed. ` + + `The Jest task may have failed before producing JSON output — run jest directly to verify.`, + ]; + if (parseFailures.length > 0) { + warnings.push(...parseFailures); + } + + const cachedCount = tasks.filter((t) => t.cached).length; + const ranCount = tasks.length - cachedCount; + + return { + taskCount: tasks.length, + cachedCount, + totalTests: tasks.reduce((sum, t) => sum + t.testCount, 0), + failed, + exitCode: result.exitCode ?? 0, + failureExcerpt: + tasks.length === 0 && result.exitCode !== 0 ? extractMoonFailureExcerpt(output) : undefined, + warnings, + verboseDetail: verbose + ? `${packageInfo.jestDirs.length} packages, ${ranCount} ran, ${cachedCount} cached, ${cpus} cpus → concurrency=${concurrency}, maxWorkers=${maxWorkers}` + : undefined, + }; + } + + const warnings = parseFailures.length > 0 ? parseFailures : undefined; + const cachedCount = tasks.filter((t) => t.cached).length; + const ranCount = tasks.length - cachedCount; + + return { + taskCount: tasks.length, + cachedCount, + totalTests: tasks.reduce((sum, t) => sum + t.testCount, 0), + failed, + exitCode: result.exitCode ?? 0, + failureExcerpt: + tasks.length === 0 && result.exitCode !== 0 ? extractMoonFailureExcerpt(output) : undefined, + warnings, + verboseDetail: verbose + ? `${packageInfo.jestDirs.length} packages, ${ranCount} ran, ${cachedCount} cached, ${cpus} cpus → concurrency=${concurrency}, maxWorkers=${maxWorkers}` + : undefined, + }; +}; + +const runJestTestsDirectly = async ( + testFiles: string[] +): Promise<{ testCount: number; passed: boolean; output: string }> => { + const execa = (await import('execa')).default; + const absolutePaths = testFiles.map((f) => Path.resolve(REPO_ROOT, f)); + + const result = await execa( + process.execPath, + ['scripts/jest', '--runTestsByPath', ...absolutePaths, '--maxWorkers=2'], + { + cwd: REPO_ROOT, + reject: false, + } + ); + + const output = [result.stdout, result.stderr].filter(Boolean).join('\n'); + const testsMatch = output.match(/Tests:\s+.*?(\d+) total/); + const testCount = testsMatch ? parseInt(testsMatch[1], 10) : 0; + + return { + testCount, + passed: result.exitCode === 0, + output, + }; +}; + +// ── Main ──────────────────────────────────────────────────────────────────── + +run( + async ({ flagsReader }) => { + const verbose = flagsReader.boolean('verbose'); + const fix = flagsReader.boolean('fix'); + const parsedValidationFlags = readValidationRunFlags(flagsReader); + const hasValidationFlags = Object.values(parsedValidationFlags).some( + (value) => value !== undefined + ); + const validationFlags = hasValidationFlags + ? parsedValidationFlags + : { ...parsedValidationFlags, profile: 'quick' }; + + const profile = validationFlags.profile ?? 'quick'; + if (isTTY) { + write(`check profile=${profile} ...`); + } + + const warnings: string[] = []; + const baseContext = await resolveValidationBaseContext({ + flags: validationFlags, + runnerDescription: 'check', + onWarning: (message) => warnings.push(message), + }); + + if (isTTY) { + clearLine(); + } + writeln(formatHeader(baseContext)); + for (const warning of warnings) { + writeln(line('warn', '!', warning)); + } + + const errors: Error[] = []; + + // Resolve changed files once for all checks. + const changedFiles = + baseContext.mode === 'contract' && baseContext.runContext.kind === 'affected' + ? baseContext.runContext.changedFiles + : []; + const isSkipOrFull = + baseContext.mode === 'contract' && + (baseContext.runContext.kind === 'skip' || + baseContext.runContext.kind === 'full' || + baseContext.contract.testMode === 'all'); + + // ── lint ─────────────────────────────────────────────────────────── + + { + const { log, captured } = createSilentLog(); + const progress = startProgress('lint'); + try { + const result = await executeEslintValidation({ baseContext, log, fix }); + if (!result) { + progress.writeResult(line('lint', '—', 'no files changed')); + } else if (result.failedFiles.length > 0) { + progress.writeResult(line('lint', '✗', 'failed', progress.elapsed())); + if (captured.length > 0) { + writeln(''); + for (const msg of captured) writeln(` ${msg}`); + } + writeln(` $ node scripts/eslint ${result.failedFiles.join(' ')}`); + writeln(''); + errors.push(new Error('eslint failed')); + } else { + const notes: string[] = []; + if (result.fixedFiles.length > 0) { + notes.push(`fixed ${pluralize(result.fixedFiles.length, 'file')}`); + } + if (result.warningCount > 0) { + notes.push(pluralize(result.warningCount, 'warning')); + } + const suffix = notes.length > 0 ? ` (${notes.join(', ')})` : ''; + progress.writeResult( + line( + 'lint', + result.warningCount > 0 ? '⚠' : '✓', + `${pluralize(result.fileCount, 'file')}${suffix}`, + progress.elapsed() + ) + ); + if (verbose && result.fixedFiles.length > 0) { + for (const file of result.fixedFiles) { + writeln(` fixed: ${file}`); + } + } + } + } catch (error) { + progress.writeResult(line('lint', '✗', 'failed', progress.elapsed())); + errors.push(error instanceof Error ? error : new Error(String(error))); + } + } + + // ── jest ─────────────────────────────────────────────────────────── + + { + const jestProgress = startProgress('jest'); + + if (isSkipOrFull) { + jestProgress.writeResult( + line('jest', '—', 'skipped (use scripts/jest directly)', jestProgress.elapsed()) + ); + } else if (changedFiles.length === 0) { + jestProgress.writeResult(line('jest', '—', 'no changed files', jestProgress.elapsed())); + } else if ( + changedFiles.every(isTestFile) && + (() => { + // Fast path only safe when all test files share a single jest config. + const configs = new Set(changedFiles.map(findJestConfig).filter(Boolean)); + return configs.size <= 1; + })() + ) { + // Fast path: all changes are test files under one config — run them directly. + try { + const result = await runJestTestsDirectly(changedFiles); + if (result.passed) { + const tests = result.testCount > 0 ? ` · ${result.testCount} tests` : ''; + jestProgress.writeResult( + line('jest', '✓', `${changedFiles.length} test files${tests}`, jestProgress.elapsed()) + ); + } else { + jestProgress.writeResult(line('jest', '✗', 'failed', jestProgress.elapsed())); + writeln(''); + const excerpt = result.output.split('\n').slice(-15); + for (const l of excerpt) writeln(` ${l}`); + const rerunCommand = + changedFiles.length === 1 + ? `node scripts/jest ${changedFiles[0]}` + : `node scripts/jest --runTestsByPath ${changedFiles.join(' ')}`; + writeln(` $ ${rerunCommand}`); + writeln(''); + errors.push(new Error('jest failed')); + } + } catch (error) { + jestProgress.writeResult(line('jest', '✗', 'failed', jestProgress.elapsed())); + errors.push(error instanceof Error ? error : new Error(String(error))); + } + } else { + // Normal path: run affected jest tasks via Moon. + try { + const downstream = + baseContext.mode === 'contract' ? baseContext.contract.downstream : 'none'; + const result = await runJestViaMoon(changedFiles, verbose, downstream, (progress) => { + jestProgress.setDetail(`[${progress.completedCount} complete]`); + }); + + const printVerbose = () => { + if (result?.warnings) { + for (const warning of result.warnings) { + writeln(line('warn', '!', warning)); + } + } + if (result?.verboseDetail) { + writeln(` ${result.verboseDetail}`); + } + }; + + const formatJestSummary = (r: JestPhaseResult) => { + const ran = r.taskCount - r.cachedCount; + const parts = []; + if (ran > 0) parts.push(`${pluralize(ran, 'config')} ran`); + if (r.cachedCount > 0) parts.push(`${pluralize(r.cachedCount, 'config')} cached`); + if (r.totalTests > 0) parts.push(pluralize(r.totalTests, 'test')); + return parts.join(', '); + }; + + if (!result || (result.taskCount === 0 && result.exitCode === 0)) { + jestProgress.writeResult( + line('jest', '—', 'no affected configs', jestProgress.elapsed()) + ); + printVerbose(); + } else if (result.taskCount === 0) { + jestProgress.writeResult(line('jest', '✗', 'failed', jestProgress.elapsed())); + writeln(''); + for (const excerptLine of result.failureExcerpt ?? []) { + writeln(` ${excerptLine}`); + } + writeln(' $ node scripts/jest --profile quick'); + writeln(''); + printVerbose(); + errors.push(new Error('jest failed')); + } else if (result.failed.length > 0) { + const failCount = result.failed.length; + jestProgress.writeResult( + line( + 'jest', + '✗', + `${pluralize(failCount, 'config')} failed, ${formatJestSummary(result)}`, + jestProgress.elapsed() + ) + ); + writeln(''); + for (const task of result.failed) { + // Group failures by file + const byFile = new Map(); + for (const f of task.failures) { + const list = byFile.get(f.file) ?? []; + list.push(f); + byFile.set(f.file, list); + } + + for (const [file, failures] of byFile) { + const firstLine = failures[0]?.line; + const fileRef = firstLine ? `${file}:${firstLine}` : file; + writeln(` FAIL ${fileRef}`); + for (const f of failures) { + writeln(` ● ${f.name}`); + writeln(''); + // Strip ANSI for clean display, trim deep stack frames + const lines = stripAnsi(f.message).split('\n'); + let pastFirstStackFrame = false; + for (const msgLine of lines) { + const trimmedLine = msgLine.trim(); + if (trimmedLine.startsWith('at ') && pastFirstStackFrame) { + // Skip subsequent stack frames after the first + continue; + } + if (trimmedLine.startsWith('at ')) { + pastFirstStackFrame = true; + } + writeln(` ${msgLine}`); + } + writeln(''); + } + } + + const jestCmd = task.configPath + ? `node scripts/jest --config ${task.configPath}` + : `node scripts/jest`; + writeln(` $ ${jestCmd}`); + writeln(''); + } + printVerbose(); + errors.push(new Error('jest failed')); + } else { + jestProgress.writeResult( + line('jest', '✓', formatJestSummary(result), jestProgress.elapsed()) + ); + printVerbose(); + } + } catch (error) { + jestProgress.writeResult(line('jest', '✗', 'failed', jestProgress.elapsed())); + errors.push(error instanceof Error ? error : new Error(String(error))); + } + } + } + + // ── tsc ──────────────────────────────────────────────────────────── + + { + const { log, captured } = createSilentLog(); + const procRunner = new ProcRunner(log); + const tscProgress = startProgress('tsc'); + + try { + const result = await executeTypeCheckValidation({ + baseContext, + log, + procRunner, + pretty: false, + cleanup: true, + }); + + if (!result) { + tscProgress.writeResult(line('tsc', '—', 'no affected projects')); + } else { + tscProgress.writeResult( + line('tsc', '✓', pluralize(result.projectCount, 'project'), tscProgress.elapsed()) + ); + } + } catch (error) { + tscProgress.writeResult(line('tsc', '✗', 'failed', tscProgress.elapsed())); + + // Parse error lines: convert file(line,col) → file:line:col, group by owning tsconfig + const errorLines = captured + .map((msg) => + stripAnsi(msg) + .replace(/^\s*proc\s+\[tsc\]\s*/, '') + .replace(/^([^\s(]+)\((\d+),(\d+)\)/, '$1:$2:$3') + .trimEnd() + ) + .filter((l) => l.includes('error TS')); + + const errorsByProject = new Map(); + for (const errorLine of errorLines) { + const fileMatch = errorLine.match(/^([^\s:]+):\d+:\d+/); + let tsconfig = ''; + if (fileMatch) { + let dir = Path.dirname(Path.resolve(REPO_ROOT, fileMatch[1])); + while (true) { + if (existsSync(Path.join(dir, 'tsconfig.json'))) { + tsconfig = Path.relative(REPO_ROOT, Path.join(dir, 'tsconfig.json')); + break; + } + if (dir === REPO_ROOT || dir === Path.dirname(dir)) { + break; + } + dir = Path.dirname(dir); + } + } + const list = errorsByProject.get(tsconfig) ?? []; + list.push(errorLine); + errorsByProject.set(tsconfig, list); + } + + writeln(''); + for (const [tsconfig, tscErrors] of errorsByProject) { + for (const errorLine of tscErrors) { + writeln(` ${errorLine}`); + } + writeln( + tsconfig + ? ` $ node scripts/type_check --project ${tsconfig}` + : ` $ node scripts/type_check --profile quick` + ); + writeln(''); + } + + if (errorsByProject.size === 0) { + writeln(` $ node scripts/type_check --profile quick`); + writeln(''); + } + errors.push(error instanceof Error ? error : new Error(String(error))); + } finally { + await procRunner.teardown(); + } + } + + if (errors.length > 0) { + process.exitCode = 1; + } + }, + { + description: ` + Run type_check, eslint, and jest with one shared validation contract. + + Defaults to --profile quick when no validation flags are provided. + + Examples: + node scripts/check --scope local + node scripts/check --profile agent + node scripts/check --scope branch --base-ref origin/main + `, + flags: { + string: [...VALIDATION_RUN_STRING_FLAGS], + boolean: ['fix'], + default: { + fix: true, + }, + help: ` +${VALIDATION_RUN_HELP} + --no-fix Disable lint auto-fix + `, + }, + } +); diff --git a/src/dev/run_eslint.js b/src/dev/run_eslint.js index 29839a2d518ff..4085178509f0f 100644 --- a/src/dev/run_eslint.js +++ b/src/dev/run_eslint.js @@ -8,17 +8,22 @@ */ import { run } from '@kbn/dev-cli-runner'; +import { hasValidationRunFlags } from '@kbn/dev-validation-runner'; import { eslintBinPath } from './eslint'; +import { runEslintContract } from './eslint/run_eslint_contract'; process.env.KIBANA_RESOLVER_HARD_CACHE = 'true'; -if (process.argv.includes('--help') || process.argv.includes('-h')) { - console.log( - "This is a wrapper around ESLint's CLI that sets some defaults - see Eslint's help for flags:" - ); - require(eslintBinPath); // eslint-disable-line import/no-dynamic-require -} else { +const runLegacyEslint = () => { + if (process.argv.includes('--help') || process.argv.includes('-h')) { + console.log( + "This is a wrapper around ESLint's CLI that sets some defaults - see Eslint's help for flags:" + ); + require(eslintBinPath); // eslint-disable-line import/no-dynamic-require + return; + } + run( ({ flags }) => { flags._ = flags._ || []; @@ -58,4 +63,10 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) { }, } ); +}; + +if (hasValidationRunFlags(process.argv.slice(2))) { + runEslintContract(); +} else { + runLegacyEslint(); } diff --git a/src/dev/run_precommit_hook.js b/src/dev/run_precommit_hook.js index 6052ff37fe248..a3425ae27fac2 100644 --- a/src/dev/run_precommit_hook.js +++ b/src/dev/run_precommit_hook.js @@ -85,10 +85,14 @@ class LinterCheck extends PrecommitCheck { async execute(log, files, options) { const filesToLint = await this.linter.pickFilesToLint(log, files); if (filesToLint.length > 0) { - await this.linter.lintFiles(log, filesToLint, { + const result = await this.linter.lintFiles(log, filesToLint, { fix: options.fix, }); + if (result?.failedFiles?.length > 0) { + throw new Error(`${this.name} errors in ${result.failedFiles.length} file(s)`); + } + if (options.fix && options.stage) { const simpleGit = new SimpleGit(REPO_ROOT); await simpleGit.add(filesToLint); diff --git a/src/dev/tsconfig.json b/src/dev/tsconfig.json index e815f747770b6..9271c6d2635d2 100644 --- a/src/dev/tsconfig.json +++ b/src/dev/tsconfig.json @@ -12,7 +12,8 @@ ], "exclude": [ "target/**/*", - "packages/**/*" + "packages/**/*", + "type_check_validation_loader.js" ], "kbn_references": [ "@kbn/core", @@ -50,6 +51,8 @@ "@kbn/dot-text", "@kbn/yarn-lock-validator", "@kbn/sort-package-json", - "@kbn/babel-register" + "@kbn/babel-register", + "@kbn/dev-validation-runner", + "@kbn/moon", ] } diff --git a/src/dev/type_check_validation_loader.d.ts b/src/dev/type_check_validation_loader.d.ts new file mode 100644 index 0000000000000..1f31dc326f002 --- /dev/null +++ b/src/dev/type_check_validation_loader.d.ts @@ -0,0 +1,41 @@ +/* + * 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". + */ + +/** + * Hand-written declarations for the JS bridge in type_check_validation_loader.js. + * These types must stay in sync with the source in + * packages/kbn-ts-type-check-cli/execute_type_check_validation.ts. + */ + +import type { ProcRunner } from '@kbn/dev-proc-runner'; +import type { ValidationBaseContext } from '@kbn/dev-validation-runner'; +import type { ToolingLog } from '@kbn/tooling-log'; + +type ProcRunnerLike = Pick; + +export interface TscValidationResult { + projectCount: number; +} + +export interface ExecuteTypeCheckValidationOptions { + baseContext: ValidationBaseContext; + log: ToolingLog; + procRunner: ProcRunnerLike; + cleanup?: boolean; + extendedDiagnostics?: boolean; + pretty?: boolean; + verbose?: boolean; + withArchive?: boolean; +} + +export declare const executeTypeCheckValidation: ( + options: ExecuteTypeCheckValidationOptions +) => Promise; + +export declare const TSC_LABEL: 'tsc'; diff --git a/src/dev/type_check_validation_loader.js b/src/dev/type_check_validation_loader.js new file mode 100644 index 0000000000000..d5a8fbdbc6747 --- /dev/null +++ b/src/dev/type_check_validation_loader.js @@ -0,0 +1,22 @@ +/* + * 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". + */ + +const { createRequire } = require('module'); + +require('@kbn/babel-register').install(); + +const requireFromHere = createRequire(__filename); +const { executeTypeCheckValidation, TSC_LABEL } = requireFromHere( + '../../packages/kbn-ts-type-check-cli' +); + +module.exports = { + executeTypeCheckValidation, + TSC_LABEL, +}; 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 index 5c4cff83e02ea..972711621e618 100644 --- 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 @@ -277,9 +277,11 @@ describe('resolveValidationRunContext', () => { 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`.' + expect(warning).toHaveBeenNthCalledWith( + 1, + 'affected file detection is unavailable in a shallow repository' ); + expect(warning).toHaveBeenNthCalledWith(2, 'run `git fetch --unshallow`'); }); }); 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 index b3d11fc6cbc3a..ebea035fc9f5e 100644 --- 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 @@ -164,9 +164,8 @@ export const resolveValidationRunContext = async ({ }); 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\`.` - ); + onWarning?.('affected file detection is unavailable in a shallow repository'); + onWarning?.('run `git fetch --unshallow`'); } return { diff --git a/src/platform/packages/shared/kbn-test/index.ts b/src/platform/packages/shared/kbn-test/index.ts index 7a71cd60d6058..a2406b6c0567d 100644 --- a/src/platform/packages/shared/kbn-test/index.ts +++ b/src/platform/packages/shared/kbn-test/index.ts @@ -65,6 +65,14 @@ export { getUrl } from './src/jest/get_url'; export { runCheckJestConfigsCli } from './src/jest/run_check_jest_configs_cli'; export { runJest } from './src/jest/run'; +export { + executeJestValidation, + JEST_LABEL, + JEST_LOG_PREFIX, + planJestContractRuns, + runJestContract, +} from './src/jest/run_contract'; +export type { JestConfigResult, JestValidationResult } from './src/jest/run_contract'; export { runJestAll } from './src/jest/run_all'; diff --git a/src/platform/packages/shared/kbn-test/moon.yml b/src/platform/packages/shared/kbn-test/moon.yml index 4fdfe40628864..f0f2cad1832c1 100644 --- a/src/platform/packages/shared/kbn-test/moon.yml +++ b/src/platform/packages/shared/kbn-test/moon.yml @@ -18,6 +18,7 @@ project: sourceRoot: src/platform/packages/shared/kbn-test dependsOn: - '@kbn/kbn-client' + - '@kbn/dev-validation-runner' - '@kbn/std' - '@kbn/test-es-server' - '@kbn/tooling-log' diff --git a/src/platform/packages/shared/kbn-test/src/jest/run_contract.test.ts b/src/platform/packages/shared/kbn-test/src/jest/run_contract.test.ts new file mode 100644 index 0000000000000..8e45193ca51cb --- /dev/null +++ b/src/platform/packages/shared/kbn-test/src/jest/run_contract.test.ts @@ -0,0 +1,173 @@ +/* + * 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". + */ + +jest.mock('@kbn/dev-cli-runner', () => ({ + run: jest.fn(), +})); + +jest.mock('@kbn/dev-cli-errors', () => ({ + createFailError: (message: string) => new Error(message), +})); + +jest.mock('@kbn/dev-validation-runner', () => ({ + buildValidationCliArgs: jest.fn(), + describeValidationNoTargetsScope: jest.fn(), + formatReproductionCommand: jest.fn(), + readValidationRunFlags: jest.fn(), + resolveValidationBaseContext: jest.fn(), + VALIDATION_RUN_HELP: '', + VALIDATION_RUN_STRING_FLAGS: [], +})); + +jest.mock('@kbn/repo-info', () => ({ + REPO_ROOT: '/repo', +})); + +jest.mock('../../jest-preset', () => ({ + testMatch: ['**/*.test.ts'], +})); + +jest.mock('./run', () => ({ + findConfigInDirectoryTree: jest.fn(), + runJest: jest.fn(), +})); + +import { parseJestRunOutput, planJestContractRuns } from './run_contract'; + +describe('planJestContractRuns', () => { + it('forces a full config run for config-only changes', () => { + expect( + planJestContractRuns({ + testMode: 'affected', + entries: [ + { + repoRelPath: 'packages/foo/jest.config.js', + isConfigFile: true, + isTestFile: false, + }, + ], + }) + ).toEqual([ + { + configPath: '/repo/packages/foo/jest.config.js', + mode: 'full', + }, + ]); + }); + + it('keeps test-only affected changes on the related-test fast path', () => { + expect( + planJestContractRuns({ + testMode: 'affected', + entries: [ + { + repoRelPath: 'packages/foo/src/b.test.ts', + owningConfigPath: '/repo/packages/foo/jest.config.js', + isConfigFile: false, + isTestFile: true, + }, + { + repoRelPath: 'packages/foo/src/a.test.ts', + owningConfigPath: '/repo/packages/foo/jest.config.js', + isConfigFile: false, + isTestFile: true, + }, + ], + }) + ).toEqual([ + { + configPath: '/repo/packages/foo/jest.config.js', + mode: 'related', + relatedFiles: ['packages/foo/src/a.test.ts', 'packages/foo/src/b.test.ts'], + }, + ]); + }); + + it('escalates affected mode to a full config run when non-test files change', () => { + expect( + planJestContractRuns({ + testMode: 'affected', + entries: [ + { + repoRelPath: 'packages/foo/src/foo.test.ts', + owningConfigPath: '/repo/packages/foo/jest.config.js', + isConfigFile: false, + isTestFile: true, + }, + { + repoRelPath: 'packages/foo/src/foo.ts', + owningConfigPath: '/repo/packages/foo/jest.config.js', + isConfigFile: false, + isTestFile: false, + }, + ], + }) + ).toEqual([ + { + configPath: '/repo/packages/foo/jest.config.js', + mode: 'full', + }, + ]); + }); + + it('still forces a full config run in related mode when the config file itself changes', () => { + expect( + planJestContractRuns({ + testMode: 'related', + entries: [ + { + repoRelPath: 'packages/foo/jest.config.js', + isConfigFile: true, + isTestFile: false, + }, + { + repoRelPath: 'packages/foo/src/foo.test.ts', + owningConfigPath: '/repo/packages/foo/jest.config.js', + isConfigFile: false, + isTestFile: true, + }, + ], + }) + ).toEqual([ + { + configPath: '/repo/packages/foo/jest.config.js', + mode: 'full', + }, + ]); + }); +}); + +describe('parseJestRunOutput', () => { + it('extracts failed test files and summary lines from jest stdout', () => { + expect( + parseJestRunOutput(` + PASS packages/foo/src/a.test.ts + FAIL packages/foo/src/b.test.ts + FAIL packages/foo/src/c.test.ts + + Test Suites: 2 failed, 1 passed, 3 total + Tests: 4 failed, 10 passed, 14 total + Snapshots: 3 failed, 1 passed, 4 total + `) + ).toEqual({ + excerpt: [ + 'PASS packages/foo/src/a.test.ts', + 'FAIL packages/foo/src/b.test.ts', + 'FAIL packages/foo/src/c.test.ts', + 'Test Suites: 2 failed, 1 passed, 3 total', + 'Tests: 4 failed, 10 passed, 14 total', + 'Snapshots: 3 failed, 1 passed, 4 total', + ], + failedTestFiles: ['packages/foo/src/b.test.ts', 'packages/foo/src/c.test.ts'], + snapshots: 'Snapshots: 3 failed, 1 passed, 4 total', + suites: 'Test Suites: 2 failed, 1 passed, 3 total', + tests: 'Tests: 4 failed, 10 passed, 14 total', + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-test/src/jest/run_contract.ts b/src/platform/packages/shared/kbn-test/src/jest/run_contract.ts new file mode 100644 index 0000000000000..fd2c899328d25 --- /dev/null +++ b/src/platform/packages/shared/kbn-test/src/jest/run_contract.ts @@ -0,0 +1,686 @@ +/* + * 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 * as Fsp from 'fs/promises'; +import Os from 'os'; +import Path from 'path'; + +import { makeRe } from 'minimatch'; + +import { run } from '@kbn/dev-cli-runner'; +import { createFailError } from '@kbn/dev-cli-errors'; +import { ProcRunner } from '@kbn/dev-proc-runner'; +import { + buildValidationCliArgs, + describeValidationNoTargetsScope, + formatReproductionCommand, + hasValidationRunFlags, + readValidationRunFlags, + resolveValidationAffectedProjects, + resolveValidationBaseContext, + type ValidationBaseContext, + VALIDATION_RUN_HELP, + VALIDATION_RUN_STRING_FLAGS, +} from '@kbn/dev-validation-runner'; +import { REPO_ROOT } from '@kbn/repo-info'; +import { ToolingLog, type Message, type Writer } from '@kbn/tooling-log'; + +import { testMatch } from '../../jest-preset'; +import { findConfigInDirectoryTree, runJest } from './run'; + +export const JEST_LABEL = 'jest'; +export const JEST_LOG_PREFIX = `[${JEST_LABEL}]`; +const JEST_CONFIG_NAMES = [ + 'jest.config.dev.js', + 'jest.config.js', + 'jest.config.cjs', + 'jest.config.mjs', + 'jest.config.ts', + 'jest.config.json', +] as const; +const testMatchers = (testMatch as string[]).flatMap((pattern) => { + const re = makeRe(pattern); + return re ? [re] : []; +}); + +type ProcRunnerLike = Pick; +type JestContractTestMode = 'related' | 'affected'; + +interface JestConfigSelectionState { + changedTestFiles: Set; + relatedFiles: Set; + runAllTests: boolean; +} + +export interface JestChangedFileEntry { + repoRelPath: string; + owningConfigPath?: string; + isConfigFile: boolean; + isTestFile: boolean; +} + +export interface JestContractRunPlan { + configPath: string; + mode: 'full' | 'related'; + relatedFiles?: string[]; +} + +export interface JestConfigResult { + index: number; + total: number; + config: string; + passed: boolean; + testCount: number; + failureOutput?: string; + command: string; +} + +export interface JestValidationResult { + configCount: number; + testCount: number; +} + +export interface ExecuteJestValidationOptions { + baseContext: ValidationBaseContext; + log: ToolingLog; + passthroughArgs?: string[]; + procRunner: ProcRunnerLike; + onConfigResult?: (result: JestConfigResult) => void; +} + +export interface ParsedJestRunOutput { + excerpt: string[]; + failedTestFiles: string[]; + snapshots?: string; + suites?: string; + tests?: string; +} + +const hasArgFlag = (args: string[], flag: string) => { + return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`)); +}; + +const VALIDATION_FLAGS = new Set(VALIDATION_RUN_STRING_FLAGS.map((flag) => `--${flag}`)); + +const stripValidationArgs = (args: string[]) => { + const passthroughArgs: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + const [flagName] = arg.split('='); + if (!VALIDATION_FLAGS.has(flagName)) { + passthroughArgs.push(arg); + continue; + } + + if (arg === flagName) { + index += 1; + } + } + + return passthroughArgs; +}; + +const createProcLogFilter = (writer: Writer): Writer => ({ + write(message: Message) { + if (message.source === 'ProcRunner') { + return false; + } + + return writer.write(message); + }, +}); + +const createQuietProcRunner = (log: ToolingLog) => { + const quietLog = new ToolingLog(); + quietLog.setWriters(log.getWriters().map(createProcLogFilter)); + return new ProcRunner(quietLog); +}; + +const createJestOutputPath = (configRepoRel: string) => + Path.join( + Os.tmpdir(), + `kbn-jest-${configRepoRel.replace(/[\\/]/g, '_')}-${process.pid}-${Date.now()}.log` + ); + +const wait = async (durationMs: number) => { + await new Promise((resolve) => setTimeout(resolve, durationMs)); +}; + +const findSummaryLine = (lines: string[], prefix: string) => + lines.find((line) => line.startsWith(`${prefix}:`)); + +/** Parses Jest stdout/stderr into a compact summary for logging and failures. */ +export const parseJestRunOutput = (output: string): ParsedJestRunOutput => { + const lines = output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean); + + return { + excerpt: lines.filter((line) => !line.startsWith('info yarn jest')).slice(-8), + failedTestFiles: lines + .filter((line) => line.startsWith('FAIL ')) + .map((line) => line.slice('FAIL '.length).trim()), + snapshots: findSummaryLine(lines, 'Snapshots'), + suites: findSummaryLine(lines, 'Test Suites'), + tests: findSummaryLine(lines, 'Tests'), + }; +}; + +/** + * Reads Jest output from a log file, retrying until two consecutive reads + * return identical content. This is a heuristic to wait for the ProcRunner + * to finish flushing — there is no explicit "write complete" signal. + */ +const readParsedJestRunOutput = async (outputPath: string) => { + let previousOutput = ''; + + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + const output = await Fsp.readFile(outputPath, 'utf8'); + if (output && output === previousOutput) { + return parseJestRunOutput(output); + } + + previousOutput = output; + } catch { + previousOutput = ''; + } + + await wait(50); + } + + return parseJestRunOutput(previousOutput); +}; + +const formatSummaryValue = (summaryLine: string | undefined, label: string) => { + if (!summaryLine) { + return undefined; + } + + return `${label} ${summaryLine.replace(/^[^:]+:\s*/u, '')}`; +}; + +const formatJestRunSummary = (parsedOutput: ParsedJestRunOutput) => + [ + formatSummaryValue(parsedOutput.suites, 'suites'), + formatSummaryValue(parsedOutput.tests, 'tests'), + formatSummaryValue(parsedOutput.snapshots, 'snapshots'), + ] + .filter((value): value is string => value !== undefined) + .join('; '); + +const extractTestCount = (parsedOutput: ParsedJestRunOutput): number => { + const match = parsedOutput.tests?.match(/(\d+) total/); + return match ? parseInt(match[1], 10) : 0; +}; + +const buildJestFailureMessage = ({ + commandForLog, + configRepoRel, + parsedOutput, +}: { + commandForLog: string; + configRepoRel: string; + parsedOutput: ParsedJestRunOutput; +}) => { + const lines = [`${JEST_LABEL} failed for ${configRepoRel}.`]; + const summary = formatJestRunSummary(parsedOutput); + + if (summary) { + lines.push(`Summary: ${summary}`); + } + + if (parsedOutput.failedTestFiles.length > 0) { + lines.push('Failed test file(s):'); + for (const failedTestFile of parsedOutput.failedTestFiles.slice(0, 5)) { + lines.push(` ${failedTestFile}`); + } + + if (parsedOutput.failedTestFiles.length > 5) { + lines.push(` ... and ${parsedOutput.failedTestFiles.length - 5} more`); + } + } + + if (!summary && parsedOutput.excerpt.length > 0) { + lines.push('Error excerpt:'); + for (const line of parsedOutput.excerpt) { + lines.push(` ${line}`); + } + } + + lines.push('Re-run directly with:'); + lines.push(` ${commandForLog}`); + + if (parsedOutput.snapshots?.includes('failed')) { + lines.push('Update snapshots with:'); + lines.push(` ${commandForLog} -u`); + } + + return lines.join('\n'); +}; + +const isUnitJestConfigFile = (repoRelPath: string) => { + const basename = Path.basename(repoRelPath); + return JEST_CONFIG_NAMES.includes(basename as (typeof JEST_CONFIG_NAMES)[number]); +}; + +const isTestFile = (repoRelPath: string) => { + return testMatchers.some((matcher) => matcher.test(repoRelPath)); +}; + +const resolveOwningConfig = (repoRelPath: string) => { + const absolutePath = Path.resolve(REPO_ROOT, repoRelPath); + return findConfigInDirectoryTree(Path.dirname(absolutePath), [...JEST_CONFIG_NAMES]); +}; + +const isFullJestRun = (baseContext: ValidationBaseContext) => { + return ( + baseContext.mode === 'contract' && + (baseContext.runContext.kind === 'full' || baseContext.contract.testMode === 'all') + ); +}; + +const createSelectionMap = () => new Map(); + +const getOrCreateSelection = ( + selections: Map, + configPath: string +): JestConfigSelectionState => { + const existing = selections.get(configPath); + if (existing) { + return existing; + } + + const selection: JestConfigSelectionState = { + changedTestFiles: new Set(), + relatedFiles: new Set(), + runAllTests: false, + }; + selections.set(configPath, selection); + return selection; +}; + +/** Builds per-config Jest run plans from changed files and the selected test mode. */ +export const planJestContractRuns = ({ + entries, + testMode, +}: { + entries: JestChangedFileEntry[]; + testMode: JestContractTestMode; +}): JestContractRunPlan[] => { + const selections = createSelectionMap(); + + for (const entry of entries) { + if (entry.isConfigFile) { + getOrCreateSelection(selections, Path.resolve(REPO_ROOT, entry.repoRelPath)).runAllTests = + true; + continue; + } + + if (!entry.owningConfigPath) { + continue; + } + + const selection = getOrCreateSelection(selections, entry.owningConfigPath); + selection.relatedFiles.add(entry.repoRelPath); + + if (entry.isTestFile) { + selection.changedTestFiles.add(entry.repoRelPath); + } else if (testMode === 'affected') { + selection.runAllTests = true; + } + } + + return [...selections.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .flatMap(([configPath, selection]): JestContractRunPlan[] => { + if (selection.runAllTests) { + return [{ configPath, mode: 'full' }]; + } + + const relatedFiles = [ + ...(testMode === 'related' ? selection.relatedFiles : selection.changedTestFiles), + ].sort((left, right) => left.localeCompare(right)); + + if (relatedFiles.length === 0) { + return []; + } + + return [ + { + configPath, + mode: 'related', + relatedFiles, + }, + ]; + }); +}; + +const runJestForConfig = async ({ + log, + procRunner, + configPath, + passthroughArgs, + relatedFiles, +}: { + log: ToolingLog; + procRunner: ProcRunnerLike; + configPath: string; + passthroughArgs: string[]; + relatedFiles?: string[]; +}): Promise => { + const configRepoRel = Path.relative(REPO_ROOT, configPath); + const args = ['scripts/jest', '--config', configRepoRel]; + const outputPath = createJestOutputPath(configRepoRel); + + if (relatedFiles && relatedFiles.length > 0) { + args.push('--findRelatedTests', ...relatedFiles); + } + + args.push('--passWithNoTests'); + args.push(...passthroughArgs); + + const commandForLog = `node ${args.join(' ')}`; + + try { + await procRunner.run('jest', { + cmd: process.execPath, + args, + cwd: REPO_ROOT, + wait: true, + writeLogsToPath: outputPath, + }); + + const parsedOutput = await readParsedJestRunOutput(outputPath); + const summary = formatJestRunSummary(parsedOutput); + log.success(`${JEST_LOG_PREFIX} passed ${configRepoRel}${summary ? ` (${summary})` : ''}`); + return parsedOutput; + } catch { + const parsedOutput = await readParsedJestRunOutput(outputPath); + throw createFailError( + buildJestFailureMessage({ + commandForLog, + configRepoRel, + parsedOutput, + }) + ); + } finally { + await Fsp.unlink(outputPath).catch(() => undefined); + } +}; + +const runJestAllConfigs = async ({ + procRunner, + passthroughArgs, +}: { + procRunner: ProcRunnerLike; + passthroughArgs: string[]; +}) => { + const args = ['scripts/jest_all', ...passthroughArgs]; + const commandForLog = `node ${args.join(' ')}`; + + try { + await procRunner.run('jest_all', { + cmd: process.execPath, + args, + cwd: REPO_ROOT, + wait: true, + }); + } catch { + throw createFailError( + `${JEST_LABEL} full run failed. Re-run directly with:\n ${commandForLog}` + ); + } +}; + +/** + * Resolves scoped Jest targets from the validation contract and executes the + * required config runs, including downstream expansion when requested. + */ +export const executeJestValidation = async ({ + baseContext, + log, + passthroughArgs = [], + procRunner, + onConfigResult, +}: ExecuteJestValidationOptions): Promise => { + if (baseContext.mode === 'direct_target') { + throw createFailError( + 'scripts/jest only supports validation-contract execution. Remove explicit test targets and use --profile/--scope instead.' + ); + } + + const resolvedBase = + baseContext.runContext.kind === 'affected' ? baseContext.runContext.resolvedBase : undefined; + const shouldRunAllConfigs = isFullJestRun(baseContext); + const cliArgs = buildValidationCliArgs({ + contract: baseContext.contract, + resolvedBase, + forceFullProfile: shouldRunAllConfigs, + }); + + log.info(`Running \`${formatReproductionCommand('jest', cliArgs.logArgs)}\``); + + if (shouldRunAllConfigs) { + log.info('Contract resolved to a full Jest run; delegating to scripts/jest_all.'); + await runJestAllConfigs({ procRunner, passthroughArgs }); + return null; + } + + if (baseContext.runContext.kind === 'skip') { + log.info( + `No changed files found ${describeValidationNoTargetsScope(baseContext)}; skipping jest.` + ); + return null; + } + + if (baseContext.runContext.kind !== 'affected') { + throw new Error('Unexpected Jest contract state: expected affected run context.'); + } + + const changedFiles = baseContext.runContext.changedFiles; + + if (changedFiles.length === 0) { + log.info( + `No changed files found ${describeValidationNoTargetsScope(baseContext)}; skipping jest.` + ); + return null; + } + + const testMode = baseContext.contract.testMode; + if (testMode === 'all') { + throw new Error( + 'Unexpected Jest contract state: testMode=all should have been handled earlier.' + ); + } + + const plans = planJestContractRuns({ + entries: changedFiles.map((repoRelPath) => { + const owningConfigPath = isUnitJestConfigFile(repoRelPath) + ? undefined + : resolveOwningConfig(repoRelPath) ?? undefined; + + return { + repoRelPath, + owningConfigPath, + isConfigFile: isUnitJestConfigFile(repoRelPath), + isTestFile: isTestFile(repoRelPath), + }; + }), + testMode, + }); + + // When downstream expansion is requested (e.g. --profile pr), discover jest + // configs in downstream-affected Moon projects that aren't already covered. + const { downstream } = baseContext.contract; + if (downstream !== 'none' && changedFiles.length > 0) { + const changedFilesJson = JSON.stringify({ files: changedFiles }); + const affected = await resolveValidationAffectedProjects({ + changedFilesJson, + downstream, + }); + + const coveredConfigs = new Set(plans.map((p) => p.configPath)); + for (const sourceRoot of affected.affectedSourceRoots) { + const absRoot = Path.resolve(REPO_ROOT, sourceRoot); + for (const configName of JEST_CONFIG_NAMES) { + const configPath = Path.join(absRoot, configName); + if (!coveredConfigs.has(configPath) && existsSync(configPath)) { + plans.push({ configPath, mode: 'full' }); + coveredConfigs.add(configPath); + } + } + } + } + + if (plans.length === 0) { + const noTargetsLabel = + baseContext.contract.testMode === 'related' + ? 'No related Jest targets found' + : 'No affected Jest targets found'; + log.info(`${noTargetsLabel} ${describeValidationNoTargetsScope(baseContext)}; skipping jest.`); + return null; + } + + log.info(`${JEST_LOG_PREFIX} planned ${plans.length} config run(s).`); + let totalTests = 0; + + for (const [index, plan] of plans.entries()) { + const configRepoRel = Path.relative(REPO_ROOT, plan.configPath); + const relatedFiles = plan.mode === 'related' ? plan.relatedFiles ?? [] : undefined; + const commandForLog = `node scripts/jest --config ${configRepoRel}${ + relatedFiles ? ` --findRelatedTests ${relatedFiles.join(' ')}` : '' + }`; + + if (plan.mode === 'full') { + log.info(`${JEST_LOG_PREFIX} ${index + 1}/${plans.length} full ${configRepoRel}`); + } else { + log.info( + `${JEST_LOG_PREFIX} ${index + 1}/${plans.length} related ${configRepoRel} (${ + (relatedFiles ?? []).length + } file(s))` + ); + } + + try { + const parsedOutput = await runJestForConfig({ + log, + procRunner, + configPath: plan.configPath, + passthroughArgs, + relatedFiles, + }); + + const testCount = extractTestCount(parsedOutput); + totalTests += testCount; + + onConfigResult?.({ + index: index + 1, + total: plans.length, + config: configRepoRel, + passed: true, + testCount, + command: commandForLog, + }); + } catch (error) { + onConfigResult?.({ + index: index + 1, + total: plans.length, + config: configRepoRel, + passed: false, + testCount: 0, + failureOutput: error instanceof Error ? error.message : String(error), + command: commandForLog, + }); + throw error; + } + } + + log.success(`${JEST_LABEL} contract run passed (${plans.length} config run(s)).`); + return { configCount: plans.length, testCount: totalTests }; +}; + +/** Runs the validation-contract-aware `scripts/jest` CLI entrypoint. */ +export const runJestContract = () => { + run( + async ({ log, flags, flagsReader, procRunner }) => { + const rawArgs = process.argv.slice(2); + const hasValidationContractFlags = hasValidationRunFlags(rawArgs); + const hasExplicitTargets = + flags._.length > 0 || + hasArgFlag(rawArgs, '--config') || + hasArgFlag(rawArgs, '--testPathPattern') || + hasArgFlag(rawArgs, '--findRelatedTests') || + hasArgFlag(rawArgs, '--runTestsByPath'); + + if (hasExplicitTargets && !hasValidationContractFlags) { + await runJest(); + return; + } + + if (hasExplicitTargets && hasValidationContractFlags) { + throw createFailError( + 'scripts/jest only supports validation-contract execution. Remove explicit test targets and use --profile/--scope instead.' + ); + } + + const passthroughArgs = stripValidationArgs(rawArgs); + const validationFlags = readValidationRunFlags(flagsReader); + const baseContext = await resolveValidationBaseContext({ + flags: validationFlags, + runnerDescription: 'jest', + onWarning: (message) => log.warning(message), + }); + + const shouldUseQuietProcRunner = + baseContext.mode !== 'contract' || + (baseContext.runContext.kind !== 'full' && baseContext.contract.testMode !== 'all'); + const quietProcRunner = shouldUseQuietProcRunner ? createQuietProcRunner(log) : undefined; + + try { + await executeJestValidation({ + baseContext, + log, + passthroughArgs, + procRunner: quietProcRunner ?? procRunner, + }); + } finally { + await quietProcRunner?.teardown(); + } + }, + { + description: ` + Run Jest using the shared validation contract to select scoped targets. + + Examples: + # quick local profile + node scripts/jest --profile quick + + # agent local profile + node scripts/jest --profile agent + + # PR-equivalent branch scope + node scripts/jest --profile pr + + # full repository run + node scripts/jest --profile full + `, + flags: { + string: [...VALIDATION_RUN_STRING_FLAGS], + allowUnexpected: true, + help: ` +${VALIDATION_RUN_HELP} + `, + }, + } + ); +}; diff --git a/src/platform/packages/shared/kbn-test/tsconfig.json b/src/platform/packages/shared/kbn-test/tsconfig.json index e9aed423dac72..c97a0f967e961 100644 --- a/src/platform/packages/shared/kbn-test/tsconfig.json +++ b/src/platform/packages/shared/kbn-test/tsconfig.json @@ -9,6 +9,7 @@ "exclude": ["types/**/*", "**/__fixtures__/**/*", "target/**/*"], "kbn_references": [ "@kbn/kbn-client", + "@kbn/dev-validation-runner", "@kbn/std", "@kbn/test-es-server", "@kbn/tooling-log", diff --git a/x-pack/scripts/jest.js b/x-pack/scripts/jest.js index 28f909820e5d4..bf40ae5c41040 100644 --- a/x-pack/scripts/jest.js +++ b/x-pack/scripts/jest.js @@ -6,4 +6,9 @@ */ require('@kbn/setup-node-env'); -require('@kbn/test').runJest(); + +if (require('@kbn/dev-validation-runner').hasValidationRunFlags(process.argv.slice(2))) { + require('@kbn/test').runJestContract(); +} else { + require('@kbn/test').runJest(); +}