From 35ec4951da65f3ff0f033dd34a494ac9d33c055c Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Wed, 21 Jan 2026 18:52:12 -0500 Subject: [PATCH 1/3] feat(plugin-typescript): support multiple tsconfigs --- .../src/lib/runner/runner.ts | 12 ++-- .../src/lib/runner/runner.unit.test.ts | 58 ++++++++++++++++--- packages/plugin-typescript/src/lib/schema.ts | 4 +- .../src/lib/schema.unit.test.ts | 19 ++++++ .../src/lib/typescript-plugin.ts | 2 +- .../src/lib/typescript-plugin.unit.test.ts | 2 +- 6 files changed, 81 insertions(+), 16 deletions(-) diff --git a/packages/plugin-typescript/src/lib/runner/runner.ts b/packages/plugin-typescript/src/lib/runner/runner.ts index 2da4c1a27..542bc5c3e 100644 --- a/packages/plugin-typescript/src/lib/runner/runner.ts +++ b/packages/plugin-typescript/src/lib/runner/runner.ts @@ -11,14 +11,12 @@ import { toSentenceCase, } from '@code-pushup/utils'; import type { AuditSlug } from '../types.js'; -import { - type DiagnosticsOptions, - getTypeScriptDiagnostics, -} from './ts-runner.js'; +import { getTypeScriptDiagnostics } from './ts-runner.js'; import type { CodeRangeName } from './types.js'; import { getIssueFromDiagnostic, tsCodeToAuditSlug } from './utils.js'; -export type RunnerOptions = DiagnosticsOptions & { +export type RunnerOptions = { + tsconfig: string[]; expectedAudits: { slug: AuditSlug }[]; }; @@ -26,7 +24,9 @@ export function createRunnerFunction(options: RunnerOptions): RunnerFunction { const { tsconfig, expectedAudits } = options; return (): AuditOutputs => { - const diagnostics = getTypeScriptDiagnostics({ tsconfig }); + const diagnostics = tsconfig.flatMap(config => [ + ...getTypeScriptDiagnostics({ tsconfig: config }), + ]); const result = diagnostics.reduce< Partial>> diff --git a/packages/plugin-typescript/src/lib/runner/runner.unit.test.ts b/packages/plugin-typescript/src/lib/runner/runner.unit.test.ts index 79d1bbb3c..0f7682fe6 100644 --- a/packages/plugin-typescript/src/lib/runner/runner.unit.test.ts +++ b/packages/plugin-typescript/src/lib/runner/runner.unit.test.ts @@ -56,7 +56,7 @@ describe('createRunnerFunction', () => { it('should return empty array if no diagnostics are found', () => { getTypeScriptDiagnosticsSpy.mockReturnValue([]); const runner = createRunnerFunction({ - tsconfig: 'tsconfig.json', + tsconfig: ['tsconfig.json'], expectedAudits: [], }); expect(runner(runnerArgs)).toStrictEqual([]); @@ -65,7 +65,7 @@ describe('createRunnerFunction', () => { it('should return empty array if no supported diagnostics are found', () => { getTypeScriptDiagnosticsSpy.mockReturnValue([mockSemanticDiagnostic]); const runner = createRunnerFunction({ - tsconfig: 'tsconfig.json', + tsconfig: ['tsconfig.json'], expectedAudits: [], }); expect(runner(runnerArgs)).toStrictEqual([]); @@ -74,7 +74,7 @@ describe('createRunnerFunction', () => { it('should pass the diagnostic code to tsCodeToSlug', () => { getTypeScriptDiagnosticsSpy.mockReturnValue([mockSemanticDiagnostic]); const runner = createRunnerFunction({ - tsconfig: 'tsconfig.json', + tsconfig: ['tsconfig.json'], expectedAudits: [], }); expect(runner(runnerArgs)).toStrictEqual([]); @@ -85,7 +85,7 @@ describe('createRunnerFunction', () => { it('should pass the diagnostic to getIssueFromDiagnostic', () => { getTypeScriptDiagnosticsSpy.mockReturnValue([mockSemanticDiagnostic]); const runner = createRunnerFunction({ - tsconfig: 'tsconfig.json', + tsconfig: ['tsconfig.json'], expectedAudits: [], }); expect(runner(runnerArgs)).toStrictEqual([]); @@ -106,7 +106,7 @@ describe('createRunnerFunction', () => { }, ]); const runner = createRunnerFunction({ - tsconfig: 'tsconfig.json', + tsconfig: ['tsconfig.json'], expectedAudits: [{ slug: 'semantic-errors' }], }); @@ -138,7 +138,7 @@ describe('createRunnerFunction', () => { mockSemanticDiagnostic, ]); const runner = createRunnerFunction({ - tsconfig: 'tsconfig.json', + tsconfig: ['tsconfig.json'], expectedAudits: [{ slug: 'semantic-errors' }, { slug: 'syntax-errors' }], }); @@ -181,10 +181,54 @@ describe('createRunnerFunction', () => { }, ]); const runner = createRunnerFunction({ - tsconfig: 'tsconfig.json', + tsconfig: ['tsconfig.json'], expectedAudits: [{ slug: 'semantic-errors' }, { slug: 'syntax-errors' }], }); const auditOutputs = runner(runnerArgs); expect(() => auditOutputsSchema.parse(auditOutputs)).not.toThrow(); }); + + it('should aggregate diagnostics from multiple tsconfigs', () => { + getTypeScriptDiagnosticsSpy + .mockReturnValueOnce([mockSemanticDiagnostic]) + .mockReturnValueOnce([mockSyntacticDiagnostic]); + + const runner = createRunnerFunction({ + tsconfig: ['tsconfig.lib.json', 'tsconfig.spec.json'], + expectedAudits: [{ slug: 'semantic-errors' }, { slug: 'syntax-errors' }], + }); + + const auditOutputs = runner(runnerArgs); + + expect(getTypeScriptDiagnosticsSpy).toHaveBeenCalledTimes(2); + expect(getTypeScriptDiagnosticsSpy).toHaveBeenNthCalledWith(1, { + tsconfig: 'tsconfig.lib.json', + }); + expect(getTypeScriptDiagnosticsSpy).toHaveBeenNthCalledWith(2, { + tsconfig: 'tsconfig.spec.json', + }); + + expect(auditOutputs).toStrictEqual([ + expect.objectContaining({ + slug: 'semantic-errors', + value: 1, + details: { + issues: [ + expect.objectContaining({ + message: `TS2322: Type 'string' is not assignable to type 'number'.`, + }), + ], + }, + }), + expect.objectContaining({ + slug: 'syntax-errors', + value: 1, + details: { + issues: [ + expect.objectContaining({ message: "TS1005: ';' expected." }), + ], + }, + }), + ]); + }); }); diff --git a/packages/plugin-typescript/src/lib/schema.ts b/packages/plugin-typescript/src/lib/schema.ts index ed680506e..6fb454296 100644 --- a/packages/plugin-typescript/src/lib/schema.ts +++ b/packages/plugin-typescript/src/lib/schema.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { pluginScoreTargetsSchema } from '@code-pushup/models'; +import { toArray } from '@code-pushup/utils'; import { AUDITS, DEFAULT_TS_CONFIG } from './constants.js'; import type { AuditSlug } from './types.js'; @@ -10,8 +11,9 @@ const auditSlugs = AUDITS.map(({ slug }) => slug) as [ export const typescriptPluginConfigSchema = z .object({ tsconfig: z - .string() + .union([z.string(), z.array(z.string()).min(1)]) .default(DEFAULT_TS_CONFIG) + .transform(toArray) .meta({ description: `Path to a tsconfig file (default is ${DEFAULT_TS_CONFIG})`, }), diff --git a/packages/plugin-typescript/src/lib/schema.unit.test.ts b/packages/plugin-typescript/src/lib/schema.unit.test.ts index dea7114cc..9d76a4d10 100644 --- a/packages/plugin-typescript/src/lib/schema.unit.test.ts +++ b/packages/plugin-typescript/src/lib/schema.unit.test.ts @@ -19,6 +19,25 @@ describe('typescriptPluginConfigSchema', () => { ).not.toThrow(); }); + it('transforms a single tsconfig string to an array', () => { + expect( + typescriptPluginConfigSchema.parse({ tsconfig }).tsconfig, + ).toStrictEqual([tsconfig]); + }); + + it('accepts an array of tsconfig paths', () => { + const tsconfigs = ['tsconfig.lib.json', 'tsconfig.spec.json']; + expect( + typescriptPluginConfigSchema.parse({ tsconfig: tsconfigs }).tsconfig, + ).toStrictEqual(tsconfigs); + }); + + it('throws for empty tsconfig array', () => { + expect(() => typescriptPluginConfigSchema.parse({ tsconfig: [] })).toThrow( + 'too_small', + ); + }); + it('accepts a configuration with tsconfig and empty onlyAudits', () => { expect(() => typescriptPluginConfigSchema.parse({ diff --git a/packages/plugin-typescript/src/lib/typescript-plugin.ts b/packages/plugin-typescript/src/lib/typescript-plugin.ts index 3334fb2e7..370e9fdc9 100644 --- a/packages/plugin-typescript/src/lib/typescript-plugin.ts +++ b/packages/plugin-typescript/src/lib/typescript-plugin.ts @@ -22,7 +22,7 @@ export function typescriptPlugin( options?: TypescriptPluginOptions, ): PluginConfig { const { - tsconfig = DEFAULT_TS_CONFIG, + tsconfig = [DEFAULT_TS_CONFIG], onlyAudits, scoreTargets, } = parseOptions(options ?? {}); diff --git a/packages/plugin-typescript/src/lib/typescript-plugin.unit.test.ts b/packages/plugin-typescript/src/lib/typescript-plugin.unit.test.ts index a01ba8f44..0022c479a 100644 --- a/packages/plugin-typescript/src/lib/typescript-plugin.unit.test.ts +++ b/packages/plugin-typescript/src/lib/typescript-plugin.unit.test.ts @@ -38,7 +38,7 @@ describe('typescriptPlugin', () => { }), ) .toThrow(`Error parsing TypeScript Plugin options: SchemaValidationError: Invalid ${ansis.bold('TypescriptPluginConfig')} -✖ Invalid input: expected string, received number +✖ Invalid input → at tsconfig `); }); From 6ae18b62b4775883e4aca36a372769b581c6dc50 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Thu, 22 Jan 2026 16:22:47 -0500 Subject: [PATCH 2/3] feat(plugin-typescript): add Nx helper for tsconfig auto-discovery --- code-pushup.config.ts | 2 +- code-pushup.preset.ts | 12 +- packages/ci/code-pushup.config.ts | 2 +- packages/cli/code-pushup.config.ts | 2 +- packages/core/code-pushup.config.ts | 2 +- packages/create-cli/code-pushup.config.ts | 2 +- packages/nx-plugin/code-pushup.config.ts | 2 +- packages/plugin-axe/code-pushup.config.ts | 2 +- .../plugin-coverage/code-pushup.config.ts | 2 +- packages/plugin-eslint/code-pushup.config.ts | 2 +- .../plugin-js-packages/code-pushup.config.ts | 2 +- packages/plugin-jsdocs/code-pushup.config.ts | 2 +- .../plugin-lighthouse/code-pushup.config.ts | 2 +- .../plugin-typescript/code-pushup.config.ts | 2 +- .../mocks/fixtures/nx-monorepo/.gitignore | 1 + .../mocks/fixtures/nx-monorepo/_nx.json | 1 + .../mocks/fixtures/nx-monorepo/_package.json | 1 + .../nx-monorepo/packages/cli/_project.json | 5 + .../packages/cli/tsconfig.lib.json | 1 + .../nx-monorepo/packages/core/_project.json | 5 + .../packages/core/tsconfig.lib.json | 1 + .../packages/nx-plugin/_project.json | 5 + .../packages/nx-plugin/tsconfig.lib.json | 1 + .../nx-monorepo/packages/utils/_project.json | 5 + .../packages/utils/tsconfig.lib.json | 1 + packages/plugin-typescript/package.json | 6 + packages/plugin-typescript/src/index.ts | 1 + .../src/lib/nx/tsconfig-paths.int.test.ts | 68 +++++++++++ .../src/lib/nx/tsconfig-paths.ts | 90 ++++++++++++++ .../src/lib/runner/runner.int.test.ts | 3 +- .../src/lib/runner/runner.ts | 111 ++++++++++++------ .../src/lib/runner/runner.unit.test.ts | 43 +++++++ .../src/lib/runner/ts-runner.ts | 4 +- packages/plugin-typescript/tsconfig.test.json | 3 +- packages/utils/code-pushup.config.ts | 2 +- tsconfig.code-pushup.json | 4 - 36 files changed, 342 insertions(+), 58 deletions(-) create mode 100644 packages/plugin-typescript/mocks/fixtures/nx-monorepo/.gitignore create mode 100644 packages/plugin-typescript/mocks/fixtures/nx-monorepo/_nx.json create mode 100644 packages/plugin-typescript/mocks/fixtures/nx-monorepo/_package.json create mode 100644 packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/cli/_project.json create mode 100644 packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/cli/tsconfig.lib.json create mode 100644 packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/core/_project.json create mode 100644 packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/core/tsconfig.lib.json create mode 100644 packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/nx-plugin/_project.json create mode 100644 packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/nx-plugin/tsconfig.lib.json create mode 100644 packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/utils/_project.json create mode 100644 packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/utils/tsconfig.lib.json create mode 100644 packages/plugin-typescript/src/lib/nx/tsconfig-paths.int.test.ts create mode 100644 packages/plugin-typescript/src/lib/nx/tsconfig-paths.ts delete mode 100644 tsconfig.code-pushup.json diff --git a/code-pushup.config.ts b/code-pushup.config.ts index 6d2629f4a..c63683234 100644 --- a/code-pushup.config.ts +++ b/code-pushup.config.ts @@ -20,7 +20,7 @@ export default mergeConfigs( await configureEslintPlugin(), await configureCoveragePlugin(), await configureJsPackagesPlugin(), - configureTypescriptPlugin(), + await configureTypescriptPlugin(), configureJsDocsPlugin(), await configureLighthousePlugin(TARGET_URL), configureAxePlugin(TARGET_URL), diff --git a/code-pushup.preset.ts b/code-pushup.preset.ts index ea8d58ae6..ab6224535 100644 --- a/code-pushup.preset.ts +++ b/code-pushup.preset.ts @@ -20,6 +20,7 @@ import { } from './packages/plugin-lighthouse/src/index.js'; import typescriptPlugin, { getCategories, + tsconfigFromAllNxProjects, } from './packages/plugin-typescript/src/index.js'; export function configureUpload(projectName: string = 'workspace'): CoreConfig { @@ -150,10 +151,17 @@ export async function configureJsPackagesPlugin(): Promise { }; } -export function configureTypescriptPlugin(projectName?: string): CoreConfig { +export async function configureTypescriptPlugin( + projectName?: string, +): Promise { const tsconfig = projectName ? `packages/${projectName}/tsconfig.lib.json` - : 'tsconfig.code-pushup.json'; + : await tsconfigFromAllNxProjects({ + exclude: [ + 'test-fixtures', // Intentionally incomplete tsconfigs + 'models', // Uses ts-patch transformer plugin + ], + }); return { plugins: [typescriptPlugin({ tsconfig })], categories: getCategories(), diff --git a/packages/ci/code-pushup.config.ts b/packages/ci/code-pushup.config.ts index b582535ae..1aec61dd9 100644 --- a/packages/ci/code-pushup.config.ts +++ b/packages/ci/code-pushup.config.ts @@ -14,6 +14,6 @@ export default mergeConfigs( configureUpload(projectName), await configureEslintPlugin(projectName), await configureCoveragePlugin(projectName), - configureTypescriptPlugin(projectName), + await configureTypescriptPlugin(projectName), configureJsDocsPlugin(projectName), ); diff --git a/packages/cli/code-pushup.config.ts b/packages/cli/code-pushup.config.ts index 0fb99f57a..a9d88dfa0 100644 --- a/packages/cli/code-pushup.config.ts +++ b/packages/cli/code-pushup.config.ts @@ -14,6 +14,6 @@ export default mergeConfigs( configureUpload(projectName), await configureEslintPlugin(projectName), await configureCoveragePlugin(projectName), - configureTypescriptPlugin(projectName), + await configureTypescriptPlugin(projectName), configureJsDocsPlugin(projectName), ); diff --git a/packages/core/code-pushup.config.ts b/packages/core/code-pushup.config.ts index 4da9f7c81..4c0fc04d8 100644 --- a/packages/core/code-pushup.config.ts +++ b/packages/core/code-pushup.config.ts @@ -14,6 +14,6 @@ export default mergeConfigs( configureUpload(projectName), await configureEslintPlugin(projectName), await configureCoveragePlugin(projectName), - configureTypescriptPlugin(projectName), + await configureTypescriptPlugin(projectName), configureJsDocsPlugin(projectName), ); diff --git a/packages/create-cli/code-pushup.config.ts b/packages/create-cli/code-pushup.config.ts index 5db62c96f..2de40031d 100644 --- a/packages/create-cli/code-pushup.config.ts +++ b/packages/create-cli/code-pushup.config.ts @@ -14,6 +14,6 @@ export default mergeConfigs( configureUpload(projectName), await configureEslintPlugin(projectName), await configureCoveragePlugin(projectName), - configureTypescriptPlugin(projectName), + await configureTypescriptPlugin(projectName), configureJsDocsPlugin(projectName), ); diff --git a/packages/nx-plugin/code-pushup.config.ts b/packages/nx-plugin/code-pushup.config.ts index 98c56397a..a14261e35 100644 --- a/packages/nx-plugin/code-pushup.config.ts +++ b/packages/nx-plugin/code-pushup.config.ts @@ -14,6 +14,6 @@ export default mergeConfigs( configureUpload(projectName), await configureEslintPlugin(projectName), await configureCoveragePlugin(projectName), - configureTypescriptPlugin(projectName), + await configureTypescriptPlugin(projectName), configureJsDocsPlugin(projectName), ); diff --git a/packages/plugin-axe/code-pushup.config.ts b/packages/plugin-axe/code-pushup.config.ts index f13bb57e5..7712c0232 100644 --- a/packages/plugin-axe/code-pushup.config.ts +++ b/packages/plugin-axe/code-pushup.config.ts @@ -14,6 +14,6 @@ export default mergeConfigs( configureUpload(projectName), await configureEslintPlugin(projectName), await configureCoveragePlugin(projectName), - configureTypescriptPlugin(projectName), + await configureTypescriptPlugin(projectName), configureJsDocsPlugin(projectName), ); diff --git a/packages/plugin-coverage/code-pushup.config.ts b/packages/plugin-coverage/code-pushup.config.ts index ff0c6710d..9593d03f2 100644 --- a/packages/plugin-coverage/code-pushup.config.ts +++ b/packages/plugin-coverage/code-pushup.config.ts @@ -14,6 +14,6 @@ export default mergeConfigs( configureUpload(projectName), await configureEslintPlugin(projectName), await configureCoveragePlugin(projectName), - configureTypescriptPlugin(projectName), + await configureTypescriptPlugin(projectName), configureJsDocsPlugin(projectName), ); diff --git a/packages/plugin-eslint/code-pushup.config.ts b/packages/plugin-eslint/code-pushup.config.ts index 7e96d9ba6..2112fc605 100644 --- a/packages/plugin-eslint/code-pushup.config.ts +++ b/packages/plugin-eslint/code-pushup.config.ts @@ -14,6 +14,6 @@ export default mergeConfigs( configureUpload(projectName), await configureEslintPlugin(projectName), await configureCoveragePlugin(projectName), - configureTypescriptPlugin(projectName), + await configureTypescriptPlugin(projectName), configureJsDocsPlugin(projectName), ); diff --git a/packages/plugin-js-packages/code-pushup.config.ts b/packages/plugin-js-packages/code-pushup.config.ts index bfdb9da88..f385bedc5 100644 --- a/packages/plugin-js-packages/code-pushup.config.ts +++ b/packages/plugin-js-packages/code-pushup.config.ts @@ -14,6 +14,6 @@ export default mergeConfigs( configureUpload(projectName), await configureEslintPlugin(projectName), await configureCoveragePlugin(projectName), - configureTypescriptPlugin(projectName), + await configureTypescriptPlugin(projectName), configureJsDocsPlugin(projectName), ); diff --git a/packages/plugin-jsdocs/code-pushup.config.ts b/packages/plugin-jsdocs/code-pushup.config.ts index 17188ebbe..096bb197e 100644 --- a/packages/plugin-jsdocs/code-pushup.config.ts +++ b/packages/plugin-jsdocs/code-pushup.config.ts @@ -14,6 +14,6 @@ export default mergeConfigs( configureUpload(projectName), await configureEslintPlugin(projectName), await configureCoveragePlugin(projectName), - configureTypescriptPlugin(projectName), + await configureTypescriptPlugin(projectName), configureJsDocsPlugin(projectName), ); diff --git a/packages/plugin-lighthouse/code-pushup.config.ts b/packages/plugin-lighthouse/code-pushup.config.ts index 6debb767d..b23261673 100644 --- a/packages/plugin-lighthouse/code-pushup.config.ts +++ b/packages/plugin-lighthouse/code-pushup.config.ts @@ -14,6 +14,6 @@ export default mergeConfigs( configureUpload(projectName), await configureEslintPlugin(projectName), await configureCoveragePlugin(projectName), - configureTypescriptPlugin(projectName), + await configureTypescriptPlugin(projectName), configureJsDocsPlugin(projectName), ); diff --git a/packages/plugin-typescript/code-pushup.config.ts b/packages/plugin-typescript/code-pushup.config.ts index 5a2915448..b6994e084 100644 --- a/packages/plugin-typescript/code-pushup.config.ts +++ b/packages/plugin-typescript/code-pushup.config.ts @@ -14,6 +14,6 @@ export default mergeConfigs( configureUpload(projectName), await configureEslintPlugin(projectName), await configureCoveragePlugin(projectName), - configureTypescriptPlugin(projectName), + await configureTypescriptPlugin(projectName), configureJsDocsPlugin(projectName), ); diff --git a/packages/plugin-typescript/mocks/fixtures/nx-monorepo/.gitignore b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/.gitignore new file mode 100644 index 000000000..f21d80021 --- /dev/null +++ b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/.gitignore @@ -0,0 +1 @@ +.nx diff --git a/packages/plugin-typescript/mocks/fixtures/nx-monorepo/_nx.json b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/_nx.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/_nx.json @@ -0,0 +1 @@ +{} diff --git a/packages/plugin-typescript/mocks/fixtures/nx-monorepo/_package.json b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/_package.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/_package.json @@ -0,0 +1 @@ +{} diff --git a/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/cli/_project.json b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/cli/_project.json new file mode 100644 index 000000000..746e2f227 --- /dev/null +++ b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/cli/_project.json @@ -0,0 +1,5 @@ +{ + "name": "cli", + "sourceRoot": "packages/cli/src", + "projectType": "library" +} diff --git a/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/cli/tsconfig.lib.json b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/cli/tsconfig.lib.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/cli/tsconfig.lib.json @@ -0,0 +1 @@ +{} diff --git a/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/core/_project.json b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/core/_project.json new file mode 100644 index 000000000..46ee6d7be --- /dev/null +++ b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/core/_project.json @@ -0,0 +1,5 @@ +{ + "name": "core", + "sourceRoot": "packages/core/src", + "projectType": "library" +} diff --git a/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/core/tsconfig.lib.json b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/core/tsconfig.lib.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/core/tsconfig.lib.json @@ -0,0 +1 @@ +{} diff --git a/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/nx-plugin/_project.json b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/nx-plugin/_project.json new file mode 100644 index 000000000..a90a7504c --- /dev/null +++ b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/nx-plugin/_project.json @@ -0,0 +1,5 @@ +{ + "name": "nx-plugin", + "sourceRoot": "packages/nx-plugin/src", + "projectType": "library" +} diff --git a/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/nx-plugin/tsconfig.lib.json b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/nx-plugin/tsconfig.lib.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/nx-plugin/tsconfig.lib.json @@ -0,0 +1 @@ +{} diff --git a/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/utils/_project.json b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/utils/_project.json new file mode 100644 index 000000000..8a2597ed9 --- /dev/null +++ b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/utils/_project.json @@ -0,0 +1,5 @@ +{ + "name": "utils", + "sourceRoot": "packages/utils/src", + "projectType": "library" +} diff --git a/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/utils/tsconfig.lib.json b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/utils/tsconfig.lib.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/plugin-typescript/mocks/fixtures/nx-monorepo/packages/utils/tsconfig.lib.json @@ -0,0 +1 @@ +{} diff --git a/packages/plugin-typescript/package.json b/packages/plugin-typescript/package.json index ac0efcef9..219d6fc0b 100644 --- a/packages/plugin-typescript/package.json +++ b/packages/plugin-typescript/package.json @@ -28,8 +28,14 @@ "zod": "^4.2.1" }, "peerDependencies": { + "@nx/devkit": ">=17.0.0", "typescript": ">=4.0.0" }, + "peerDependenciesMeta": { + "@nx/devkit": { + "optional": true + } + }, "scripts": {}, "files": [ "src", diff --git a/packages/plugin-typescript/src/index.ts b/packages/plugin-typescript/src/index.ts index a483ce6a5..5d0fd8d0d 100644 --- a/packages/plugin-typescript/src/index.ts +++ b/packages/plugin-typescript/src/index.ts @@ -9,3 +9,4 @@ export { type TypescriptPluginOptions, } from './lib/schema.js'; export { getCategories, getCategoryRefsFromGroups } from './lib/utils.js'; +export { tsconfigFromAllNxProjects } from './lib/nx/tsconfig-paths.js'; diff --git a/packages/plugin-typescript/src/lib/nx/tsconfig-paths.int.test.ts b/packages/plugin-typescript/src/lib/nx/tsconfig-paths.int.test.ts new file mode 100644 index 000000000..408d09207 --- /dev/null +++ b/packages/plugin-typescript/src/lib/nx/tsconfig-paths.int.test.ts @@ -0,0 +1,68 @@ +import { cp } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import type { MockInstance } from 'vitest'; +import { + restoreNxIgnoredFiles, + teardownTestFolder, +} from '@code-pushup/test-utils'; +import { executeProcess } from '@code-pushup/utils'; +import { tsconfigFromAllNxProjects } from './tsconfig-paths.js'; + +// Test setup adapted from packages/plugin-eslint/src/lib/nx/nx.int.test.ts +describe.skipIf(process.platform === 'win32')('Nx helpers', () => { + const thisDir = fileURLToPath(path.dirname(import.meta.url)); + const fixturesDir = path.join(thisDir, '..', '..', '..', 'mocks', 'fixtures'); + const tmpDir = path.join(process.cwd(), 'tmp', 'int', 'plugin-typescript'); + let cwdSpy: MockInstance<[], string>; + + beforeAll(async () => { + const workspaceDir = path.join(tmpDir, 'nx-monorepo'); + await cp(path.join(fixturesDir, 'nx-monorepo'), workspaceDir, { + recursive: true, + }); + await restoreNxIgnoredFiles(workspaceDir); + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(workspaceDir); + + await executeProcess({ + command: 'npx nx graph --file=.nx/graph.json', + cwd: workspaceDir, + }); + }); + + afterAll(async () => { + cwdSpy.mockRestore(); + await teardownTestFolder(tmpDir); + }); + + describe('tsconfigFromAllNxProjects', () => { + it('should find tsconfig.lib.json from all Nx projects', async () => { + await expect(tsconfigFromAllNxProjects()).resolves.toEqual([ + 'packages/cli/tsconfig.lib.json', + 'packages/core/tsconfig.lib.json', + 'packages/nx-plugin/tsconfig.lib.json', + 'packages/utils/tsconfig.lib.json', + ]); + }); + + it('should exclude specified projects', async () => { + await expect( + tsconfigFromAllNxProjects({ + exclude: ['cli', 'core'], + }), + ).resolves.toEqual([ + 'packages/nx-plugin/tsconfig.lib.json', + 'packages/utils/tsconfig.lib.json', + ]); + }); + + it('should return empty array when all projects are excluded', async () => { + await expect( + tsconfigFromAllNxProjects({ + exclude: ['cli', 'core', 'nx-plugin', 'utils'], + }), + ).resolves.toEqual([]); + }); + }); +}); diff --git a/packages/plugin-typescript/src/lib/nx/tsconfig-paths.ts b/packages/plugin-typescript/src/lib/nx/tsconfig-paths.ts new file mode 100644 index 000000000..0bed5d926 --- /dev/null +++ b/packages/plugin-typescript/src/lib/nx/tsconfig-paths.ts @@ -0,0 +1,90 @@ +import type { ProjectConfiguration } from '@nx/devkit'; +import { readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { logger, pluralizeToken, stringifyError } from '@code-pushup/utils'; +import { formatMetaLog } from '../format.js'; + +/** + * TypeScript config patterns to look for in each project. + */ +const TSCONFIG_PATTERNS = new Set([ + 'tsconfig.lib.json', + 'tsconfig.spec.json', + 'tsconfig.test.json', + 'tsconfig.app.json', +]); + +/** + * Resolves the cached project graph for the current Nx workspace. + * Tries to read from cache first, falls back to async creation. + */ +async function resolveCachedProjectGraph() { + const { readCachedProjectGraph, createProjectGraphAsync } = await import( + '@nx/devkit' + ); + try { + return readCachedProjectGraph(); + } catch (error) { + logger.warn( + `Could not read cached project graph, falling back to async creation.\n${stringifyError(error)}`, + ); + return await createProjectGraphAsync({ exitOnError: false }); + } +} + +function isProjectIncluded( + project: ProjectConfiguration, + exclude?: string[], +): boolean { + return !exclude?.includes(project.name ?? ''); +} + +/** + * Finds tsconfig files matching known patterns in a project directory. + */ +async function findTsconfigsInProject(projectRoot: string): Promise { + const absoluteRoot = path.resolve(process.cwd(), projectRoot); + const files = await readdir(absoluteRoot); + return files + .filter(file => TSCONFIG_PATTERNS.has(file)) + .map(file => path.join(projectRoot, file)); +} + +/** + * Finds all tsconfig files from Nx projects in the workspace. + * + * @param options - Configuration options + * @param options.exclude - Array of project names to exclude + * @returns Array of tsconfig file paths + */ +export async function tsconfigFromAllNxProjects( + options: { exclude?: string[] } = {}, +): Promise { + const projectGraph = await resolveCachedProjectGraph(); + + const { readProjectsConfigurationFromProjectGraph } = await import( + '@nx/devkit' + ); + const { projects } = readProjectsConfigurationFromProjectGraph(projectGraph); + + const projectRoots = Object.values(projects) + .filter(project => isProjectIncluded(project, options.exclude)) + .map(project => project.root) + .toSorted(); + + const tsconfigs = ( + await Promise.all(projectRoots.map(findTsconfigsInProject)) + ).flat(); + + logger.info( + formatMetaLog( + `Found ${pluralizeToken('tsconfig', tsconfigs.length)} in ${pluralizeToken('Nx project', projectRoots.length)}${ + options.exclude?.length + ? ` (excluding ${pluralizeToken('project', options.exclude.length)})` + : '' + }`, + ), + ); + + return tsconfigs; +} diff --git a/packages/plugin-typescript/src/lib/runner/runner.int.test.ts b/packages/plugin-typescript/src/lib/runner/runner.int.test.ts index 167cae7ef..4624bbc54 100644 --- a/packages/plugin-typescript/src/lib/runner/runner.int.test.ts +++ b/packages/plugin-typescript/src/lib/runner/runner.int.test.ts @@ -7,8 +7,9 @@ import { createRunnerFunction } from './runner.js'; describe('createRunnerFunction', () => { it('should create valid audit outputs when called', async () => { const runnerFunction = createRunnerFunction({ - tsconfig: + tsconfig: [ 'packages/plugin-typescript/mocks/fixtures/basic-setup/tsconfig.all-audits.json', + ], expectedAudits: getAudits(), }); diff --git a/packages/plugin-typescript/src/lib/runner/runner.ts b/packages/plugin-typescript/src/lib/runner/runner.ts index 542bc5c3e..28504e329 100644 --- a/packages/plugin-typescript/src/lib/runner/runner.ts +++ b/packages/plugin-typescript/src/lib/runner/runner.ts @@ -8,6 +8,7 @@ import { formatAsciiTable, logger, pluralizeToken, + stringifyError, toSentenceCase, } from '@code-pushup/utils'; import type { AuditSlug } from '../types.js'; @@ -20,47 +21,89 @@ export type RunnerOptions = { expectedAudits: { slug: AuditSlug }[]; }; +type DiagnosticResult = { code: number; issue: Issue }; +type CollectResult = { diagnostics: DiagnosticResult[]; skipped: number }; + +type GroupedDiagnostics = Partial< + Record> +>; + +function collectDiagnostics(tsconfigs: string[]): CollectResult { + return tsconfigs.reduce( + (acc, config) => { + try { + const diagnostics = [ + ...getTypeScriptDiagnostics({ tsconfig: config }), + ].map(diag => ({ + code: diag.code, + issue: getIssueFromDiagnostic(diag), + })); + return { ...acc, diagnostics: [...acc.diagnostics, ...diagnostics] }; + } catch (error) { + if (tsconfigs.length === 1) { + throw error; + } + logger.warn( + `Skipping ${config}: ${stringifyError(error, { oneline: true })}`, + ); + return { ...acc, skipped: acc.skipped + 1 }; + } + }, + { diagnostics: [], skipped: 0 }, + ); +} + +function groupDiagnosticsByAudit( + diagnostics: DiagnosticResult[], +): GroupedDiagnostics { + return diagnostics.reduce((acc, { code, issue }) => { + const slug = tsCodeToAuditSlug(code); + const existingIssues: Issue[] = acc[slug]?.details?.issues ?? []; + return { + ...acc, + [slug]: { slug, details: { issues: [...existingIssues, issue] } }, + }; + }, {}); +} + +function logDiagnosticsSummary(result: GroupedDiagnostics): void { + logger.debug( + formatAsciiTable( + { + columns: ['left', 'right'], + rows: Object.values(result).map(audit => [ + `• ${toSentenceCase(audit.slug)}`, + audit.details?.issues?.length ?? 0, + ]), + }, + { borderless: true }, + ), + ); +} + export function createRunnerFunction(options: RunnerOptions): RunnerFunction { const { tsconfig, expectedAudits } = options; return (): AuditOutputs => { - const diagnostics = tsconfig.flatMap(config => [ - ...getTypeScriptDiagnostics({ tsconfig: config }), - ]); - - const result = diagnostics.reduce< - Partial>> - >((acc, diag) => { - const slug = tsCodeToAuditSlug(diag.code); - const existingIssues: Issue[] = acc[slug]?.details?.issues ?? []; - return { - ...acc, - [slug]: { - slug, - details: { - issues: [...existingIssues, getIssueFromDiagnostic(diag)], - }, - }, - }; - }, {}); + const { diagnostics, skipped } = collectDiagnostics(tsconfig); - logger.debug( - formatAsciiTable( - { - columns: ['left', 'right'], - rows: Object.values(result).map(audit => [ - `• ${toSentenceCase(audit.slug)}`, - audit.details?.issues?.length ?? 0, - ]), - }, - { borderless: true }, - ), + if (skipped === tsconfig.length) { + throw new Error( + `All ${tsconfig.length} TypeScript configurations failed to load`, + ); + } + logger.info( + diagnostics.length === 0 + ? 'No TypeScript errors found' + : `TypeScript compiler found ${pluralizeToken('diagnostic', diagnostics.length)}`, ); - return expectedAudits.map(({ slug }): AuditOutput => { - const { details } = result[slug] ?? {}; + const result = groupDiagnosticsByAudit(diagnostics); + + logDiagnosticsSummary(result); - const issues = details?.issues ?? []; + return expectedAudits.map(({ slug }): AuditOutput => { + const issues = result[slug]?.details?.issues ?? []; return { slug, score: issues.length === 0 ? 1 : 0, @@ -69,7 +112,7 @@ export function createRunnerFunction(options: RunnerOptions): RunnerFunction { issues.length === 0 ? 'passed' : pluralizeToken('error', issues.length), - ...(issues.length > 0 ? { details } : {}), + ...(issues.length > 0 ? { details: { issues } } : {}), }; }); }; diff --git a/packages/plugin-typescript/src/lib/runner/runner.unit.test.ts b/packages/plugin-typescript/src/lib/runner/runner.unit.test.ts index 0f7682fe6..2edde0b8c 100644 --- a/packages/plugin-typescript/src/lib/runner/runner.unit.test.ts +++ b/packages/plugin-typescript/src/lib/runner/runner.unit.test.ts @@ -231,4 +231,47 @@ describe('createRunnerFunction', () => { }), ]); }); + + it('should skip failed tsconfigs and warn when multiple configs provided', () => { + getTypeScriptDiagnosticsSpy + .mockReturnValueOnce([mockSemanticDiagnostic]) + .mockImplementationOnce(() => { + throw new Error('File not found'); + }); + const runner = createRunnerFunction({ + tsconfig: ['tsconfig.lib.json', 'tsconfig.broken.json'], + expectedAudits: [{ slug: 'semantic-errors' }], + }); + const auditOutputs = runner(runnerArgs); + expect(auditOutputs).toStrictEqual([ + expect.objectContaining({ + slug: 'semantic-errors', + value: 1, + }), + ]); + }); + + it('should throw if single tsconfig fails', () => { + getTypeScriptDiagnosticsSpy.mockImplementation(() => { + throw new Error('File not found'); + }); + const runner = createRunnerFunction({ + tsconfig: ['tsconfig.json'], + expectedAudits: [], + }); + expect(() => runner(runnerArgs)).toThrow('File not found'); + }); + + it('should throw if all tsconfigs fail', () => { + getTypeScriptDiagnosticsSpy.mockImplementation(() => { + throw new Error('Error'); + }); + const runner = createRunnerFunction({ + tsconfig: ['tsconfig.a.json', 'tsconfig.b.json'], + expectedAudits: [], + }); + expect(() => runner(runnerArgs)).toThrow( + 'All 2 TypeScript configurations failed to load', + ); + }); }); diff --git a/packages/plugin-typescript/src/lib/runner/ts-runner.ts b/packages/plugin-typescript/src/lib/runner/ts-runner.ts index 6103b6e1e..a086ae4ed 100644 --- a/packages/plugin-typescript/src/lib/runner/ts-runner.ts +++ b/packages/plugin-typescript/src/lib/runner/ts-runner.ts @@ -14,13 +14,13 @@ export function getTypeScriptDiagnostics({ tsconfig, }: DiagnosticsOptions): readonly Diagnostic[] { const { fileNames, options } = loadTargetConfig(tsconfig); - logger.info( + logger.debug( `Parsed TypeScript config file ${tsconfig}, program includes ${pluralizeToken('file', fileNames.length)}`, ); try { const program = createProgram(fileNames, options); const diagnostics = getPreEmitDiagnostics(program); - logger.info( + logger.debug( `TypeScript compiler found ${pluralizeToken('diagnostic', diagnostics.length)}`, ); return diagnostics; diff --git a/packages/plugin-typescript/tsconfig.test.json b/packages/plugin-typescript/tsconfig.test.json index b243bb3c5..54cacd82f 100644 --- a/packages/plugin-typescript/tsconfig.test.json +++ b/packages/plugin-typescript/tsconfig.test.json @@ -12,6 +12,7 @@ "src/**/*.test.tsx", "src/**/*.test.js", "src/**/*.test.jsx", - "src/**/*.d.ts" + "src/**/*.d.ts", + "../../testing/test-setup/src/vitest.d.ts" ] } diff --git a/packages/utils/code-pushup.config.ts b/packages/utils/code-pushup.config.ts index c47945a37..0f2a693a1 100644 --- a/packages/utils/code-pushup.config.ts +++ b/packages/utils/code-pushup.config.ts @@ -14,6 +14,6 @@ export default mergeConfigs( configureUpload(projectName), await configureEslintPlugin(projectName), await configureCoveragePlugin(projectName), - configureTypescriptPlugin(projectName), + await configureTypescriptPlugin(projectName), configureJsDocsPlugin(projectName), ); diff --git a/tsconfig.code-pushup.json b/tsconfig.code-pushup.json deleted file mode 100644 index d664ab211..000000000 --- a/tsconfig.code-pushup.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "exclude": ["node_modules", "tmp", "**/*.test.ts"] -} From 6122c1306ab4379827bb43a59002393c22f6092d Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Thu, 22 Jan 2026 16:48:04 -0500 Subject: [PATCH 3/3] docs(plugin-typescript): document multiple tsconfigs and Nx helper --- packages/plugin-typescript/README.md | 38 ++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/plugin-typescript/README.md b/packages/plugin-typescript/README.md index 888092567..b08e1da3b 100644 --- a/packages/plugin-typescript/README.md +++ b/packages/plugin-typescript/README.md @@ -84,14 +84,14 @@ Each set is also available as group in the plugin. See more under [Audits and Gr The plugin accepts the following parameters: -| Option | Type | Default | Description | -| ---------- | -------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| tsconfig | string | `tsconfig.json` | A string that defines the path to your `tsconfig.json` file | -| onlyAudits | string[] | undefined | An array of audit slugs to specify which documentation types you want to measure. Only the specified audits will be included in the results | +| Option | Type | Default | Description | +| ---------- | ------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| tsconfig | string \| string[] | `tsconfig.json` | Path(s) to your `tsconfig.json` file(s) | +| onlyAudits | string[] | undefined | An array of audit slugs to specify which documentation types you want to measure. Only the specified audits will be included in the results | #### `tsconfig` -Optional parameter. The `tsconfig` option accepts a string that defines the path to your config file and defaults to `tsconfig.json`. +Optional parameter. The `tsconfig` option accepts a path or an array of paths to your config files. Defaults to `tsconfig.json`. ```js typescriptPlugin({ @@ -99,6 +99,34 @@ typescriptPlugin({ }); ``` +You can also provide multiple tsconfigs to combine results from different configurations (e.g., separate configs for source and test files): + +```js +typescriptPlugin({ + tsconfig: ['./tsconfig.lib.json', './tsconfig.spec.json'], +}); +``` + +If you're using an Nx monorepo, a helper function is provided to auto-discover tsconfigs from all projects: + +```js +import typescriptPlugin, { tsconfigFromAllNxProjects } from '@code-pushup/typescript-plugin'; + +export default { + plugins: [ + await typescriptPlugin({ + tsconfig: await tsconfigFromAllNxProjects(), + }), + ], +}; +``` + +You can exclude specific projects by name: + +```js +await tsconfigFromAllNxProjects({ exclude: ['my-app-e2e'] }); +``` + #### `onlyAudits` The `onlyAudits` option allows you to specify which documentation types you want to measure. Only the specified audits will be included in the results. All audits are included by default. Example: