From bf1a70d679bba9fdccf1deaa33581d33d67660dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Wed, 22 Oct 2025 18:45:02 +0200 Subject: [PATCH 01/13] refactor(models): replace .describe('...') with recommended .meta({ description: '...' }) --- packages/models/src/lib/audit-output.ts | 20 +++--- packages/models/src/lib/audit.ts | 4 +- packages/models/src/lib/cache-config.ts | 17 ++--- packages/models/src/lib/category-config.ts | 9 ++- packages/models/src/lib/commit.ts | 12 ++-- packages/models/src/lib/configuration.ts | 6 +- packages/models/src/lib/group.ts | 2 +- .../models/src/lib/implementation/schemas.ts | 62 ++++++++++-------- packages/models/src/lib/issue.ts | 6 +- packages/models/src/lib/persist-config.ts | 6 +- packages/models/src/lib/plugin-config.ts | 6 +- packages/models/src/lib/report.ts | 9 ++- packages/models/src/lib/reports-diff.ts | 41 ++++++------ packages/models/src/lib/runner-config.ts | 27 +++++--- packages/models/src/lib/source.ts | 6 +- packages/models/src/lib/table.ts | 17 +++-- packages/models/src/lib/tree.ts | 64 ++++++++++--------- packages/models/src/lib/upload-config.ts | 21 +++--- packages/plugin-coverage/src/lib/config.ts | 35 +++++----- packages/plugin-eslint/src/lib/config.ts | 29 +++++---- packages/plugin-js-packages/src/lib/config.ts | 16 +++-- packages/plugin-jsdocs/src/lib/config.ts | 2 +- packages/plugin-typescript/src/lib/schema.ts | 8 ++- 23 files changed, 242 insertions(+), 183 deletions(-) diff --git a/packages/models/src/lib/audit-output.ts b/packages/models/src/lib/audit-output.ts index 878451acb..72cd46009 100644 --- a/packages/models/src/lib/audit-output.ts +++ b/packages/models/src/lib/audit-output.ts @@ -10,35 +10,39 @@ import { issueSchema } from './issue.js'; import { tableSchema } from './table.js'; import { treeSchema } from './tree.js'; -export const auditValueSchema = - nonnegativeNumberSchema.describe('Raw numeric value'); +export const auditValueSchema = nonnegativeNumberSchema.meta({ + description: 'Raw numeric value', +}); export const auditDisplayValueSchema = z .string() .optional() - .describe("Formatted value (e.g. '0.9 s', '2.1 MB')"); + .meta({ description: "Formatted value (e.g. '0.9 s', '2.1 MB')" }); export const auditDetailsSchema = z .object({ - issues: z.array(issueSchema).describe('List of findings').optional(), + issues: z + .array(issueSchema) + .meta({ description: 'List of findings' }) + .optional(), table: tableSchema('Table of related findings').optional(), trees: z .array(treeSchema) - .describe('Findings in tree structure') + .meta({ description: 'Findings in tree structure' }) .optional(), }) - .describe('Detailed information'); + .meta({ description: 'Detailed information' }); export type AuditDetails = z.infer; export const auditOutputSchema = z .object({ - slug: slugSchema.describe('Reference to audit'), + slug: slugSchema.meta({ description: 'Reference to audit' }), displayValue: auditDisplayValueSchema, value: auditValueSchema, score: scoreSchema, scoreTarget: scoreTargetSchema, details: auditDetailsSchema.optional(), }) - .describe('Audit information'); + .meta({ description: 'Audit information' }); export type AuditOutput = z.infer; diff --git a/packages/models/src/lib/audit.ts b/packages/models/src/lib/audit.ts index 92ca66c9a..b881fbf77 100644 --- a/packages/models/src/lib/audit.ts +++ b/packages/models/src/lib/audit.ts @@ -4,7 +4,7 @@ import { metaSchema, slugSchema } from './implementation/schemas.js'; export const auditSchema = z .object({ - slug: slugSchema.describe('ID (unique within plugin)'), + slug: slugSchema.meta({ description: 'ID (unique within plugin)' }), }) .extend( metaSchema({ @@ -21,4 +21,4 @@ export const pluginAuditsSchema = z .array(auditSchema) .min(1) .check(createDuplicateSlugsCheck('Audit')) - .describe('List of audits maintained in a plugin'); + .meta({ description: 'List of audits maintained in a plugin' }); diff --git a/packages/models/src/lib/cache-config.ts b/packages/models/src/lib/cache-config.ts index e595a4320..000256fc3 100644 --- a/packages/models/src/lib/cache-config.ts +++ b/packages/models/src/lib/cache-config.ts @@ -4,26 +4,27 @@ export const cacheConfigObjectSchema = z .object({ read: z .boolean() - .describe('Whether to read from cache if available') + .meta({ description: 'Whether to read from cache if available' }) .default(false), write: z .boolean() - .describe('Whether to write results to cache') + .meta({ description: 'Whether to write results to cache' }) .default(false), }) - .describe('Cache configuration object for read and/or write operations'); + .meta({ + description: 'Cache configuration object for read and/or write operations', + }); export type CacheConfigObject = z.infer; -export const cacheConfigShorthandSchema = z - .boolean() - .describe( +export const cacheConfigShorthandSchema = z.boolean().meta({ + description: 'Cache configuration shorthand for both, read and write operations', - ); +}); export type CacheConfigShorthand = z.infer; export const cacheConfigSchema = z .union([cacheConfigShorthandSchema, cacheConfigObjectSchema]) - .describe('Cache configuration for read and write operations') + .meta({ description: 'Cache configuration for read and write operations' }) .default(false); export type CacheConfig = z.infer; diff --git a/packages/models/src/lib/category-config.ts b/packages/models/src/lib/category-config.ts index ff618b19b..fe8384fcb 100644 --- a/packages/models/src/lib/category-config.ts +++ b/packages/models/src/lib/category-config.ts @@ -16,11 +16,10 @@ export const categoryRefSchema = weightedRefSchema( 'Weighted references to audits and/or groups for the category', 'Slug of an audit or group (depending on `type`)', ).extend({ - type: z - .enum(['audit', 'group']) - .describe( + type: z.enum(['audit', 'group']).meta({ + description: 'Discriminant for reference kind, affects where `slug` is looked up', - ), + }), plugin: slugSchema.describe( 'Plugin slug (plugin should contain referenced audit or group)', ), @@ -70,4 +69,4 @@ function formatSerializedCategoryRefTargets(keys: string[]): string { export const categoriesSchema = z .array(categoryConfigSchema) .check(createDuplicateSlugsCheck('Category')) - .describe('Categorization of individual audits'); + .meta({ description: 'Categorization of individual audits' }); diff --git a/packages/models/src/lib/commit.ts b/packages/models/src/lib/commit.ts index c92609ef2..b07af9809 100644 --- a/packages/models/src/lib/commit.ts +++ b/packages/models/src/lib/commit.ts @@ -8,11 +8,13 @@ export const commitSchema = z /^[\da-f]{40}$/, 'Commit SHA should be a 40-character hexadecimal string', ) - .describe('Commit SHA (full)'), - message: z.string().describe('Commit message'), - date: z.coerce.date().describe('Date and time when commit was authored'), - author: z.string().trim().describe('Commit author name'), + .meta({ description: 'Commit SHA (full)' }), + message: z.string().meta({ description: 'Commit message' }), + date: z.coerce + .date() + .meta({ description: 'Date and time when commit was authored' }), + author: z.string().trim().meta({ description: 'Commit author name' }), }) - .describe('Git commit'); + .meta({ description: 'Git commit' }); export type Commit = z.infer; diff --git a/packages/models/src/lib/configuration.ts b/packages/models/src/lib/configuration.ts index 7151e2ceb..1a009e6c3 100644 --- a/packages/models/src/lib/configuration.ts +++ b/packages/models/src/lib/configuration.ts @@ -5,9 +5,9 @@ import { globPathSchema } from './implementation/schemas.js'; * Generic schema for a tool command configuration, reusable across plugins. */ export const artifactGenerationCommandSchema = z.union([ - z.string().min(1).describe('Generate artifact files'), + z.string().min(1).meta({ description: 'Generate artifact files' }), z.object({ - command: z.string().min(1).describe('Generate artifact files'), + command: z.string().min(1).meta({ description: 'Generate artifact files' }), args: z.array(z.string()).optional(), }), ]); @@ -16,7 +16,7 @@ export const pluginArtifactOptionsSchema = z.object({ generateArtifactsCommand: artifactGenerationCommandSchema.optional(), artifactsPaths: z .union([globPathSchema, z.array(globPathSchema).min(1)]) - .describe('File paths or glob patterns for artifact files'), + .meta({ description: 'File paths or glob patterns for artifact files' }), }); export type PluginArtifactOptions = z.infer; diff --git a/packages/models/src/lib/group.ts b/packages/models/src/lib/group.ts index a7b16eec0..2633698f0 100644 --- a/packages/models/src/lib/group.ts +++ b/packages/models/src/lib/group.ts @@ -42,4 +42,4 @@ export const groupsSchema = z .array(groupSchema) .check(createDuplicateSlugsCheck('Group')) .optional() - .describe('List of groups'); + .meta({ description: 'List of groups' }); diff --git a/packages/models/src/lib/implementation/schemas.ts b/packages/models/src/lib/implementation/schemas.ts index 522de6074..a1245402b 100644 --- a/packages/models/src/lib/implementation/schemas.ts +++ b/packages/models/src/lib/implementation/schemas.ts @@ -47,13 +47,13 @@ export const slugSchema = z .max(MAX_SLUG_LENGTH, { message: `The slug can be max ${MAX_SLUG_LENGTH} characters long`, }) - .describe('Unique ID (human-readable, URL-safe)'); + .meta({ description: 'Unique ID (human-readable, URL-safe)' }); /** Schema for a general description property */ export const descriptionSchema = z .string() .max(MAX_DESCRIPTION_LENGTH) - .describe('Description (markdown)') + .meta({ description: 'Description (markdown)' }) .optional(); /* Schema for a URL */ @@ -79,20 +79,20 @@ export const docsUrlSchema = urlSchema } throw new ZodError(ctx.error.issues); }) - .describe('Documentation site'); + .meta({ description: 'Documentation site' }); /** Schema for a title of a plugin, category and audit */ export const titleSchema = z .string() .max(MAX_TITLE_LENGTH) - .describe('Descriptive name'); + .meta({ description: 'Descriptive name' }); /** Schema for score of audit, category or group */ export const scoreSchema = z .number() .min(0) .max(1) - .describe('Value between 0 and 1'); + .meta({ description: 'Value between 0 and 1' }); /** Schema for a property indicating whether an entity is filtered out */ export const isSkippedSchema = z.boolean().optional(); @@ -153,9 +153,10 @@ export const globPathSchema = z message: 'The path must be a valid file path or glob pattern (supports *, **, {}, [], !, ?)', }) - .describe( - 'Schema for a glob pattern (supports wildcards like *, **, {}, !, etc.)', - ); + .meta({ + description: + 'Schema for a glob pattern (supports wildcards like *, **, {}, !, etc.)', + }); /** Schema for a fileNameSchema */ export const fileNameSchema = z @@ -176,16 +177,16 @@ export function packageVersionSchema< >(options?: { versionDescription?: string; required?: TRequired }) { const { versionDescription = 'NPM version of the package', required } = options ?? {}; - const packageSchema = z.string().describe('NPM package name'); - const versionSchema = z.string().describe(versionDescription); + const packageSchema = z.string().meta({ description: 'NPM package name' }); + const versionSchema = z.string().meta({ description: versionDescription }); return z .object({ packageName: required ? packageSchema : packageSchema.optional(), version: required ? versionSchema : versionSchema.optional(), }) - .describe( - 'NPM package name and version of a published package', - ) as ZodObject<{ + .meta({ + description: 'NPM package name and version of a published package', + }) as ZodObject<{ packageName: TRequired extends true ? ZodString : ZodOptional; version: TRequired extends true ? ZodString : ZodOptional; }>; @@ -194,13 +195,14 @@ export function packageVersionSchema< /** Schema for a binary score threshold */ export const scoreTargetSchema = nonnegativeNumberSchema .max(1) - .describe('Pass/fail score threshold (0-1)') + .meta({ description: 'Pass/fail score threshold (0-1)' }) .optional(); /** Schema for a weight */ -export const weightSchema = nonnegativeNumberSchema.describe( - 'Coefficient for the given score (use weight 0 if only for display)', -); +export const weightSchema = nonnegativeNumberSchema.meta({ + description: + 'Coefficient for the given score (use weight 0 if only for display)', +}); export function weightedRefSchema( description: string, @@ -208,10 +210,12 @@ export function weightedRefSchema( ) { return z .object({ - slug: slugSchema.describe(slugDescription), - weight: weightSchema.describe('Weight used to calculate score'), + slug: slugSchema.meta({ description: slugDescription }), + weight: weightSchema.meta({ + description: 'Weight used to calculate score', + }), }) - .describe(description); + .meta({ description }); } export type WeightedRef = z.infer>; @@ -223,7 +227,9 @@ export function scorableSchema>( ) { return z .object({ - slug: slugSchema.describe('Human-readable unique ID, e.g. "performance"'), + slug: slugSchema.meta({ + description: 'Human-readable unique ID, e.g. "performance"', + }), refs: z .array(refSchema) .min(1, { message: 'In a category, there has to be at least one ref' }) @@ -239,7 +245,7 @@ export function scorableSchema>( export const materialIconSchema = z .enum(MATERIAL_ICONS) - .describe('Icon from VSCode Material Icons extension'); + .meta({ description: 'Icon from VSCode Material Icons extension' }); export type MaterialIcon = z.infer; type Ref = { weight: number }; @@ -250,9 +256,11 @@ function hasNonZeroWeightedRef(refs: Ref[]) { export const filePositionSchema = z .object({ - startLine: positiveIntSchema.describe('Start line'), - startColumn: positiveIntSchema.describe('Start column').optional(), - endLine: positiveIntSchema.describe('End line').optional(), - endColumn: positiveIntSchema.describe('End column').optional(), + startLine: positiveIntSchema.meta({ description: 'Start line' }), + startColumn: positiveIntSchema + .meta({ description: 'Start column' }) + .optional(), + endLine: positiveIntSchema.meta({ description: 'End line' }).optional(), + endColumn: positiveIntSchema.meta({ description: 'End column' }).optional(), }) - .describe('Location in file'); + .meta({ description: 'Location in file' }); diff --git a/packages/models/src/lib/issue.ts b/packages/models/src/lib/issue.ts index 378db354e..6073263d9 100644 --- a/packages/models/src/lib/issue.ts +++ b/packages/models/src/lib/issue.ts @@ -4,7 +4,7 @@ import { sourceFileLocationSchema } from './source.js'; export const issueSeveritySchema = z .enum(['info', 'warning', 'error']) - .describe('Severity level'); + .meta({ description: 'Severity level' }); export type IssueSeverity = z.infer; export const issueSchema = z @@ -12,9 +12,9 @@ export const issueSchema = z message: z .string() .max(MAX_ISSUE_MESSAGE_LENGTH) - .describe('Descriptive error message'), + .meta({ description: 'Descriptive error message' }), severity: issueSeveritySchema, source: sourceFileLocationSchema.optional(), }) - .describe('Issue information'); + .meta({ description: 'Issue information' }); export type Issue = z.infer; diff --git a/packages/models/src/lib/persist-config.ts b/packages/models/src/lib/persist-config.ts index 81cc80c0b..e6dd85843 100644 --- a/packages/models/src/lib/persist-config.ts +++ b/packages/models/src/lib/persist-config.ts @@ -5,9 +5,11 @@ export const formatSchema = z.enum(['json', 'md']); export type Format = z.infer; export const persistConfigSchema = z.object({ - outputDir: filePathSchema.describe('Artifacts folder').optional(), + outputDir: filePathSchema + .meta({ description: 'Artifacts folder' }) + .optional(), filename: fileNameSchema - .describe('Artifacts file name (without extension)') + .meta({ description: 'Artifacts file name (without extension)' }) .optional(), format: z.array(formatSchema).optional(), skipReports: z.boolean().optional(), diff --git a/packages/models/src/lib/plugin-config.ts b/packages/models/src/lib/plugin-config.ts index 865a6c7d4..35d5a5f28 100644 --- a/packages/models/src/lib/plugin-config.ts +++ b/packages/models/src/lib/plugin-config.ts @@ -15,7 +15,7 @@ import { runnerConfigSchema, runnerFunctionSchema } from './runner-config.js'; export const pluginContextSchema = z .record(z.string(), z.unknown()) .optional() - .describe('Plugin-specific context data for helpers'); + .meta({ description: 'Plugin-specific context data for helpers' }); export type PluginContext = z.infer; export const pluginMetaSchema = packageVersionSchema() @@ -28,7 +28,9 @@ export const pluginMetaSchema = packageVersionSchema() }).shape, ) .extend({ - slug: slugSchema.describe('Unique plugin slug within core config'), + slug: slugSchema.meta({ + description: 'Unique plugin slug within core config', + }), icon: materialIconSchema, }); export type PluginMeta = z.infer; diff --git a/packages/models/src/lib/report.ts b/packages/models/src/lib/report.ts index e18945dd9..b49a9d93b 100644 --- a/packages/models/src/lib/report.ts +++ b/packages/models/src/lib/report.ts @@ -50,12 +50,15 @@ export const reportSchema = packageVersionSchema({ plugins: z.array(pluginReportSchema).min(1), categories: z.array(categoryConfigSchema).optional(), commit: commitSchema - .describe('Git commit for which report was collected') + .meta({ description: 'Git commit for which report was collected' }) .nullable(), - label: z.string().optional().describe('Label (e.g. project name)'), + label: z + .string() + .optional() + .meta({ description: 'Label (e.g. project name)' }), }), ) .check(createCheck(findMissingSlugsInCategoryRefs)) - .describe('Collect output data'); + .meta({ description: 'Collect output data' }); export type Report = z.infer; diff --git a/packages/models/src/lib/reports-diff.ts b/packages/models/src/lib/reports-diff.ts index bc4802169..da4e97ae2 100644 --- a/packages/models/src/lib/reports-diff.ts +++ b/packages/models/src/lib/reports-diff.ts @@ -19,8 +19,10 @@ import { pluginMetaSchema } from './plugin-config.js'; function makeComparisonSchema(schema: T) { const sharedDescription = schema.description || 'Result'; return z.object({ - before: schema.describe(`${sharedDescription} (source commit)`), - after: schema.describe(`${sharedDescription} (target commit)`), + before: schema.meta({ + description: `${sharedDescription} (source commit)`, + }), + after: schema.meta({ description: `${sharedDescription} (target commit)` }), }); } @@ -47,7 +49,7 @@ const scorableWithPluginMetaSchema = scorableMetaSchema.merge( z.object({ plugin: pluginMetaSchema .pick({ slug: true, title: true, docsUrl: true }) - .describe('Plugin which defines it'), + .meta({ description: 'Plugin which defines it' }), }), ); @@ -56,14 +58,12 @@ const scorableDiffSchema = scorableMetaSchema.merge( scores: makeComparisonSchema(scoreSchema) .merge( z.object({ - diff: z - .number() - .min(-1) - .max(1) - .describe('Score change (`scores.after - scores.before`)'), + diff: z.number().min(-1).max(1).meta({ + description: 'Score change (`scores.after - scores.before`)', + }), }), ) - .describe('Score comparison'), + .meta({ description: 'Score comparison' }), }), ); const scorableWithPluginDiffSchema = scorableDiffSchema.merge( @@ -77,15 +77,15 @@ export const auditDiffSchema = scorableWithPluginDiffSchema.merge( values: makeComparisonSchema(auditValueSchema) .merge( z.object({ - diff: z - .number() - .describe('Value change (`values.after - values.before`)'), + diff: z.number().meta({ + description: 'Value change (`values.after - values.before`)', + }), }), ) - .describe('Audit `value` comparison'), - displayValues: makeComparisonSchema(auditDisplayValueSchema).describe( - 'Audit `displayValue` comparison', - ), + .meta({ description: 'Audit `value` comparison' }), + displayValues: makeComparisonSchema(auditDisplayValueSchema).meta({ + description: 'Audit `displayValue` comparison', + }), }), ); @@ -110,11 +110,14 @@ export const reportsDiffSchema = z .object({ commits: makeComparisonSchema(commitSchema) .nullable() - .describe('Commits identifying compared reports'), + .meta({ description: 'Commits identifying compared reports' }), portalUrl: urlSchema .optional() - .describe('Link to comparison page in Code PushUp portal'), - label: z.string().optional().describe('Label (e.g. project name)'), + .meta({ description: 'Link to comparison page in Code PushUp portal' }), + label: z + .string() + .optional() + .meta({ description: 'Label (e.g. project name)' }), categories: makeArraysComparisonSchema( categoryDiffSchema, categoryResultSchema, diff --git a/packages/models/src/lib/runner-config.ts b/packages/models/src/lib/runner-config.ts index 04145f3de..5ff24f0ae 100644 --- a/packages/models/src/lib/runner-config.ts +++ b/packages/models/src/lib/runner-config.ts @@ -16,20 +16,25 @@ export const runnerArgsSchema = z .object({ persist: persistConfigSchema .required() - .describe('Persist config with defaults applied'), + .meta({ description: 'Persist config with defaults applied' }), }) - .describe('Arguments passed to runner'); + .meta({ description: 'Arguments passed to runner' }); export type RunnerArgs = z.infer; export const runnerConfigSchema = z .object({ - command: z.string().describe('Shell command to execute'), - args: z.array(z.string()).describe('Command arguments').optional(), - outputFile: filePathSchema.describe('Runner output path'), + command: z.string().meta({ description: 'Shell command to execute' }), + args: z + .array(z.string()) + .meta({ description: 'Command arguments' }) + .optional(), + outputFile: filePathSchema.meta({ description: 'Runner output path' }), outputTransform: outputTransformSchema.optional(), - configFile: filePathSchema.describe('Runner config path').optional(), + configFile: filePathSchema + .meta({ description: 'Runner config path' }) + .optional(), }) - .describe('How to execute runner using shell script'); + .meta({ description: 'How to execute runner using shell script' }); export type RunnerConfig = z.infer; export const runnerFunctionSchema = convertAsyncZodFunctionToSchema( @@ -37,11 +42,13 @@ export const runnerFunctionSchema = convertAsyncZodFunctionToSchema( input: [runnerArgsSchema], output: z.union([auditOutputsSchema, z.promise(auditOutputsSchema)]), }), -).describe('Callback function for async runner execution in JS/TS'); +).meta({ + description: 'Callback function for async runner execution in JS/TS', +}); export type RunnerFunction = z.infer; export const runnerFilesPathsSchema = z.object({ - runnerConfigPath: filePathSchema.describe('Runner config path'), - runnerOutputPath: filePathSchema.describe('Runner output path'), + runnerConfigPath: filePathSchema.meta({ description: 'Runner config path' }), + runnerOutputPath: filePathSchema.meta({ description: 'Runner output path' }), }); export type RunnerFilesPaths = z.infer; diff --git a/packages/models/src/lib/source.ts b/packages/models/src/lib/source.ts index e1ca11e6a..b9215fd0a 100644 --- a/packages/models/src/lib/source.ts +++ b/packages/models/src/lib/source.ts @@ -6,9 +6,11 @@ import { export const sourceFileLocationSchema = z .object({ - file: filePathSchema.describe('Relative path to source file in Git repo'), + file: filePathSchema.meta({ + description: 'Relative path to source file in Git repo', + }), position: filePositionSchema.optional(), }) - .describe('Source file location'); + .meta({ description: 'Source file location' }); export type SourceFileLocation = z.infer; diff --git a/packages/models/src/lib/table.ts b/packages/models/src/lib/table.ts index 80b36f3c8..99ed00f20 100644 --- a/packages/models/src/lib/table.ts +++ b/packages/models/src/lib/table.ts @@ -3,7 +3,7 @@ import { tableCellValueSchema } from './implementation/schemas.js'; export const tableAlignmentSchema = z .enum(['left', 'center', 'right']) - .describe('Cell alignment'); + .meta({ description: 'Cell alignment' }); export type TableAlignment = z.infer; export const tableColumnPrimitiveSchema = tableAlignmentSchema; @@ -18,16 +18,16 @@ export type TableColumnObject = z.infer; export const tableRowObjectSchema = z .record(z.string(), tableCellValueSchema) - .describe('Object row'); + .meta({ description: 'Object row' }); export type TableRowObject = z.infer; export const tableRowPrimitiveSchema = z .array(tableCellValueSchema) - .describe('Primitive row'); + .meta({ description: 'Primitive row' }); export type TableRowPrimitive = z.infer; const tableSharedSchema = z.object({ - title: z.string().optional().describe('Display title for table'), + title: z.string().optional().meta({ description: 'Display title for table' }), }); const tablePrimitiveSchema = tableSharedSchema .merge( @@ -36,7 +36,9 @@ const tablePrimitiveSchema = tableSharedSchema rows: z.array(tableRowPrimitiveSchema), }), ) - .describe('Table with primitive rows and optional alignment columns'); + .meta({ + description: 'Table with primitive rows and optional alignment columns', + }); const tableObjectSchema = tableSharedSchema .merge( z.object({ @@ -49,7 +51,10 @@ const tableObjectSchema = tableSharedSchema rows: z.array(tableRowObjectSchema), }), ) - .describe('Table with object rows and optional alignment or object columns'); + .meta({ + description: + 'Table with object rows and optional alignment or object columns', + }); export const tableSchema = (description = 'Table information') => z.union([tablePrimitiveSchema, tableObjectSchema]).describe(description); diff --git a/packages/models/src/lib/tree.ts b/packages/models/src/lib/tree.ts index 645193320..406515d02 100644 --- a/packages/models/src/lib/tree.ts +++ b/packages/models/src/lib/tree.ts @@ -6,19 +6,18 @@ const basicTreeNodeValuesSchema = z.record( z.union([z.number(), z.string()]), ); const basicTreeNodeDataSchema = z.object({ - name: z.string().min(1).describe('Text label for node'), + name: z.string().min(1).meta({ description: 'Text label for node' }), values: basicTreeNodeValuesSchema .optional() - .describe('Additional values for node'), + .meta({ description: 'Additional values for node' }), }); export const basicTreeNodeSchema: z.ZodType = basicTreeNodeDataSchema.extend({ get children() { - return z - .array(basicTreeNodeSchema) - .optional() - .describe('Direct descendants of this node (omit if leaf)'); + return z.array(basicTreeNodeSchema).optional().meta({ + description: 'Direct descendants of this node (omit if leaf)', + }); }, }); export type BasicTreeNode = z.infer & { @@ -27,37 +26,44 @@ export type BasicTreeNode = z.infer & { export const coverageTreeMissingLOCSchema = filePositionSchema .extend({ - name: z.string().optional().describe('Identifier of function/class/etc.'), - kind: z.string().optional().describe('E.g. "function", "class"'), + name: z + .string() + .optional() + .meta({ description: 'Identifier of function/class/etc.' }), + kind: z + .string() + .optional() + .meta({ description: 'E.g. "function", "class"' }), }) - .describe( - 'Uncovered line of code, optionally referring to a named function/class/etc.', - ); + .meta({ + description: + 'Uncovered line of code, optionally referring to a named function/class/etc.', + }); export type CoverageTreeMissingLOC = z.infer< typeof coverageTreeMissingLOCSchema >; const coverageTreeNodeValuesSchema = z.object({ - coverage: z.number().min(0).max(1).describe('Coverage ratio'), + coverage: z.number().min(0).max(1).meta({ description: 'Coverage ratio' }), missing: z .array(coverageTreeMissingLOCSchema) .optional() - .describe('Uncovered lines of code'), + .meta({ description: 'Uncovered lines of code' }), }); const coverageTreeNodeDataSchema = z.object({ - name: z.string().min(1).describe('File or folder name'), - values: coverageTreeNodeValuesSchema.describe( - 'Coverage metrics for file/folder', - ), + name: z.string().min(1).meta({ description: 'File or folder name' }), + values: coverageTreeNodeValuesSchema.meta({ + description: 'Coverage metrics for file/folder', + }), }); export const coverageTreeNodeSchema: z.ZodType = coverageTreeNodeDataSchema.extend({ get children() { - return z - .array(coverageTreeNodeSchema) - .optional() - .describe('Files and folders contained in this folder (omit if file)'); + return z.array(coverageTreeNodeSchema).optional().meta({ + description: + 'Files and folders contained in this folder (omit if file)', + }); }, }); export type CoverageTreeNode = z.infer & { @@ -66,20 +72,20 @@ export type CoverageTreeNode = z.infer & { export const basicTreeSchema = z .object({ - title: z.string().optional().describe('Heading'), - type: z.literal('basic').optional().describe('Discriminant'), - root: basicTreeNodeSchema.describe('Root node'), + title: z.string().optional().meta({ description: 'Heading' }), + type: z.literal('basic').optional().meta({ description: 'Discriminant' }), + root: basicTreeNodeSchema.meta({ description: 'Root node' }), }) - .describe('Generic tree'); + .meta({ description: 'Generic tree' }); export type BasicTree = z.infer; export const coverageTreeSchema = z .object({ - title: z.string().optional().describe('Heading'), - type: z.literal('coverage').describe('Discriminant'), - root: coverageTreeNodeSchema.describe('Root folder'), + title: z.string().optional().meta({ description: 'Heading' }), + type: z.literal('coverage').meta({ description: 'Discriminant' }), + root: coverageTreeNodeSchema.meta({ description: 'Root folder' }), }) - .describe('Coverage for files and folders'); + .meta({ description: 'Coverage for files and folders' }); export type CoverageTree = z.infer; export const treeSchema = z.union([basicTreeSchema, coverageTreeSchema]); diff --git a/packages/models/src/lib/upload-config.ts b/packages/models/src/lib/upload-config.ts index bc68d00db..794244ed5 100644 --- a/packages/models/src/lib/upload-config.ts +++ b/packages/models/src/lib/upload-config.ts @@ -2,22 +2,23 @@ import { z } from 'zod'; import { slugSchema, urlSchema } from './implementation/schemas.js'; export const uploadConfigSchema = z.object({ - server: urlSchema.describe('URL of deployed portal API'), - apiKey: z - .string() - .describe( + server: urlSchema.meta({ description: 'URL of deployed portal API' }), + apiKey: z.string().meta({ + description: 'API key with write access to portal (use `process.env` for security)', - ), - organization: slugSchema.describe( - 'Organization slug from Code PushUp portal', - ), - project: slugSchema.describe('Project slug from Code PushUp portal'), + }), + organization: slugSchema.meta({ + description: 'Organization slug from Code PushUp portal', + }), + project: slugSchema.meta({ + description: 'Project slug from Code PushUp portal', + }), timeout: z .number() .positive() .int() .optional() - .describe('Request timeout in minutes (default is 5)'), + .meta({ description: 'Request timeout in minutes (default is 5)' }), }); export type UploadConfig = z.infer; diff --git a/packages/plugin-coverage/src/lib/config.ts b/packages/plugin-coverage/src/lib/config.ts index daf51c182..9b3cafdf0 100644 --- a/packages/plugin-coverage/src/lib/config.ts +++ b/packages/plugin-coverage/src/lib/config.ts @@ -9,42 +9,45 @@ export const coverageResultSchema = z.union([ resultsPath: z .string() .includes('lcov') - .describe('Path to coverage results for Nx setup.'), + .meta({ description: 'Path to coverage results for Nx setup.' }), pathToProject: z .string() - .describe( - 'Path from workspace root to project root. Necessary for LCOV reports which provide a relative path.', - ) + .meta({ + description: + 'Path from workspace root to project root. Necessary for LCOV reports which provide a relative path.', + }) .optional(), }), - z - .string() - .includes('lcov') - .describe('Path to coverage results for a single project setup.'), + z.string().includes('lcov').meta({ + description: 'Path to coverage results for a single project setup.', + }), ]); export type CoverageResult = z.infer; export const coveragePluginConfigSchema = z.object({ coverageToolCommand: z .object({ - command: z.string().min(1).describe('Command to run coverage tool.'), + command: z + .string() + .min(1) + .meta({ description: 'Command to run coverage tool.' }), args: z .array(z.string()) .optional() - .describe('Arguments to be passed to the coverage tool.'), + .meta({ description: 'Arguments to be passed to the coverage tool.' }), }) .optional(), - continueOnCommandFail: z - .boolean() - .default(true) - .describe( + continueOnCommandFail: z.boolean().default(true).meta({ + description: 'Continue on coverage tool command failure or error. Defaults to true.', - ), + }), coverageTypes: z .array(coverageTypeSchema) .min(1) .default(['function', 'branch', 'line']) - .describe('Coverage types measured. Defaults to all available types.'), + .meta({ + description: 'Coverage types measured. Defaults to all available types.', + }), reports: z .array(coverageResultSchema) .min(1) diff --git a/packages/plugin-eslint/src/lib/config.ts b/packages/plugin-eslint/src/lib/config.ts index e49e14990..430121904 100644 --- a/packages/plugin-eslint/src/lib/config.ts +++ b/packages/plugin-eslint/src/lib/config.ts @@ -5,13 +5,14 @@ import { } from '@code-pushup/models'; import { toArray } from '@code-pushup/utils'; -const patternsSchema = z - .union([z.string(), z.array(z.string()).min(1)]) - .describe( +const patternsSchema = z.union([z.string(), z.array(z.string()).min(1)]).meta({ + description: 'Lint target files. May contain file paths, directory paths or glob patterns', - ); +}); -const eslintrcSchema = z.string().describe('Path to ESLint config file'); +const eslintrcSchema = z + .string() + .meta({ description: 'Path to ESLint config file' }); const eslintTargetObjectSchema = z.object({ eslintrc: eslintrcSchema.optional(), @@ -50,15 +51,19 @@ const customGroupRulesSchema = z error: 'Custom group rules must contain at least 1 element', }), ]) - .describe( - 'Array of rule IDs with equal weights or object mapping rule IDs to specific weights', - ); + .meta({ + description: + 'Array of rule IDs with equal weights or object mapping rule IDs to specific weights', + }); const customGroupSchema = z.object({ - slug: z.string().describe('Unique group identifier'), - title: z.string().describe('Group display title'), - description: z.string().describe('Group metadata').optional(), - docsUrl: z.string().describe('Group documentation site').optional(), + slug: z.string().meta({ description: 'Unique group identifier' }), + title: z.string().meta({ description: 'Group display title' }), + description: z.string().meta({ description: 'Group metadata' }).optional(), + docsUrl: z + .string() + .meta({ description: 'Group documentation site' }) + .optional(), rules: customGroupRulesSchema, }); export type CustomGroup = z.infer; diff --git a/packages/plugin-js-packages/src/lib/config.ts b/packages/plugin-js-packages/src/lib/config.ts index 62f086d9b..97d23109a 100644 --- a/packages/plugin-js-packages/src/lib/config.ts +++ b/packages/plugin-js-packages/src/lib/config.ts @@ -24,9 +24,10 @@ export type PackageManagerId = z.infer; const packageJsonPathSchema = z .string() .regex(/package\.json$/, 'File path must end with package.json') - .describe( - 'File path to package.json, tries to use root package.json at CWD by default', - ) + .meta({ + description: + 'File path to package.json, tries to use root package.json at CWD by default', + }) .default('package.json'); export type PackageJsonPath = z.infer; @@ -60,11 +61,12 @@ export const jsPackagesPluginConfigSchema = z.object({ .array(packageCommandSchema) .min(1) .default(['audit', 'outdated']) - .describe( - 'Package manager commands to be run. Defaults to both audit and outdated.', - ), + .meta({ + description: + 'Package manager commands to be run. Defaults to both audit and outdated.', + }), packageManager: packageManagerIdSchema - .describe('Package manager to be used.') + .meta({ description: 'Package manager to be used.' }) .optional(), dependencyGroups: z .array(dependencyGroupSchema) diff --git a/packages/plugin-jsdocs/src/lib/config.ts b/packages/plugin-jsdocs/src/lib/config.ts index d746bf922..69bc8bc71 100644 --- a/packages/plugin-jsdocs/src/lib/config.ts +++ b/packages/plugin-jsdocs/src/lib/config.ts @@ -3,7 +3,7 @@ import { pluginScoreTargetsSchema } from '@code-pushup/models'; const patternsSchema = z .union([z.string(), z.array(z.string()).min(1)]) - .describe('Glob pattern to match source files to evaluate.'); + .meta({ description: 'Glob pattern to match source files to evaluate.' }); const jsDocsTargetObjectSchema = z .object({ diff --git a/packages/plugin-typescript/src/lib/schema.ts b/packages/plugin-typescript/src/lib/schema.ts index ac4b9da48..529216ec0 100644 --- a/packages/plugin-typescript/src/lib/schema.ts +++ b/packages/plugin-typescript/src/lib/schema.ts @@ -11,10 +11,14 @@ export const typescriptPluginConfigSchema = z.object({ tsconfig: z .string() .default(DEFAULT_TS_CONFIG) - .describe(`Path to a tsconfig file (default is ${DEFAULT_TS_CONFIG})`), + .meta({ + description: `Path to a tsconfig file (default is ${DEFAULT_TS_CONFIG})`, + }), onlyAudits: z .array(z.enum(auditSlugs)) - .describe('Filters TypeScript compiler errors by diagnostic codes') + .meta({ + description: 'Filters TypeScript compiler errors by diagnostic codes', + }) .optional(), scoreTargets: pluginScoreTargetsSchema, }); From 68fad172ecc9f5404d376a86cf29d2c1017790f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Wed, 22 Oct 2025 19:16:00 +0200 Subject: [PATCH 02/13] refactor(models): add meta.title to all named schemas --- packages/models/src/lib/audit-output.ts | 18 ++- packages/models/src/lib/audit.ts | 8 +- packages/models/src/lib/cache-config.ts | 7 +- packages/models/src/lib/category-config.ts | 29 +++-- packages/models/src/lib/commit.ts | 5 +- packages/models/src/lib/configuration.ts | 33 +++--- packages/models/src/lib/group.ts | 13 ++- .../models/src/lib/implementation/schemas.ts | 85 +++++++++----- packages/models/src/lib/issue.ts | 12 +- packages/models/src/lib/persist-config.ts | 24 ++-- packages/models/src/lib/plugin-config.ts | 39 ++++--- packages/models/src/lib/report.ts | 13 ++- packages/models/src/lib/reports-diff.ts | 41 ++++--- packages/models/src/lib/runner-config.ts | 27 +++-- packages/models/src/lib/source.ts | 5 +- packages/models/src/lib/table.ts | 35 +++--- packages/models/src/lib/tree.ts | 51 ++++++--- packages/models/src/lib/upload-config.ts | 40 +++---- packages/plugin-coverage/src/lib/config.ts | 106 +++++++++--------- packages/plugin-eslint/src/lib/config.ts | 21 ++-- packages/plugin-js-packages/src/lib/config.ts | 72 ++++++------ packages/plugin-jsdocs/src/lib/config.ts | 3 +- packages/plugin-typescript/src/lib/schema.ts | 32 +++--- 23 files changed, 437 insertions(+), 282 deletions(-) diff --git a/packages/models/src/lib/audit-output.ts b/packages/models/src/lib/audit-output.ts index 72cd46009..8736dd957 100644 --- a/packages/models/src/lib/audit-output.ts +++ b/packages/models/src/lib/audit-output.ts @@ -30,7 +30,10 @@ export const auditDetailsSchema = z .meta({ description: 'Findings in tree structure' }) .optional(), }) - .meta({ description: 'Detailed information' }); + .meta({ + title: 'AuditDetails', + description: 'Detailed information', + }); export type AuditDetails = z.infer; export const auditOutputSchema = z @@ -42,14 +45,19 @@ export const auditOutputSchema = z scoreTarget: scoreTargetSchema, details: auditDetailsSchema.optional(), }) - .meta({ description: 'Audit information' }); + .meta({ + title: 'AuditOutput', + description: 'Audit information', + }); export type AuditOutput = z.infer; export const auditOutputsSchema = z .array(auditOutputSchema) .check(createDuplicateSlugsCheck('Audit')) - .describe( - 'List of JSON formatted audit output emitted by the runner process of a plugin', - ); + .meta({ + title: 'AuditOutputs', + description: + 'List of JSON formatted audit output emitted by the runner process of a plugin', + }); export type AuditOutputs = z.infer; diff --git a/packages/models/src/lib/audit.ts b/packages/models/src/lib/audit.ts index b881fbf77..2823533b2 100644 --- a/packages/models/src/lib/audit.ts +++ b/packages/models/src/lib/audit.ts @@ -14,11 +14,15 @@ export const auditSchema = z description: 'List of scorable metrics for the given plugin', isSkippedDescription: 'Indicates whether the audit is skipped', }).shape, - ); + ) + .meta({ title: 'Audit' }); export type Audit = z.infer; export const pluginAuditsSchema = z .array(auditSchema) .min(1) .check(createDuplicateSlugsCheck('Audit')) - .meta({ description: 'List of audits maintained in a plugin' }); + .meta({ + title: 'PluginAudits', + description: 'List of audits maintained in a plugin', + }); diff --git a/packages/models/src/lib/cache-config.ts b/packages/models/src/lib/cache-config.ts index 000256fc3..35467bb32 100644 --- a/packages/models/src/lib/cache-config.ts +++ b/packages/models/src/lib/cache-config.ts @@ -12,11 +12,13 @@ export const cacheConfigObjectSchema = z .default(false), }) .meta({ + title: 'CacheConfigObject', description: 'Cache configuration object for read and/or write operations', }); export type CacheConfigObject = z.infer; export const cacheConfigShorthandSchema = z.boolean().meta({ + title: 'CacheConfigShorthand', description: 'Cache configuration shorthand for both, read and write operations', }); @@ -24,7 +26,10 @@ export type CacheConfigShorthand = z.infer; export const cacheConfigSchema = z .union([cacheConfigShorthandSchema, cacheConfigObjectSchema]) - .meta({ description: 'Cache configuration for read and write operations' }) + .meta({ + title: 'CacheConfig', + description: 'Cache configuration for read and write operations', + }) .default(false); export type CacheConfig = z.infer; diff --git a/packages/models/src/lib/category-config.ts b/packages/models/src/lib/category-config.ts index fe8384fcb..17b2ce3d1 100644 --- a/packages/models/src/lib/category-config.ts +++ b/packages/models/src/lib/category-config.ts @@ -15,15 +15,18 @@ import { formatRef } from './implementation/utils.js'; export const categoryRefSchema = weightedRefSchema( 'Weighted references to audits and/or groups for the category', 'Slug of an audit or group (depending on `type`)', -).extend({ - type: z.enum(['audit', 'group']).meta({ - description: - 'Discriminant for reference kind, affects where `slug` is looked up', - }), - plugin: slugSchema.describe( - 'Plugin slug (plugin should contain referenced audit or group)', - ), -}); +) + .extend({ + type: z.enum(['audit', 'group']).meta({ + description: + 'Discriminant for reference kind, affects where `slug` is looked up', + }), + plugin: slugSchema.describe( + 'Plugin slug (plugin should contain referenced audit or group)', + ), + }) + .meta({ title: 'CategoryRef' }); + export type CategoryRef = z.infer; export const categoryConfigSchema = scorableSchema( @@ -43,7 +46,8 @@ export const categoryConfigSchema = scorableSchema( description: 'Meta info for category', }).shape, ) - .extend({ scoreTarget: scoreTargetSchema }); + .extend({ scoreTarget: scoreTargetSchema }) + .meta({ title: 'CategoryConfig' }); export type CategoryConfig = z.infer; @@ -69,4 +73,7 @@ function formatSerializedCategoryRefTargets(keys: string[]): string { export const categoriesSchema = z .array(categoryConfigSchema) .check(createDuplicateSlugsCheck('Category')) - .meta({ description: 'Categorization of individual audits' }); + .meta({ + title: 'Categories', + description: 'Categorization of individual audits', + }); diff --git a/packages/models/src/lib/commit.ts b/packages/models/src/lib/commit.ts index b07af9809..dd09291ce 100644 --- a/packages/models/src/lib/commit.ts +++ b/packages/models/src/lib/commit.ts @@ -15,6 +15,9 @@ export const commitSchema = z .meta({ description: 'Date and time when commit was authored' }), author: z.string().trim().meta({ description: 'Commit author name' }), }) - .meta({ description: 'Git commit' }); + .meta({ + title: 'Commit', + description: 'Git commit', + }); export type Commit = z.infer; diff --git a/packages/models/src/lib/configuration.ts b/packages/models/src/lib/configuration.ts index 1a009e6c3..f57ca6028 100644 --- a/packages/models/src/lib/configuration.ts +++ b/packages/models/src/lib/configuration.ts @@ -4,19 +4,26 @@ import { globPathSchema } from './implementation/schemas.js'; /** * Generic schema for a tool command configuration, reusable across plugins. */ -export const artifactGenerationCommandSchema = z.union([ - z.string().min(1).meta({ description: 'Generate artifact files' }), - z.object({ - command: z.string().min(1).meta({ description: 'Generate artifact files' }), - args: z.array(z.string()).optional(), - }), -]); +export const artifactGenerationCommandSchema = z + .union([ + z.string().min(1).meta({ description: 'Generate artifact files' }), + z.object({ + command: z + .string() + .min(1) + .meta({ description: 'Generate artifact files' }), + args: z.array(z.string()).optional(), + }), + ]) + .meta({ title: 'ArtifactGenerationCommand' }); -export const pluginArtifactOptionsSchema = z.object({ - generateArtifactsCommand: artifactGenerationCommandSchema.optional(), - artifactsPaths: z - .union([globPathSchema, z.array(globPathSchema).min(1)]) - .meta({ description: 'File paths or glob patterns for artifact files' }), -}); +export const pluginArtifactOptionsSchema = z + .object({ + generateArtifactsCommand: artifactGenerationCommandSchema.optional(), + artifactsPaths: z + .union([globPathSchema, z.array(globPathSchema).min(1)]) + .meta({ description: 'File paths or glob patterns for artifact files' }), + }) + .meta({ title: 'PluginArtifactOptions' }); export type PluginArtifactOptions = z.infer; diff --git a/packages/models/src/lib/group.ts b/packages/models/src/lib/group.ts index 2633698f0..8039ecbeb 100644 --- a/packages/models/src/lib/group.ts +++ b/packages/models/src/lib/group.ts @@ -13,7 +13,7 @@ import { formatSlugsList } from './implementation/utils.js'; export const groupRefSchema = weightedRefSchema( 'Weighted reference to a group', "Reference slug to a group within this plugin (e.g. 'max-lines')", -); +).meta({ title: 'GroupRef' }); export type GroupRef = z.infer; export const groupMetaSchema = metaSchema({ @@ -22,7 +22,7 @@ export const groupMetaSchema = metaSchema({ docsUrlDescription: 'Group documentation site', description: 'Group metadata', isSkippedDescription: 'Indicates whether the group is skipped', -}); +}).meta({ title: 'GroupMeta' }); export type GroupMeta = z.infer; export const groupSchema = scorableSchema( @@ -34,7 +34,9 @@ export const groupSchema = scorableSchema( duplicates => `Group has duplicate references to audits: ${formatSlugsList(duplicates)}`, ), -).merge(groupMetaSchema); +) + .merge(groupMetaSchema) + .meta({ title: 'Group' }); export type Group = z.infer; @@ -42,4 +44,7 @@ export const groupsSchema = z .array(groupSchema) .check(createDuplicateSlugsCheck('Group')) .optional() - .meta({ description: 'List of groups' }); + .meta({ + title: 'Groups', + description: 'List of groups', + }); diff --git a/packages/models/src/lib/implementation/schemas.ts b/packages/models/src/lib/implementation/schemas.ts index a1245402b..0a85899fc 100644 --- a/packages/models/src/lib/implementation/schemas.ts +++ b/packages/models/src/lib/implementation/schemas.ts @@ -16,7 +16,9 @@ import { filenameRegex, slugRegex } from './utils.js'; export const tableCellValueSchema = z .union([z.string(), z.number(), z.boolean(), z.null()]) - .default(null); + .default(null) + .meta({ title: 'TableCellValue' }); + export type TableCellValue = z.infer; /** @@ -47,17 +49,23 @@ export const slugSchema = z .max(MAX_SLUG_LENGTH, { message: `The slug can be max ${MAX_SLUG_LENGTH} characters long`, }) - .meta({ description: 'Unique ID (human-readable, URL-safe)' }); + .meta({ + title: 'Slug', + description: 'Unique ID (human-readable, URL-safe)', + }); /** Schema for a general description property */ export const descriptionSchema = z .string() .max(MAX_DESCRIPTION_LENGTH) - .meta({ description: 'Description (markdown)' }) + .meta({ + title: 'Description', + description: 'Description (markdown)', + }) .optional(); /* Schema for a URL */ -export const urlSchema = z.string().url(); +export const urlSchema = z.string().url().meta({ title: 'URL' }); /** Schema for a docsUrl */ export const docsUrlSchema = urlSchema @@ -79,23 +87,25 @@ export const docsUrlSchema = urlSchema } throw new ZodError(ctx.error.issues); }) - .meta({ description: 'Documentation site' }); + .meta({ title: 'DocsUrl', description: 'Documentation site' }); /** Schema for a title of a plugin, category and audit */ -export const titleSchema = z - .string() - .max(MAX_TITLE_LENGTH) - .meta({ description: 'Descriptive name' }); +export const titleSchema = z.string().max(MAX_TITLE_LENGTH).meta({ + title: 'Title', + description: 'Descriptive name', +}); /** Schema for score of audit, category or group */ -export const scoreSchema = z - .number() - .min(0) - .max(1) - .meta({ description: 'Value between 0 and 1' }); +export const scoreSchema = z.number().min(0).max(1).meta({ + title: 'Score', + description: 'Value between 0 and 1', +}); /** Schema for a property indicating whether an entity is filtered out */ -export const isSkippedSchema = z.boolean().optional(); +export const isSkippedSchema = z + .boolean() + .optional() + .meta({ title: 'IsSkipped' }); /** * Used for categories, plugins and audits @@ -136,7 +146,8 @@ export function metaSchema(options?: { export const filePathSchema = z .string() .trim() - .min(1, { message: 'The path is invalid' }); + .min(1, { message: 'The path is invalid' }) + .meta({ title: 'FilePath' }); /** * Regex for glob patterns - validates file paths and glob patterns @@ -149,11 +160,9 @@ export const globPathSchema = z .string() .trim() .min(1, { message: 'The glob pattern is invalid' }) - .regex(globRegex, { - message: - 'The path must be a valid file path or glob pattern (supports *, **, {}, [], !, ?)', - }) + .regex(globRegex) .meta({ + title: 'GlobPath', description: 'Schema for a glob pattern (supports wildcards like *, **, {}, !, etc.)', }); @@ -162,15 +171,21 @@ export const globPathSchema = z export const fileNameSchema = z .string() .trim() - .regex(filenameRegex, { - message: `The filename has to be valid`, - }) - .min(1, { message: 'The file name is invalid' }); + .regex(filenameRegex) + .min(1) + .meta({ title: 'FileName' }); /** Schema for a positiveInt */ -export const positiveIntSchema = z.number().int().positive(); +export const positiveIntSchema = z + .number() + .int() + .positive() + .meta({ title: 'PositiveInt' }); -export const nonnegativeNumberSchema = z.number().nonnegative(); +export const nonnegativeNumberSchema = z + .number() + .nonnegative() + .meta({ title: 'NonnegativeNumber' }); export function packageVersionSchema< TRequired extends boolean = false, @@ -195,11 +210,15 @@ export function packageVersionSchema< /** Schema for a binary score threshold */ export const scoreTargetSchema = nonnegativeNumberSchema .max(1) - .meta({ description: 'Pass/fail score threshold (0-1)' }) + .meta({ + title: 'ScoreTarget', + description: 'Pass/fail score threshold (0-1)', + }) .optional(); /** Schema for a weight */ export const weightSchema = nonnegativeNumberSchema.meta({ + title: 'Weight', description: 'Coefficient for the given score (use weight 0 if only for display)', }); @@ -243,9 +262,10 @@ export function scorableSchema>( .describe(description); } -export const materialIconSchema = z - .enum(MATERIAL_ICONS) - .meta({ description: 'Icon from VSCode Material Icons extension' }); +export const materialIconSchema = z.enum(MATERIAL_ICONS).meta({ + title: 'MaterialIcon', + description: 'Icon from VSCode Material Icons extension', +}); export type MaterialIcon = z.infer; type Ref = { weight: number }; @@ -263,4 +283,7 @@ export const filePositionSchema = z endLine: positiveIntSchema.meta({ description: 'End line' }).optional(), endColumn: positiveIntSchema.meta({ description: 'End column' }).optional(), }) - .meta({ description: 'Location in file' }); + .meta({ + title: 'FilePosition', + description: 'Location in file', + }); diff --git a/packages/models/src/lib/issue.ts b/packages/models/src/lib/issue.ts index 6073263d9..be388daab 100644 --- a/packages/models/src/lib/issue.ts +++ b/packages/models/src/lib/issue.ts @@ -2,9 +2,10 @@ import { z } from 'zod'; import { MAX_ISSUE_MESSAGE_LENGTH } from './implementation/limits.js'; import { sourceFileLocationSchema } from './source.js'; -export const issueSeveritySchema = z - .enum(['info', 'warning', 'error']) - .meta({ description: 'Severity level' }); +export const issueSeveritySchema = z.enum(['info', 'warning', 'error']).meta({ + title: 'IssueSeverity', + description: 'Severity level', +}); export type IssueSeverity = z.infer; export const issueSchema = z @@ -16,5 +17,8 @@ export const issueSchema = z severity: issueSeveritySchema, source: sourceFileLocationSchema.optional(), }) - .meta({ description: 'Issue information' }); + .meta({ + title: 'Issue', + description: 'Issue information', + }); export type Issue = z.infer; diff --git a/packages/models/src/lib/persist-config.ts b/packages/models/src/lib/persist-config.ts index e6dd85843..f615ac3cb 100644 --- a/packages/models/src/lib/persist-config.ts +++ b/packages/models/src/lib/persist-config.ts @@ -1,18 +1,20 @@ import { z } from 'zod'; import { fileNameSchema, filePathSchema } from './implementation/schemas.js'; -export const formatSchema = z.enum(['json', 'md']); +export const formatSchema = z.enum(['json', 'md']).meta({ title: 'Format' }); export type Format = z.infer; -export const persistConfigSchema = z.object({ - outputDir: filePathSchema - .meta({ description: 'Artifacts folder' }) - .optional(), - filename: fileNameSchema - .meta({ description: 'Artifacts file name (without extension)' }) - .optional(), - format: z.array(formatSchema).optional(), - skipReports: z.boolean().optional(), -}); +export const persistConfigSchema = z + .object({ + outputDir: filePathSchema + .meta({ description: 'Artifacts folder' }) + .optional(), + filename: fileNameSchema + .meta({ description: 'Artifacts file name (without extension)' }) + .optional(), + format: z.array(formatSchema).optional(), + skipReports: z.boolean().optional(), + }) + .meta({ title: 'PersistConfig' }); export type PersistConfig = z.infer; diff --git a/packages/models/src/lib/plugin-config.ts b/packages/models/src/lib/plugin-config.ts index 35d5a5f28..423434f8b 100644 --- a/packages/models/src/lib/plugin-config.ts +++ b/packages/models/src/lib/plugin-config.ts @@ -15,7 +15,11 @@ import { runnerConfigSchema, runnerFunctionSchema } from './runner-config.js'; export const pluginContextSchema = z .record(z.string(), z.unknown()) .optional() - .meta({ description: 'Plugin-specific context data for helpers' }); + .meta({ + title: 'PluginContext', + description: 'Plugin-specific context data for helpers', + }); + export type PluginContext = z.infer; export const pluginMetaSchema = packageVersionSchema() @@ -32,7 +36,9 @@ export const pluginMetaSchema = packageVersionSchema() description: 'Unique plugin slug within core config', }), icon: materialIconSchema, - }); + }) + .meta({ title: 'PluginMeta' }); + export type PluginMeta = z.infer; export const pluginScoreTargetsSchema = z @@ -40,24 +46,29 @@ export const pluginScoreTargetsSchema = z scoreTargetSchema, z.record(z.string(), scoreTargetSchema.nonoptional()), ]) - .describe( - 'Score targets that trigger a perfect score. Number for all audits or record { slug: target } for specific audits', - ) - .optional(); + .optional() + .meta({ + title: 'PluginScoreTargets', + description: + 'Score targets that trigger a perfect score. Number for all audits or record { slug: target } for specific audits', + }); export type PluginScoreTargets = z.infer; -export const pluginDataSchema = z.object({ - runner: z.union([runnerConfigSchema, runnerFunctionSchema]), - audits: pluginAuditsSchema, - groups: groupsSchema, - scoreTargets: pluginScoreTargetsSchema, - context: pluginContextSchema, -}); +export const pluginDataSchema = z + .object({ + runner: z.union([runnerConfigSchema, runnerFunctionSchema]), + audits: pluginAuditsSchema, + groups: groupsSchema, + scoreTargets: pluginScoreTargetsSchema, + context: pluginContextSchema, + }) + .meta({ title: 'PluginData' }); export const pluginConfigSchema = pluginMetaSchema .extend(pluginDataSchema.shape) - .check(createCheck(findMissingSlugsInGroupRefs)); + .check(createCheck(findMissingSlugsInGroupRefs)) + .meta({ title: 'PluginConfig' }); export type PluginConfig = z.infer; diff --git a/packages/models/src/lib/report.ts b/packages/models/src/lib/report.ts index b49a9d93b..38055c210 100644 --- a/packages/models/src/lib/report.ts +++ b/packages/models/src/lib/report.ts @@ -15,7 +15,10 @@ import { pluginMetaSchema, } from './plugin-config.js'; -export const auditReportSchema = auditSchema.merge(auditOutputSchema); +export const auditReportSchema = auditSchema + .merge(auditOutputSchema) + .meta({ title: 'AuditReport' }); + export type AuditReport = z.infer; export const pluginReportSchema = pluginMetaSchema @@ -31,7 +34,8 @@ export const pluginReportSchema = pluginMetaSchema groups: z.array(groupSchema).optional(), }), ) - .check(createCheck(findMissingSlugsInGroupRefs)); + .check(createCheck(findMissingSlugsInGroupRefs)) + .meta({ title: 'PluginReport' }); export type PluginReport = z.infer; @@ -59,6 +63,9 @@ export const reportSchema = packageVersionSchema({ }), ) .check(createCheck(findMissingSlugsInCategoryRefs)) - .meta({ description: 'Collect output data' }); + .meta({ + title: 'Report', + description: 'Collect output data', + }); export type Report = z.infer; diff --git a/packages/models/src/lib/reports-diff.ts b/packages/models/src/lib/reports-diff.ts index da4e97ae2..10ca7df85 100644 --- a/packages/models/src/lib/reports-diff.ts +++ b/packages/models/src/lib/reports-diff.ts @@ -70,24 +70,30 @@ const scorableWithPluginDiffSchema = scorableDiffSchema.merge( scorableWithPluginMetaSchema, ); -export const categoryDiffSchema = scorableDiffSchema; -export const groupDiffSchema = scorableWithPluginDiffSchema; -export const auditDiffSchema = scorableWithPluginDiffSchema.merge( - z.object({ - values: makeComparisonSchema(auditValueSchema) - .merge( - z.object({ - diff: z.number().meta({ - description: 'Value change (`values.after - values.before`)', +export const categoryDiffSchema = scorableDiffSchema.meta({ + title: 'CategoryDiff', +}); +export const groupDiffSchema = scorableWithPluginDiffSchema.meta({ + title: 'GroupDiff', +}); +export const auditDiffSchema = scorableWithPluginDiffSchema + .merge( + z.object({ + values: makeComparisonSchema(auditValueSchema) + .merge( + z.object({ + diff: z.number().meta({ + description: 'Value change (`values.after - values.before`)', + }), }), - }), - ) - .meta({ description: 'Audit `value` comparison' }), - displayValues: makeComparisonSchema(auditDisplayValueSchema).meta({ - description: 'Audit `displayValue` comparison', + ) + .meta({ description: 'Audit `value` comparison' }), + displayValues: makeComparisonSchema(auditDisplayValueSchema).meta({ + description: 'Audit `displayValue` comparison', + }), }), - }), -); + ) + .meta({ title: 'AuditDiff' }); export const categoryResultSchema = scorableMetaSchema.merge( z.object({ score: scoreSchema }), @@ -145,6 +151,7 @@ export const reportsDiffSchema = z descriptionDate: 'Start date and time of the compare run', descriptionDuration: 'Duration of the compare run in ms', }), - ); + ) + .meta({ title: 'ReportsDiff' }); export type ReportsDiff = z.infer; diff --git a/packages/models/src/lib/runner-config.ts b/packages/models/src/lib/runner-config.ts index 5ff24f0ae..b5e0debc3 100644 --- a/packages/models/src/lib/runner-config.ts +++ b/packages/models/src/lib/runner-config.ts @@ -9,7 +9,7 @@ export const outputTransformSchema = convertAsyncZodFunctionToSchema( input: [z.unknown()], output: z.union([auditOutputsSchema, z.promise(auditOutputsSchema)]), }), -); +).meta({ title: 'OutputTransform' }); export type OutputTransform = z.infer; export const runnerArgsSchema = z @@ -18,7 +18,10 @@ export const runnerArgsSchema = z .required() .meta({ description: 'Persist config with defaults applied' }), }) - .meta({ description: 'Arguments passed to runner' }); + .meta({ + title: 'RunnerArgs', + description: 'Arguments passed to runner', + }); export type RunnerArgs = z.infer; export const runnerConfigSchema = z @@ -34,7 +37,10 @@ export const runnerConfigSchema = z .meta({ description: 'Runner config path' }) .optional(), }) - .meta({ description: 'How to execute runner using shell script' }); + .meta({ + title: 'RunnerConfig', + description: 'How to execute runner using shell script', + }); export type RunnerConfig = z.infer; export const runnerFunctionSchema = convertAsyncZodFunctionToSchema( @@ -43,12 +49,19 @@ export const runnerFunctionSchema = convertAsyncZodFunctionToSchema( output: z.union([auditOutputsSchema, z.promise(auditOutputsSchema)]), }), ).meta({ + title: 'RunnerFunction', description: 'Callback function for async runner execution in JS/TS', }); export type RunnerFunction = z.infer; -export const runnerFilesPathsSchema = z.object({ - runnerConfigPath: filePathSchema.meta({ description: 'Runner config path' }), - runnerOutputPath: filePathSchema.meta({ description: 'Runner output path' }), -}); +export const runnerFilesPathsSchema = z + .object({ + runnerConfigPath: filePathSchema.meta({ + description: 'Runner config path', + }), + runnerOutputPath: filePathSchema.meta({ + description: 'Runner output path', + }), + }) + .meta({ title: 'RunnerFilesPaths' }); export type RunnerFilesPaths = z.infer; diff --git a/packages/models/src/lib/source.ts b/packages/models/src/lib/source.ts index b9215fd0a..4500adff9 100644 --- a/packages/models/src/lib/source.ts +++ b/packages/models/src/lib/source.ts @@ -11,6 +11,9 @@ export const sourceFileLocationSchema = z }), position: filePositionSchema.optional(), }) - .meta({ description: 'Source file location' }); + .meta({ + title: 'SourceFileLocation', + description: 'Source file location', + }); export type SourceFileLocation = z.infer; diff --git a/packages/models/src/lib/table.ts b/packages/models/src/lib/table.ts index 99ed00f20..d7bfe7327 100644 --- a/packages/models/src/lib/table.ts +++ b/packages/models/src/lib/table.ts @@ -1,29 +1,36 @@ import { z } from 'zod'; import { tableCellValueSchema } from './implementation/schemas.js'; -export const tableAlignmentSchema = z - .enum(['left', 'center', 'right']) - .meta({ description: 'Cell alignment' }); +export const tableAlignmentSchema = z.enum(['left', 'center', 'right']).meta({ + title: 'TableAlignment', + description: 'Cell alignment', +}); export type TableAlignment = z.infer; export const tableColumnPrimitiveSchema = tableAlignmentSchema; export type TableColumnPrimitive = z.infer; -export const tableColumnObjectSchema = z.object({ - key: z.string(), - label: z.string().optional(), - align: tableAlignmentSchema.optional(), -}); +export const tableColumnObjectSchema = z + .object({ + key: z.string(), + label: z.string().optional(), + align: tableAlignmentSchema.optional(), + }) + .meta({ title: 'TableColumnObject' }); export type TableColumnObject = z.infer; export const tableRowObjectSchema = z .record(z.string(), tableCellValueSchema) - .meta({ description: 'Object row' }); + .meta({ + title: 'TableRowObject', + description: 'Object row', + }); export type TableRowObject = z.infer; -export const tableRowPrimitiveSchema = z - .array(tableCellValueSchema) - .meta({ description: 'Primitive row' }); +export const tableRowPrimitiveSchema = z.array(tableCellValueSchema).meta({ + title: 'TableRowPrimitive', + description: 'Primitive row', +}); export type TableRowPrimitive = z.infer; const tableSharedSchema = z.object({ @@ -57,5 +64,7 @@ const tableObjectSchema = tableSharedSchema }); export const tableSchema = (description = 'Table information') => - z.union([tablePrimitiveSchema, tableObjectSchema]).describe(description); + z + .union([tablePrimitiveSchema, tableObjectSchema]) + .meta({ title: 'Table', description }); export type Table = z.infer>; diff --git a/packages/models/src/lib/tree.ts b/packages/models/src/lib/tree.ts index 406515d02..e572e1991 100644 --- a/packages/models/src/lib/tree.ts +++ b/packages/models/src/lib/tree.ts @@ -13,13 +13,16 @@ const basicTreeNodeDataSchema = z.object({ }); export const basicTreeNodeSchema: z.ZodType = - basicTreeNodeDataSchema.extend({ - get children() { - return z.array(basicTreeNodeSchema).optional().meta({ - description: 'Direct descendants of this node (omit if leaf)', - }); - }, - }); + basicTreeNodeDataSchema + .extend({ + get children() { + return z.array(basicTreeNodeSchema).optional().meta({ + description: 'Direct descendants of this node (omit if leaf)', + }); + }, + }) + .meta({ title: 'BasicTreeNode' }); + export type BasicTreeNode = z.infer & { children?: BasicTreeNode[]; }; @@ -36,6 +39,7 @@ export const coverageTreeMissingLOCSchema = filePositionSchema .meta({ description: 'E.g. "function", "class"' }), }) .meta({ + title: 'CoverageTreeMissingLOC', description: 'Uncovered line of code, optionally referring to a named function/class/etc.', }); @@ -58,14 +62,17 @@ const coverageTreeNodeDataSchema = z.object({ }); export const coverageTreeNodeSchema: z.ZodType = - coverageTreeNodeDataSchema.extend({ - get children() { - return z.array(coverageTreeNodeSchema).optional().meta({ - description: - 'Files and folders contained in this folder (omit if file)', - }); - }, - }); + coverageTreeNodeDataSchema + .extend({ + get children() { + return z.array(coverageTreeNodeSchema).optional().meta({ + description: + 'Files and folders contained in this folder (omit if file)', + }); + }, + }) + .meta({ title: 'CoverageTreeNode' }); + export type CoverageTreeNode = z.infer & { children?: CoverageTreeNode[]; }; @@ -76,7 +83,10 @@ export const basicTreeSchema = z type: z.literal('basic').optional().meta({ description: 'Discriminant' }), root: basicTreeNodeSchema.meta({ description: 'Root node' }), }) - .meta({ description: 'Generic tree' }); + .meta({ + title: 'BasicTree', + description: 'Generic tree', + }); export type BasicTree = z.infer; export const coverageTreeSchema = z @@ -85,8 +95,13 @@ export const coverageTreeSchema = z type: z.literal('coverage').meta({ description: 'Discriminant' }), root: coverageTreeNodeSchema.meta({ description: 'Root folder' }), }) - .meta({ description: 'Coverage for files and folders' }); + .meta({ + title: 'CoverageTree', + description: 'Coverage for files and folders', + }); export type CoverageTree = z.infer; -export const treeSchema = z.union([basicTreeSchema, coverageTreeSchema]); +export const treeSchema = z + .union([basicTreeSchema, coverageTreeSchema]) + .meta({ title: 'Tree' }); export type Tree = z.infer; diff --git a/packages/models/src/lib/upload-config.ts b/packages/models/src/lib/upload-config.ts index 794244ed5..185863d56 100644 --- a/packages/models/src/lib/upload-config.ts +++ b/packages/models/src/lib/upload-config.ts @@ -1,24 +1,26 @@ import { z } from 'zod'; import { slugSchema, urlSchema } from './implementation/schemas.js'; -export const uploadConfigSchema = z.object({ - server: urlSchema.meta({ description: 'URL of deployed portal API' }), - apiKey: z.string().meta({ - description: - 'API key with write access to portal (use `process.env` for security)', - }), - organization: slugSchema.meta({ - description: 'Organization slug from Code PushUp portal', - }), - project: slugSchema.meta({ - description: 'Project slug from Code PushUp portal', - }), - timeout: z - .number() - .positive() - .int() - .optional() - .meta({ description: 'Request timeout in minutes (default is 5)' }), -}); +export const uploadConfigSchema = z + .object({ + server: urlSchema.meta({ description: 'URL of deployed portal API' }), + apiKey: z.string().meta({ + description: + 'API key with write access to portal (use `process.env` for security)', + }), + organization: slugSchema.meta({ + description: 'Organization slug from Code PushUp portal', + }), + project: slugSchema.meta({ + description: 'Project slug from Code PushUp portal', + }), + timeout: z + .number() + .positive() + .int() + .optional() + .meta({ description: 'Request timeout in minutes (default is 5)' }), + }) + .meta({ title: 'UploadConfig' }); export type UploadConfig = z.infer; diff --git a/packages/plugin-coverage/src/lib/config.ts b/packages/plugin-coverage/src/lib/config.ts index 9b3cafdf0..cf4c62cf9 100644 --- a/packages/plugin-coverage/src/lib/config.ts +++ b/packages/plugin-coverage/src/lib/config.ts @@ -1,61 +1,67 @@ import { z } from 'zod'; import { pluginScoreTargetsSchema } from '@code-pushup/models'; -export const coverageTypeSchema = z.enum(['function', 'branch', 'line']); +export const coverageTypeSchema = z + .enum(['function', 'branch', 'line']) + .meta({ title: 'CoverageType' }); export type CoverageType = z.infer; -export const coverageResultSchema = z.union([ - z.object({ - resultsPath: z - .string() - .includes('lcov') - .meta({ description: 'Path to coverage results for Nx setup.' }), - pathToProject: z - .string() - .meta({ - description: - 'Path from workspace root to project root. Necessary for LCOV reports which provide a relative path.', - }) - .optional(), - }), - z.string().includes('lcov').meta({ - description: 'Path to coverage results for a single project setup.', - }), -]); +export const coverageResultSchema = z + .union([ + z.object({ + resultsPath: z + .string() + .includes('lcov') + .meta({ description: 'Path to coverage results for Nx setup.' }), + pathToProject: z + .string() + .meta({ + description: + 'Path from workspace root to project root. Necessary for LCOV reports which provide a relative path.', + }) + .optional(), + }), + z.string().includes('lcov').meta({ + description: 'Path to coverage results for a single project setup.', + }), + ]) + .meta({ title: 'CoverageResult' }); export type CoverageResult = z.infer; -export const coveragePluginConfigSchema = z.object({ - coverageToolCommand: z - .object({ - command: z - .string() - .min(1) - .meta({ description: 'Command to run coverage tool.' }), - args: z - .array(z.string()) - .optional() - .meta({ description: 'Arguments to be passed to the coverage tool.' }), - }) - .optional(), - continueOnCommandFail: z.boolean().default(true).meta({ - description: - 'Continue on coverage tool command failure or error. Defaults to true.', - }), - coverageTypes: z - .array(coverageTypeSchema) - .min(1) - .default(['function', 'branch', 'line']) - .meta({ - description: 'Coverage types measured. Defaults to all available types.', +export const coveragePluginConfigSchema = z + .object({ + coverageToolCommand: z + .object({ + command: z + .string() + .min(1) + .meta({ description: 'Command to run coverage tool.' }), + args: z.array(z.string()).optional().meta({ + description: 'Arguments to be passed to the coverage tool.', + }), + }) + .optional(), + continueOnCommandFail: z.boolean().default(true).meta({ + description: + 'Continue on coverage tool command failure or error. Defaults to true.', }), - reports: z - .array(coverageResultSchema) - .min(1) - .describe( - 'Path to all code coverage report files. Only LCOV format is supported for now.', - ), - scoreTargets: pluginScoreTargetsSchema, -}); + coverageTypes: z + .array(coverageTypeSchema) + .min(1) + .default(['function', 'branch', 'line']) + .meta({ + description: + 'Coverage types measured. Defaults to all available types.', + }), + reports: z + .array(coverageResultSchema) + .min(1) + .describe( + 'Path to all code coverage report files. Only LCOV format is supported for now.', + ), + scoreTargets: pluginScoreTargetsSchema, + }) + .meta({ title: 'CoveragePluginConfig' }); export type CoveragePluginConfig = z.input; export type FinalCoveragePluginConfig = z.infer< typeof coveragePluginConfigSchema diff --git a/packages/plugin-eslint/src/lib/config.ts b/packages/plugin-eslint/src/lib/config.ts index 430121904..a42d8f42a 100644 --- a/packages/plugin-eslint/src/lib/config.ts +++ b/packages/plugin-eslint/src/lib/config.ts @@ -27,12 +27,16 @@ export const eslintTargetSchema = z typeof target === 'string' || Array.isArray(target) ? { patterns: target } : target, - ); + ) + .meta({ title: 'ESLintTarget' }); + export type ESLintTarget = z.infer; export const eslintPluginConfigSchema = z .union([eslintTargetSchema, z.array(eslintTargetSchema).min(1)]) - .transform(toArray); + .transform(toArray) + .meta({ title: 'ESLintPluginConfig' }); + export type ESLintPluginConfig = z.input; export type ESLintPluginRunnerConfig = { @@ -68,9 +72,12 @@ const customGroupSchema = z.object({ }); export type CustomGroup = z.infer; -export const eslintPluginOptionsSchema = z.object({ - groups: z.array(customGroupSchema).optional(), - artifacts: pluginArtifactOptionsSchema.optional(), - scoreTargets: pluginScoreTargetsSchema, -}); +export const eslintPluginOptionsSchema = z + .object({ + groups: z.array(customGroupSchema).optional(), + artifacts: pluginArtifactOptionsSchema.optional(), + scoreTargets: pluginScoreTargetsSchema, + }) + .meta({ title: 'ESLintPluginOptions' }); + export type ESLintPluginOptions = z.infer; diff --git a/packages/plugin-js-packages/src/lib/config.ts b/packages/plugin-js-packages/src/lib/config.ts index 97d23109a..dba8278a2 100644 --- a/packages/plugin-js-packages/src/lib/config.ts +++ b/packages/plugin-js-packages/src/lib/config.ts @@ -10,21 +10,21 @@ export const dependencyGroups = ['prod', 'dev', 'optional'] as const; const dependencyGroupSchema = z.enum(dependencyGroups); export type DependencyGroup = (typeof dependencyGroups)[number]; -const packageCommandSchema = z.enum(['audit', 'outdated']); +const packageCommandSchema = z.enum(['audit', 'outdated']).meta({ + title: 'PackageCommand', +}); export type PackageCommand = z.infer; -const packageManagerIdSchema = z.enum([ - 'npm', - 'yarn-classic', - 'yarn-modern', - 'pnpm', -]); +const packageManagerIdSchema = z + .enum(['npm', 'yarn-classic', 'yarn-modern', 'pnpm']) + .meta({ title: 'PackageManagerId' }); export type PackageManagerId = z.infer; const packageJsonPathSchema = z .string() .regex(/package\.json$/, 'File path must end with package.json') .meta({ + title: 'PackageJsonPath', description: 'File path to package.json, tries to use root package.json at CWD by default', }) @@ -39,7 +39,9 @@ export const packageAuditLevels = [ 'low', 'info', ] as const; -const packageAuditLevelSchema = z.enum(packageAuditLevels); +const packageAuditLevelSchema = z.enum(packageAuditLevels).meta({ + title: 'PackageAuditLevel', +}); export type PackageAuditLevel = z.infer; export type AuditSeverity = Record; @@ -56,32 +58,34 @@ export function fillAuditLevelMapping( }; } -export const jsPackagesPluginConfigSchema = z.object({ - checks: z - .array(packageCommandSchema) - .min(1) - .default(['audit', 'outdated']) - .meta({ - description: - 'Package manager commands to be run. Defaults to both audit and outdated.', - }), - packageManager: packageManagerIdSchema - .meta({ description: 'Package manager to be used.' }) - .optional(), - dependencyGroups: z - .array(dependencyGroupSchema) - .min(1) - .default(['prod', 'dev']), - auditLevelMapping: z - .partialRecord(packageAuditLevelSchema, issueSeveritySchema) - .default(defaultAuditLevelMapping) - .transform(fillAuditLevelMapping) - .describe( - 'Mapping of audit levels to issue severity. Custom mapping or overrides may be entered manually, otherwise has a default preset.', - ), - packageJsonPath: packageJsonPathSchema, - scoreTargets: pluginScoreTargetsSchema, -}); +export const jsPackagesPluginConfigSchema = z + .object({ + checks: z + .array(packageCommandSchema) + .min(1) + .default(['audit', 'outdated']) + .meta({ + description: + 'Package manager commands to be run. Defaults to both audit and outdated.', + }), + packageManager: packageManagerIdSchema + .meta({ description: 'Package manager to be used.' }) + .optional(), + dependencyGroups: z + .array(dependencyGroupSchema) + .min(1) + .default(['prod', 'dev']), + auditLevelMapping: z + .partialRecord(packageAuditLevelSchema, issueSeveritySchema) + .default(defaultAuditLevelMapping) + .transform(fillAuditLevelMapping) + .describe( + 'Mapping of audit levels to issue severity. Custom mapping or overrides may be entered manually, otherwise has a default preset.', + ), + packageJsonPath: packageJsonPathSchema, + scoreTargets: pluginScoreTargetsSchema, + }) + .meta({ title: 'JSPackagesPluginConfig' }); export type JSPackagesPluginConfig = z.input< typeof jsPackagesPluginConfigSchema diff --git a/packages/plugin-jsdocs/src/lib/config.ts b/packages/plugin-jsdocs/src/lib/config.ts index 69bc8bc71..d02aa8734 100644 --- a/packages/plugin-jsdocs/src/lib/config.ts +++ b/packages/plugin-jsdocs/src/lib/config.ts @@ -33,7 +33,8 @@ export const jsDocsPluginConfigSchema = z typeof target === 'string' || Array.isArray(target) ? { patterns: target } : target, - ); + ) + .meta({ title: 'JsDocsPluginConfig' }); /** Type of the config that is passed to the plugin */ export type JsDocsPluginConfig = z.input; diff --git a/packages/plugin-typescript/src/lib/schema.ts b/packages/plugin-typescript/src/lib/schema.ts index 529216ec0..ed680506e 100644 --- a/packages/plugin-typescript/src/lib/schema.ts +++ b/packages/plugin-typescript/src/lib/schema.ts @@ -7,21 +7,23 @@ const auditSlugs = AUDITS.map(({ slug }) => slug) as [ AuditSlug, ...AuditSlug[], ]; -export const typescriptPluginConfigSchema = z.object({ - tsconfig: z - .string() - .default(DEFAULT_TS_CONFIG) - .meta({ - description: `Path to a tsconfig file (default is ${DEFAULT_TS_CONFIG})`, - }), - onlyAudits: z - .array(z.enum(auditSlugs)) - .meta({ - description: 'Filters TypeScript compiler errors by diagnostic codes', - }) - .optional(), - scoreTargets: pluginScoreTargetsSchema, -}); +export const typescriptPluginConfigSchema = z + .object({ + tsconfig: z + .string() + .default(DEFAULT_TS_CONFIG) + .meta({ + description: `Path to a tsconfig file (default is ${DEFAULT_TS_CONFIG})`, + }), + onlyAudits: z + .array(z.enum(auditSlugs)) + .meta({ + description: 'Filters TypeScript compiler errors by diagnostic codes', + }) + .optional(), + scoreTargets: pluginScoreTargetsSchema, + }) + .meta({ title: 'TypescriptPluginConfig' }); export type TypescriptPluginOptions = z.input< typeof typescriptPluginConfigSchema From 2f807f8f85fcbab1eb0c2544a7cc8b34d5cba7b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Wed, 22 Oct 2025 19:24:43 +0200 Subject: [PATCH 03/13] test(models): fix asserted error messages --- .../models/src/lib/implementation/schemas.ts | 17 ++++++----------- .../src/lib/implementation/schemas.unit.test.ts | 2 +- .../models/src/lib/persist-config.unit.test.ts | 4 ++-- .../models/src/lib/runner-config.unit.test.ts | 2 +- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/models/src/lib/implementation/schemas.ts b/packages/models/src/lib/implementation/schemas.ts index 0a85899fc..cad8cb4a1 100644 --- a/packages/models/src/lib/implementation/schemas.ts +++ b/packages/models/src/lib/implementation/schemas.ts @@ -146,7 +146,7 @@ export function metaSchema(options?: { export const filePathSchema = z .string() .trim() - .min(1, { message: 'The path is invalid' }) + .min(1) .meta({ title: 'FilePath' }); /** @@ -156,16 +156,11 @@ export const filePathSchema = z */ const globRegex = /^!?[^<>"|]+$/; -export const globPathSchema = z - .string() - .trim() - .min(1, { message: 'The glob pattern is invalid' }) - .regex(globRegex) - .meta({ - title: 'GlobPath', - description: - 'Schema for a glob pattern (supports wildcards like *, **, {}, !, etc.)', - }); +export const globPathSchema = z.string().trim().min(1).regex(globRegex).meta({ + title: 'GlobPath', + description: + 'Schema for a glob pattern (supports wildcards like *, **, {}, !, etc.)', +}); /** Schema for a fileNameSchema */ export const fileNameSchema = z diff --git a/packages/models/src/lib/implementation/schemas.unit.test.ts b/packages/models/src/lib/implementation/schemas.unit.test.ts index 818797d82..f42c773b3 100644 --- a/packages/models/src/lib/implementation/schemas.unit.test.ts +++ b/packages/models/src/lib/implementation/schemas.unit.test.ts @@ -82,7 +82,7 @@ describe('globPathSchema', () => { 'should throw for invalid path with forbidden character: %s', pattern => { expect(() => globPathSchema.parse(pattern)).toThrow( - 'valid file path or glob pattern', + 'Invalid string: must match pattern', ); }, ); diff --git a/packages/models/src/lib/persist-config.unit.test.ts b/packages/models/src/lib/persist-config.unit.test.ts index 77ca8157b..b39cc71e2 100644 --- a/packages/models/src/lib/persist-config.unit.test.ts +++ b/packages/models/src/lib/persist-config.unit.test.ts @@ -23,13 +23,13 @@ describe('persistConfigSchema', () => { it('should throw for an empty file name', () => { expect(() => persistConfigSchema.parse({ filename: ' ' } as PersistConfig), - ).toThrow('file name is invalid'); + ).toThrow('Invalid string: must match pattern'); }); it('should throw for an empty output directory', () => { expect(() => persistConfigSchema.parse({ outputDir: ' ' } as PersistConfig), - ).toThrow('path is invalid'); + ).toThrow('Too small: expected string to have >=1 characters'); }); it('should throw for an invalid format', () => { diff --git a/packages/models/src/lib/runner-config.unit.test.ts b/packages/models/src/lib/runner-config.unit.test.ts index 747236042..705628d44 100644 --- a/packages/models/src/lib/runner-config.unit.test.ts +++ b/packages/models/src/lib/runner-config.unit.test.ts @@ -43,7 +43,7 @@ describe('runnerConfigSchema', () => { command: 'node', outputFile: '', }), - ).toThrow('path is invalid'); + ).toThrow('Too small: expected string to have >=1 characters'); }); }); From 95fa04b73c9a3eae2d535e7e4edb3b6aaad51049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Wed, 22 Oct 2025 19:37:09 +0200 Subject: [PATCH 04/13] refactor(models,utils): move schema validation helper to models --- packages/core/src/lib/implementation/read-rc-file.ts | 5 +++-- packages/models/package.json | 5 +++-- packages/models/src/index.ts | 4 ++++ .../src/lib/implementation/validate.ts} | 2 +- packages/plugin-eslint/src/lib/eslint-plugin.ts | 7 +++---- packages/utils/package.json | 1 - packages/utils/src/index.ts | 1 - 7 files changed, 14 insertions(+), 11 deletions(-) rename packages/{utils/src/lib/zod-validation.ts => models/src/lib/implementation/validate.ts} (94%) diff --git a/packages/core/src/lib/implementation/read-rc-file.ts b/packages/core/src/lib/implementation/read-rc-file.ts index 5560efdc3..881dbd386 100644 --- a/packages/core/src/lib/implementation/read-rc-file.ts +++ b/packages/core/src/lib/implementation/read-rc-file.ts @@ -4,8 +4,9 @@ import { type CoreConfig, SUPPORTED_CONFIG_FILE_FORMATS, coreConfigSchema, + validate, } from '@code-pushup/models'; -import { fileExists, importModule, parseSchema } from '@code-pushup/utils'; +import { fileExists, importModule } from '@code-pushup/utils'; export class ConfigPathError extends Error { constructor(configPath: string) { @@ -31,7 +32,7 @@ export async function readRcByPath( format: 'esm', }); - return parseSchema(coreConfigSchema, cfg, { + return validate(coreConfigSchema, cfg, { schemaType: 'core config', sourcePath: filepath, }); diff --git a/packages/models/package.json b/packages/models/package.json index 68f181c6e..92f3b3146 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -27,8 +27,9 @@ }, "type": "module", "dependencies": { - "zod": "^4.0.5", - "vscode-material-icons": "^0.1.0" + "ansis": "^3.3.2", + "vscode-material-icons": "^0.1.0", + "zod": "^4.0.5" }, "files": [ "src", diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 244830657..58391eef5 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -71,6 +71,10 @@ export { type MaterialIcon, } from './lib/implementation/schemas.js'; export { exists } from './lib/implementation/utils.js'; +export { + SchemaValidationError, + validate, +} from './lib/implementation/validate.js'; export { issueSchema, issueSeveritySchema, diff --git a/packages/utils/src/lib/zod-validation.ts b/packages/models/src/lib/implementation/validate.ts similarity index 94% rename from packages/utils/src/lib/zod-validation.ts rename to packages/models/src/lib/implementation/validate.ts index dec02d5d9..b0a5450e1 100644 --- a/packages/utils/src/lib/zod-validation.ts +++ b/packages/models/src/lib/implementation/validate.ts @@ -20,7 +20,7 @@ export class SchemaValidationError extends Error { } } -export function parseSchema( +export function validate( schema: T, data: z.input, { schemaType, sourcePath }: SchemaValidationContext, diff --git a/packages/plugin-eslint/src/lib/eslint-plugin.ts b/packages/plugin-eslint/src/lib/eslint-plugin.ts index 2c5617b41..e3b38f981 100644 --- a/packages/plugin-eslint/src/lib/eslint-plugin.ts +++ b/packages/plugin-eslint/src/lib/eslint-plugin.ts @@ -1,6 +1,5 @@ import { createRequire } from 'node:module'; -import type { PluginConfig } from '@code-pushup/models'; -import { parseSchema } from '@code-pushup/utils'; +import { type PluginConfig, validate } from '@code-pushup/models'; import { type ESLintPluginConfig, type ESLintPluginOptions, @@ -36,7 +35,7 @@ export async function eslintPlugin( config: ESLintPluginConfig, options?: ESLintPluginOptions, ): Promise { - const targets = parseSchema(eslintPluginConfigSchema, config, { + const targets = validate(eslintPluginConfigSchema, config, { schemaType: 'ESLint plugin config', }); @@ -45,7 +44,7 @@ export async function eslintPlugin( artifacts, scoreTargets, } = options - ? parseSchema(eslintPluginOptionsSchema, options, { + ? validate(eslintPluginOptionsSchema, options, { schemaType: 'ESLint plugin options', }) : {}; diff --git a/packages/utils/package.json b/packages/utils/package.json index f8438c3ba..4cd23a39c 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -37,7 +37,6 @@ "multi-progress-bars": "^5.0.3", "semver": "^7.6.0", "simple-git": "^3.20.0", - "zod": "^4.0.5", "ora": "^9.0.0" }, "files": [ diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e010cf7ac..2ca6c9925 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -155,4 +155,3 @@ export type { Prettify, WithRequired, } from './lib/types.js'; -export { parseSchema, SchemaValidationError } from './lib/zod-validation.js'; From 72356939d652841f8ca4650afbf837a6dbcc656f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Thu, 23 Oct 2025 09:58:42 +0200 Subject: [PATCH 05/13] refactor(models): use schema title from meta registry --- .../src/lib/implementation/read-rc-file.ts | 15 +++---- .../models/src/lib/implementation/validate.ts | 39 ++++++++++--------- .../plugin-eslint/src/lib/eslint-plugin.ts | 10 +---- 3 files changed, 29 insertions(+), 35 deletions(-) diff --git a/packages/core/src/lib/implementation/read-rc-file.ts b/packages/core/src/lib/implementation/read-rc-file.ts index 881dbd386..9dd2afb5f 100644 --- a/packages/core/src/lib/implementation/read-rc-file.ts +++ b/packages/core/src/lib/implementation/read-rc-file.ts @@ -15,27 +15,24 @@ export class ConfigPathError extends Error { } export async function readRcByPath( - filepath: string, + filePath: string, tsconfig?: string, ): Promise { - if (filepath.length === 0) { + if (filePath.length === 0) { throw new Error('The path to the configuration file is empty.'); } - if (!(await fileExists(filepath))) { - throw new ConfigPathError(filepath); + if (!(await fileExists(filePath))) { + throw new ConfigPathError(filePath); } const cfg: CoreConfig = await importModule({ - filepath, + filepath: filePath, tsconfig, format: 'esm', }); - return validate(coreConfigSchema, cfg, { - schemaType: 'core config', - sourcePath: filepath, - }); + return validate(coreConfigSchema, cfg, { filePath }); } export async function autoloadRc(tsconfig?: string): Promise { diff --git a/packages/models/src/lib/implementation/validate.ts b/packages/models/src/lib/implementation/validate.ts index b0a5450e1..4d5f55b3e 100644 --- a/packages/models/src/lib/implementation/validate.ts +++ b/packages/models/src/lib/implementation/validate.ts @@ -1,36 +1,39 @@ -import { bold } from 'ansis'; +import ansis from 'ansis'; import path from 'node:path'; -import { ZodError, z } from 'zod'; +import { ZodError, type ZodType, z } from 'zod'; type SchemaValidationContext = { - schemaType: string; - sourcePath?: string; + filePath?: string; }; export class SchemaValidationError extends Error { constructor( - { schemaType, sourcePath }: SchemaValidationContext, error: ZodError, + schema: ZodType, + { filePath }: SchemaValidationContext, ) { const formattedError = z.prettifyError(error); - const pathDetails = sourcePath - ? ` in ${bold(path.relative(process.cwd(), sourcePath))}` - : ''; - super(`Failed parsing ${schemaType}${pathDetails}.\n\n${formattedError}`); + const schemaTitle = z.globalRegistry.get(schema)?.title; + const summary = [ + 'Invalid', + schemaTitle ? ansis.bold(schemaTitle) : 'data', + filePath && + `in ${ansis.bold(path.relative(process.cwd(), filePath))} file`, + ] + .filter(Boolean) + .join(' '); + super(`${summary}\n${formattedError}\n`); } } -export function validate( +export function validate( schema: T, data: z.input, - { schemaType, sourcePath }: SchemaValidationContext, + context: SchemaValidationContext = {}, ): z.output { - try { - return schema.parse(data); - } catch (error) { - if (error instanceof ZodError) { - throw new SchemaValidationError({ schemaType, sourcePath }, error); - } - throw error; + const result = schema.safeParse(data); + if (result.success) { + return result.data; } + throw new SchemaValidationError(result.error, schema, context); } diff --git a/packages/plugin-eslint/src/lib/eslint-plugin.ts b/packages/plugin-eslint/src/lib/eslint-plugin.ts index e3b38f981..079e27764 100644 --- a/packages/plugin-eslint/src/lib/eslint-plugin.ts +++ b/packages/plugin-eslint/src/lib/eslint-plugin.ts @@ -35,19 +35,13 @@ export async function eslintPlugin( config: ESLintPluginConfig, options?: ESLintPluginOptions, ): Promise { - const targets = validate(eslintPluginConfigSchema, config, { - schemaType: 'ESLint plugin config', - }); + const targets = validate(eslintPluginConfigSchema, config); const { groups: customGroups, artifacts, scoreTargets, - } = options - ? validate(eslintPluginOptionsSchema, options, { - schemaType: 'ESLint plugin options', - }) - : {}; + } = options ? validate(eslintPluginOptionsSchema, options) : {}; const { audits, groups } = await listAuditsAndGroups(targets, customGroups); From fc5e9065bc3f3663cd3d28d27b151cd97ee2ec9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Thu, 23 Oct 2025 12:44:10 +0200 Subject: [PATCH 06/13] test(models): unit test zod validation helpers --- .../lib/implementation/validate.unit.test.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 packages/models/src/lib/implementation/validate.unit.test.ts diff --git a/packages/models/src/lib/implementation/validate.unit.test.ts b/packages/models/src/lib/implementation/validate.unit.test.ts new file mode 100644 index 000000000..3ab2a1b74 --- /dev/null +++ b/packages/models/src/lib/implementation/validate.unit.test.ts @@ -0,0 +1,96 @@ +import ansis from 'ansis'; +import path from 'node:path'; +import z, { ZodError } from 'zod'; +import { SchemaValidationError, validate } from './validate.js'; + +describe('validate', () => { + it('should return parsed data if valid', () => { + const configSchema = z + .object({ + entry: z.string(), + tsconfig: z.string().default('tsconfig.json'), + }) + .meta({ title: 'Config' }); + type Config = z.infer; + + expect(validate(configSchema, { entry: 'src/main.ts' })).toEqual({ + entry: 'src/main.ts', + tsconfig: 'tsconfig.json', + }); + }); + + it('should throw formatted error if invalid', () => { + const userSchema = z + .object({ + name: z.string().min(1), + address: z.string(), + dateOfBirth: z.iso.date().optional(), + }) + .meta({ title: 'User' }); + type User = z.infer; + + expect(() => + validate(userSchema, { name: '', dateOfBirth: 'Jul 1, 1980' } as User), + ).toThrow(`Invalid ${ansis.bold('User')} +✖ Too small: expected string to have >=1 characters + → at name +✖ Invalid input: expected string, received undefined + → at address +✖ Invalid ISO date + → at dateOfBirth`); + }); +}); + +describe('SchemaValidationError', () => { + it('should format ZodError with z.prettifyError', () => { + const error = new ZodError([ + { + code: 'invalid_type', + expected: 'string', + input: 42, + message: 'Invalid input: expected string, received number', + path: ['id'], + }, + { + code: 'invalid_format', + format: 'datetime', + input: '1980-07-31', + message: 'Invalid ISO datetime', + path: ['logs', 11, 'timestamp'], + }, + ]); + + expect(new SchemaValidationError(error, z.any(), {}).message).toContain(` +✖ Invalid input: expected string, received number + → at id +✖ Invalid ISO datetime + → at logs[11].timestamp`); + }); + + it('should use schema title from meta registry', () => { + const schema = z.number().min(0).max(1).meta({ title: 'Score' }); + + expect( + new SchemaValidationError(new ZodError([]), schema, {}).message, + ).toContain(`Invalid ${ansis.bold('Score')}\n`); + }); + + it('should use generic message if schema title not in registry', () => { + const schema = z.number().min(0).max(1); + + expect( + new SchemaValidationError(new ZodError([]), schema, {}).message, + ).toContain('Invalid data\n'); + }); + + it('should include relative file path if provided', () => { + const schema = z.object({}).meta({ title: 'CoreConfig' }); + const filePath = path.join(process.cwd(), 'code-pushup.config.ts'); + + expect( + new SchemaValidationError(new ZodError([]), schema, { filePath }).message, + ).toContain( + `Invalid ${ansis.bold('CoreConfig')} in ${ansis.bold('code-pushup.config.ts')} file\n`, + ); + }); +}); From e6d77916599a3bfd90d4687cc8b74341aed0f02c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Thu, 23 Oct 2025 13:57:07 +0200 Subject: [PATCH 07/13] feat: use formatted zod schema validation errors --- packages/ci/src/lib/schemas.ts | 28 ++++++++++--------- packages/ci/src/lib/settings.ts | 10 +++---- .../implementation/core-config.int.test.ts | 8 +++++- .../implementation/core-config.middleware.ts | 6 ++-- packages/cli/src/lib/yargs-cli.ts | 17 +++++++---- packages/core/src/lib/collect-and-persist.ts | 3 +- packages/core/src/lib/compare.ts | 5 ++-- .../src/lib/implementation/execute-plugin.ts | 22 +++++++-------- packages/models/README.md | 4 +-- .../models/src/lib/implementation/validate.ts | 2 +- .../lib/implementation/validate.unit.test.ts | 6 ++-- .../src/lib/coverage-plugin.ts | 9 ++++-- .../src/lib/eslint-plugin.int.test.ts | 3 +- packages/plugin-js-packages/src/lib/utils.ts | 8 ++++-- .../plugin-jsdocs/src/lib/jsdocs-plugin.ts | 4 +-- .../src/lib/typescript-plugin.ts | 4 +-- .../src/lib/typescript-plugin.unit.test.ts | 7 ++++- .../utils/src/lib/git/git.commits-and-tags.ts | 4 +-- packages/utils/src/lib/reports/load-report.ts | 6 ++-- .../lib/utils/dynamic-mocks/config.mock.ts | 10 +++++-- .../dynamic-mocks/persist-config.mock.ts | 3 +- .../utils/dynamic-mocks/plugin-config.mock.ts | 7 +++-- .../utils/dynamic-mocks/upload-config.mock.ts | 8 ++++-- 23 files changed, 110 insertions(+), 74 deletions(-) diff --git a/packages/ci/src/lib/schemas.ts b/packages/ci/src/lib/schemas.ts index 21424d506..1825affb0 100644 --- a/packages/ci/src/lib/schemas.ts +++ b/packages/ci/src/lib/schemas.ts @@ -26,16 +26,18 @@ export const interpolatedSlugSchema = slugSchema.catch(ctx => { throw new ZodError(ctx.error.issues); }); -export const configPatternsSchema = z.object({ - persist: persistConfigSchema.transform(persist => ({ - ...DEFAULT_PERSIST_CONFIG, - ...persist, - })), - upload: uploadConfigSchema - .omit({ organization: true, project: true }) - .extend({ - organization: interpolatedSlugSchema, - project: interpolatedSlugSchema, - }) - .optional(), -}); +export const configPatternsSchema = z + .object({ + persist: persistConfigSchema.transform(persist => ({ + ...DEFAULT_PERSIST_CONFIG, + ...persist, + })), + upload: uploadConfigSchema + .omit({ organization: true, project: true }) + .extend({ + organization: interpolatedSlugSchema, + project: interpolatedSlugSchema, + }) + .optional(), + }) + .meta({ title: 'ConfigPatterns' }); diff --git a/packages/ci/src/lib/settings.ts b/packages/ci/src/lib/settings.ts index f942fc891..28fcea4ac 100644 --- a/packages/ci/src/lib/settings.ts +++ b/packages/ci/src/lib/settings.ts @@ -1,4 +1,4 @@ -import { ZodError, z } from 'zod'; +import { SchemaValidationError, validate } from '@code-pushup/models'; import type { ConfigPatterns, Settings } from './models.js'; import { configPatternsSchema } from './schemas.js'; @@ -32,17 +32,15 @@ export function parseConfigPatternsFromString( try { const json = JSON.parse(value); - return configPatternsSchema.parse(json); + return validate(configPatternsSchema, json); } catch (error) { if (error instanceof SyntaxError) { throw new TypeError( `Invalid JSON value for configPatterns input - ${error.message}`, ); } - if (error instanceof ZodError) { - throw new TypeError( - `Invalid shape of configPatterns input:\n${z.prettifyError(error)}`, - ); + if (error instanceof SchemaValidationError) { + throw new TypeError(`Invalid shape of configPatterns input:\n${error}`); } throw error; } diff --git a/packages/cli/src/lib/implementation/core-config.int.test.ts b/packages/cli/src/lib/implementation/core-config.int.test.ts index 0e9e57616..4baec9526 100644 --- a/packages/cli/src/lib/implementation/core-config.int.test.ts +++ b/packages/cli/src/lib/implementation/core-config.int.test.ts @@ -1,3 +1,4 @@ +import ansis from 'ansis'; import { describe, expect, vi } from 'vitest'; import { type CoreConfig, @@ -203,6 +204,11 @@ describe('parsing values from CLI and middleware', () => { middlewares: [{ middlewareFunction: coreConfigMiddleware }], }, ).parseAsync(), - ).rejects.toThrow('invalid_type'); + ).rejects.toThrow(`Invalid ${ansis.bold('UploadConfig')} +✖ Invalid input: expected string, received undefined + → at server +✖ Invalid input: expected string, received undefined + → at apiKey +`); }); }); diff --git a/packages/cli/src/lib/implementation/core-config.middleware.ts b/packages/cli/src/lib/implementation/core-config.middleware.ts index cb334b5b8..dea6a7aea 100644 --- a/packages/cli/src/lib/implementation/core-config.middleware.ts +++ b/packages/cli/src/lib/implementation/core-config.middleware.ts @@ -8,6 +8,7 @@ import { DEFAULT_PERSIST_OUTPUT_DIR, type Format, uploadConfigSchema, + validate, } from '@code-pushup/models'; import type { CoreConfigCliOptions } from './core-config.model.js'; import type { FilterOptions } from './filter.model.js'; @@ -58,10 +59,7 @@ export async function coreConfigMiddleware< const upload = rcUpload == null && cliUpload == null ? undefined - : uploadConfigSchema.parse({ - ...rcUpload, - ...cliUpload, - }); + : validate(uploadConfigSchema, { ...rcUpload, ...cliUpload }); return { ...(config != null && { config }), diff --git a/packages/cli/src/lib/yargs-cli.ts b/packages/cli/src/lib/yargs-cli.ts index 8caafa30c..0dbc9ec35 100644 --- a/packages/cli/src/lib/yargs-cli.ts +++ b/packages/cli/src/lib/yargs-cli.ts @@ -8,7 +8,11 @@ import yargs, { type Options, type ParserConfigurationOptions, } from 'yargs'; -import { type PersistConfig, formatSchema } from '@code-pushup/models'; +import { + type PersistConfig, + formatSchema, + validate, +} from '@code-pushup/models'; import { TERMINAL_WIDTH } from '@code-pushup/utils'; import { descriptionStyle, @@ -37,9 +41,8 @@ export const yargsDecorator = { * returns configurable yargs CLI for code-pushup * * @example - * yargsCli(hideBin(process.argv)) - * // bootstrap CLI; format arguments - * .argv; + * // bootstrap CLI; format arguments + * yargsCli(hideBin(process.argv)).argv; */ export function yargsCli( argv: string[], @@ -150,11 +153,13 @@ function validatePersistFormat(persist: PersistConfig) { if (persist.format != null) { persist.format .flatMap(format => format.split(',')) - .forEach(format => formatSchema.parse(format)); + .forEach(format => { + validate(formatSchema, format); + }); } return true; } catch { - throw new Error( + throw new TypeError( `Invalid persist.format option. Valid options are: ${formatSchema.options.join( ', ', )}`, diff --git a/packages/core/src/lib/collect-and-persist.ts b/packages/core/src/lib/collect-and-persist.ts index 414c0275f..eb7d266d9 100644 --- a/packages/core/src/lib/collect-and-persist.ts +++ b/packages/core/src/lib/collect-and-persist.ts @@ -3,6 +3,7 @@ import { type CoreConfig, type PersistConfig, pluginReportSchema, + validate, } from '@code-pushup/models'; import { isVerbose, @@ -57,6 +58,6 @@ export async function collectAndPersistReports( // validate report and throw if invalid reportResult.plugins.forEach(plugin => { // Running checks after persisting helps while debugging as you can check the invalid output after the error is thrown - pluginReportSchema.parse(plugin); + validate(pluginReportSchema, plugin); }); } diff --git a/packages/core/src/lib/compare.ts b/packages/core/src/lib/compare.ts index 13d09ab3d..18f4c391c 100644 --- a/packages/core/src/lib/compare.ts +++ b/packages/core/src/lib/compare.ts @@ -7,6 +7,7 @@ import { type ReportsDiff, type UploadConfig, reportSchema, + validate, } from '@code-pushup/models'; import { type Diff, @@ -49,8 +50,8 @@ export async function compareReportFiles( readJsonFile(options?.after ?? defaultInputPath('after')), ]); const reports: Diff = { - before: reportSchema.parse(reportBefore), - after: reportSchema.parse(reportAfter), + before: validate(reportSchema, reportBefore), + after: validate(reportSchema, reportAfter), }; const diff = compareReports(reports); diff --git a/packages/core/src/lib/implementation/execute-plugin.ts b/packages/core/src/lib/implementation/execute-plugin.ts index e492b4453..225988f4f 100644 --- a/packages/core/src/lib/implementation/execute-plugin.ts +++ b/packages/core/src/lib/implementation/execute-plugin.ts @@ -34,16 +34,16 @@ import { * * @example * // plugin execution - * const pluginCfg = pluginConfigSchema.parse({...}); + * const pluginCfg = validate(pluginConfigSchema, {...}); * const output = await executePlugin(pluginCfg); * - * @example - * // error handling - * try { - * await executePlugin(pluginCfg); - * } catch (e) { - * console.error(e.message); - * } + * @example + * // error handling + * try { + * await executePlugin(pluginCfg); + * } catch (e) { + * console.error(e.message); + * } */ export async function executePlugin( pluginConfig: PluginConfig, @@ -144,14 +144,14 @@ const wrapProgress = async ( * * @example * // plugin execution - * const plugins = [pluginConfigSchema.parse({...})]; + * const plugins = [validate(pluginConfigSchema, {...})]; * * @example * // error handling * try { - * await executePlugins(plugins); + * await executePlugins(plugins); * } catch (e) { - * console.error(e.message); // Plugin output is invalid + * console.error(e.message); // Plugin output is invalid * } * */ diff --git a/packages/models/README.md b/packages/models/README.md index 532286363..7c3ebe896 100644 --- a/packages/models/README.md +++ b/packages/models/README.md @@ -65,8 +65,8 @@ Import the type definitions if using TypeScript: If you need runtime validation, use the underlying Zod schemas: ```ts -import { coreConfigSchema } from '@code-pushup/models'; +import { coreConfigSchema, validate } from '@code-pushup/models'; const json = JSON.parse(readFileSync('code-pushup.config.json')); -const config = coreConfigSchema.parse(json); // throws ZodError if invalid +const config = validate(coreConfigSchema, json); // throws SchemaValidationError if invalid ``` diff --git a/packages/models/src/lib/implementation/validate.ts b/packages/models/src/lib/implementation/validate.ts index 4d5f55b3e..28613b800 100644 --- a/packages/models/src/lib/implementation/validate.ts +++ b/packages/models/src/lib/implementation/validate.ts @@ -28,7 +28,7 @@ export class SchemaValidationError extends Error { export function validate( schema: T, - data: z.input, + data: z.input | {} | null | undefined, // loose autocomplete context: SchemaValidationContext = {}, ): z.output { const result = schema.safeParse(data); diff --git a/packages/models/src/lib/implementation/validate.unit.test.ts b/packages/models/src/lib/implementation/validate.unit.test.ts index 3ab2a1b74..a16c6a34c 100644 --- a/packages/models/src/lib/implementation/validate.unit.test.ts +++ b/packages/models/src/lib/implementation/validate.unit.test.ts @@ -27,11 +27,9 @@ describe('validate', () => { dateOfBirth: z.iso.date().optional(), }) .meta({ title: 'User' }); - type User = z.infer; - expect(() => - validate(userSchema, { name: '', dateOfBirth: 'Jul 1, 1980' } as User), - ).toThrow(`Invalid ${ansis.bold('User')} + expect(() => validate(userSchema, { name: '', dateOfBirth: 'Jul 1, 1980' })) + .toThrow(`Invalid ${ansis.bold('User')} ✖ Too small: expected string to have >=1 characters → at name ✖ Invalid input: expected string, received undefined diff --git a/packages/plugin-coverage/src/lib/coverage-plugin.ts b/packages/plugin-coverage/src/lib/coverage-plugin.ts index 2727f3f73..93a94b4c6 100644 --- a/packages/plugin-coverage/src/lib/coverage-plugin.ts +++ b/packages/plugin-coverage/src/lib/coverage-plugin.ts @@ -1,7 +1,12 @@ import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { Audit, Group, PluginConfig } from '@code-pushup/models'; +import { + type Audit, + type Group, + type PluginConfig, + validate, +} from '@code-pushup/models'; import { capitalize } from '@code-pushup/utils'; import { type CoveragePluginConfig, @@ -32,7 +37,7 @@ import { coverageDescription, coverageTypeWeightMapper } from './utils.js'; export async function coveragePlugin( config: CoveragePluginConfig, ): Promise { - const coverageConfig = coveragePluginConfigSchema.parse(config); + const coverageConfig = validate(coveragePluginConfigSchema, config); const audits = coverageConfig.coverageTypes.map( (type): Audit => ({ diff --git a/packages/plugin-eslint/src/lib/eslint-plugin.int.test.ts b/packages/plugin-eslint/src/lib/eslint-plugin.int.test.ts index eef2cf872..02273d462 100644 --- a/packages/plugin-eslint/src/lib/eslint-plugin.int.test.ts +++ b/packages/plugin-eslint/src/lib/eslint-plugin.int.test.ts @@ -1,3 +1,4 @@ +import ansis from 'ansis'; import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; @@ -129,7 +130,7 @@ describe('eslintPlugin', () => { await expect( // @ts-expect-error simulating invalid non-TS config eslintPlugin({ eslintrc: '.eslintrc.json' }), - ).rejects.toThrow('Failed parsing ESLint plugin config'); + ).rejects.toThrow(`Invalid ${ansis.bold('ESLintPluginConfig')}`); }); it("should throw if eslintrc file doesn't exist", async () => { diff --git a/packages/plugin-js-packages/src/lib/utils.ts b/packages/plugin-js-packages/src/lib/utils.ts index b87835ba9..e40ea3388 100644 --- a/packages/plugin-js-packages/src/lib/utils.ts +++ b/packages/plugin-js-packages/src/lib/utils.ts @@ -1,3 +1,4 @@ +import { validate } from '@code-pushup/models'; import { type JSPackagesPluginConfig, jsPackagesPluginConfigSchema, @@ -5,8 +6,11 @@ import { import { derivePackageManager } from './package-managers/derive-package-manager.js'; import { packageManagers } from './package-managers/package-managers.js'; -export async function normalizeConfig(config?: JSPackagesPluginConfig) { - const jsPackagesPluginConfig = jsPackagesPluginConfigSchema.parse( +export async function normalizeConfig( + config: JSPackagesPluginConfig | undefined, +) { + const jsPackagesPluginConfig = validate( + jsPackagesPluginConfigSchema, config ?? {}, ); diff --git a/packages/plugin-jsdocs/src/lib/jsdocs-plugin.ts b/packages/plugin-jsdocs/src/lib/jsdocs-plugin.ts index 8de4c3410..9da282a36 100644 --- a/packages/plugin-jsdocs/src/lib/jsdocs-plugin.ts +++ b/packages/plugin-jsdocs/src/lib/jsdocs-plugin.ts @@ -1,4 +1,4 @@ -import type { PluginConfig } from '@code-pushup/models'; +import { type PluginConfig, validate } from '@code-pushup/models'; import { type JsDocsPluginConfig, jsDocsPluginConfigSchema } from './config.js'; import { PLUGIN_SLUG, groups } from './constants.js'; import { createRunnerFunction } from './runner/runner.js'; @@ -31,7 +31,7 @@ export const PLUGIN_DOCS_URL = * @returns Plugin configuration. */ export function jsDocsPlugin(config: JsDocsPluginConfig): PluginConfig { - const jsDocsConfig = jsDocsPluginConfigSchema.parse(config); + const jsDocsConfig = validate(jsDocsPluginConfigSchema, config); const scoreTargets = jsDocsConfig.scoreTargets; return { diff --git a/packages/plugin-typescript/src/lib/typescript-plugin.ts b/packages/plugin-typescript/src/lib/typescript-plugin.ts index 80c95f21a..48fa8de2d 100644 --- a/packages/plugin-typescript/src/lib/typescript-plugin.ts +++ b/packages/plugin-typescript/src/lib/typescript-plugin.ts @@ -1,5 +1,5 @@ import { createRequire } from 'node:module'; -import type { PluginConfig } from '@code-pushup/models'; +import { type PluginConfig, validate } from '@code-pushup/models'; import { stringifyError } from '@code-pushup/utils'; import { DEFAULT_TS_CONFIG, TYPESCRIPT_PLUGIN_SLUG } from './constants.js'; import { createRunnerFunction } from './runner/runner.js'; @@ -50,7 +50,7 @@ function parseOptions( tsPluginOptions: TypescriptPluginOptions, ): TypescriptPluginConfig { try { - return typescriptPluginConfigSchema.parse(tsPluginOptions); + return validate(typescriptPluginConfigSchema, tsPluginOptions); } catch (error) { throw new Error( `Error parsing TypeScript Plugin options: ${stringifyError(error)}`, 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 f3425389a..1d9ae3365 100644 --- a/packages/plugin-typescript/src/lib/typescript-plugin.unit.test.ts +++ b/packages/plugin-typescript/src/lib/typescript-plugin.unit.test.ts @@ -1,3 +1,4 @@ +import ansis from 'ansis'; import { expect } from 'vitest'; import { pluginConfigSchema } from '@code-pushup/models'; import { AUDITS, GROUPS } from './constants.js'; @@ -35,7 +36,11 @@ describe('typescriptPlugin-config-object', () => { typescriptPlugin({ tsconfig: 42, } as unknown as TypescriptPluginOptions), - ).rejects.toThrow(/invalid_type/); + ).rejects + .toThrow(`Error parsing TypeScript Plugin options: SchemaValidationError: Invalid ${ansis.bold('TypescriptPluginConfig')} +✖ Invalid input: expected string, received number + → at tsconfig +`); }); it('should pass scoreTargets to PluginConfig when provided', async () => { diff --git a/packages/utils/src/lib/git/git.commits-and-tags.ts b/packages/utils/src/lib/git/git.commits-and-tags.ts index 8acf41a03..f4bef08d6 100644 --- a/packages/utils/src/lib/git/git.commits-and-tags.ts +++ b/packages/utils/src/lib/git/git.commits-and-tags.ts @@ -1,5 +1,5 @@ import { type LogOptions as SimpleGitLogOptions, simpleGit } from 'simple-git'; -import { type Commit, commitSchema } from '@code-pushup/models'; +import { type Commit, commitSchema, validate } from '@code-pushup/models'; import { stringifyError } from '../errors.js'; import { ui } from '../logging.js'; import { isSemver } from '../semver.js'; @@ -13,7 +13,7 @@ export async function getLatestCommit( // git log -1 --pretty=format:"%H %s %an %aI" - See: https://git-scm.com/docs/pretty-formats format: { hash: '%H', message: '%s', author: '%an', date: '%aI' }, }); - return commitSchema.parse(log.latest); + return validate(commitSchema, log.latest); } catch (error) { ui().logger.error(stringifyError(error)); return null; diff --git a/packages/utils/src/lib/reports/load-report.ts b/packages/utils/src/lib/reports/load-report.ts index 0f94d2b4f..587ebc9d1 100644 --- a/packages/utils/src/lib/reports/load-report.ts +++ b/packages/utils/src/lib/reports/load-report.ts @@ -4,6 +4,7 @@ import { type PersistConfig, type Report, reportSchema, + validate, } from '@code-pushup/models'; import { ensureDirectoryExists, @@ -24,9 +25,10 @@ export async function loadReport( if (format === 'json') { const content = await readJsonFile(filePath); - return reportSchema.parse(content) as LoadedReportFormat; + const report: Report = validate(reportSchema, content); + return report as LoadedReportFormat; } - const text = await readTextFile(filePath); + const text: string = await readTextFile(filePath); return text as LoadedReportFormat; } diff --git a/testing/test-utils/src/lib/utils/dynamic-mocks/config.mock.ts b/testing/test-utils/src/lib/utils/dynamic-mocks/config.mock.ts index 5a9d2fc53..ee6ea0e06 100644 --- a/testing/test-utils/src/lib/utils/dynamic-mocks/config.mock.ts +++ b/testing/test-utils/src/lib/utils/dynamic-mocks/config.mock.ts @@ -1,4 +1,8 @@ -import { type CoreConfig, coreConfigSchema } from '@code-pushup/models'; +import { + type CoreConfig, + coreConfigSchema, + validate, +} from '@code-pushup/models'; import { categoryConfigsMock } from './categories.mock.js'; import { eslintPluginConfigMock } from './eslint-plugin.mock.js'; import { lighthousePluginConfigMock } from './lighthouse-plugin.mock.js'; @@ -6,7 +10,7 @@ import { persistConfigMock } from './persist-config.mock.js'; import { auditReportMock, pluginConfigMock } from './plugin-config.mock.js'; export function configMock(outputDir = 'tmp'): CoreConfig { - return coreConfigSchema.parse({ + return validate(coreConfigSchema, { persist: persistConfigMock({ outputDir }), upload: { organization: 'code-pushup', @@ -29,7 +33,7 @@ export function minimalConfigMock( const AUDIT_1_SLUG = 'audit-1'; const outputFile = `${PLUGIN_1_SLUG}.${Date.now()}.json`; - const cfg = coreConfigSchema.parse({ + const cfg = validate(coreConfigSchema, { persist: persistConfigMock({ outputDir }), upload: { organization: 'code-pushup', diff --git a/testing/test-utils/src/lib/utils/dynamic-mocks/persist-config.mock.ts b/testing/test-utils/src/lib/utils/dynamic-mocks/persist-config.mock.ts index 7f26936e4..f4537d719 100644 --- a/testing/test-utils/src/lib/utils/dynamic-mocks/persist-config.mock.ts +++ b/testing/test-utils/src/lib/utils/dynamic-mocks/persist-config.mock.ts @@ -4,12 +4,13 @@ import { DEFAULT_PERSIST_OUTPUT_DIR, type PersistConfig, persistConfigSchema, + validate, } from '@code-pushup/models'; export function persistConfigMock( opt?: Partial, ): Required { - return persistConfigSchema.parse({ + return validate(persistConfigSchema, { outputDir: DEFAULT_PERSIST_OUTPUT_DIR, filename: DEFAULT_PERSIST_FILENAME, format: DEFAULT_PERSIST_FORMAT, diff --git a/testing/test-utils/src/lib/utils/dynamic-mocks/plugin-config.mock.ts b/testing/test-utils/src/lib/utils/dynamic-mocks/plugin-config.mock.ts index da9cf94fa..0ff24a144 100644 --- a/testing/test-utils/src/lib/utils/dynamic-mocks/plugin-config.mock.ts +++ b/testing/test-utils/src/lib/utils/dynamic-mocks/plugin-config.mock.ts @@ -6,6 +6,7 @@ import { auditReportSchema, auditSchema, pluginConfigSchema, + validate, } from '@code-pushup/models'; import { echoRunnerConfigMock } from './runner-config.mock.js'; @@ -18,7 +19,7 @@ export function pluginConfigMock( outputDir || 'tmp', outputFile || `out.${Date.now()}.json`, ); - return pluginConfigSchema.parse({ + return validate(pluginConfigSchema, { slug: 'mock-plugin-slug', title: 'Plugin Title', icon: 'nrwl', @@ -31,7 +32,7 @@ export function pluginConfigMock( } export function auditConfigMock(opt?: Partial): Audit { - return auditSchema.parse({ + return validate(auditSchema, { slug: opt?.slug || 'mock-audit-slug', title: opt?.title || 'Audit Title', description: opt?.description || 'audit description', @@ -40,7 +41,7 @@ export function auditConfigMock(opt?: Partial): Audit { } export function auditReportMock(opt?: Partial): AuditReport { - return auditReportSchema.parse({ + return validate(auditReportSchema, { slug: 'mock-audit-slug', title: 'Audit Title', description: 'audit description', diff --git a/testing/test-utils/src/lib/utils/dynamic-mocks/upload-config.mock.ts b/testing/test-utils/src/lib/utils/dynamic-mocks/upload-config.mock.ts index 6571c55f5..2cd3924a0 100644 --- a/testing/test-utils/src/lib/utils/dynamic-mocks/upload-config.mock.ts +++ b/testing/test-utils/src/lib/utils/dynamic-mocks/upload-config.mock.ts @@ -1,7 +1,11 @@ -import { type UploadConfig, uploadConfigSchema } from '@code-pushup/models'; +import { + type UploadConfig, + uploadConfigSchema, + validate, +} from '@code-pushup/models'; export function uploadConfig(opt?: Partial): UploadConfig { - return uploadConfigSchema.parse({ + return validate(uploadConfigSchema, { apiKey: 'm0ck-API-k3y', server: 'http://test.server.io', organization: 'code-pushup', From 23a32237d172d27b463c8c6e149213a62ad2fb8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Thu, 23 Oct 2025 16:56:39 +0200 Subject: [PATCH 08/13] feat(utils): prettify zod errors in stringifyError utility --- .../models/src/lib/implementation/validate.ts | 1 + packages/utils/package.json | 3 +- packages/utils/src/lib/errors.ts | 10 +++- packages/utils/src/lib/errors.unit.test.ts | 51 +++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/packages/models/src/lib/implementation/validate.ts b/packages/models/src/lib/implementation/validate.ts index 28613b800..4e4e6b2dd 100644 --- a/packages/models/src/lib/implementation/validate.ts +++ b/packages/models/src/lib/implementation/validate.ts @@ -23,6 +23,7 @@ export class SchemaValidationError extends Error { .filter(Boolean) .join(' '); super(`${summary}\n${formattedError}\n`); + this.name = SchemaValidationError.name; } } diff --git a/packages/utils/package.json b/packages/utils/package.json index 4cd23a39c..a6441089d 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -37,7 +37,8 @@ "multi-progress-bars": "^5.0.3", "semver": "^7.6.0", "simple-git": "^3.20.0", - "ora": "^9.0.0" + "ora": "^9.0.0", + "zod": "^4.0.5" }, "files": [ "src", diff --git a/packages/utils/src/lib/errors.ts b/packages/utils/src/lib/errors.ts index 9c92cb38f..2485290bc 100644 --- a/packages/utils/src/lib/errors.ts +++ b/packages/utils/src/lib/errors.ts @@ -1,5 +1,13 @@ +import { ZodError, z } from 'zod'; + export function stringifyError(error: unknown): string { - // TODO: special handling for ZodError instances + if (error instanceof ZodError) { + const formattedError = z.prettifyError(error); + if (formattedError.includes('\n')) { + return `${error.name}:\n${formattedError}\n`; + } + return `${error.name}: ${formattedError}`; + } if (error instanceof Error) { if (error.name === 'Error' || error.message.startsWith(error.name)) { return error.message; diff --git a/packages/utils/src/lib/errors.unit.test.ts b/packages/utils/src/lib/errors.unit.test.ts index 56c9f6271..4c0f98c2f 100644 --- a/packages/utils/src/lib/errors.unit.test.ts +++ b/packages/utils/src/lib/errors.unit.test.ts @@ -1,3 +1,6 @@ +import ansis from 'ansis'; +import { z } from 'zod'; +import { SchemaValidationError } from '@code-pushup/models'; import { stringifyError } from './errors.js'; describe('stringifyError', () => { @@ -22,4 +25,52 @@ describe('stringifyError', () => { '{"status":400,"statusText":"Bad Request"}', ); }); + + it('should prettify ZodError instances spanning multiple lines', () => { + const schema = z.object({ + name: z.string().min(1), + address: z.string(), + dateOfBirth: z.iso.date().optional(), + }); + const { error } = schema.safeParse({ name: '', dateOfBirth: '' }); + + expect(stringifyError(error)).toBe(`ZodError: +✖ Too small: expected string to have >=1 characters + → at name +✖ Invalid input: expected string, received undefined + → at address +✖ Invalid ISO date + → at dateOfBirth +`); + }); + + it('should prettify ZodError instances on one line if possible', () => { + const schema = z.enum(['json', 'md']); + const { error } = schema.safeParse('html'); + + expect(stringifyError(error)).toBe( + 'ZodError: ✖ Invalid option: expected one of "json"|"md"', + ); + }); + + it('should use custom SchemaValidationError formatted messages', () => { + const schema = z + .object({ + name: z.string().min(1), + address: z.string(), + dateOfBirth: z.iso.date().optional(), + }) + .meta({ title: 'User' }); + const { error } = schema.safeParse({ name: '', dateOfBirth: '' }); + + expect(stringifyError(new SchemaValidationError(error!, schema, {}))) + .toBe(`SchemaValidationError: Invalid ${ansis.bold('User')} +✖ Too small: expected string to have >=1 characters + → at name +✖ Invalid input: expected string, received undefined + → at address +✖ Invalid ISO date + → at dateOfBirth +`); + }); }); From 25252d0031dccbd9c72705c81a09ca468613c253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Thu, 23 Oct 2025 17:34:21 +0200 Subject: [PATCH 09/13] fix: use safe error to string conversions --- .github/actions/code-pushup/src/runner.ts | 4 ++-- .../src/lib/implementation/execute-plugin.ts | 17 ++++++++--------- packages/core/src/lib/implementation/persist.ts | 3 ++- packages/core/src/lib/load-portal-client.ts | 7 +++++-- .../nx-plugin/src/executors/cli/executor.ts | 2 +- .../src/lib/runner/details/opportunity.type.ts | 10 +++++----- .../details/opportunity.type.unit.test.ts | 2 +- .../src/lib/runner/details/table.type.ts | 10 +++++----- .../lib/runner/details/table.type.unit.test.ts | 2 +- .../src/lib/runner/details/utils.ts | 5 +++-- .../plugin-lighthouse/src/lib/runner/runner.ts | 4 ++-- .../plugin-lighthouse/src/lib/runner/utils.ts | 9 ++++++--- packages/utils/src/lib/file-system.ts | 5 +++-- testing/test-utils/src/lib/utils/file-system.ts | 5 +++-- tools/scripts/publish.mjs | 4 ++-- tools/src/debug/utils.ts | 4 +--- 16 files changed, 50 insertions(+), 43 deletions(-) diff --git a/.github/actions/code-pushup/src/runner.ts b/.github/actions/code-pushup/src/runner.ts index e6acc0da0..5db13799d 100644 --- a/.github/actions/code-pushup/src/runner.ts +++ b/.github/actions/code-pushup/src/runner.ts @@ -10,7 +10,7 @@ import { type SourceFileIssue, runInCI, } from '@code-pushup/ci'; -import { CODE_PUSHUP_UNICODE_LOGO } from '@code-pushup/utils'; +import { CODE_PUSHUP_UNICODE_LOGO, stringifyError } from '@code-pushup/utils'; type GitHubRefs = { head: GitBranch; @@ -150,7 +150,7 @@ async function run(): Promise { core.info(`${LOG_PREFIX} Finished running successfully`); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = stringifyError(error); core.error(`${LOG_PREFIX} Failed: ${message}`); core.setFailed(message); } diff --git a/packages/core/src/lib/implementation/execute-plugin.ts b/packages/core/src/lib/implementation/execute-plugin.ts index 225988f4f..a9a24de67 100644 --- a/packages/core/src/lib/implementation/execute-plugin.ts +++ b/packages/core/src/lib/implementation/execute-plugin.ts @@ -16,6 +16,7 @@ import { logMultipleResults, pluralizeToken, scoreAuditsWithTarget, + stringifyError, } from '@code-pushup/utils'; import { executePluginRunner, @@ -41,8 +42,8 @@ import { * // error handling * try { * await executePlugin(pluginCfg); - * } catch (e) { - * console.error(e.message); + * } catch (error) { + * console.error(error); * } */ export async function executePlugin( @@ -125,11 +126,9 @@ const wrapProgress = async ( } catch (error) { progressBar?.incrementInSteps(steps); throw new Error( - error instanceof Error - ? `- Plugin ${bold(pluginCfg.title)} (${bold( - pluginCfg.slug, - )}) produced the following error:\n - ${error.message}` - : String(error), + `- Plugin ${bold(pluginCfg.title)} (${bold( + pluginCfg.slug, + )}) produced the following error:\n - ${stringifyError(error)}`, ); } }; @@ -150,8 +149,8 @@ const wrapProgress = async ( * // error handling * try { * await executePlugins(plugins); - * } catch (e) { - * console.error(e.message); // Plugin output is invalid + * } catch (error) { + * console.error(error); // Plugin output is invalid * } * */ diff --git a/packages/core/src/lib/implementation/persist.ts b/packages/core/src/lib/implementation/persist.ts index 069b16c69..340d61b96 100644 --- a/packages/core/src/lib/implementation/persist.ts +++ b/packages/core/src/lib/implementation/persist.ts @@ -7,6 +7,7 @@ import { directoryExists, generateMdReport, logMultipleFileResults, + stringifyError, ui, } from '@code-pushup/utils'; @@ -51,7 +52,7 @@ export async function persistReport( try { await mkdir(outputDir, { recursive: true }); } catch (error) { - ui().logger.warning((error as Error).toString()); + ui().logger.warning(stringifyError(error)); throw new PersistDirError(outputDir); } } diff --git a/packages/core/src/lib/load-portal-client.ts b/packages/core/src/lib/load-portal-client.ts index 2010b22f9..4ee3bb0df 100644 --- a/packages/core/src/lib/load-portal-client.ts +++ b/packages/core/src/lib/load-portal-client.ts @@ -1,11 +1,14 @@ -import { ui } from '@code-pushup/utils'; +import { stringifyError, ui } from '@code-pushup/utils'; export async function loadPortalClient(): Promise< typeof import('@code-pushup/portal-client') | null > { try { return await import('@code-pushup/portal-client'); - } catch { + } catch (error) { + ui().logger.warning( + `Failed to import @code-pushup/portal-client - ${stringifyError(error)}`, + ); ui().logger.error( 'Optional peer dependency @code-pushup/portal-client is not available. Make sure it is installed to enable upload functionality.', ); diff --git a/packages/nx-plugin/src/executors/cli/executor.ts b/packages/nx-plugin/src/executors/cli/executor.ts index 7eece1129..9490582e5 100644 --- a/packages/nx-plugin/src/executors/cli/executor.ts +++ b/packages/nx-plugin/src/executors/cli/executor.ts @@ -49,7 +49,7 @@ export default async function runAutorunExecutor( return { success: false, command: commandString, - error: error as Error, + error: error instanceof Error ? error : new Error(`${error}`), }; } } diff --git a/packages/plugin-lighthouse/src/lib/runner/details/opportunity.type.ts b/packages/plugin-lighthouse/src/lib/runner/details/opportunity.type.ts index 3a662d659..f79ea1c20 100644 --- a/packages/plugin-lighthouse/src/lib/runner/details/opportunity.type.ts +++ b/packages/plugin-lighthouse/src/lib/runner/details/opportunity.type.ts @@ -11,7 +11,7 @@ import { LighthouseAuditDetailsParsingError } from './utils.js'; export function parseOpportunityToAuditDetailsTable( details: Details.Opportunity, ): Table | undefined { - const { headings: rawHeadings, items } = details; + const { headings, items } = details; if (items.length === 0) { return undefined; @@ -20,14 +20,14 @@ export function parseOpportunityToAuditDetailsTable( try { return tableSchema().parse({ title: 'Opportunity', - columns: parseTableColumns(rawHeadings), - rows: items.map(row => parseOpportunityItemToTableRow(row, rawHeadings)), + columns: parseTableColumns(headings), + rows: items.map(row => parseOpportunityItemToTableRow(row, headings)), }); } catch (error) { throw new LighthouseAuditDetailsParsingError( 'opportunity', - { items, headings: rawHeadings }, - (error as Error).message.toString(), + { items, headings }, + error, ); } } diff --git a/packages/plugin-lighthouse/src/lib/runner/details/opportunity.type.unit.test.ts b/packages/plugin-lighthouse/src/lib/runner/details/opportunity.type.unit.test.ts index f0dcf20ec..37dc625e3 100644 --- a/packages/plugin-lighthouse/src/lib/runner/details/opportunity.type.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/runner/details/opportunity.type.unit.test.ts @@ -256,7 +256,7 @@ describe('parseOpportunityDetails', () => { items: [null], headings, }, - 'Cannot convert undefined or null to object', + 'TypeError: Cannot convert undefined or null to object', ), ); }); diff --git a/packages/plugin-lighthouse/src/lib/runner/details/table.type.ts b/packages/plugin-lighthouse/src/lib/runner/details/table.type.ts index 9d5e60947..4f9e8e542 100644 --- a/packages/plugin-lighthouse/src/lib/runner/details/table.type.ts +++ b/packages/plugin-lighthouse/src/lib/runner/details/table.type.ts @@ -11,7 +11,7 @@ import { LighthouseAuditDetailsParsingError } from './utils.js'; export function parseTableToAuditDetailsTable( details: Details.Table, ): Table | undefined { - const { headings: rawHeadings, items } = details; + const { headings, items } = details; if (items.length === 0) { return undefined; @@ -19,14 +19,14 @@ export function parseTableToAuditDetailsTable( try { return tableSchema().parse({ - columns: parseTableColumns(rawHeadings), - rows: items.map(row => parseTableRow(row, rawHeadings)), + columns: parseTableColumns(headings), + rows: items.map(row => parseTableRow(row, headings)), }); } catch (error) { throw new LighthouseAuditDetailsParsingError( 'table', - { items, headings: rawHeadings }, - (error as Error).message.toString(), + { items, headings }, + error, ); } } diff --git a/packages/plugin-lighthouse/src/lib/runner/details/table.type.unit.test.ts b/packages/plugin-lighthouse/src/lib/runner/details/table.type.unit.test.ts index f9605a43b..65ff2b5bd 100644 --- a/packages/plugin-lighthouse/src/lib/runner/details/table.type.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/runner/details/table.type.unit.test.ts @@ -301,7 +301,7 @@ describe('parseTableToAuditDetails', () => { items: [null], headings, }, - 'Cannot convert undefined or null to object', + 'TypeError: Cannot convert undefined or null to object', ), ); }); diff --git a/packages/plugin-lighthouse/src/lib/runner/details/utils.ts b/packages/plugin-lighthouse/src/lib/runner/details/utils.ts index ec85c5d0e..f467224f0 100644 --- a/packages/plugin-lighthouse/src/lib/runner/details/utils.ts +++ b/packages/plugin-lighthouse/src/lib/runner/details/utils.ts @@ -1,16 +1,17 @@ import { bold } from 'ansis'; import type Details from 'lighthouse/types/lhr/audit-details'; +import { stringifyError } from '@code-pushup/utils'; export class LighthouseAuditDetailsParsingError extends Error { constructor( type: Details['type'], rawTable: Record, - error: string, + error: unknown, ) { super( `Parsing lighthouse report details ${bold( type, - )} failed: \nRaw data:\n ${JSON.stringify(rawTable, null, 2)}\n${error}`, + )} failed: \nRaw data:\n ${JSON.stringify(rawTable, null, 2)}\n${stringifyError(error)}`, ); } } diff --git a/packages/plugin-lighthouse/src/lib/runner/runner.ts b/packages/plugin-lighthouse/src/lib/runner/runner.ts index 61518f7d8..252389d00 100644 --- a/packages/plugin-lighthouse/src/lib/runner/runner.ts +++ b/packages/plugin-lighthouse/src/lib/runner/runner.ts @@ -2,7 +2,7 @@ import type { Config, RunnerResult } from 'lighthouse'; import { runLighthouse } from 'lighthouse/cli/run.js'; import path from 'node:path'; import type { AuditOutputs, RunnerFunction } from '@code-pushup/models'; -import { ensureDirectoryExists, ui } from '@code-pushup/utils'; +import { ensureDirectoryExists, stringifyError, ui } from '@code-pushup/utils'; import { orderSlug, shouldExpandForUrls } from '../processing.js'; import type { LighthouseOptions } from '../types.js'; import { DEFAULT_CLI_FLAGS } from './constants.js'; @@ -46,7 +46,7 @@ export function createRunnerFunction( return [...acc, ...processedOutputs]; } catch (error) { - ui().logger.warning((error as Error).message); + ui().logger.warning(stringifyError(error)); return acc; } }, Promise.resolve([])); diff --git a/packages/plugin-lighthouse/src/lib/runner/utils.ts b/packages/plugin-lighthouse/src/lib/runner/utils.ts index b1f34b711..376bfffba 100644 --- a/packages/plugin-lighthouse/src/lib/runner/utils.ts +++ b/packages/plugin-lighthouse/src/lib/runner/utils.ts @@ -14,6 +14,7 @@ import { importModule, pluginWorkDir, readJsonFile, + stringifyError, ui, } from '@code-pushup/utils'; import { LIGHTHOUSE_PLUGIN_SLUG } from '../constants.js'; @@ -30,8 +31,10 @@ export function normalizeAuditOutputs( } export class LighthouseAuditParsingError extends Error { - constructor(slug: string, error: Error) { - super(`\nAudit ${bold(slug)} failed parsing details: \n${error.message}`); + constructor(slug: string, error: unknown) { + super( + `\nAudit ${bold(slug)} failed parsing details: \n${stringifyError(error)}`, + ); } } @@ -69,7 +72,7 @@ function processAuditDetails( ? { ...auditOutput, details: parsedDetails } : auditOutput; } catch (error) { - throw new LighthouseAuditParsingError(auditOutput.slug, error as Error); + throw new LighthouseAuditParsingError(auditOutput.slug, error); } } diff --git a/packages/utils/src/lib/file-system.ts b/packages/utils/src/lib/file-system.ts index 268eed737..72bb6acc6 100644 --- a/packages/utils/src/lib/file-system.ts +++ b/packages/utils/src/lib/file-system.ts @@ -40,8 +40,9 @@ export async function ensureDirectoryExists(baseDir: string) { await mkdir(baseDir, { recursive: true }); return; } catch (error) { - ui().logger.info((error as { code: string; message: string }).message); - if ((error as { code: string }).code !== 'EEXIST') { + const fsError = error as NodeJS.ErrnoException; + ui().logger.warning(fsError.message); + if (fsError.code !== 'EEXIST') { throw error; } } diff --git a/testing/test-utils/src/lib/utils/file-system.ts b/testing/test-utils/src/lib/utils/file-system.ts index 63f3175b6..648217fb1 100644 --- a/testing/test-utils/src/lib/utils/file-system.ts +++ b/testing/test-utils/src/lib/utils/file-system.ts @@ -5,8 +5,9 @@ export async function ensureDirectoryExists(baseDir: string) { await mkdir(baseDir, { recursive: true }); return; } catch (error) { - console.error((error as { code: string; message: string }).message); - if ((error as { code: string }).code !== 'EEXIST') { + const fsError = error as NodeJS.ErrnoException; + console.error(fsError.message); + if (fsError.code !== 'EEXIST') { throw error; } } diff --git a/tools/scripts/publish.mjs b/tools/scripts/publish.mjs index deaea8ee7..79c31e3d3 100644 --- a/tools/scripts/publish.mjs +++ b/tools/scripts/publish.mjs @@ -51,8 +51,8 @@ try { const json = JSON.parse(readFileSync(`package.json`).toString()); json.version = version; writeFileSync(`package.json`, JSON.stringify(json, null, 2)); -} catch (e) { - console.error(`Error reading package.json file from library build output.`); +} catch { + console.error('Error reading package.json file from library build output.'); } // Execute "npm publish" to publish diff --git a/tools/src/debug/utils.ts b/tools/src/debug/utils.ts index 9e258024d..62ae90c28 100644 --- a/tools/src/debug/utils.ts +++ b/tools/src/debug/utils.ts @@ -133,9 +133,7 @@ export function getNpmrcPath(scope: NpmScope = 'user'): string { const npmConfigArg = scope === 'global' ? 'globalconfig' : 'userconfig'; return execSync(`npm config get ${npmConfigArg}`).toString().trim(); } catch (error) { - throw new Error( - `Failed to retrieve .npmrc path: ${(error as Error).message}`, - ); + throw new Error(`Failed to retrieve .npmrc path: ${error}`); } } From e5b6e0d72baa806e8aea170eeb2fa6b7ef4f51af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Thu, 23 Oct 2025 17:36:22 +0200 Subject: [PATCH 10/13] feat(plugin-lighthouse): prettify table validation errors --- .../src/lib/runner/details/opportunity.type.ts | 3 ++- .../plugin-lighthouse/src/lib/runner/details/table.type.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/plugin-lighthouse/src/lib/runner/details/opportunity.type.ts b/packages/plugin-lighthouse/src/lib/runner/details/opportunity.type.ts index f79ea1c20..6be8b23b2 100644 --- a/packages/plugin-lighthouse/src/lib/runner/details/opportunity.type.ts +++ b/packages/plugin-lighthouse/src/lib/runner/details/opportunity.type.ts @@ -3,6 +3,7 @@ import { type Table, type TableRowObject, tableSchema, + validate, } from '@code-pushup/models'; import { formatBytes, formatDuration, html } from '@code-pushup/utils'; import { parseTableColumns, parseTableEntry } from './table.type.js'; @@ -18,7 +19,7 @@ export function parseOpportunityToAuditDetailsTable( } try { - return tableSchema().parse({ + return validate(tableSchema(), { title: 'Opportunity', columns: parseTableColumns(headings), rows: items.map(row => parseOpportunityItemToTableRow(row, headings)), diff --git a/packages/plugin-lighthouse/src/lib/runner/details/table.type.ts b/packages/plugin-lighthouse/src/lib/runner/details/table.type.ts index 4f9e8e542..d2937754f 100644 --- a/packages/plugin-lighthouse/src/lib/runner/details/table.type.ts +++ b/packages/plugin-lighthouse/src/lib/runner/details/table.type.ts @@ -4,6 +4,7 @@ import { type TableColumnObject, type TableRowObject, tableSchema, + validate, } from '@code-pushup/models'; import { formatTableItemPropertyValue } from './item-value.js'; import { LighthouseAuditDetailsParsingError } from './utils.js'; @@ -18,7 +19,7 @@ export function parseTableToAuditDetailsTable( } try { - return tableSchema().parse({ + return validate(tableSchema(), { columns: parseTableColumns(headings), rows: items.map(row => parseTableRow(row, headings)), }); From 1583c735b6c384f4447bda2b6c92bde262c7f126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 24 Oct 2025 10:21:39 +0200 Subject: [PATCH 11/13] feat(utils): add option to truncate error messages to one-liner --- .../src/lib/meta/transform.unit.test.ts | 2 +- .../runner/details/item-value.unit.test.ts | 4 +- packages/utils/src/index.ts | 2 + packages/utils/src/lib/errors.ts | 19 +++++++-- packages/utils/src/lib/errors.unit.test.ts | 39 +++++++++++++++++++ packages/utils/src/lib/formatting.ts | 25 +++++++++++- .../utils/src/lib/formatting.unit.test.ts | 34 ++++++++++++---- 7 files changed, 110 insertions(+), 15 deletions(-) diff --git a/packages/plugin-eslint/src/lib/meta/transform.unit.test.ts b/packages/plugin-eslint/src/lib/meta/transform.unit.test.ts index 8d3b2951a..26bddc9e1 100644 --- a/packages/plugin-eslint/src/lib/meta/transform.unit.test.ts +++ b/packages/plugin-eslint/src/lib/meta/transform.unit.test.ts @@ -161,7 +161,7 @@ Custom options: ).toEqual({ slug: 'angular-eslint-template-mouse-events-have-key-events', title: - '[Accessibility] Ensures that the mouse events `mouseout` and `mouseover` are accompanied by `focus` and `blur` events respectively. Coding for the keyboard is important for users with physical disabilities who cannot use a mouse, AT compatibility, and s...', + '[Accessibility] Ensures that the mouse events `mouseout` and `mouseover` are accompanied by `focus` and `blur` events respectively. Coding for the keyboard is important for users with physical disabilities who cannot use a mouse, AT compatibility, and scr…', description: 'ESLint rule **mouse-events-have-key-events**, from _@angular-eslint/template_ plugin.', docsUrl: diff --git a/packages/plugin-lighthouse/src/lib/runner/details/item-value.unit.test.ts b/packages/plugin-lighthouse/src/lib/runner/details/item-value.unit.test.ts index 70fa705ce..fe50f6748 100644 --- a/packages/plugin-lighthouse/src/lib/runner/details/item-value.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/runner/details/item-value.unit.test.ts @@ -300,7 +300,7 @@ describe('formatTableItemPropertyValue', () => { ) as string; expect(formattedStr.length).toBeLessThanOrEqual(200); - expect(formattedStr.slice(-3)).toBe('...'); + expect(formattedStr.slice(-1)).toBe('…'); }); it('should format value based on itemValueFormat "numeric" as int', () => { @@ -352,7 +352,7 @@ describe('formatTableItemPropertyValue', () => { ) as string; expect(formattedStr.length).toBeLessThanOrEqual(500); - expect(formattedStr.slice(-3)).toBe('...'); + expect(formattedStr.slice(-1)).toBe('…'); }); it('should format value based on itemValueFormat "multi"', () => { diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 2ca6c9925..edf9f1963 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -60,8 +60,10 @@ export { transformLines, truncateDescription, truncateIssueMessage, + truncateMultilineText, truncateText, truncateTitle, + UNICODE_ELLIPSIS, } from './lib/formatting.js'; export { getCurrentBranchOrTag, diff --git a/packages/utils/src/lib/errors.ts b/packages/utils/src/lib/errors.ts index 2485290bc..3ce467bfd 100644 --- a/packages/utils/src/lib/errors.ts +++ b/packages/utils/src/lib/errors.ts @@ -1,21 +1,32 @@ import { ZodError, z } from 'zod'; +import { UNICODE_ELLIPSIS, truncateMultilineText } from './formatting.js'; + +export function stringifyError( + error: unknown, + format?: { oneline: boolean }, +): string { + const truncate = (text: string) => + format?.oneline ? truncateMultilineText(text) : text; -export function stringifyError(error: unknown): string { if (error instanceof ZodError) { const formattedError = z.prettifyError(error); if (formattedError.includes('\n')) { + if (format?.oneline) { + return `${error.name} [${UNICODE_ELLIPSIS}]`; + } return `${error.name}:\n${formattedError}\n`; } return `${error.name}: ${formattedError}`; } + if (error instanceof Error) { if (error.name === 'Error' || error.message.startsWith(error.name)) { - return error.message; + return truncate(error.message); } - return `${error.name}: ${error.message}`; + return truncate(`${error.name}: ${error.message}`); } if (typeof error === 'string') { - return error; + return truncate(error); } return JSON.stringify(error); } diff --git a/packages/utils/src/lib/errors.unit.test.ts b/packages/utils/src/lib/errors.unit.test.ts index 4c0f98c2f..6424819ae 100644 --- a/packages/utils/src/lib/errors.unit.test.ts +++ b/packages/utils/src/lib/errors.unit.test.ts @@ -26,6 +26,17 @@ describe('stringifyError', () => { ); }); + it('should truncate multiline error messages if one-liner requested', () => { + expect( + stringifyError( + new Error( + 'Failed to execute 2 out of 5 plugins:\n- ESLint\n- Lighthouse', + ), + { oneline: true }, + ), + ).toBe('Failed to execute 2 out of 5 plugins: […]'); + }); + it('should prettify ZodError instances spanning multiple lines', () => { const schema = z.object({ name: z.string().min(1), @@ -44,6 +55,17 @@ describe('stringifyError', () => { `); }); + it('should omit multiline ZodError message if one-liner requested', () => { + const schema = z.object({ + name: z.string().min(1), + address: z.string(), + dateOfBirth: z.iso.date().optional(), + }); + const { error } = schema.safeParse({ name: '', dateOfBirth: '' }); + + expect(stringifyError(error, { oneline: true })).toBe('ZodError […]'); + }); + it('should prettify ZodError instances on one line if possible', () => { const schema = z.enum(['json', 'md']); const { error } = schema.safeParse('html'); @@ -73,4 +95,21 @@ describe('stringifyError', () => { → at dateOfBirth `); }); + + it('should truncate SchemaValidationError if one-liner requested', () => { + const schema = z + .object({ + name: z.string().min(1), + address: z.string(), + dateOfBirth: z.iso.date().optional(), + }) + .meta({ title: 'User' }); + const { error } = schema.safeParse({ name: '', dateOfBirth: '' }); + + expect( + stringifyError(new SchemaValidationError(error!, schema, {}), { + oneline: true, + }), + ).toBe(`SchemaValidationError: Invalid ${ansis.bold('User')} […]`); + }); }); diff --git a/packages/utils/src/lib/formatting.ts b/packages/utils/src/lib/formatting.ts index 260da8bc1..5456b3f59 100644 --- a/packages/utils/src/lib/formatting.ts +++ b/packages/utils/src/lib/formatting.ts @@ -4,6 +4,8 @@ import { MAX_TITLE_LENGTH, } from '@code-pushup/models'; +export const UNICODE_ELLIPSIS = '…'; + export function roundDecimals(value: number, maxDecimals: number) { const multiplier = Math.pow(10, maxDecimals); return Math.round(value * multiplier) / multiplier; @@ -87,7 +89,7 @@ export function truncateText( const { maxChars, position = 'end', - ellipsis = '...', + ellipsis = UNICODE_ELLIPSIS, } = typeof options === 'number' ? { maxChars: options } : options; if (text.length <= maxChars) { return text; @@ -121,6 +123,27 @@ export function truncateIssueMessage(text: string): string { return truncateText(text, MAX_ISSUE_MESSAGE_LENGTH); } +export function truncateMultilineText( + text: string, + options?: { ellipsis?: string }, +): string { + const { ellipsis = `[${UNICODE_ELLIPSIS}]` } = options ?? {}; + + const crlfIndex = text.indexOf('\r\n'); + const lfIndex = text.indexOf('\n'); + const index = crlfIndex >= 0 ? crlfIndex : lfIndex; + + if (index < 0) { + return text; + } + + const firstLine = text.slice(0, index); + if (text.slice(index).trim().length === 0) { + return firstLine; + } + return `${firstLine} ${ellipsis}`; +} + export function transformLines( text: string, fn: (line: string) => string, diff --git a/packages/utils/src/lib/formatting.unit.test.ts b/packages/utils/src/lib/formatting.unit.test.ts index b4234279f..cfa384e61 100644 --- a/packages/utils/src/lib/formatting.unit.test.ts +++ b/packages/utils/src/lib/formatting.unit.test.ts @@ -10,6 +10,7 @@ import { roundDecimals, slugify, transformLines, + truncateMultilineText, truncateText, } from './formatting.js'; @@ -135,7 +136,7 @@ describe('formatDate', () => { describe('truncateText', () => { it('should replace overflowing text with ellipsis at the end', () => { expect(truncateText('All work and no play makes Jack a dull boy', 32)).toBe( - 'All work and no play makes Ja...', + 'All work and no play makes Jack…', ); }); @@ -161,31 +162,50 @@ describe('truncateText', () => { maxChars: 10, position: 'start', }), - ).toBe('...dy day.'); + ).toBe('…oudy day.'); }); it('should produce truncated text with ellipsis at the middle', () => { expect( truncateText('Horrendous amounts of lint issues are present Tony!', { - maxChars: 10, + maxChars: 8, position: 'middle', }), - ).toBe('Hor...ny!'); + ).toBe('Hor…ny!'); }); it('should produce truncated text with ellipsis at the end', () => { expect(truncateText("I'm Johnny!", { maxChars: 10, position: 'end' })).toBe( - "I'm Joh...", + "I'm Johnn…", ); }); it('should produce truncated text with custom ellipsis', () => { - expect(truncateText("I'm Johnny!", { maxChars: 10, ellipsis: '*' })).toBe( - "I'm Johnn*", + expect(truncateText("I'm Johnny!", { maxChars: 10, ellipsis: '...' })).toBe( + "I'm Joh...", ); }); }); +describe('transformMultilineText', () => { + it('should replace additional lines with an ellipsis', () => { + const error = `SchemaValidationError: Invalid CoreConfig in code-pushup.config.ts file +✖ Invalid input: expected array, received undefined + → at plugins`; + expect(truncateMultilineText(error)).toBe( + 'SchemaValidationError: Invalid CoreConfig in code-pushup.config.ts file […]', + ); + }); + + it('should leave one-liner texts unchanged', () => { + expect(truncateMultilineText('Hello, world!')).toBe('Hello, world!'); + }); + + it('should omit ellipsis if additional lines have no non-whitespace characters', () => { + expect(truncateMultilineText('- item 1\n \n\n')).toBe('- item 1'); + }); +}); + describe('transformLines', () => { it('should apply custom transformation to each line', () => { let count = 0; From f5a8b2f2bbf63ec05af54b732099084a9e6d36a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 24 Oct 2025 10:28:56 +0200 Subject: [PATCH 12/13] feat(utils): truncate group/spinner inline errors to one-liner --- packages/models/src/lib/core-config.ts | 4 +++- packages/utils/mocks/logger-demo.ts | 7 ++++++- packages/utils/project.json | 7 +++++-- packages/utils/src/lib/logger.int.test.ts | 10 +++++----- packages/utils/src/lib/logger.ts | 19 +++++++++++-------- 5 files changed, 30 insertions(+), 17 deletions(-) diff --git a/packages/models/src/lib/core-config.ts b/packages/models/src/lib/core-config.ts index a4e6469f7..c34a08b0d 100644 --- a/packages/models/src/lib/core-config.ts +++ b/packages/models/src/lib/core-config.ts @@ -20,7 +20,9 @@ export const unrefinedCoreConfigSchema = z.object({ categories: categoriesSchema.optional(), }); -export const coreConfigSchema = refineCoreConfig(unrefinedCoreConfigSchema); +export const coreConfigSchema = refineCoreConfig( + unrefinedCoreConfigSchema, +).meta({ title: 'CoreConfig' }); /** * Add refinements to coreConfigSchema diff --git a/packages/utils/mocks/logger-demo.ts b/packages/utils/mocks/logger-demo.ts index ae45e7f4f..741826625 100644 --- a/packages/utils/mocks/logger-demo.ts +++ b/packages/utils/mocks/logger-demo.ts @@ -1,4 +1,5 @@ import ansis from 'ansis'; +import { coreConfigSchema, validate } from '@code-pushup/models'; import { logger } from '../src/index.js'; async function sleep(delay: number) { @@ -20,6 +21,10 @@ try { await logger.task('Importing code-pushup.config.ts', async () => { await sleep(500); + if (errorStage === 'config') { + validate(coreConfigSchema, {}, { filePath: 'code-pushup.config.ts' }); + } + return 'Loaded configuration from code-pushup.config.ts'; }); logger.debug('2 plugins:'); @@ -76,7 +81,7 @@ try { 'Sent GraphQL mutation to https://api.code-pushup.example.com/graphql (organization: "example", project: "website")', ); await sleep(2000); - if (errorStage === 'core') { + if (errorStage === 'upload') { throw new Error('GraphQL error'); } return ansis.bold('Uploaded report to portal'); diff --git a/packages/utils/project.json b/packages/utils/project.json index bcef53a99..135dc661b 100644 --- a/packages/utils/project.json +++ b/packages/utils/project.json @@ -35,11 +35,14 @@ "verbose": { "args": ["--verbose"] }, - "error-core": { - "args": ["--error=core"] + "error-config": { + "args": ["--error=config"] }, "error-plugin": { "args": ["--error=plugin"] + }, + "error-upload": { + "args": ["--error=upload"] } } } diff --git a/packages/utils/src/lib/logger.int.test.ts b/packages/utils/src/lib/logger.int.test.ts index e48020591..8a015237d 100644 --- a/packages/utils/src/lib/logger.int.test.ts +++ b/packages/utils/src/lib/logger.int.test.ts @@ -166,7 +166,7 @@ ${ansis.red('Failed to load config')} ` ${ansis.bold.cyan('❯ Running plugin "ESLint"')} ${ansis.cyan('│')} $ npx eslint . --format=json --output-file=.code-pushup/eslint/results.json -${ansis.cyan('└')} ${ansis.red("Error: ENOENT: no such file or directory, open '.code-pushup/eslint/results.json'")} +${ansis.cyan('└')} ${ansis.red("ENOENT: no such file or directory, open '.code-pushup/eslint/results.json'")} `, ); @@ -333,7 +333,7 @@ ${ansis.magenta('└')} ${ansis.green(`Total line coverage is ${ansis.bold('82%' await expect(task).rejects.toThrow('GraphQL error: Invalid API key'); expect(output).toBe( - `${ansis.red('✖')} Uploading report to portal → ${ansis.red('Error: GraphQL error: Invalid API key')}\n`, + `${ansis.red('✖')} Uploading report to portal → ${ansis.red('GraphQL error: Invalid API key')}\n`, ); }); @@ -428,7 +428,7 @@ ${ansis.green('✔')} Uploaded report to portal ${ansis.gray('(42 ms)')} expect(output).toBe( ` -${ansis.red('✖')} Uploading report to portal → ${ansis.red('Error: GraphQL error: Invalid API key')} +${ansis.red('✖')} Uploading report to portal → ${ansis.red('GraphQL error: Invalid API key')} ${ansis.gray('Sent request to Portal API')} ${ansis.gray('Received response from Portal API')} `.trimStart(), @@ -689,7 +689,7 @@ ${ansis.cyan('-')} ${ansis.blue('$')} npx eslint . --format=json`, ` ${ansis.bold.cyan('❯ Running plugin "ESLint"')} ${ansis.cyan('│')} ${ansis.red('$')} npx eslint . --format=json -${ansis.cyan('└')} ${ansis.red('Error: Process failed with exit code 1')} +${ansis.cyan('└')} ${ansis.red('Process failed with exit code 1')} `, ); @@ -786,7 +786,7 @@ ${ansis.red.bold('Cancelled by SIGINT')} │ ESLint couldn't find a configuration file. │ │ $ npx eslint . --format=json -└ Error: Process failed with exit code 2 +└ Process failed with exit code 2 `, ); diff --git a/packages/utils/src/lib/logger.ts b/packages/utils/src/lib/logger.ts index 087c4c55f..6ab059a44 100644 --- a/packages/utils/src/lib/logger.ts +++ b/packages/utils/src/lib/logger.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import ora, { type Ora } from 'ora'; import { dateToUnixTimestamp } from './dates.js'; import { isEnvVarEnabled } from './env.js'; +import { stringifyError } from './errors.js'; import { formatDuration, indentLines, transformLines } from './formatting.js'; import { settlePromise } from './promises.js'; @@ -19,12 +20,11 @@ const GROUP_COLOR_ENV_VAR_NAME = 'CP_LOGGER_GROUP_COLOR'; export class Logger { #isVerbose = isEnvVarEnabled('CP_VERBOSE'); #isCI = isEnvVarEnabled('CI'); - #ciPlatform: CiPlatform | undefined = - process.env['GITHUB_ACTIONS'] === 'true' - ? 'GitHub Actions' - : process.env['GITLAB_CI'] === 'true' - ? 'GitLab CI/CD' - : undefined; + #ciPlatform: CiPlatform | undefined = isEnvVarEnabled('GITHUB_ACTIONS') + ? 'GitHub Actions' + : isEnvVarEnabled('GITLAB_CI') + ? 'GitLab CI/CD' + : undefined; #groupColor: GroupColor | undefined = process.env[GROUP_COLOR_ENV_VAR_NAME] === 'cyan' || process.env[GROUP_COLOR_ENV_VAR_NAME] === 'magenta' @@ -275,7 +275,10 @@ export class Logger { console.log( [ this.#colorize(this.#groupSymbols.end, this.#groupColor), - this.#colorize(`${result.reason}`, 'red'), + this.#colorize( + `${stringifyError(result.reason, { oneline: true })}`, + 'red', + ), ].join(' '), ); } @@ -391,7 +394,7 @@ export class Logger { messages.success(result.value), this.#formatDurationSuffix({ start, end }), ].join(' ') - : messages.failure(result.reason); + : messages.failure(stringifyError(result.reason, { oneline: true })); if (this.#activeSpinner) { if (this.#groupColor) { From ff2d4be90b5434696eb9bac126820f388baae0a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Wed, 29 Oct 2025 12:53:28 +0100 Subject: [PATCH 13/13] feat(utils): provide validateAsync alternative to synchronous validate --- packages/models/src/index.ts | 1 + .../models/src/lib/implementation/validate.ts | 23 +++++- .../lib/implementation/validate.unit.test.ts | 76 ++++++++++++++++++- 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 58391eef5..95e6b09d8 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -74,6 +74,7 @@ export { exists } from './lib/implementation/utils.js'; export { SchemaValidationError, validate, + validateAsync, } from './lib/implementation/validate.js'; export { issueSchema, diff --git a/packages/models/src/lib/implementation/validate.ts b/packages/models/src/lib/implementation/validate.ts index 4e4e6b2dd..97983b82c 100644 --- a/packages/models/src/lib/implementation/validate.ts +++ b/packages/models/src/lib/implementation/validate.ts @@ -6,6 +6,15 @@ type SchemaValidationContext = { filePath?: string; }; +/** + * Autocompletes valid Zod Schema input for convience, but will accept any other data as well + */ +type ZodInputLooseAutocomplete = + | z.input + | {} + | null + | undefined; + export class SchemaValidationError extends Error { constructor( error: ZodError, @@ -29,7 +38,7 @@ export class SchemaValidationError extends Error { export function validate( schema: T, - data: z.input | {} | null | undefined, // loose autocomplete + data: ZodInputLooseAutocomplete, context: SchemaValidationContext = {}, ): z.output { const result = schema.safeParse(data); @@ -38,3 +47,15 @@ export function validate( } throw new SchemaValidationError(result.error, schema, context); } + +export async function validateAsync( + schema: T, + data: ZodInputLooseAutocomplete, + context: SchemaValidationContext = {}, +): Promise> { + const result = await schema.safeParseAsync(data); + if (result.success) { + return result.data; + } + throw new SchemaValidationError(result.error, schema, context); +} diff --git a/packages/models/src/lib/implementation/validate.unit.test.ts b/packages/models/src/lib/implementation/validate.unit.test.ts index a16c6a34c..96e36891c 100644 --- a/packages/models/src/lib/implementation/validate.unit.test.ts +++ b/packages/models/src/lib/implementation/validate.unit.test.ts @@ -1,7 +1,9 @@ import ansis from 'ansis'; +import { vol } from 'memfs'; +import { readFile, stat } from 'node:fs/promises'; import path from 'node:path'; -import z, { ZodError } from 'zod'; -import { SchemaValidationError, validate } from './validate.js'; +import { ZodError, z } from 'zod'; +import { SchemaValidationError, validate, validateAsync } from './validate.js'; describe('validate', () => { it('should return parsed data if valid', () => { @@ -37,6 +39,76 @@ describe('validate', () => { ✖ Invalid ISO date → at dateOfBirth`); }); + + it('should throw if async schema provided (handled by validateAsync)', () => { + const projectNameSchema = z + .string() + .optional() + .transform( + async name => + name || JSON.parse(await readFile('package.json', 'utf8')).name, + ) + .meta({ title: 'ProjectName' }); + + expect(() => validate(projectNameSchema, undefined)).toThrow( + 'Encountered Promise during synchronous parse. Use .parseAsync() instead.', + ); + }); +}); + +describe('validateAsync', () => { + it('should parse schema with async transform', async () => { + vol.fromJSON({ 'package.json': '{ "name": "core" }' }, '/test'); + const projectNameSchema = z + .string() + .optional() + .transform( + async name => + name || JSON.parse(await readFile('package.json', 'utf8')).name, + ) + .meta({ title: 'ProjectName' }); + + await expect(validateAsync(projectNameSchema, undefined)).resolves.toBe( + 'core', + ); + }); + + it('should parse schema with async refinement', async () => { + vol.fromJSON({ 'package.json': '{}' }, '/test'); + const filePathSchema = z + .string() + .refine( + file => + stat(file) + .then(stats => stats.isFile()) + .catch(() => false), + { error: 'File does not exist' }, + ) + .transform(file => path.resolve(process.cwd(), file)) + .meta({ title: 'FilePath' }); + + await expect(validateAsync(filePathSchema, 'package.json')).resolves.toBe( + path.join(process.cwd(), 'package.json'), + ); + }); + + it('should reject with formatted error if async schema is invalid', async () => { + vol.fromJSON({}, '/test'); + const filePathSchema = z + .string() + .refine( + file => + stat(file) + .then(stats => stats.isFile()) + .catch(() => false), + { error: 'File does not exist' }, + ) + .meta({ title: 'FilePath' }); + + await expect(validateAsync(filePathSchema, 'package.json')).rejects.toThrow( + `Invalid ${ansis.bold('FilePath')}\n✖ File does not exist`, + ); + }); }); describe('SchemaValidationError', () => {