From 502ec8893c133ce1e9523bb86d68cc3410e4faed Mon Sep 17 00:00:00 2001 From: fullsend-code <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:42:16 +0000 Subject: [PATCH 1/7] feat(#4256): add catalog-metadata module for scorecard Add new scorecard backend module that checks catalog entity metadata attributes against configurable rules. The CatalogMetadataMetricProvider evaluates entity fields (e.g., metadata.title, spec.lifecycle) and maps field states to configurable status strings via a three-tier status mapping merge (check-level > options-level > hardcoded defaults). The module supports: - Required attribute checks (field exists, non-empty) - Value whitelist checks (field must be one of accepted values) - Per-check entity filters (e.g., only Components, only Templates) - Configurable status mapping for field states (exists, empty, emptyString, emptyArray, missed) and specific values - Automatic threshold rule generation from status mappings New files: - CatalogMetadataConfig.ts: config parsing and status mapping merge - CatalogMetadataMetricProvider.ts: MetricProvider<'number'> impl - CatalogMetadataMetricProvider.test.ts: 53 tests covering config parsing, field resolution, status evaluation, filter matching, threshold generation, and calculateMetrics scenarios Closes #4256 --- .../.eslintrc.js | 1 + .../config.d.ts | 97 +++ .../package.json | 67 ++ .../src/index.ts | 23 + .../metricProviders/CatalogMetadataConfig.ts | 228 ++++++ .../CatalogMetadataMetricProvider.test.ts | 771 ++++++++++++++++++ .../CatalogMetadataMetricProvider.ts | 307 +++++++ .../src/module.ts | 40 + 8 files changed, 1534 insertions(+) create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/.eslintrc.js create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/config.d.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/package.json create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/index.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataConfig.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataMetricProvider.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataMetricProvider.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/module.ts diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/.eslintrc.js b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/.eslintrc.js new file mode 100644 index 00000000000..e2a53a6ad28 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/config.d.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/config.d.ts new file mode 100644 index 00000000000..4305dfaa858 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/config.d.ts @@ -0,0 +1,97 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SchedulerServiceTaskScheduleDefinitionConfig } from '@backstage/backend-plugin-api'; +import { ThresholdConfig } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +export interface Config { + /** Configuration for scorecard plugin */ + scorecard?: { + /** Metric providers calculate one or more metrics on a schedule. */ + metricProviders?: { + /** Catalog metadata check configuration */ + catalogMetadata?: { + requiredAttributes?: { + /** How often catalogMetadata.requiredAttributes metrics will be calculated */ + schedule?: SchedulerServiceTaskScheduleDefinitionConfig; + /** How catalog metadata metric values are categorized */ + thresholds?: ThresholdConfig; + /** Provider-specific options */ + options?: { + /** List of attribute checks to run */ + checks?: Array<{ + /** Metric definition */ + metric: { + /** Metric identifier suffix (used as catalogMetadata.) */ + id: string; + /** Human-readable title */ + title: string; + /** Human-readable description */ + description: string; + }; + /** Entity filter — keys are dotted field paths, values are expected values */ + filter: { + [fieldPath: string]: string; + }; + /** Dotted field path to check on the entity (e.g. metadata.title, spec.lifecycle) */ + field: string; + /** Per-check status mapping overrides */ + statusMapping?: { + /** Status when field exists with a non-empty value not matched by values */ + exists?: string; + /** Status when field resolves to null or undefined */ + empty?: string; + /** Status when field resolves to an empty string */ + emptyString?: string; + /** Status when field resolves to an empty array */ + emptyArray?: string; + /** Status when the field path does not resolve */ + missed?: string; + /** Status per specific field value */ + values?: { + [value: string]: string; + }; + }; + }>; + /** Options-level status mapping defaults for all checks */ + statusMapping?: { + /** Status when field exists with a non-empty value not matched by values */ + exists?: string; + /** Status when field resolves to null or undefined */ + empty?: string; + /** Status when field resolves to an empty string */ + emptyString?: string; + /** Status when field resolves to an empty array */ + emptyArray?: string; + /** Status when the field path does not resolve */ + missed?: string; + /** Status per specific field value */ + values?: { + [value: string]: string; + }; + }; + }; + /** Per-metric configuration. Keys are local metric names (no datasource prefix). */ + metrics?: { + [metricName: string]: { + thresholds?: ThresholdConfig; + }; + }; + }; + }; + }; + }; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/package.json b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/package.json new file mode 100644 index 00000000000..98516e46365 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/package.json @@ -0,0 +1,67 @@ +{ + "name": "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog-metadata", + "version": "1.0.0", + "license": "Apache-2.0", + "description": "The catalog-metadata backend module for the scorecard plugin.", + "main": "src/index.ts", + "types": "src/index.ts", + "publishConfig": { + "access": "public" + }, + "backstage": { + "role": "backend-plugin-module", + "pluginId": "scorecard", + "pluginPackage": "@red-hat-developer-hub/backstage-plugin-scorecard-backend" + }, + "configSchema": "config.d.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "package.json": [ + "package.json" + ] + } + }, + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "start": "backstage-cli package start", + "test": "backstage-cli package test", + "tsc": "tsc", + "prettier:check": "prettier --ignore-unknown --check .", + "prettier:fix": "prettier --ignore-unknown --write ." + }, + "dependencies": { + "@backstage/backend-plugin-api": "^1.9.2", + "@backstage/catalog-model": "^1.9.0", + "@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^", + "@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^" + }, + "devDependencies": { + "@backstage/backend-test-utils": "^1.11.4", + "@backstage/cli": "^0.36.3", + "@backstage/config": "^1.3.8" + }, + "files": [ + "config.d.ts", + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/redhat-developer/rhdh-plugins", + "directory": "workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata" + }, + "keywords": [ + "backstage", + "plugin" + ], + "homepage": "https://red.ht/rhdh", + "bugs": "https://github.com/redhat-developer/rhdh-plugins/issues", + "author": "Red Hat" +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/index.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/index.ts new file mode 100644 index 00000000000..107f1b8855a --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The catalog-metadata backend module for the scorecard plugin. + * + * @packageDocumentation + */ + +export { scorecardModuleCatalogMetadata as default } from './module'; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataConfig.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataConfig.ts new file mode 100644 index 00000000000..04084e22743 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataConfig.ts @@ -0,0 +1,228 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Config } from '@backstage/config'; + +/** + * Status mapping that maps field states to status strings (threshold keys). + */ +export type StatusMapping = { + /** Status when field exists with a non-empty value not matched by values */ + exists: string; + /** Status when field resolves to null or undefined */ + empty: string; + /** Status when field resolves to an empty string */ + emptyString: string; + /** Status when field resolves to an empty array */ + emptyArray: string; + /** Status when the field path does not resolve */ + missed: string; + /** Status per specific field value */ + values: Record; +}; + +/** + * A single check configuration parsed from app-config.yaml. + */ +export type CheckConfig = { + metric: { + id: string; + title: string; + description: string; + }; + filter: Record; + field: string; + statusMapping: StatusMapping; +}; + +/** + * Parsed configuration for the catalog-metadata metric provider. + */ +export type CatalogMetadataConfig = { + checks: CheckConfig[]; +}; + +/** Hardcoded default status mapping as described in the issue. */ +export const DEFAULT_STATUS_MAPPING: StatusMapping = { + exists: 'found', + empty: 'missed', + emptyString: 'missed', + emptyArray: 'missed', + missed: 'missed', + values: {}, +}; + +/** + * Merges status mappings with priority: check-level > options-level > defaults. + * Each field is individually resolved by priority. + */ +export function mergeStatusMappings( + checkMapping: Partial | undefined, + optionsMapping: Partial | undefined, +): StatusMapping { + return { + exists: + checkMapping?.exists ?? + optionsMapping?.exists ?? + DEFAULT_STATUS_MAPPING.exists, + empty: + checkMapping?.empty ?? + optionsMapping?.empty ?? + DEFAULT_STATUS_MAPPING.empty, + emptyString: + checkMapping?.emptyString ?? + optionsMapping?.emptyString ?? + DEFAULT_STATUS_MAPPING.emptyString, + emptyArray: + checkMapping?.emptyArray ?? + optionsMapping?.emptyArray ?? + DEFAULT_STATUS_MAPPING.emptyArray, + missed: + checkMapping?.missed ?? + optionsMapping?.missed ?? + DEFAULT_STATUS_MAPPING.missed, + values: { + ...DEFAULT_STATUS_MAPPING.values, + ...(optionsMapping?.values ?? {}), + ...(checkMapping?.values ?? {}), + }, + }; +} + +/** + * Reads a partial StatusMapping from a Backstage Config node. + */ +function readStatusMapping(config: Config): Partial | undefined { + const result: Partial = {}; + let hasAny = false; + + const exists = config.getOptionalString('exists'); + if (exists !== undefined) { + result.exists = exists; + hasAny = true; + } + + const empty = config.getOptionalString('empty'); + if (empty !== undefined) { + result.empty = empty; + hasAny = true; + } + + const emptyString = config.getOptionalString('emptyString'); + if (emptyString !== undefined) { + result.emptyString = emptyString; + hasAny = true; + } + + const emptyArray = config.getOptionalString('emptyArray'); + if (emptyArray !== undefined) { + result.emptyArray = emptyArray; + hasAny = true; + } + + const missed = config.getOptionalString('missed'); + if (missed !== undefined) { + result.missed = missed; + hasAny = true; + } + + const valuesConfig = config.getOptionalConfig('values'); + if (valuesConfig) { + const values: Record = {}; + for (const key of valuesConfig.keys()) { + values[key] = valuesConfig.getString(key); + } + if (Object.keys(values).length > 0) { + result.values = values; + hasAny = true; + } + } + + return hasAny ? result : undefined; +} + +/** + * Parses the catalog-metadata configuration from the root Backstage config. + * Returns undefined if no checks are configured. + */ +export function parseCatalogMetadataConfig( + config: Config, +): CatalogMetadataConfig | undefined { + const optionsConfig = config.getOptionalConfig( + 'scorecard.metricProviders.catalogMetadata.requiredAttributes.options', + ); + + if (!optionsConfig) { + return undefined; + } + + const checksConfigArray = + optionsConfig.getOptionalConfigArray('checks') ?? []; + if (checksConfigArray.length === 0) { + return undefined; + } + + // Read options-level status mapping + const optionsStatusMappingConfig = + optionsConfig.getOptionalConfig('statusMapping'); + const optionsStatusMapping = optionsStatusMappingConfig + ? readStatusMapping(optionsStatusMappingConfig) + : undefined; + + const checks: CheckConfig[] = checksConfigArray.map((checkConfig, index) => { + // Read metric + const metricConfig = checkConfig.getConfig('metric'); + const metric = { + id: metricConfig.getString('id'), + title: metricConfig.getString('title'), + description: metricConfig.getString('description'), + }; + + if (!metric.id) { + throw new Error(`Check at index ${index} has an empty metric id`); + } + + // Read filter + const filterConfig = checkConfig.getConfig('filter'); + const filter: Record = {}; + for (const key of filterConfig.keys()) { + filter[key] = filterConfig.getString(key); + } + + // Read field + const field = checkConfig.getString('field'); + if (!field) { + throw new Error(`Check '${metric.id}' has an empty field path`); + } + + // Read check-level status mapping + const checkStatusMappingConfig = + checkConfig.getOptionalConfig('statusMapping'); + const checkStatusMapping = checkStatusMappingConfig + ? readStatusMapping(checkStatusMappingConfig) + : undefined; + + // Merge status mappings: check > options > defaults + const statusMapping = mergeStatusMappings( + checkStatusMapping, + optionsStatusMapping, + ); + + return { metric, filter, field, statusMapping }; + }); + + return { checks }; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataMetricProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataMetricProvider.test.ts new file mode 100644 index 00000000000..004b84222e4 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataMetricProvider.test.ts @@ -0,0 +1,771 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import type { Entity } from '@backstage/catalog-model'; +import { + mergeStatusMappings, + DEFAULT_STATUS_MAPPING, +} from './CatalogMetadataConfig'; +import { + createCatalogMetadataMetricProvider, + resolveFieldPath, + evaluateFieldStatus, + entityMatchesFilter, +} from './CatalogMetadataMetricProvider'; + +// ── helpers ──────────────────────────────────────────────────────────── + +function buildConfig(checks: object[], optionsStatusMapping?: object) { + return { + scorecard: { + metricProviders: { + catalogMetadata: { + requiredAttributes: { + options: { + checks, + ...(optionsStatusMapping + ? { statusMapping: optionsStatusMapping } + : {}), + }, + }, + }, + }, + }, + }; +} + +function titleCheck(overrides?: object) { + return { + metric: { + id: 'title', + title: 'Title is required', + description: 'The metadata.title should be defined.', + }, + filter: { kind: 'Component' }, + field: 'metadata.title', + ...overrides, + }; +} + +function lifecycleCheck(overrides?: object) { + return { + metric: { + id: 'lifecycle', + title: 'lifecycle should be prod, stage, test or dev', + description: + 'The spec.lifecycle field should be one of four accepted values.', + }, + filter: { kind: 'Component' }, + field: 'spec.lifecycle', + statusMapping: { + exists: 'invalid', + values: { + prod: 'ok', + stage: 'ok', + test: 'ok', + dev: 'ok', + }, + }, + ...overrides, + }; +} + +const componentEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + title: 'My Component', + tags: ['typescript', 'backstage'], + annotations: { + 'backstage.io/source-location': + 'url:https://github.com/org/my-repo/tree/main/', + }, + links: [{ url: 'https://example.com', title: 'Homepage' }], + }, + spec: { + type: 'service', + lifecycle: 'prod', + owner: 'team-a', + }, +}; + +const templateEntity: Entity = { + apiVersion: 'scaffolder.backstage.io/v1beta3', + kind: 'Template', + metadata: { + name: 'test-template', + }, + spec: { + type: 'service', + owner: 'team-b', + }, +}; + +// ── resolveFieldPath ─────────────────────────────────────────────────── + +describe('resolveFieldPath', () => { + const entity = componentEntity; + + it('should resolve a top-level field', () => { + expect(resolveFieldPath(entity, 'kind')).toBe('Component'); + }); + + it('should resolve a nested field', () => { + expect(resolveFieldPath(entity, 'metadata.name')).toBe('test-component'); + }); + + it('should resolve a deeply nested field', () => { + expect(resolveFieldPath(entity, 'spec.lifecycle')).toBe('prod'); + }); + + it('should return NOT_FOUND for a missing top-level field', () => { + const result = resolveFieldPath(entity, 'nonexistent'); + expect(typeof result).toBe('symbol'); + }); + + it('should return NOT_FOUND for a missing nested field', () => { + const result = resolveFieldPath(entity, 'metadata.nonexistent'); + expect(typeof result).toBe('symbol'); + }); + + it('should return NOT_FOUND when traversing through a non-object', () => { + const result = resolveFieldPath(entity, 'metadata.name.something'); + expect(typeof result).toBe('symbol'); + }); + + it('should return NOT_FOUND for dotted annotation keys', () => { + // Dotted annotation keys like "backstage.io/source-location" require + // special handling. The path splits on "." so it tries + // entity.metadata.annotations.backstage which does not exist. + // This is expected behavior — callers should use a non-dotted + // annotation key or a custom resolution strategy. + const result = resolveFieldPath( + entity, + 'metadata.annotations.backstage.io/source-location', + ); + expect(typeof result).toBe('symbol'); + }); + + it('should resolve array fields', () => { + expect(resolveFieldPath(entity, 'metadata.tags')).toEqual([ + 'typescript', + 'backstage', + ]); + }); +}); + +// ── evaluateFieldStatus ──────────────────────────────────────────────── + +describe('evaluateFieldStatus', () => { + it('should return "found" for an existing non-empty value', () => { + const status = evaluateFieldStatus( + componentEntity, + 'metadata.title', + DEFAULT_STATUS_MAPPING, + ); + expect(status).toBe('found'); + }); + + it('should return "missed" for a missing field', () => { + const status = evaluateFieldStatus( + templateEntity, + 'metadata.title', + DEFAULT_STATUS_MAPPING, + ); + expect(status).toBe('missed'); + }); + + it('should return "missed" for a null field', () => { + const entity: Entity = { + ...componentEntity, + metadata: { + ...componentEntity.metadata, + title: null as unknown as string, + }, + }; + const status = evaluateFieldStatus( + entity, + 'metadata.title', + DEFAULT_STATUS_MAPPING, + ); + expect(status).toBe('missed'); + }); + + it('should return "missed" for an empty string field', () => { + const entity: Entity = { + ...componentEntity, + metadata: { ...componentEntity.metadata, title: '' }, + }; + const status = evaluateFieldStatus( + entity, + 'metadata.title', + DEFAULT_STATUS_MAPPING, + ); + expect(status).toBe('missed'); + }); + + it('should return "missed" for an empty array field', () => { + const entity: Entity = { + ...componentEntity, + metadata: { ...componentEntity.metadata, tags: [] }, + }; + const status = evaluateFieldStatus( + entity, + 'metadata.tags', + DEFAULT_STATUS_MAPPING, + ); + expect(status).toBe('missed'); + }); + + it('should return matched value status for a known value', () => { + const statusMapping = { + ...DEFAULT_STATUS_MAPPING, + exists: 'invalid', + values: { prod: 'ok', stage: 'ok', test: 'ok', dev: 'ok' }, + }; + const status = evaluateFieldStatus( + componentEntity, + 'spec.lifecycle', + statusMapping, + ); + expect(status).toBe('ok'); + }); + + it('should return "exists" status for an unknown value', () => { + const entity: Entity = { + ...componentEntity, + spec: { ...componentEntity.spec, lifecycle: 'experimental' }, + }; + const statusMapping = { + ...DEFAULT_STATUS_MAPPING, + exists: 'invalid', + values: { prod: 'ok', stage: 'ok' }, + }; + const status = evaluateFieldStatus(entity, 'spec.lifecycle', statusMapping); + expect(status).toBe('invalid'); + }); + + it('should return "missed" for a missing field with values mapping', () => { + const statusMapping = { + ...DEFAULT_STATUS_MAPPING, + exists: 'invalid', + values: { prod: 'ok' }, + }; + const status = evaluateFieldStatus( + templateEntity, + 'spec.lifecycle', + statusMapping, + ); + expect(status).toBe('missed'); + }); + + it('should handle non-empty arrays as "exists"', () => { + const status = evaluateFieldStatus( + componentEntity, + 'metadata.tags', + DEFAULT_STATUS_MAPPING, + ); + expect(status).toBe('found'); + }); +}); + +// ── entityMatchesFilter ──────────────────────────────────────────────── + +describe('entityMatchesFilter', () => { + it('should match with an empty filter', () => { + expect(entityMatchesFilter(componentEntity, {})).toBe(true); + }); + + it('should match on kind (case-insensitive)', () => { + expect(entityMatchesFilter(componentEntity, { kind: 'Component' })).toBe( + true, + ); + expect(entityMatchesFilter(componentEntity, { kind: 'component' })).toBe( + true, + ); + }); + + it('should not match on wrong kind', () => { + expect(entityMatchesFilter(componentEntity, { kind: 'Template' })).toBe( + false, + ); + }); + + it('should match on multiple filter fields', () => { + expect( + entityMatchesFilter(componentEntity, { + kind: 'Component', + 'spec.type': 'service', + }), + ).toBe(true); + }); + + it('should not match when one filter field does not match', () => { + expect( + entityMatchesFilter(componentEntity, { + kind: 'Component', + 'spec.type': 'website', + }), + ).toBe(false); + }); + + it('should not match when filter field path does not resolve', () => { + expect( + entityMatchesFilter(componentEntity, { + 'spec.nonexistent': 'value', + }), + ).toBe(false); + }); +}); + +// ── mergeStatusMappings ──────────────────────────────────────────────── + +describe('mergeStatusMappings', () => { + it('should return defaults when no overrides', () => { + const result = mergeStatusMappings(undefined, undefined); + expect(result).toEqual(DEFAULT_STATUS_MAPPING); + }); + + it('should apply options-level overrides', () => { + const result = mergeStatusMappings(undefined, { + exists: 'present', + }); + expect(result.exists).toBe('present'); + expect(result.missed).toBe('missed'); + }); + + it('should apply check-level overrides over options-level', () => { + const result = mergeStatusMappings( + { exists: 'check-level' }, + { exists: 'options-level' }, + ); + expect(result.exists).toBe('check-level'); + }); + + it('should merge values maps with check > options > defaults', () => { + const result = mergeStatusMappings( + { values: { prod: 'check-prod' } }, + { values: { prod: 'options-prod', stage: 'options-stage' } }, + ); + expect(result.values).toEqual({ + prod: 'check-prod', + stage: 'options-stage', + }); + }); + + it('should fall back to defaults for unset fields', () => { + const result = mergeStatusMappings({ exists: 'custom' }, undefined); + expect(result.exists).toBe('custom'); + expect(result.empty).toBe('missed'); + expect(result.emptyString).toBe('missed'); + expect(result.emptyArray).toBe('missed'); + expect(result.missed).toBe('missed'); + }); +}); + +// ── createCatalogMetadataMetricProvider ──────────────────────────────── + +describe('createCatalogMetadataMetricProvider', () => { + it('should return undefined when no config is provided', () => { + const provider = createCatalogMetadataMetricProvider(new ConfigReader({})); + expect(provider).toBeUndefined(); + }); + + it('should return undefined when checks array is empty', () => { + const provider = createCatalogMetadataMetricProvider( + new ConfigReader(buildConfig([])), + ); + expect(provider).toBeUndefined(); + }); + + it('should create provider with a single check', () => { + const config = new ConfigReader(buildConfig([titleCheck()])); + const provider = createCatalogMetadataMetricProvider(config); + + expect(provider).toBeDefined(); + expect(provider?.getMetrics().map(m => m.id)).toEqual([ + 'catalogMetadata.title', + ]); + }); + + it('should create provider with multiple checks', () => { + const config = new ConfigReader( + buildConfig([titleCheck(), lifecycleCheck()]), + ); + const provider = createCatalogMetadataMetricProvider(config); + + expect(provider).toBeDefined(); + expect(provider?.getMetrics().map(m => m.id)).toEqual([ + 'catalogMetadata.title', + 'catalogMetadata.lifecycle', + ]); + }); +}); + +// ── provider methods ─────────────────────────────────────────────────── + +describe('CatalogMetadataMetricProvider', () => { + describe('provider identification', () => { + const provider = createCatalogMetadataMetricProvider( + new ConfigReader(buildConfig([titleCheck()])), + ); + + it('should return correct provider ID', () => { + expect(provider?.getProviderId()).toBe( + 'catalogMetadata.requiredAttributes', + ); + }); + + it('should return correct datasource ID', () => { + expect(provider?.getProviderDatasourceId()).toBe('catalogMetadata'); + }); + }); + + describe('getMetrics', () => { + it('should return metrics with correct type', () => { + const provider = createCatalogMetadataMetricProvider( + new ConfigReader(buildConfig([titleCheck()])), + ); + const metrics = provider?.getMetrics(); + + expect(metrics).toHaveLength(1); + metrics?.forEach(m => { + expect(m.type).toBe('number'); + }); + }); + + it('should generate threshold rules from status mapping', () => { + const provider = createCatalogMetadataMetricProvider( + new ConfigReader(buildConfig([titleCheck()])), + ); + const metrics = provider?.getMetrics(); + + // Default status mapping produces 'found' and 'missed' statuses + const thresholds = metrics?.[0].thresholds; + expect(thresholds?.rules).toBeDefined(); + const keys = thresholds?.rules.map(r => r.key); + expect(keys).toContain('found'); + expect(keys).toContain('missed'); + }); + + it('should generate threshold rules for value-specific mapping', () => { + const provider = createCatalogMetadataMetricProvider( + new ConfigReader(buildConfig([lifecycleCheck()])), + ); + const metrics = provider?.getMetrics(); + + const thresholds = metrics?.[0].thresholds; + const keys = thresholds?.rules.map(r => r.key); + expect(keys).toContain('ok'); + expect(keys).toContain('invalid'); + expect(keys).toContain('missed'); + }); + + it('should include metric metadata', () => { + const provider = createCatalogMetadataMetricProvider( + new ConfigReader(buildConfig([titleCheck()])), + ); + const metric = provider?.getMetrics()[0]; + + expect(metric?.id).toBe('catalogMetadata.title'); + expect(metric?.title).toBe('Title is required'); + expect(metric?.description).toBe('The metadata.title should be defined.'); + }); + }); + + describe('getCatalogFilter', () => { + it('should return kind filter when all checks share the same kind', () => { + const provider = createCatalogMetadataMetricProvider( + new ConfigReader( + buildConfig([ + titleCheck({ filter: { kind: 'Component' } }), + lifecycleCheck({ filter: { kind: 'Component' } }), + ]), + ), + ); + expect(provider?.getCatalogFilter()).toEqual({ + kind: 'component', + }); + }); + + it('should return multi-kind filter for different kinds', () => { + const provider = createCatalogMetadataMetricProvider( + new ConfigReader( + buildConfig([ + titleCheck({ filter: { kind: 'Component' } }), + titleCheck({ + metric: { + id: 'templateOwner', + title: 'Owner', + description: 'desc', + }, + filter: { kind: 'Template' }, + field: 'spec.owner', + }), + ]), + ), + ); + const catalogFilter = provider?.getCatalogFilter(); + expect(catalogFilter?.kind).toBeDefined(); + expect( + Array.isArray(catalogFilter?.kind) + ? (catalogFilter?.kind as string[]).sort() + : [], + ).toEqual(['component', 'template']); + }); + + it('should return empty filter when any check has no kind filter', () => { + const provider = createCatalogMetadataMetricProvider( + new ConfigReader( + buildConfig([ + titleCheck({ filter: { kind: 'Component' } }), + titleCheck({ + metric: { id: 'allTitle', title: 'T', description: 'D' }, + filter: {}, + field: 'metadata.title', + }), + ]), + ), + ); + expect(provider?.getCatalogFilter()).toEqual({}); + }); + }); + + describe('calculateMetrics', () => { + it('should return "found" status code for existing field', async () => { + const provider = createCatalogMetadataMetricProvider( + new ConfigReader(buildConfig([titleCheck()])), + ); + const result = await provider?.calculateMetrics(componentEntity); + + // The metric value is a numeric code mapping to "found" + const metrics = provider?.getMetrics(); + const titleMetric = metrics?.find(m => m.id === 'catalogMetadata.title'); + const foundRule = titleMetric?.thresholds.rules.find( + r => r.key === 'found', + ); + const expectedCode = Number(foundRule?.expression.replace('==', '')); + + expect(result?.get('catalogMetadata.title')).toBe(expectedCode); + }); + + it('should return "missed" status code for missing field', async () => { + const entityWithoutTitle: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'no-title-component' }, + spec: { type: 'service', lifecycle: 'prod', owner: 'team-a' }, + }; + const provider = createCatalogMetadataMetricProvider( + new ConfigReader(buildConfig([titleCheck()])), + ); + const result = await provider?.calculateMetrics(entityWithoutTitle); + + const metrics = provider?.getMetrics(); + const titleMetric = metrics?.find(m => m.id === 'catalogMetadata.title'); + const missedRule = titleMetric?.thresholds.rules.find( + r => r.key === 'missed', + ); + const expectedCode = Number(missedRule?.expression.replace('==', '')); + + expect(result?.get('catalogMetadata.title')).toBe(expectedCode); + }); + + it('should skip checks for non-matching entities', async () => { + const provider = createCatalogMetadataMetricProvider( + new ConfigReader( + buildConfig([titleCheck({ filter: { kind: 'Component' } })]), + ), + ); + const result = await provider?.calculateMetrics(templateEntity); + + expect(result?.has('catalogMetadata.title')).toBe(false); + }); + + it('should return "ok" for valid lifecycle value', async () => { + const provider = createCatalogMetadataMetricProvider( + new ConfigReader(buildConfig([lifecycleCheck()])), + ); + const result = await provider?.calculateMetrics(componentEntity); + + const metrics = provider?.getMetrics(); + const lcMetric = metrics?.find(m => m.id === 'catalogMetadata.lifecycle'); + const okRule = lcMetric?.thresholds.rules.find(r => r.key === 'ok'); + const expectedCode = Number(okRule?.expression.replace('==', '')); + + expect(result?.get('catalogMetadata.lifecycle')).toBe(expectedCode); + }); + + it('should return "invalid" for unknown lifecycle value', async () => { + const entity: Entity = { + ...componentEntity, + spec: { ...componentEntity.spec, lifecycle: 'experimental' }, + }; + const provider = createCatalogMetadataMetricProvider( + new ConfigReader(buildConfig([lifecycleCheck()])), + ); + const result = await provider?.calculateMetrics(entity); + + const metrics = provider?.getMetrics(); + const lcMetric = metrics?.find(m => m.id === 'catalogMetadata.lifecycle'); + const invalidRule = lcMetric?.thresholds.rules.find( + r => r.key === 'invalid', + ); + const expectedCode = Number(invalidRule?.expression.replace('==', '')); + + expect(result?.get('catalogMetadata.lifecycle')).toBe(expectedCode); + }); + + it('should return "missed" for missing lifecycle value', async () => { + const entity: Entity = { + ...componentEntity, + spec: { type: 'service', owner: 'team-a' }, + }; + const provider = createCatalogMetadataMetricProvider( + new ConfigReader(buildConfig([lifecycleCheck()])), + ); + const result = await provider?.calculateMetrics(entity); + + const metrics = provider?.getMetrics(); + const lcMetric = metrics?.find(m => m.id === 'catalogMetadata.lifecycle'); + const missedRule = lcMetric?.thresholds.rules.find( + r => r.key === 'missed', + ); + const expectedCode = Number(missedRule?.expression.replace('==', '')); + + expect(result?.get('catalogMetadata.lifecycle')).toBe(expectedCode); + }); + + it('should handle empty string field with default mapping', async () => { + const entity: Entity = { + ...componentEntity, + metadata: { ...componentEntity.metadata, title: '' }, + }; + const provider = createCatalogMetadataMetricProvider( + new ConfigReader(buildConfig([titleCheck()])), + ); + const result = await provider?.calculateMetrics(entity); + + // Default mapping: emptyString → 'missed' + const metrics = provider?.getMetrics(); + const titleMetric = metrics?.find(m => m.id === 'catalogMetadata.title'); + const missedRule = titleMetric?.thresholds.rules.find( + r => r.key === 'missed', + ); + const expectedCode = Number(missedRule?.expression.replace('==', '')); + + expect(result?.get('catalogMetadata.title')).toBe(expectedCode); + }); + + it('should handle empty array field with default mapping', async () => { + const entity: Entity = { + ...componentEntity, + metadata: { ...componentEntity.metadata, tags: [] }, + }; + const provider = createCatalogMetadataMetricProvider( + new ConfigReader( + buildConfig([ + titleCheck({ + metric: { + id: 'tags', + title: 'Tags', + description: 'Tags should exist', + }, + field: 'metadata.tags', + }), + ]), + ), + ); + const result = await provider?.calculateMetrics(entity); + + const metrics = provider?.getMetrics(); + const tagsMetric = metrics?.find(m => m.id === 'catalogMetadata.tags'); + const missedRule = tagsMetric?.thresholds.rules.find( + r => r.key === 'missed', + ); + const expectedCode = Number(missedRule?.expression.replace('==', '')); + + expect(result?.get('catalogMetadata.tags')).toBe(expectedCode); + }); + + it('should handle multiple checks on the same entity', async () => { + const provider = createCatalogMetadataMetricProvider( + new ConfigReader(buildConfig([titleCheck(), lifecycleCheck()])), + ); + const result = await provider?.calculateMetrics(componentEntity); + + expect(result?.has('catalogMetadata.title')).toBe(true); + expect(result?.has('catalogMetadata.lifecycle')).toBe(true); + }); + + it('should apply options-level status mapping to all checks', async () => { + const config = new ConfigReader( + buildConfig([titleCheck()], { + exists: 'present', + missed: 'absent', + emptyString: 'absent', + emptyArray: 'absent', + empty: 'absent', + }), + ); + const provider = createCatalogMetadataMetricProvider(config); + const metrics = provider?.getMetrics(); + + const titleMetric = metrics?.find(m => m.id === 'catalogMetadata.title'); + const keys = titleMetric?.thresholds.rules.map(r => r.key); + expect(keys).toContain('present'); + expect(keys).toContain('absent'); + }); + + it('should override options-level mapping with check-level mapping', async () => { + const config = new ConfigReader( + buildConfig( + [ + titleCheck({ + statusMapping: { + exists: 'check-present', + }, + }), + ], + { + exists: 'options-present', + }, + ), + ); + const provider = createCatalogMetadataMetricProvider(config); + const metrics = provider?.getMetrics(); + + const titleMetric = metrics?.find(m => m.id === 'catalogMetadata.title'); + const keys = titleMetric?.thresholds.rules.map(r => r.key); + expect(keys).toContain('check-present'); + expect(keys).not.toContain('options-present'); + }); + + it('should apply check filter with empty filter matching all entities', async () => { + const provider = createCatalogMetadataMetricProvider( + new ConfigReader(buildConfig([titleCheck({ filter: {} })])), + ); + + const componentResult = await provider?.calculateMetrics(componentEntity); + expect(componentResult?.has('catalogMetadata.title')).toBe(true); + + const templateResult = await provider?.calculateMetrics(templateEntity); + expect(templateResult?.has('catalogMetadata.title')).toBe(true); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataMetricProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataMetricProvider.ts new file mode 100644 index 00000000000..65a5e1a1f85 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataMetricProvider.ts @@ -0,0 +1,307 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Entity } from '@backstage/catalog-model'; +import type { Config } from '@backstage/config'; +import { + Metric, + ScorecardThresholdRuleColors, + ThresholdConfig, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { + type CatalogMetadataConfig, + type StatusMapping, + parseCatalogMetadataConfig, +} from './CatalogMetadataConfig'; + +/** Sentinel for a field path that does not resolve. */ +const NOT_FOUND = Symbol('NOT_FOUND'); + +/** + * Resolves a dotted field path on an entity object. + * Returns the value at the path, or NOT_FOUND if the path does not resolve. + */ +export function resolveFieldPath( + entity: Entity, + path: string, +): unknown | typeof NOT_FOUND { + const parts = path.split('.'); + let current: unknown = entity; + for (const part of parts) { + if ( + current === null || + current === undefined || + typeof current !== 'object' + ) { + return NOT_FOUND; + } + if (!(part in (current as Record))) { + return NOT_FOUND; + } + current = (current as Record)[part]; + } + return current; +} + +/** + * Determines the status string for a field value using the status mapping. + */ +export function evaluateFieldStatus( + entity: Entity, + field: string, + statusMapping: StatusMapping, +): string { + const value = resolveFieldPath(entity, field); + + if (value === NOT_FOUND) { + return statusMapping.missed; + } + + if (value === null || value === undefined) { + return statusMapping.empty; + } + + if (typeof value === 'string' && value === '') { + return statusMapping.emptyString; + } + + if (Array.isArray(value) && value.length === 0) { + return statusMapping.emptyArray; + } + + // Field exists with a non-empty value — check for specific value matches + if ( + typeof value === 'string' && + Object.keys(statusMapping.values).length > 0 && + value in statusMapping.values + ) { + return statusMapping.values[value]; + } + + return statusMapping.exists; +} + +/** + * Returns whether an entity matches a check's filter. + * An empty filter matches all entities. + * Each key in the filter is a dotted field path; the entity's field + * value (stringified) must equal the filter value (case-insensitive). + */ +export function entityMatchesFilter( + entity: Entity, + filter: Record, +): boolean { + for (const [path, expected] of Object.entries(filter)) { + const value = resolveFieldPath(entity, path); + if (value === NOT_FOUND) { + return false; + } + if (String(value).toLowerCase() !== String(expected).toLowerCase()) { + return false; + } + } + return true; +} + +/** + * Collects all distinct status strings from a StatusMapping. + */ +function collectDistinctStatuses(statusMapping: StatusMapping): string[] { + const statuses = new Set(); + statuses.add(statusMapping.exists); + statuses.add(statusMapping.empty); + statuses.add(statusMapping.emptyString); + statuses.add(statusMapping.emptyArray); + statuses.add(statusMapping.missed); + for (const value of Object.values(statusMapping.values)) { + statuses.add(value); + } + return [...statuses]; +} + +/** + * Builds a mapping from status strings to numeric codes and generates + * threshold rules that map those codes back to status strings. + */ +function buildStatusCodeMapping(statusMapping: StatusMapping): { + statusToCode: Map; + thresholds: ThresholdConfig; +} { + const statuses = collectDistinctStatuses(statusMapping); + const statusToCode = new Map(); + + statuses.forEach((status, index) => { + statusToCode.set(status, index); + }); + + const rules = statuses.map((status, index) => ({ + key: status, + expression: `==${index}`, + color: getDefaultColor(status), + icon: getDefaultIcon(status), + })); + + return { statusToCode, thresholds: { rules } }; +} + +/** + * Returns a default color for well-known status strings. + */ +function getDefaultColor(status: string): string { + switch (status.toLowerCase()) { + case 'found': + case 'ok': + case 'success': + case 'valid': + return ScorecardThresholdRuleColors.SUCCESS; + case 'missed': + case 'invalid': + case 'error': + case 'failed': + return ScorecardThresholdRuleColors.ERROR; + case 'warning': + return ScorecardThresholdRuleColors.WARNING; + default: + return ScorecardThresholdRuleColors.WARNING; + } +} + +/** + * Returns a default icon for well-known status strings. + */ +function getDefaultIcon(status: string): string { + switch (status.toLowerCase()) { + case 'found': + case 'ok': + case 'success': + case 'valid': + return 'scorecardSuccessStatusIcon'; + case 'missed': + case 'invalid': + case 'error': + case 'failed': + return 'scorecardErrorStatusIcon'; + default: + return 'scorecardWarningStatusIcon'; + } +} + +export class CatalogMetadataMetricProvider implements MetricProvider<'number'> { + private readonly catalogMetadataConfig: CatalogMetadataConfig; + private readonly statusCodeMappings: Map< + string, + { statusToCode: Map; thresholds: ThresholdConfig } + >; + + constructor(catalogMetadataConfig: CatalogMetadataConfig) { + this.catalogMetadataConfig = catalogMetadataConfig; + this.statusCodeMappings = new Map(); + for (const check of catalogMetadataConfig.checks) { + this.statusCodeMappings.set( + check.metric.id, + buildStatusCodeMapping(check.statusMapping), + ); + } + } + + getProviderDatasourceId(): string { + return 'catalogMetadata'; + } + + getProviderId(): string { + return 'catalogMetadata.requiredAttributes'; + } + + getMetrics(): Metric<'number'>[] { + return this.catalogMetadataConfig.checks.map(check => { + const mapping = this.statusCodeMappings.get(check.metric.id)!; + return { + id: `catalogMetadata.${check.metric.id}`, + title: check.metric.title, + description: check.metric.description, + type: 'number' as const, + thresholds: mapping.thresholds, + }; + }); + } + + getCatalogFilter(): Record { + // Aggregate kind filters from all checks for efficient catalog querying. + // If any check has no filter or does not filter by kind, return an + // empty filter (all entities). + const kinds = new Set(); + let allHaveKind = true; + + for (const check of this.catalogMetadataConfig.checks) { + const kindValue = check.filter.kind; + if (kindValue) { + kinds.add(kindValue.toLowerCase()); + } else { + allHaveKind = false; + } + } + + if (allHaveKind && kinds.size > 0) { + if (kinds.size === 1) { + return { kind: [...kinds][0] }; + } + return { kind: [...kinds] }; + } + + // If not all checks filter by kind, return empty filter (all entities) + return {}; + } + + async calculateMetrics(entity: Entity): Promise> { + const results = new Map(); + + for (const check of this.catalogMetadataConfig.checks) { + // Apply per-check filter + if (!entityMatchesFilter(entity, check.filter)) { + continue; + } + + const status = evaluateFieldStatus( + entity, + check.field, + check.statusMapping, + ); + + const mapping = this.statusCodeMappings.get(check.metric.id)!; + const code = mapping.statusToCode.get(status); + if (code !== undefined) { + results.set(`catalogMetadata.${check.metric.id}`, code); + } + } + + return results; + } +} + +/** + * Creates a CatalogMetadataMetricProvider from root Backstage config. + * Returns undefined if no checks are configured. + */ +export function createCatalogMetadataMetricProvider( + config: Config, +): CatalogMetadataMetricProvider | undefined { + const catalogMetadataConfig = parseCatalogMetadataConfig(config); + if (!catalogMetadataConfig) { + return undefined; + } + return new CatalogMetadataMetricProvider(catalogMetadataConfig); +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/module.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/module.ts new file mode 100644 index 00000000000..d9048c7c027 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/module.ts @@ -0,0 +1,40 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + coreServices, + createBackendModule, +} from '@backstage/backend-plugin-api'; +import { scorecardMetricsExtensionPoint } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { createCatalogMetadataMetricProvider } from './metricProviders/CatalogMetadataMetricProvider'; + +export const scorecardModuleCatalogMetadata = createBackendModule({ + pluginId: 'scorecard', + moduleId: 'catalog-metadata', + register(reg) { + reg.registerInit({ + deps: { + config: coreServices.rootConfig, + metrics: scorecardMetricsExtensionPoint, + }, + async init({ config, metrics }) { + const provider = createCatalogMetadataMetricProvider(config); + if (provider) { + metrics.addMetricProvider(provider); + } + }, + }); + }, +}); From 8687831caa4c252daf3a593bd0d2c51431158967 Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:17:16 +0000 Subject: [PATCH 2/7] fix(#4256): set initial version to 0.0.0, add changeset and API report - Change package version from 1.0.0 to 0.0.0 for initial release - Add patch changeset to release as 0.0.1 - Generate API report via yarn build:api-reports:only Addresses review feedback on #4258 --- .../.changeset/add-catalog-metadata-module.md | 5 +++++ .../package.json | 2 +- .../report.api.md | 11 +++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 workspaces/scorecard/.changeset/add-catalog-metadata-module.md create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/report.api.md diff --git a/workspaces/scorecard/.changeset/add-catalog-metadata-module.md b/workspaces/scorecard/.changeset/add-catalog-metadata-module.md new file mode 100644 index 00000000000..1c0b4ce5b16 --- /dev/null +++ b/workspaces/scorecard/.changeset/add-catalog-metadata-module.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog-metadata': patch +--- + +Add catalog-metadata backend module for the scorecard plugin. diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/package.json b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/package.json index 98516e46365..9ae86ad3b95 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/package.json +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/package.json @@ -1,6 +1,6 @@ { "name": "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog-metadata", - "version": "1.0.0", + "version": "0.0.0", "license": "Apache-2.0", "description": "The catalog-metadata backend module for the scorecard plugin.", "main": "src/index.ts", diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/report.api.md b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/report.api.md new file mode 100644 index 00000000000..52449898c7b --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/report.api.md @@ -0,0 +1,11 @@ +## API Report File for "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog-metadata" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; + +// @public (undocumented) +const scorecardModuleCatalogMetadata: BackendFeature; +export default scorecardModuleCatalogMetadata; +``` From 51fdf85f70c8623671c1706c93552afa4c12f82d Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:33:16 +0000 Subject: [PATCH 3/7] docs(#4256): add README for catalog-metadata module Add comprehensive README documenting the catalog-metadata backend module with feature overview, installation instructions, and six app-config configuration examples covering required attribute checks, value whitelist checks, multi-kind entity filters, options-level status mapping defaults, per-check overrides, and full configuration with schedule and per-metric thresholds. Addresses review feedback on #4258 --- .../README.md | 358 ++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/README.md diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/README.md b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/README.md new file mode 100644 index 00000000000..ad8e8258aad --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/README.md @@ -0,0 +1,358 @@ +# Scorecard Backend Module for Catalog Metadata + +This is an extension module to the `backstage-plugin-scorecard-backend` plugin. It provides configurable catalog entity metadata checks, evaluating entity fields (e.g., `metadata.title`, `spec.lifecycle`) against configurable rules and mapping field states to status strings via a three-tier status mapping merge (check-level > options-level > hardcoded defaults). + +The module supports: + +- **Required attribute checks** — verify that a field exists and is non-empty +- **Value whitelist checks** — verify that a field contains one of a set of accepted values +- **Per-check entity filters** — scope each check to specific entity kinds or types +- **Configurable status mapping** — control what status is reported for each field state (`exists`, `empty`, `emptyString`, `emptyArray`, `missed`) and for specific field values +- **Automatic threshold rule generation** — threshold rules are derived from the status mappings, so you don't need to define them manually + +## Prerequisites + +Before installing this module, ensure that the Scorecard backend plugin is integrated into your Backstage instance. Follow the [Scorecard backend plugin README](../scorecard-backend/README.md) for setup instructions. + +## Installation + +To install this backend module: + +```bash +# From your root directory +yarn workspace backend add @red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog-metadata +``` + +```ts +// packages/backend/src/index.ts +import { createBackend } from '@backstage/backend-defaults'; + +const backend = createBackend(); + +// Scorecard backend plugin +backend.add( + import('@red-hat-developer-hub/backstage-plugin-scorecard-backend'), +); + +// Install the Catalog Metadata module +/* highlight-add-next-line */ +backend.add( + import( + '@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog-metadata' + ), +); + +backend.start(); +``` + +## Configuration + +All checks are defined under `scorecard.metricProviders.catalogMetadata.requiredAttributes.options.checks` in your `app-config.yaml`. Each check specifies a metric definition, an entity filter, a dotted field path to evaluate, and an optional status mapping override. + +If no checks are configured, the module has no effect. + +### Example 1: Required attribute check + +The simplest use case — verify that a field exists and is non-empty. Uses the default status mapping where existing fields map to `found` and missing/empty fields map to `missed`. + +```yaml +# app-config.yaml +scorecard: + metricProviders: + catalogMetadata: + requiredAttributes: + options: + checks: + - metric: + id: title + title: Title is required + description: Every component should have a human-readable title. + filter: + kind: Component + field: metadata.title +``` + +This produces a single metric `catalogMetadata.title` that reports `found` when the entity has a non-empty `metadata.title`, or `missed` when it is absent, null, or empty. + +### Example 2: Value whitelist check + +Verify that a field contains one of a set of accepted values. Values not in the whitelist are reported with the `exists` status (here overridden to `invalid`). + +```yaml +# app-config.yaml +scorecard: + metricProviders: + catalogMetadata: + requiredAttributes: + options: + checks: + - metric: + id: lifecycle + title: Lifecycle must be a known value + description: The spec.lifecycle field should be prod, stage, test, or dev. + filter: + kind: Component + field: spec.lifecycle + statusMapping: + exists: invalid + values: + prod: ok + stage: ok + test: ok + dev: ok +``` + +This produces metric `catalogMetadata.lifecycle` with three possible statuses: + +| Field state | Status | +| --- | --- | +| Value is `prod`, `stage`, `test`, or `dev` | `ok` | +| Value exists but is not in the whitelist | `invalid` | +| Field is missing, null, or empty | `missed` | + +### Example 3: Multiple checks with different entity kinds + +Define multiple checks targeting different entity kinds. The module aggregates kind filters for efficient catalog querying. + +```yaml +# app-config.yaml +scorecard: + metricProviders: + catalogMetadata: + requiredAttributes: + options: + checks: + - metric: + id: title + title: Title is required + description: Every component should have a human-readable title. + filter: + kind: Component + field: metadata.title + + - metric: + id: owner + title: Owner is required + description: Every component should declare an owner. + filter: + kind: Component + field: spec.owner + + - metric: + id: templateOwner + title: Template owner is required + description: Every template should declare an owner. + filter: + kind: Template + field: spec.owner +``` + +This produces three metrics: `catalogMetadata.title`, `catalogMetadata.owner`, and `catalogMetadata.templateOwner`. The module automatically queries only Component and Template entities from the catalog. + +### Example 4: Options-level status mapping defaults + +Set default status strings for all checks at the options level. Individual checks can still override specific fields. + +```yaml +# app-config.yaml +scorecard: + metricProviders: + catalogMetadata: + requiredAttributes: + options: + statusMapping: + exists: present + empty: absent + emptyString: absent + emptyArray: absent + missed: absent + checks: + - metric: + id: title + title: Title is required + description: The metadata.title should be defined. + filter: + kind: Component + field: metadata.title + # Inherits options-level mapping: present/absent + + - metric: + id: tags + title: Tags are required + description: Components should have at least one tag. + filter: + kind: Component + field: metadata.tags + statusMapping: + exists: present + emptyArray: warning + # Overrides only emptyArray; other states inherit from options-level +``` + +### Example 5: Check with multi-field entity filter + +Filter by multiple entity fields. All filter conditions must match (AND logic). Filter values are compared case-insensitively. + +```yaml +# app-config.yaml +scorecard: + metricProviders: + catalogMetadata: + requiredAttributes: + options: + checks: + - metric: + id: serviceLifecycle + title: Service lifecycle is required + description: Service components should have a lifecycle set. + filter: + kind: Component + spec.type: service + field: spec.lifecycle +``` + +This check only runs against entities where `kind` is `Component` **and** `spec.type` is `service`. + +### Example 6: Full configuration with schedule and per-metric thresholds + +A comprehensive example combining schedule configuration, options-level defaults, multiple checks, and per-metric threshold overrides. + +```yaml +# app-config.yaml +scorecard: + metricProviders: + catalogMetadata: + requiredAttributes: + schedule: + frequency: + cron: '0 */2 * * *' + timeout: + minutes: 10 + initialDelay: + seconds: 30 + + options: + statusMapping: + exists: found + missed: missed + checks: + - metric: + id: title + title: Title is required + description: Every component should have a human-readable title. + filter: + kind: Component + field: metadata.title + + - metric: + id: lifecycle + title: Lifecycle must be valid + description: The spec.lifecycle field should be one of the accepted values. + filter: + kind: Component + field: spec.lifecycle + statusMapping: + exists: invalid + values: + production: ok + experimental: warning + deprecated: warning + + metrics: + lifecycle: + thresholds: + rules: + - key: ok + expression: '==0' + color: 'success.main' + icon: scorecardSuccessStatusIcon + - key: warning + expression: '==1' + color: 'warning.main' + icon: scorecardWarningStatusIcon + - key: invalid + expression: '==2' + color: 'error.main' + icon: scorecardErrorStatusIcon + - key: missed + expression: '==3' + color: 'error.main' + icon: scorecardErrorStatusIcon +``` + +## How It Works + +### Field Resolution + +Fields are resolved using dotted path notation on the entity object. For example, `metadata.title` resolves to `entity.metadata.title`, and `spec.lifecycle` resolves to `entity.spec.lifecycle`. + +> **Note:** Dotted annotation keys (e.g., `backstage.io/source-location`) cannot be resolved directly because the path is split on `.`. Use annotations with non-dotted keys, or check a different field path. + +### Status Evaluation + +Each field value is evaluated against the check's status mapping to produce a status string: + +| Field state | Status mapping key | Default status | +| --- | --- | --- | +| Field exists with a non-empty value, no value match | `exists` | `found` | +| Field exists, value matches an entry in `values` | `values.` | _(per value)_ | +| Field resolves to `null` or `undefined` | `empty` | `missed` | +| Field resolves to an empty string (`""`) | `emptyString` | `missed` | +| Field resolves to an empty array (`[]`) | `emptyArray` | `missed` | +| Field path does not resolve on the entity | `missed` | `missed` | + +### Three-Tier Status Mapping Merge + +Status mappings are resolved with the following priority: + +1. **Check-level** (`checks[].statusMapping`) — highest priority +2. **Options-level** (`options.statusMapping`) — middle priority +3. **Hardcoded defaults** — lowest priority (see table above) + +Each field in the status mapping is resolved independently, so a check can override just `exists` while inheriting the options-level `missed` value. + +The `values` maps are deep-merged: hardcoded defaults (empty), then options-level values, then check-level values. A check-level entry for the same key wins over the options-level entry. + +### Automatic Threshold Generation + +The module automatically generates threshold rules from each check's resolved status mapping. Each distinct status string becomes a threshold rule with a numeric code. Well-known status strings get default colors and icons: + +| Status string | Color | Icon | +| --- | --- | --- | +| `found`, `ok`, `success`, `valid` | success (green) | `scorecardSuccessStatusIcon` | +| `missed`, `invalid`, `error`, `failed` | error (red) | `scorecardErrorStatusIcon` | +| `warning` | warning (yellow) | `scorecardWarningStatusIcon` | +| _(any other)_ | warning (yellow) | `scorecardWarningStatusIcon` | + +You can override the auto-generated thresholds using per-metric threshold configuration (see Example 6). + +## Available Metrics + +### Catalog metadata check (`catalogMetadata.`) + +Each configured check produces one numeric metric. + +- **Metric ID**: `catalogMetadata.` (where `` is the `metric.id` from the check config) +- **Provider ID**: `catalogMetadata.requiredAttributes` +- **Type**: Number (numeric code mapped to a status string via threshold rules) +- **Datasource**: `catalogMetadata` + +## Schedule Configuration + +The Scorecard plugin uses Backstage's built-in scheduler service to automatically collect metrics from all registered providers every hour by default. You can change this schedule in the `app-config.yaml` file: + +```yaml +scorecard: + metricProviders: + catalogMetadata: + requiredAttributes: + schedule: + frequency: + cron: '0 6 * * *' + timeout: + minutes: 5 + initialDelay: + seconds: 5 +``` + +The schedule configuration follows Backstage's `SchedulerServiceTaskScheduleDefinitionConfig` [schema](https://github.com/backstage/backstage/blob/master/packages/backend-plugin-api/src/services/definitions/SchedulerService.ts#L157). For more details on how to configure schedule, see [Metric Collection Scheduling](../scorecard-backend/docs/providers.md#metric-collection-scheduling). From f38f2ac098478fa7a52cd7728bb576235c5ba7d7 Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:07:22 +0000 Subject: [PATCH 4/7] style(#4256): align markdown table formatting with prettier Run `yarn prettier:fix` to align README markdown table columns per project code style conventions. Addresses review feedback on #4258 --- .../README.md | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/README.md b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/README.md index ad8e8258aad..d59299ff9bc 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/README.md @@ -104,11 +104,11 @@ scorecard: This produces metric `catalogMetadata.lifecycle` with three possible statuses: -| Field state | Status | -| --- | --- | -| Value is `prod`, `stage`, `test`, or `dev` | `ok` | -| Value exists but is not in the whitelist | `invalid` | -| Field is missing, null, or empty | `missed` | +| Field state | Status | +| ------------------------------------------ | --------- | +| Value is `prod`, `stage`, `test`, or `dev` | `ok` | +| Value exists but is not in the whitelist | `invalid` | +| Field is missing, null, or empty | `missed` | ### Example 3: Multiple checks with different entity kinds @@ -292,14 +292,14 @@ Fields are resolved using dotted path notation on the entity object. For example Each field value is evaluated against the check's status mapping to produce a status string: -| Field state | Status mapping key | Default status | -| --- | --- | --- | -| Field exists with a non-empty value, no value match | `exists` | `found` | -| Field exists, value matches an entry in `values` | `values.` | _(per value)_ | -| Field resolves to `null` or `undefined` | `empty` | `missed` | -| Field resolves to an empty string (`""`) | `emptyString` | `missed` | -| Field resolves to an empty array (`[]`) | `emptyArray` | `missed` | -| Field path does not resolve on the entity | `missed` | `missed` | +| Field state | Status mapping key | Default status | +| --------------------------------------------------- | ------------------ | -------------- | +| Field exists with a non-empty value, no value match | `exists` | `found` | +| Field exists, value matches an entry in `values` | `values.` | _(per value)_ | +| Field resolves to `null` or `undefined` | `empty` | `missed` | +| Field resolves to an empty string (`""`) | `emptyString` | `missed` | +| Field resolves to an empty array (`[]`) | `emptyArray` | `missed` | +| Field path does not resolve on the entity | `missed` | `missed` | ### Three-Tier Status Mapping Merge @@ -317,12 +317,12 @@ The `values` maps are deep-merged: hardcoded defaults (empty), then options-leve The module automatically generates threshold rules from each check's resolved status mapping. Each distinct status string becomes a threshold rule with a numeric code. Well-known status strings get default colors and icons: -| Status string | Color | Icon | -| --- | --- | --- | -| `found`, `ok`, `success`, `valid` | success (green) | `scorecardSuccessStatusIcon` | -| `missed`, `invalid`, `error`, `failed` | error (red) | `scorecardErrorStatusIcon` | -| `warning` | warning (yellow) | `scorecardWarningStatusIcon` | -| _(any other)_ | warning (yellow) | `scorecardWarningStatusIcon` | +| Status string | Color | Icon | +| -------------------------------------- | ---------------- | ---------------------------- | +| `found`, `ok`, `success`, `valid` | success (green) | `scorecardSuccessStatusIcon` | +| `missed`, `invalid`, `error`, `failed` | error (red) | `scorecardErrorStatusIcon` | +| `warning` | warning (yellow) | `scorecardWarningStatusIcon` | +| _(any other)_ | warning (yellow) | `scorecardWarningStatusIcon` | You can override the auto-generated thresholds using per-metric threshold configuration (see Example 6). From 4148df64b8cb7b20141b276d12dc9a10f61be2a5 Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 06:17:44 +0000 Subject: [PATCH 5/7] fix(#4256): rename catalog-metadata module to catalog per review - Rename folder from scorecard-backend-module-catalog-metadata to scorecard-backend-module-catalog - Rename CatalogMetadataConfig to CatalogRequiredAttributesConfig - Rename CatalogMetadataMetricProvider to CatalogRequiredAttributesMetricProvider - Update getProviderDatasourceId to 'catalog' and getProviderId to 'catalog.requiredAttributes' - Update metric ID prefix from catalogMetadata to catalog - Update config path from catalogMetadata to catalog - Update all README examples and config.d.ts references - Add example entities catalog-scorecard-without-title and catalog-metadata-scorecard-with-title with scorecard.example annotation - Add catalog module configuration to app-config.yaml Addresses review feedback on #4258 --- .../.changeset/add-catalog-metadata-module.md | 4 +- workspaces/scorecard/app-config.yaml | 15 +++ .../examples/all-scorecards-location.yaml | 2 + ...catalog-metadata-scorecard-with-title.yaml | 11 ++ .../catalog-scorecard-without-title.yaml | 10 ++ .../.eslintrc.js | 0 .../README.md | 40 +++--- .../config.d.ts | 8 +- .../package.json | 6 +- .../report.api.md | 6 +- .../src/index.ts | 4 +- .../CatalogRequiredAttributesConfig.ts} | 12 +- ...gRequiredAttributesMetricProvider.test.ts} | 122 +++++++++--------- ...atalogRequiredAttributesMetricProvider.ts} | 49 ++++--- .../src/module.ts | 8 +- 15 files changed, 170 insertions(+), 127 deletions(-) create mode 100644 workspaces/scorecard/examples/components/catalog-metadata-scorecard-with-title.yaml create mode 100644 workspaces/scorecard/examples/components/catalog-scorecard-without-title.yaml rename workspaces/scorecard/plugins/{scorecard-backend-module-catalog-metadata => scorecard-backend-module-catalog}/.eslintrc.js (100%) rename workspaces/scorecard/plugins/{scorecard-backend-module-catalog-metadata => scorecard-backend-module-catalog}/README.md (88%) rename workspaces/scorecard/plugins/{scorecard-backend-module-catalog-metadata => scorecard-backend-module-catalog}/config.d.ts (93%) rename workspaces/scorecard/plugins/{scorecard-backend-module-catalog-metadata => scorecard-backend-module-catalog}/package.json (93%) rename workspaces/scorecard/plugins/{scorecard-backend-module-catalog-metadata => scorecard-backend-module-catalog}/report.api.md (64%) rename workspaces/scorecard/plugins/{scorecard-backend-module-catalog-metadata => scorecard-backend-module-catalog}/src/index.ts (82%) rename workspaces/scorecard/plugins/{scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataConfig.ts => scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesConfig.ts} (94%) rename workspaces/scorecard/plugins/{scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataMetricProvider.test.ts => scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.test.ts} (84%) rename workspaces/scorecard/plugins/{scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataMetricProvider.ts => scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.ts} (84%) rename workspaces/scorecard/plugins/{scorecard-backend-module-catalog-metadata => scorecard-backend-module-catalog}/src/module.ts (79%) diff --git a/workspaces/scorecard/.changeset/add-catalog-metadata-module.md b/workspaces/scorecard/.changeset/add-catalog-metadata-module.md index 1c0b4ce5b16..4adba3f52ff 100644 --- a/workspaces/scorecard/.changeset/add-catalog-metadata-module.md +++ b/workspaces/scorecard/.changeset/add-catalog-metadata-module.md @@ -1,5 +1,5 @@ --- -'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog-metadata': patch +'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog': patch --- -Add catalog-metadata backend module for the scorecard plugin. +Add catalog backend module for the scorecard plugin. diff --git a/workspaces/scorecard/app-config.yaml b/workspaces/scorecard/app-config.yaml index f41c2fc2f09..82a17c5bb1e 100644 --- a/workspaces/scorecard/app-config.yaml +++ b/workspaces/scorecard/app-config.yaml @@ -380,6 +380,21 @@ scorecard: frequency: { minutes: 5 } timeout: { minutes: 10 } initialDelay: { seconds: 10 } + catalog: + requiredAttributes: + options: + checks: + - metric: + id: title + title: Title is required + description: Every component should have a human-readable title. + filter: + kind: Component + field: metadata.title + schedule: + frequency: { minutes: 5 } + timeout: { minutes: 10 } + initialDelay: { seconds: 10 } filecheck: fileExistence: options: diff --git a/workspaces/scorecard/examples/all-scorecards-location.yaml b/workspaces/scorecard/examples/all-scorecards-location.yaml index 60736cb5c6c..bdb37b4d502 100644 --- a/workspaces/scorecard/examples/all-scorecards-location.yaml +++ b/workspaces/scorecard/examples/all-scorecards-location.yaml @@ -8,6 +8,8 @@ spec: targets: - ./components/all-scorecards-service-different-owner.yaml - ./components/all-scorecards.yaml + - ./components/catalog-metadata-scorecard-with-title.yaml + - ./components/catalog-scorecard-without-title.yaml - ./components/code-coverage-scorecard-only.yaml - ./components/dependabot-scorecard-only.yaml - ./components/github-scorecard-only.yaml diff --git a/workspaces/scorecard/examples/components/catalog-metadata-scorecard-with-title.yaml b/workspaces/scorecard/examples/components/catalog-metadata-scorecard-with-title.yaml new file mode 100644 index 00000000000..6253439fb1d --- /dev/null +++ b/workspaces/scorecard/examples/components/catalog-metadata-scorecard-with-title.yaml @@ -0,0 +1,11 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: catalog-metadata-scorecard-with-title + title: Catalog Metadata Scorecard With Title + annotations: + scorecard.example: catalog-metadata +spec: + type: service + owner: group:development/guests + lifecycle: production diff --git a/workspaces/scorecard/examples/components/catalog-scorecard-without-title.yaml b/workspaces/scorecard/examples/components/catalog-scorecard-without-title.yaml new file mode 100644 index 00000000000..c00c80d5dfc --- /dev/null +++ b/workspaces/scorecard/examples/components/catalog-scorecard-without-title.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: catalog-scorecard-without-title + annotations: + scorecard.example: catalog-metadata +spec: + type: service + owner: group:development/guests + lifecycle: production diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/.eslintrc.js b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/.eslintrc.js similarity index 100% rename from workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/.eslintrc.js rename to workspaces/scorecard/plugins/scorecard-backend-module-catalog/.eslintrc.js diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/README.md b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/README.md similarity index 88% rename from workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/README.md rename to workspaces/scorecard/plugins/scorecard-backend-module-catalog/README.md index d59299ff9bc..22c31102ab4 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/README.md @@ -1,6 +1,6 @@ -# Scorecard Backend Module for Catalog Metadata +# Scorecard Backend Module for Catalog -This is an extension module to the `backstage-plugin-scorecard-backend` plugin. It provides configurable catalog entity metadata checks, evaluating entity fields (e.g., `metadata.title`, `spec.lifecycle`) against configurable rules and mapping field states to status strings via a three-tier status mapping merge (check-level > options-level > hardcoded defaults). +This is an extension module to the `backstage-plugin-scorecard-backend` plugin. It provides configurable catalog entity checks, evaluating entity fields (e.g., `metadata.title`, `spec.lifecycle`) against configurable rules and mapping field states to status strings via a three-tier status mapping merge (check-level > options-level > hardcoded defaults). The module supports: @@ -20,7 +20,7 @@ To install this backend module: ```bash # From your root directory -yarn workspace backend add @red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog-metadata +yarn workspace backend add @red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog ``` ```ts @@ -34,11 +34,11 @@ backend.add( import('@red-hat-developer-hub/backstage-plugin-scorecard-backend'), ); -// Install the Catalog Metadata module +// Install the Catalog module /* highlight-add-next-line */ backend.add( import( - '@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog-metadata' + '@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog' ), ); @@ -47,7 +47,7 @@ backend.start(); ## Configuration -All checks are defined under `scorecard.metricProviders.catalogMetadata.requiredAttributes.options.checks` in your `app-config.yaml`. Each check specifies a metric definition, an entity filter, a dotted field path to evaluate, and an optional status mapping override. +All checks are defined under `scorecard.metricProviders.catalog.requiredAttributes.options.checks` in your `app-config.yaml`. Each check specifies a metric definition, an entity filter, a dotted field path to evaluate, and an optional status mapping override. If no checks are configured, the module has no effect. @@ -59,7 +59,7 @@ The simplest use case — verify that a field exists and is non-empty. Uses the # app-config.yaml scorecard: metricProviders: - catalogMetadata: + catalog: requiredAttributes: options: checks: @@ -72,7 +72,7 @@ scorecard: field: metadata.title ``` -This produces a single metric `catalogMetadata.title` that reports `found` when the entity has a non-empty `metadata.title`, or `missed` when it is absent, null, or empty. +This produces a single metric `catalog.title` that reports `found` when the entity has a non-empty `metadata.title`, or `missed` when it is absent, null, or empty. ### Example 2: Value whitelist check @@ -82,7 +82,7 @@ Verify that a field contains one of a set of accepted values. Values not in the # app-config.yaml scorecard: metricProviders: - catalogMetadata: + catalog: requiredAttributes: options: checks: @@ -102,7 +102,7 @@ scorecard: dev: ok ``` -This produces metric `catalogMetadata.lifecycle` with three possible statuses: +This produces metric `catalog.lifecycle` with three possible statuses: | Field state | Status | | ------------------------------------------ | --------- | @@ -118,7 +118,7 @@ Define multiple checks targeting different entity kinds. The module aggregates k # app-config.yaml scorecard: metricProviders: - catalogMetadata: + catalog: requiredAttributes: options: checks: @@ -147,7 +147,7 @@ scorecard: field: spec.owner ``` -This produces three metrics: `catalogMetadata.title`, `catalogMetadata.owner`, and `catalogMetadata.templateOwner`. The module automatically queries only Component and Template entities from the catalog. +This produces three metrics: `catalog.title`, `catalog.owner`, and `catalog.templateOwner`. The module automatically queries only Component and Template entities from the catalog. ### Example 4: Options-level status mapping defaults @@ -157,7 +157,7 @@ Set default status strings for all checks at the options level. Individual check # app-config.yaml scorecard: metricProviders: - catalogMetadata: + catalog: requiredAttributes: options: statusMapping: @@ -197,7 +197,7 @@ Filter by multiple entity fields. All filter conditions must match (AND logic). # app-config.yaml scorecard: metricProviders: - catalogMetadata: + catalog: requiredAttributes: options: checks: @@ -221,7 +221,7 @@ A comprehensive example combining schedule configuration, options-level defaults # app-config.yaml scorecard: metricProviders: - catalogMetadata: + catalog: requiredAttributes: schedule: frequency: @@ -328,14 +328,14 @@ You can override the auto-generated thresholds using per-metric threshold config ## Available Metrics -### Catalog metadata check (`catalogMetadata.`) +### Catalog check (`catalog.`) Each configured check produces one numeric metric. -- **Metric ID**: `catalogMetadata.` (where `` is the `metric.id` from the check config) -- **Provider ID**: `catalogMetadata.requiredAttributes` +- **Metric ID**: `catalog.` (where `` is the `metric.id` from the check config) +- **Provider ID**: `catalog.requiredAttributes` - **Type**: Number (numeric code mapped to a status string via threshold rules) -- **Datasource**: `catalogMetadata` +- **Datasource**: `catalog` ## Schedule Configuration @@ -344,7 +344,7 @@ The Scorecard plugin uses Backstage's built-in scheduler service to automaticall ```yaml scorecard: metricProviders: - catalogMetadata: + catalog: requiredAttributes: schedule: frequency: diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/config.d.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/config.d.ts similarity index 93% rename from workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/config.d.ts rename to workspaces/scorecard/plugins/scorecard-backend-module-catalog/config.d.ts index 4305dfaa858..d1368b63606 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/config.d.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/config.d.ts @@ -22,10 +22,10 @@ export interface Config { scorecard?: { /** Metric providers calculate one or more metrics on a schedule. */ metricProviders?: { - /** Catalog metadata check configuration */ - catalogMetadata?: { + /** Catalog check configuration */ + catalog?: { requiredAttributes?: { - /** How often catalogMetadata.requiredAttributes metrics will be calculated */ + /** How often catalog.requiredAttributes metrics will be calculated */ schedule?: SchedulerServiceTaskScheduleDefinitionConfig; /** How catalog metadata metric values are categorized */ thresholds?: ThresholdConfig; @@ -35,7 +35,7 @@ export interface Config { checks?: Array<{ /** Metric definition */ metric: { - /** Metric identifier suffix (used as catalogMetadata.) */ + /** Metric identifier suffix (used as catalog.) */ id: string; /** Human-readable title */ title: string; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/package.json b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/package.json similarity index 93% rename from workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/package.json rename to workspaces/scorecard/plugins/scorecard-backend-module-catalog/package.json index 9ae86ad3b95..ca4713298a9 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/package.json +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/package.json @@ -1,8 +1,8 @@ { - "name": "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog-metadata", + "name": "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog", "version": "0.0.0", "license": "Apache-2.0", - "description": "The catalog-metadata backend module for the scorecard plugin.", + "description": "The catalog backend module for the scorecard plugin.", "main": "src/index.ts", "types": "src/index.ts", "publishConfig": { @@ -55,7 +55,7 @@ "repository": { "type": "git", "url": "https://github.com/redhat-developer/rhdh-plugins", - "directory": "workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata" + "directory": "workspaces/scorecard/plugins/scorecard-backend-module-catalog" }, "keywords": [ "backstage", diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/report.api.md b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/report.api.md similarity index 64% rename from workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/report.api.md rename to workspaces/scorecard/plugins/scorecard-backend-module-catalog/report.api.md index 52449898c7b..65b1f9500a7 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/report.api.md +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/report.api.md @@ -1,4 +1,4 @@ -## API Report File for "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog-metadata" +## API Report File for "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog" > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). @@ -6,6 +6,6 @@ import { BackendFeature } from '@backstage/backend-plugin-api'; // @public (undocumented) -const scorecardModuleCatalogMetadata: BackendFeature; -export default scorecardModuleCatalogMetadata; +const scorecardModuleCatalog: BackendFeature; +export default scorecardModuleCatalog; ``` diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/index.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/index.ts similarity index 82% rename from workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/index.ts rename to workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/index.ts index 107f1b8855a..280bfec457b 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/index.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/index.ts @@ -15,9 +15,9 @@ */ /** - * The catalog-metadata backend module for the scorecard plugin. + * The catalog backend module for the scorecard plugin. * * @packageDocumentation */ -export { scorecardModuleCatalogMetadata as default } from './module'; +export { scorecardModuleCatalog as default } from './module'; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataConfig.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesConfig.ts similarity index 94% rename from workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataConfig.ts rename to workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesConfig.ts index 04084e22743..c1e5eb653a0 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataConfig.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesConfig.ts @@ -49,9 +49,9 @@ export type CheckConfig = { }; /** - * Parsed configuration for the catalog-metadata metric provider. + * Parsed configuration for the catalog required attributes metric provider. */ -export type CatalogMetadataConfig = { +export type CatalogRequiredAttributesConfig = { checks: CheckConfig[]; }; @@ -155,14 +155,14 @@ function readStatusMapping(config: Config): Partial | undefined { } /** - * Parses the catalog-metadata configuration from the root Backstage config. + * Parses the catalog required attributes configuration from the root Backstage config. * Returns undefined if no checks are configured. */ -export function parseCatalogMetadataConfig( +export function parseCatalogRequiredAttributesConfig( config: Config, -): CatalogMetadataConfig | undefined { +): CatalogRequiredAttributesConfig | undefined { const optionsConfig = config.getOptionalConfig( - 'scorecard.metricProviders.catalogMetadata.requiredAttributes.options', + 'scorecard.metricProviders.catalog.requiredAttributes.options', ); if (!optionsConfig) { diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataMetricProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.test.ts similarity index 84% rename from workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataMetricProvider.test.ts rename to workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.test.ts index 004b84222e4..4657f86d5ba 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataMetricProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.test.ts @@ -19,13 +19,13 @@ import type { Entity } from '@backstage/catalog-model'; import { mergeStatusMappings, DEFAULT_STATUS_MAPPING, -} from './CatalogMetadataConfig'; +} from './CatalogRequiredAttributesConfig'; import { - createCatalogMetadataMetricProvider, + createCatalogRequiredAttributesMetricProvider, resolveFieldPath, evaluateFieldStatus, entityMatchesFilter, -} from './CatalogMetadataMetricProvider'; +} from './CatalogRequiredAttributesMetricProvider'; // ── helpers ──────────────────────────────────────────────────────────── @@ -33,7 +33,7 @@ function buildConfig(checks: object[], optionsStatusMapping?: object) { return { scorecard: { metricProviders: { - catalogMetadata: { + catalog: { requiredAttributes: { options: { checks, @@ -378,16 +378,18 @@ describe('mergeStatusMappings', () => { }); }); -// ── createCatalogMetadataMetricProvider ──────────────────────────────── +// ── createCatalogRequiredAttributesMetricProvider ──────────────────────────────── -describe('createCatalogMetadataMetricProvider', () => { +describe('createCatalogRequiredAttributesMetricProvider', () => { it('should return undefined when no config is provided', () => { - const provider = createCatalogMetadataMetricProvider(new ConfigReader({})); + const provider = createCatalogRequiredAttributesMetricProvider( + new ConfigReader({}), + ); expect(provider).toBeUndefined(); }); it('should return undefined when checks array is empty', () => { - const provider = createCatalogMetadataMetricProvider( + const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader(buildConfig([])), ); expect(provider).toBeUndefined(); @@ -395,50 +397,46 @@ describe('createCatalogMetadataMetricProvider', () => { it('should create provider with a single check', () => { const config = new ConfigReader(buildConfig([titleCheck()])); - const provider = createCatalogMetadataMetricProvider(config); + const provider = createCatalogRequiredAttributesMetricProvider(config); expect(provider).toBeDefined(); - expect(provider?.getMetrics().map(m => m.id)).toEqual([ - 'catalogMetadata.title', - ]); + expect(provider?.getMetrics().map(m => m.id)).toEqual(['catalog.title']); }); it('should create provider with multiple checks', () => { const config = new ConfigReader( buildConfig([titleCheck(), lifecycleCheck()]), ); - const provider = createCatalogMetadataMetricProvider(config); + const provider = createCatalogRequiredAttributesMetricProvider(config); expect(provider).toBeDefined(); expect(provider?.getMetrics().map(m => m.id)).toEqual([ - 'catalogMetadata.title', - 'catalogMetadata.lifecycle', + 'catalog.title', + 'catalog.lifecycle', ]); }); }); // ── provider methods ─────────────────────────────────────────────────── -describe('CatalogMetadataMetricProvider', () => { +describe('CatalogRequiredAttributesMetricProvider', () => { describe('provider identification', () => { - const provider = createCatalogMetadataMetricProvider( + const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader(buildConfig([titleCheck()])), ); it('should return correct provider ID', () => { - expect(provider?.getProviderId()).toBe( - 'catalogMetadata.requiredAttributes', - ); + expect(provider?.getProviderId()).toBe('catalog.requiredAttributes'); }); it('should return correct datasource ID', () => { - expect(provider?.getProviderDatasourceId()).toBe('catalogMetadata'); + expect(provider?.getProviderDatasourceId()).toBe('catalog'); }); }); describe('getMetrics', () => { it('should return metrics with correct type', () => { - const provider = createCatalogMetadataMetricProvider( + const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader(buildConfig([titleCheck()])), ); const metrics = provider?.getMetrics(); @@ -450,7 +448,7 @@ describe('CatalogMetadataMetricProvider', () => { }); it('should generate threshold rules from status mapping', () => { - const provider = createCatalogMetadataMetricProvider( + const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader(buildConfig([titleCheck()])), ); const metrics = provider?.getMetrics(); @@ -464,7 +462,7 @@ describe('CatalogMetadataMetricProvider', () => { }); it('should generate threshold rules for value-specific mapping', () => { - const provider = createCatalogMetadataMetricProvider( + const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader(buildConfig([lifecycleCheck()])), ); const metrics = provider?.getMetrics(); @@ -477,12 +475,12 @@ describe('CatalogMetadataMetricProvider', () => { }); it('should include metric metadata', () => { - const provider = createCatalogMetadataMetricProvider( + const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader(buildConfig([titleCheck()])), ); const metric = provider?.getMetrics()[0]; - expect(metric?.id).toBe('catalogMetadata.title'); + expect(metric?.id).toBe('catalog.title'); expect(metric?.title).toBe('Title is required'); expect(metric?.description).toBe('The metadata.title should be defined.'); }); @@ -490,7 +488,7 @@ describe('CatalogMetadataMetricProvider', () => { describe('getCatalogFilter', () => { it('should return kind filter when all checks share the same kind', () => { - const provider = createCatalogMetadataMetricProvider( + const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader( buildConfig([ titleCheck({ filter: { kind: 'Component' } }), @@ -504,7 +502,7 @@ describe('CatalogMetadataMetricProvider', () => { }); it('should return multi-kind filter for different kinds', () => { - const provider = createCatalogMetadataMetricProvider( + const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader( buildConfig([ titleCheck({ filter: { kind: 'Component' } }), @@ -530,7 +528,7 @@ describe('CatalogMetadataMetricProvider', () => { }); it('should return empty filter when any check has no kind filter', () => { - const provider = createCatalogMetadataMetricProvider( + const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader( buildConfig([ titleCheck({ filter: { kind: 'Component' } }), @@ -548,20 +546,20 @@ describe('CatalogMetadataMetricProvider', () => { describe('calculateMetrics', () => { it('should return "found" status code for existing field', async () => { - const provider = createCatalogMetadataMetricProvider( + const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader(buildConfig([titleCheck()])), ); const result = await provider?.calculateMetrics(componentEntity); // The metric value is a numeric code mapping to "found" const metrics = provider?.getMetrics(); - const titleMetric = metrics?.find(m => m.id === 'catalogMetadata.title'); + const titleMetric = metrics?.find(m => m.id === 'catalog.title'); const foundRule = titleMetric?.thresholds.rules.find( r => r.key === 'found', ); const expectedCode = Number(foundRule?.expression.replace('==', '')); - expect(result?.get('catalogMetadata.title')).toBe(expectedCode); + expect(result?.get('catalog.title')).toBe(expectedCode); }); it('should return "missed" status code for missing field', async () => { @@ -571,44 +569,44 @@ describe('CatalogMetadataMetricProvider', () => { metadata: { name: 'no-title-component' }, spec: { type: 'service', lifecycle: 'prod', owner: 'team-a' }, }; - const provider = createCatalogMetadataMetricProvider( + const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader(buildConfig([titleCheck()])), ); const result = await provider?.calculateMetrics(entityWithoutTitle); const metrics = provider?.getMetrics(); - const titleMetric = metrics?.find(m => m.id === 'catalogMetadata.title'); + const titleMetric = metrics?.find(m => m.id === 'catalog.title'); const missedRule = titleMetric?.thresholds.rules.find( r => r.key === 'missed', ); const expectedCode = Number(missedRule?.expression.replace('==', '')); - expect(result?.get('catalogMetadata.title')).toBe(expectedCode); + expect(result?.get('catalog.title')).toBe(expectedCode); }); it('should skip checks for non-matching entities', async () => { - const provider = createCatalogMetadataMetricProvider( + const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader( buildConfig([titleCheck({ filter: { kind: 'Component' } })]), ), ); const result = await provider?.calculateMetrics(templateEntity); - expect(result?.has('catalogMetadata.title')).toBe(false); + expect(result?.has('catalog.title')).toBe(false); }); it('should return "ok" for valid lifecycle value', async () => { - const provider = createCatalogMetadataMetricProvider( + const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader(buildConfig([lifecycleCheck()])), ); const result = await provider?.calculateMetrics(componentEntity); const metrics = provider?.getMetrics(); - const lcMetric = metrics?.find(m => m.id === 'catalogMetadata.lifecycle'); + const lcMetric = metrics?.find(m => m.id === 'catalog.lifecycle'); const okRule = lcMetric?.thresholds.rules.find(r => r.key === 'ok'); const expectedCode = Number(okRule?.expression.replace('==', '')); - expect(result?.get('catalogMetadata.lifecycle')).toBe(expectedCode); + expect(result?.get('catalog.lifecycle')).toBe(expectedCode); }); it('should return "invalid" for unknown lifecycle value', async () => { @@ -616,19 +614,19 @@ describe('CatalogMetadataMetricProvider', () => { ...componentEntity, spec: { ...componentEntity.spec, lifecycle: 'experimental' }, }; - const provider = createCatalogMetadataMetricProvider( + const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader(buildConfig([lifecycleCheck()])), ); const result = await provider?.calculateMetrics(entity); const metrics = provider?.getMetrics(); - const lcMetric = metrics?.find(m => m.id === 'catalogMetadata.lifecycle'); + const lcMetric = metrics?.find(m => m.id === 'catalog.lifecycle'); const invalidRule = lcMetric?.thresholds.rules.find( r => r.key === 'invalid', ); const expectedCode = Number(invalidRule?.expression.replace('==', '')); - expect(result?.get('catalogMetadata.lifecycle')).toBe(expectedCode); + expect(result?.get('catalog.lifecycle')).toBe(expectedCode); }); it('should return "missed" for missing lifecycle value', async () => { @@ -636,19 +634,19 @@ describe('CatalogMetadataMetricProvider', () => { ...componentEntity, spec: { type: 'service', owner: 'team-a' }, }; - const provider = createCatalogMetadataMetricProvider( + const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader(buildConfig([lifecycleCheck()])), ); const result = await provider?.calculateMetrics(entity); const metrics = provider?.getMetrics(); - const lcMetric = metrics?.find(m => m.id === 'catalogMetadata.lifecycle'); + const lcMetric = metrics?.find(m => m.id === 'catalog.lifecycle'); const missedRule = lcMetric?.thresholds.rules.find( r => r.key === 'missed', ); const expectedCode = Number(missedRule?.expression.replace('==', '')); - expect(result?.get('catalogMetadata.lifecycle')).toBe(expectedCode); + expect(result?.get('catalog.lifecycle')).toBe(expectedCode); }); it('should handle empty string field with default mapping', async () => { @@ -656,20 +654,20 @@ describe('CatalogMetadataMetricProvider', () => { ...componentEntity, metadata: { ...componentEntity.metadata, title: '' }, }; - const provider = createCatalogMetadataMetricProvider( + const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader(buildConfig([titleCheck()])), ); const result = await provider?.calculateMetrics(entity); // Default mapping: emptyString → 'missed' const metrics = provider?.getMetrics(); - const titleMetric = metrics?.find(m => m.id === 'catalogMetadata.title'); + const titleMetric = metrics?.find(m => m.id === 'catalog.title'); const missedRule = titleMetric?.thresholds.rules.find( r => r.key === 'missed', ); const expectedCode = Number(missedRule?.expression.replace('==', '')); - expect(result?.get('catalogMetadata.title')).toBe(expectedCode); + expect(result?.get('catalog.title')).toBe(expectedCode); }); it('should handle empty array field with default mapping', async () => { @@ -677,7 +675,7 @@ describe('CatalogMetadataMetricProvider', () => { ...componentEntity, metadata: { ...componentEntity.metadata, tags: [] }, }; - const provider = createCatalogMetadataMetricProvider( + const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader( buildConfig([ titleCheck({ @@ -694,23 +692,23 @@ describe('CatalogMetadataMetricProvider', () => { const result = await provider?.calculateMetrics(entity); const metrics = provider?.getMetrics(); - const tagsMetric = metrics?.find(m => m.id === 'catalogMetadata.tags'); + const tagsMetric = metrics?.find(m => m.id === 'catalog.tags'); const missedRule = tagsMetric?.thresholds.rules.find( r => r.key === 'missed', ); const expectedCode = Number(missedRule?.expression.replace('==', '')); - expect(result?.get('catalogMetadata.tags')).toBe(expectedCode); + expect(result?.get('catalog.tags')).toBe(expectedCode); }); it('should handle multiple checks on the same entity', async () => { - const provider = createCatalogMetadataMetricProvider( + const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader(buildConfig([titleCheck(), lifecycleCheck()])), ); const result = await provider?.calculateMetrics(componentEntity); - expect(result?.has('catalogMetadata.title')).toBe(true); - expect(result?.has('catalogMetadata.lifecycle')).toBe(true); + expect(result?.has('catalog.title')).toBe(true); + expect(result?.has('catalog.lifecycle')).toBe(true); }); it('should apply options-level status mapping to all checks', async () => { @@ -723,10 +721,10 @@ describe('CatalogMetadataMetricProvider', () => { empty: 'absent', }), ); - const provider = createCatalogMetadataMetricProvider(config); + const provider = createCatalogRequiredAttributesMetricProvider(config); const metrics = provider?.getMetrics(); - const titleMetric = metrics?.find(m => m.id === 'catalogMetadata.title'); + const titleMetric = metrics?.find(m => m.id === 'catalog.title'); const keys = titleMetric?.thresholds.rules.map(r => r.key); expect(keys).toContain('present'); expect(keys).toContain('absent'); @@ -747,25 +745,25 @@ describe('CatalogMetadataMetricProvider', () => { }, ), ); - const provider = createCatalogMetadataMetricProvider(config); + const provider = createCatalogRequiredAttributesMetricProvider(config); const metrics = provider?.getMetrics(); - const titleMetric = metrics?.find(m => m.id === 'catalogMetadata.title'); + const titleMetric = metrics?.find(m => m.id === 'catalog.title'); const keys = titleMetric?.thresholds.rules.map(r => r.key); expect(keys).toContain('check-present'); expect(keys).not.toContain('options-present'); }); it('should apply check filter with empty filter matching all entities', async () => { - const provider = createCatalogMetadataMetricProvider( + const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader(buildConfig([titleCheck({ filter: {} })])), ); const componentResult = await provider?.calculateMetrics(componentEntity); - expect(componentResult?.has('catalogMetadata.title')).toBe(true); + expect(componentResult?.has('catalog.title')).toBe(true); const templateResult = await provider?.calculateMetrics(templateEntity); - expect(templateResult?.has('catalogMetadata.title')).toBe(true); + expect(templateResult?.has('catalog.title')).toBe(true); }); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataMetricProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.ts similarity index 84% rename from workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataMetricProvider.ts rename to workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.ts index 65a5e1a1f85..9d846445c47 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/metricProviders/CatalogMetadataMetricProvider.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.ts @@ -23,10 +23,10 @@ import { } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; import { - type CatalogMetadataConfig, + type CatalogRequiredAttributesConfig, type StatusMapping, - parseCatalogMetadataConfig, -} from './CatalogMetadataConfig'; + parseCatalogRequiredAttributesConfig, +} from './CatalogRequiredAttributesConfig'; /** Sentinel for a field path that does not resolve. */ const NOT_FOUND = Symbol('NOT_FOUND'); @@ -200,17 +200,21 @@ function getDefaultIcon(status: string): string { } } -export class CatalogMetadataMetricProvider implements MetricProvider<'number'> { - private readonly catalogMetadataConfig: CatalogMetadataConfig; +export class CatalogRequiredAttributesMetricProvider + implements MetricProvider<'number'> +{ + private readonly catalogRequiredAttributesConfig: CatalogRequiredAttributesConfig; private readonly statusCodeMappings: Map< string, { statusToCode: Map; thresholds: ThresholdConfig } >; - constructor(catalogMetadataConfig: CatalogMetadataConfig) { - this.catalogMetadataConfig = catalogMetadataConfig; + constructor( + catalogRequiredAttributesConfig: CatalogRequiredAttributesConfig, + ) { + this.catalogRequiredAttributesConfig = catalogRequiredAttributesConfig; this.statusCodeMappings = new Map(); - for (const check of catalogMetadataConfig.checks) { + for (const check of catalogRequiredAttributesConfig.checks) { this.statusCodeMappings.set( check.metric.id, buildStatusCodeMapping(check.statusMapping), @@ -219,18 +223,18 @@ export class CatalogMetadataMetricProvider implements MetricProvider<'number'> { } getProviderDatasourceId(): string { - return 'catalogMetadata'; + return 'catalog'; } getProviderId(): string { - return 'catalogMetadata.requiredAttributes'; + return 'catalog.requiredAttributes'; } getMetrics(): Metric<'number'>[] { - return this.catalogMetadataConfig.checks.map(check => { + return this.catalogRequiredAttributesConfig.checks.map(check => { const mapping = this.statusCodeMappings.get(check.metric.id)!; return { - id: `catalogMetadata.${check.metric.id}`, + id: `catalog.${check.metric.id}`, title: check.metric.title, description: check.metric.description, type: 'number' as const, @@ -246,7 +250,7 @@ export class CatalogMetadataMetricProvider implements MetricProvider<'number'> { const kinds = new Set(); let allHaveKind = true; - for (const check of this.catalogMetadataConfig.checks) { + for (const check of this.catalogRequiredAttributesConfig.checks) { const kindValue = check.filter.kind; if (kindValue) { kinds.add(kindValue.toLowerCase()); @@ -269,7 +273,7 @@ export class CatalogMetadataMetricProvider implements MetricProvider<'number'> { async calculateMetrics(entity: Entity): Promise> { const results = new Map(); - for (const check of this.catalogMetadataConfig.checks) { + for (const check of this.catalogRequiredAttributesConfig.checks) { // Apply per-check filter if (!entityMatchesFilter(entity, check.filter)) { continue; @@ -284,7 +288,7 @@ export class CatalogMetadataMetricProvider implements MetricProvider<'number'> { const mapping = this.statusCodeMappings.get(check.metric.id)!; const code = mapping.statusToCode.get(status); if (code !== undefined) { - results.set(`catalogMetadata.${check.metric.id}`, code); + results.set(`catalog.${check.metric.id}`, code); } } @@ -293,15 +297,18 @@ export class CatalogMetadataMetricProvider implements MetricProvider<'number'> { } /** - * Creates a CatalogMetadataMetricProvider from root Backstage config. + * Creates a CatalogRequiredAttributesMetricProvider from root Backstage config. * Returns undefined if no checks are configured. */ -export function createCatalogMetadataMetricProvider( +export function createCatalogRequiredAttributesMetricProvider( config: Config, -): CatalogMetadataMetricProvider | undefined { - const catalogMetadataConfig = parseCatalogMetadataConfig(config); - if (!catalogMetadataConfig) { +): CatalogRequiredAttributesMetricProvider | undefined { + const catalogRequiredAttributesConfig = + parseCatalogRequiredAttributesConfig(config); + if (!catalogRequiredAttributesConfig) { return undefined; } - return new CatalogMetadataMetricProvider(catalogMetadataConfig); + return new CatalogRequiredAttributesMetricProvider( + catalogRequiredAttributesConfig, + ); } diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/module.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/module.ts similarity index 79% rename from workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/module.ts rename to workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/module.ts index d9048c7c027..42df5b0ee17 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-catalog-metadata/src/module.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/module.ts @@ -18,11 +18,11 @@ import { createBackendModule, } from '@backstage/backend-plugin-api'; import { scorecardMetricsExtensionPoint } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; -import { createCatalogMetadataMetricProvider } from './metricProviders/CatalogMetadataMetricProvider'; +import { createCatalogRequiredAttributesMetricProvider } from './metricProviders/CatalogRequiredAttributesMetricProvider'; -export const scorecardModuleCatalogMetadata = createBackendModule({ +export const scorecardModuleCatalog = createBackendModule({ pluginId: 'scorecard', - moduleId: 'catalog-metadata', + moduleId: 'catalog', register(reg) { reg.registerInit({ deps: { @@ -30,7 +30,7 @@ export const scorecardModuleCatalogMetadata = createBackendModule({ metrics: scorecardMetricsExtensionPoint, }, async init({ config, metrics }) { - const provider = createCatalogMetadataMetricProvider(config); + const provider = createCatalogRequiredAttributesMetricProvider(config); if (provider) { metrics.addMetricProvider(provider); } From cb86a43242ba2a99ec497ab31b41a4f31bb7233d Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 06:47:46 +0000 Subject: [PATCH 6/7] fix: regenerate yarn.lock after rebase onto main Rebased onto main to incorporate the code-coverage module merge (PR #3476) and other recent changes. Resolved conflicts in yarn.lock (regenerated via yarn install) and all-scorecards-location.yaml (kept both code-coverage and catalog example entries). Addresses rebase request on #4258 --- workspaces/scorecard/yarn.lock | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/workspaces/scorecard/yarn.lock b/workspaces/scorecard/yarn.lock index 107b9a76976..28a734655f0 100644 --- a/workspaces/scorecard/yarn.lock +++ b/workspaces/scorecard/yarn.lock @@ -9605,6 +9605,20 @@ __metadata: languageName: node linkType: hard +"@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog@workspace:plugins/scorecard-backend-module-catalog": + version: 0.0.0-use.local + resolution: "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-catalog@workspace:plugins/scorecard-backend-module-catalog" + dependencies: + "@backstage/backend-plugin-api": "npm:^1.9.2" + "@backstage/backend-test-utils": "npm:^1.11.4" + "@backstage/catalog-model": "npm:^1.9.0" + "@backstage/cli": "npm:^0.36.3" + "@backstage/config": "npm:^1.3.8" + "@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^" + "@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^" + languageName: unknown + linkType: soft + "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-code-coverage@workspace:^, @red-hat-developer-hub/backstage-plugin-scorecard-backend-module-code-coverage@workspace:plugins/scorecard-backend-module-code-coverage": version: 0.0.0-use.local resolution: "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-code-coverage@workspace:plugins/scorecard-backend-module-code-coverage" From 405a8f668d2de3d83de4edd10a000c43fcc89add Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 08:09:00 +0000 Subject: [PATCH 7/7] fix: use object-based metrics config instead of array-based checks Change the configuration structure for catalog required attributes: - Rename `options.checks` (array) to `options.metrics` (object) - Metric IDs are now object keys instead of nested `metric.id` fields - Flatten metric metadata (title, description) to top level of each entry - Update config.d.ts schema, parsing logic, provider, tests, and docs Addresses review feedback on #4258 --- workspaces/scorecard/app-config.yaml | 9 +- .../README.md | 126 ++++++------ .../config.d.ts | 57 +++--- .../CatalogRequiredAttributesConfig.ts | 69 ++++--- ...ogRequiredAttributesMetricProvider.test.ts | 187 +++++++++--------- ...CatalogRequiredAttributesMetricProvider.ts | 47 ++--- 6 files changed, 242 insertions(+), 253 deletions(-) diff --git a/workspaces/scorecard/app-config.yaml b/workspaces/scorecard/app-config.yaml index 82a17c5bb1e..91f409f1f7d 100644 --- a/workspaces/scorecard/app-config.yaml +++ b/workspaces/scorecard/app-config.yaml @@ -383,11 +383,10 @@ scorecard: catalog: requiredAttributes: options: - checks: - - metric: - id: title - title: Title is required - description: Every component should have a human-readable title. + metrics: + title: + title: Title is required + description: Every component should have a human-readable title. filter: kind: Component field: metadata.title diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog/README.md b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/README.md index 22c31102ab4..5949fc65487 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-catalog/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/README.md @@ -1,12 +1,12 @@ # Scorecard Backend Module for Catalog -This is an extension module to the `backstage-plugin-scorecard-backend` plugin. It provides configurable catalog entity checks, evaluating entity fields (e.g., `metadata.title`, `spec.lifecycle`) against configurable rules and mapping field states to status strings via a three-tier status mapping merge (check-level > options-level > hardcoded defaults). +This is an extension module to the `backstage-plugin-scorecard-backend` plugin. It provides configurable catalog entity metrics, evaluating entity fields (e.g., `metadata.title`, `spec.lifecycle`) against configurable rules and mapping field states to status strings via a three-tier status mapping merge (metric-level > options-level > hardcoded defaults). The module supports: -- **Required attribute checks** — verify that a field exists and is non-empty -- **Value whitelist checks** — verify that a field contains one of a set of accepted values -- **Per-check entity filters** — scope each check to specific entity kinds or types +- **Required attribute metrics** — verify that a field exists and is non-empty +- **Value whitelist metrics** — verify that a field contains one of a set of accepted values +- **Per-metric entity filters** — scope each metric to specific entity kinds or types - **Configurable status mapping** — control what status is reported for each field state (`exists`, `empty`, `emptyString`, `emptyArray`, `missed`) and for specific field values - **Automatic threshold rule generation** — threshold rules are derived from the status mappings, so you don't need to define them manually @@ -47,11 +47,11 @@ backend.start(); ## Configuration -All checks are defined under `scorecard.metricProviders.catalog.requiredAttributes.options.checks` in your `app-config.yaml`. Each check specifies a metric definition, an entity filter, a dotted field path to evaluate, and an optional status mapping override. +All metrics are defined under `scorecard.metricProviders.catalog.requiredAttributes.options.metrics` in your `app-config.yaml`. The `metrics` key is an object where each key is a metric ID and each value specifies an entity filter, a dotted field path to evaluate, and an optional status mapping override. -If no checks are configured, the module has no effect. +If no metrics are configured, the module has no effect. -### Example 1: Required attribute check +### Example 1: Required attribute metric The simplest use case — verify that a field exists and is non-empty. Uses the default status mapping where existing fields map to `found` and missing/empty fields map to `missed`. @@ -62,11 +62,10 @@ scorecard: catalog: requiredAttributes: options: - checks: - - metric: - id: title - title: Title is required - description: Every component should have a human-readable title. + metrics: + title: + title: Title is required + description: Every component should have a human-readable title. filter: kind: Component field: metadata.title @@ -74,7 +73,7 @@ scorecard: This produces a single metric `catalog.title` that reports `found` when the entity has a non-empty `metadata.title`, or `missed` when it is absent, null, or empty. -### Example 2: Value whitelist check +### Example 2: Value whitelist metric Verify that a field contains one of a set of accepted values. Values not in the whitelist are reported with the `exists` status (here overridden to `invalid`). @@ -85,11 +84,10 @@ scorecard: catalog: requiredAttributes: options: - checks: - - metric: - id: lifecycle - title: Lifecycle must be a known value - description: The spec.lifecycle field should be prod, stage, test, or dev. + metrics: + lifecycle: + title: Lifecycle must be a known value + description: The spec.lifecycle field should be prod, stage, test, or dev. filter: kind: Component field: spec.lifecycle @@ -110,9 +108,9 @@ This produces metric `catalog.lifecycle` with three possible statuses: | Value exists but is not in the whitelist | `invalid` | | Field is missing, null, or empty | `missed` | -### Example 3: Multiple checks with different entity kinds +### Example 3: Multiple metrics with different entity kinds -Define multiple checks targeting different entity kinds. The module aggregates kind filters for efficient catalog querying. +Define multiple metrics targeting different entity kinds. The module aggregates kind filters for efficient catalog querying. ```yaml # app-config.yaml @@ -121,27 +119,24 @@ scorecard: catalog: requiredAttributes: options: - checks: - - metric: - id: title - title: Title is required - description: Every component should have a human-readable title. + metrics: + title: + title: Title is required + description: Every component should have a human-readable title. filter: kind: Component field: metadata.title - - metric: - id: owner - title: Owner is required - description: Every component should declare an owner. + owner: + title: Owner is required + description: Every component should declare an owner. filter: kind: Component field: spec.owner - - metric: - id: templateOwner - title: Template owner is required - description: Every template should declare an owner. + templateOwner: + title: Template owner is required + description: Every template should declare an owner. filter: kind: Template field: spec.owner @@ -151,7 +146,7 @@ This produces three metrics: `catalog.title`, `catalog.owner`, and `catalog.temp ### Example 4: Options-level status mapping defaults -Set default status strings for all checks at the options level. Individual checks can still override specific fields. +Set default status strings for all metrics at the options level. Individual metrics can still override specific fields. ```yaml # app-config.yaml @@ -166,20 +161,18 @@ scorecard: emptyString: absent emptyArray: absent missed: absent - checks: - - metric: - id: title - title: Title is required - description: The metadata.title should be defined. + metrics: + title: + title: Title is required + description: The metadata.title should be defined. filter: kind: Component field: metadata.title # Inherits options-level mapping: present/absent - - metric: - id: tags - title: Tags are required - description: Components should have at least one tag. + tags: + title: Tags are required + description: Components should have at least one tag. filter: kind: Component field: metadata.tags @@ -189,7 +182,7 @@ scorecard: # Overrides only emptyArray; other states inherit from options-level ``` -### Example 5: Check with multi-field entity filter +### Example 5: Metric with multi-field entity filter Filter by multiple entity fields. All filter conditions must match (AND logic). Filter values are compared case-insensitively. @@ -200,22 +193,21 @@ scorecard: catalog: requiredAttributes: options: - checks: - - metric: - id: serviceLifecycle - title: Service lifecycle is required - description: Service components should have a lifecycle set. + metrics: + serviceLifecycle: + title: Service lifecycle is required + description: Service components should have a lifecycle set. filter: kind: Component spec.type: service field: spec.lifecycle ``` -This check only runs against entities where `kind` is `Component` **and** `spec.type` is `service`. +This metric only runs against entities where `kind` is `Component` **and** `spec.type` is `service`. ### Example 6: Full configuration with schedule and per-metric thresholds -A comprehensive example combining schedule configuration, options-level defaults, multiple checks, and per-metric threshold overrides. +A comprehensive example combining schedule configuration, options-level defaults, multiple metrics, and per-metric threshold overrides. ```yaml # app-config.yaml @@ -235,19 +227,17 @@ scorecard: statusMapping: exists: found missed: missed - checks: - - metric: - id: title - title: Title is required - description: Every component should have a human-readable title. + metrics: + title: + title: Title is required + description: Every component should have a human-readable title. filter: kind: Component field: metadata.title - - metric: - id: lifecycle - title: Lifecycle must be valid - description: The spec.lifecycle field should be one of the accepted values. + lifecycle: + title: Lifecycle must be valid + description: The spec.lifecycle field should be one of the accepted values. filter: kind: Component field: spec.lifecycle @@ -290,7 +280,7 @@ Fields are resolved using dotted path notation on the entity object. For example ### Status Evaluation -Each field value is evaluated against the check's status mapping to produce a status string: +Each field value is evaluated against the metric's status mapping to produce a status string: | Field state | Status mapping key | Default status | | --------------------------------------------------- | ------------------ | -------------- | @@ -305,17 +295,17 @@ Each field value is evaluated against the check's status mapping to produce a st Status mappings are resolved with the following priority: -1. **Check-level** (`checks[].statusMapping`) — highest priority +1. **Metric-level** (`metrics..statusMapping`) — highest priority 2. **Options-level** (`options.statusMapping`) — middle priority 3. **Hardcoded defaults** — lowest priority (see table above) -Each field in the status mapping is resolved independently, so a check can override just `exists` while inheriting the options-level `missed` value. +Each field in the status mapping is resolved independently, so a metric can override just `exists` while inheriting the options-level `missed` value. -The `values` maps are deep-merged: hardcoded defaults (empty), then options-level values, then check-level values. A check-level entry for the same key wins over the options-level entry. +The `values` maps are deep-merged: hardcoded defaults (empty), then options-level values, then metric-level values. A metric-level entry for the same key wins over the options-level entry. ### Automatic Threshold Generation -The module automatically generates threshold rules from each check's resolved status mapping. Each distinct status string becomes a threshold rule with a numeric code. Well-known status strings get default colors and icons: +The module automatically generates threshold rules from each metric's resolved status mapping. Each distinct status string becomes a threshold rule with a numeric code. Well-known status strings get default colors and icons: | Status string | Color | Icon | | -------------------------------------- | ---------------- | ---------------------------- | @@ -328,11 +318,11 @@ You can override the auto-generated thresholds using per-metric threshold config ## Available Metrics -### Catalog check (`catalog.`) +### Catalog metric (`catalog.`) -Each configured check produces one numeric metric. +Each configured metric produces one numeric metric. -- **Metric ID**: `catalog.` (where `` is the `metric.id` from the check config) +- **Metric ID**: `catalog.` (where `` is the key from the `metrics` object) - **Provider ID**: `catalog.requiredAttributes` - **Type**: Number (numeric code mapped to a status string via threshold rules) - **Datasource**: `catalog` diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog/config.d.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/config.d.ts index d1368b63606..3d0bc5caa5d 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-catalog/config.d.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/config.d.ts @@ -31,42 +31,39 @@ export interface Config { thresholds?: ThresholdConfig; /** Provider-specific options */ options?: { - /** List of attribute checks to run */ - checks?: Array<{ - /** Metric definition */ - metric: { - /** Metric identifier suffix (used as catalog.) */ - id: string; + /** Metrics to evaluate — keys are metric IDs (used as catalog.) */ + metrics?: { + [metricId: string]: { /** Human-readable title */ title: string; /** Human-readable description */ description: string; - }; - /** Entity filter — keys are dotted field paths, values are expected values */ - filter: { - [fieldPath: string]: string; - }; - /** Dotted field path to check on the entity (e.g. metadata.title, spec.lifecycle) */ - field: string; - /** Per-check status mapping overrides */ - statusMapping?: { - /** Status when field exists with a non-empty value not matched by values */ - exists?: string; - /** Status when field resolves to null or undefined */ - empty?: string; - /** Status when field resolves to an empty string */ - emptyString?: string; - /** Status when field resolves to an empty array */ - emptyArray?: string; - /** Status when the field path does not resolve */ - missed?: string; - /** Status per specific field value */ - values?: { - [value: string]: string; + /** Entity filter — keys are dotted field paths, values are expected values */ + filter: { + [fieldPath: string]: string; + }; + /** Dotted field path to check on the entity (e.g. metadata.title, spec.lifecycle) */ + field: string; + /** Per-metric status mapping overrides */ + statusMapping?: { + /** Status when field exists with a non-empty value not matched by values */ + exists?: string; + /** Status when field resolves to null or undefined */ + empty?: string; + /** Status when field resolves to an empty string */ + emptyString?: string; + /** Status when field resolves to an empty array */ + emptyArray?: string; + /** Status when the field path does not resolve */ + missed?: string; + /** Status per specific field value */ + values?: { + [value: string]: string; + }; }; }; - }>; - /** Options-level status mapping defaults for all checks */ + }; + /** Options-level status mapping defaults for all metrics */ statusMapping?: { /** Status when field exists with a non-empty value not matched by values */ exists?: string; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesConfig.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesConfig.ts index c1e5eb653a0..87985643d52 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesConfig.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesConfig.ts @@ -35,14 +35,12 @@ export type StatusMapping = { }; /** - * A single check configuration parsed from app-config.yaml. + * A single metric configuration parsed from app-config.yaml. */ -export type CheckConfig = { - metric: { - id: string; - title: string; - description: string; - }; +export type MetricConfig = { + id: string; + title: string; + description: string; filter: Record; field: string; statusMapping: StatusMapping; @@ -52,7 +50,7 @@ export type CheckConfig = { * Parsed configuration for the catalog required attributes metric provider. */ export type CatalogRequiredAttributesConfig = { - checks: CheckConfig[]; + metrics: MetricConfig[]; }; /** Hardcoded default status mapping as described in the issue. */ @@ -156,7 +154,7 @@ function readStatusMapping(config: Config): Partial | undefined { /** * Parses the catalog required attributes configuration from the root Backstage config. - * Returns undefined if no checks are configured. + * Returns undefined if no metrics are configured. */ export function parseCatalogRequiredAttributesConfig( config: Config, @@ -169,9 +167,13 @@ export function parseCatalogRequiredAttributesConfig( return undefined; } - const checksConfigArray = - optionsConfig.getOptionalConfigArray('checks') ?? []; - if (checksConfigArray.length === 0) { + const metricsConfig = optionsConfig.getOptionalConfig('metrics'); + if (!metricsConfig) { + return undefined; + } + + const metricKeys = metricsConfig.keys(); + if (metricKeys.length === 0) { return undefined; } @@ -182,47 +184,44 @@ export function parseCatalogRequiredAttributesConfig( ? readStatusMapping(optionsStatusMappingConfig) : undefined; - const checks: CheckConfig[] = checksConfigArray.map((checkConfig, index) => { - // Read metric - const metricConfig = checkConfig.getConfig('metric'); - const metric = { - id: metricConfig.getString('id'), - title: metricConfig.getString('title'), - description: metricConfig.getString('description'), - }; - - if (!metric.id) { - throw new Error(`Check at index ${index} has an empty metric id`); + const metrics: MetricConfig[] = metricKeys.map(metricId => { + const metricConfig = metricsConfig.getConfig(metricId); + + if (!metricId) { + throw new Error(`Metric has an empty id (object key)`); } + const title = metricConfig.getString('title'); + const description = metricConfig.getString('description'); + // Read filter - const filterConfig = checkConfig.getConfig('filter'); + const filterConfig = metricConfig.getConfig('filter'); const filter: Record = {}; for (const key of filterConfig.keys()) { filter[key] = filterConfig.getString(key); } // Read field - const field = checkConfig.getString('field'); + const field = metricConfig.getString('field'); if (!field) { - throw new Error(`Check '${metric.id}' has an empty field path`); + throw new Error(`Metric '${metricId}' has an empty field path`); } - // Read check-level status mapping - const checkStatusMappingConfig = - checkConfig.getOptionalConfig('statusMapping'); - const checkStatusMapping = checkStatusMappingConfig - ? readStatusMapping(checkStatusMappingConfig) + // Read metric-level status mapping + const metricStatusMappingConfig = + metricConfig.getOptionalConfig('statusMapping'); + const metricStatusMapping = metricStatusMappingConfig + ? readStatusMapping(metricStatusMappingConfig) : undefined; - // Merge status mappings: check > options > defaults + // Merge status mappings: metric > options > defaults const statusMapping = mergeStatusMappings( - checkStatusMapping, + metricStatusMapping, optionsStatusMapping, ); - return { metric, filter, field, statusMapping }; + return { id: metricId, title, description, filter, field, statusMapping }; }); - return { checks }; + return { metrics }; } diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.test.ts index 4657f86d5ba..080eb63bb43 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.test.ts @@ -29,14 +29,17 @@ import { // ── helpers ──────────────────────────────────────────────────────────── -function buildConfig(checks: object[], optionsStatusMapping?: object) { +function buildConfig( + metrics: Record, + optionsStatusMapping?: object, +) { return { scorecard: { metricProviders: { catalog: { requiredAttributes: { options: { - checks, + metrics, ...(optionsStatusMapping ? { statusMapping: optionsStatusMapping } : {}), @@ -48,27 +51,21 @@ function buildConfig(checks: object[], optionsStatusMapping?: object) { }; } -function titleCheck(overrides?: object) { +function titleMetric(overrides?: object) { return { - metric: { - id: 'title', - title: 'Title is required', - description: 'The metadata.title should be defined.', - }, + title: 'Title is required', + description: 'The metadata.title should be defined.', filter: { kind: 'Component' }, field: 'metadata.title', ...overrides, }; } -function lifecycleCheck(overrides?: object) { +function lifecycleMetric(overrides?: object) { return { - metric: { - id: 'lifecycle', - title: 'lifecycle should be prod, stage, test or dev', - description: - 'The spec.lifecycle field should be one of four accepted values.', - }, + title: 'lifecycle should be prod, stage, test or dev', + description: + 'The spec.lifecycle field should be one of four accepted values.', filter: { kind: 'Component' }, field: 'spec.lifecycle', statusMapping: { @@ -388,24 +385,27 @@ describe('createCatalogRequiredAttributesMetricProvider', () => { expect(provider).toBeUndefined(); }); - it('should return undefined when checks array is empty', () => { + it('should return undefined when metrics object is empty', () => { const provider = createCatalogRequiredAttributesMetricProvider( - new ConfigReader(buildConfig([])), + new ConfigReader(buildConfig({})), ); expect(provider).toBeUndefined(); }); - it('should create provider with a single check', () => { - const config = new ConfigReader(buildConfig([titleCheck()])); + it('should create provider with a single metric', () => { + const config = new ConfigReader(buildConfig({ title: titleMetric() })); const provider = createCatalogRequiredAttributesMetricProvider(config); expect(provider).toBeDefined(); expect(provider?.getMetrics().map(m => m.id)).toEqual(['catalog.title']); }); - it('should create provider with multiple checks', () => { + it('should create provider with multiple metrics', () => { const config = new ConfigReader( - buildConfig([titleCheck(), lifecycleCheck()]), + buildConfig({ + title: titleMetric(), + lifecycle: lifecycleMetric(), + }), ); const provider = createCatalogRequiredAttributesMetricProvider(config); @@ -422,7 +422,7 @@ describe('createCatalogRequiredAttributesMetricProvider', () => { describe('CatalogRequiredAttributesMetricProvider', () => { describe('provider identification', () => { const provider = createCatalogRequiredAttributesMetricProvider( - new ConfigReader(buildConfig([titleCheck()])), + new ConfigReader(buildConfig({ title: titleMetric() })), ); it('should return correct provider ID', () => { @@ -437,7 +437,7 @@ describe('CatalogRequiredAttributesMetricProvider', () => { describe('getMetrics', () => { it('should return metrics with correct type', () => { const provider = createCatalogRequiredAttributesMetricProvider( - new ConfigReader(buildConfig([titleCheck()])), + new ConfigReader(buildConfig({ title: titleMetric() })), ); const metrics = provider?.getMetrics(); @@ -449,7 +449,7 @@ describe('CatalogRequiredAttributesMetricProvider', () => { it('should generate threshold rules from status mapping', () => { const provider = createCatalogRequiredAttributesMetricProvider( - new ConfigReader(buildConfig([titleCheck()])), + new ConfigReader(buildConfig({ title: titleMetric() })), ); const metrics = provider?.getMetrics(); @@ -463,7 +463,7 @@ describe('CatalogRequiredAttributesMetricProvider', () => { it('should generate threshold rules for value-specific mapping', () => { const provider = createCatalogRequiredAttributesMetricProvider( - new ConfigReader(buildConfig([lifecycleCheck()])), + new ConfigReader(buildConfig({ lifecycle: lifecycleMetric() })), ); const metrics = provider?.getMetrics(); @@ -476,7 +476,7 @@ describe('CatalogRequiredAttributesMetricProvider', () => { it('should include metric metadata', () => { const provider = createCatalogRequiredAttributesMetricProvider( - new ConfigReader(buildConfig([titleCheck()])), + new ConfigReader(buildConfig({ title: titleMetric() })), ); const metric = provider?.getMetrics()[0]; @@ -487,13 +487,13 @@ describe('CatalogRequiredAttributesMetricProvider', () => { }); describe('getCatalogFilter', () => { - it('should return kind filter when all checks share the same kind', () => { + it('should return kind filter when all metrics share the same kind', () => { const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader( - buildConfig([ - titleCheck({ filter: { kind: 'Component' } }), - lifecycleCheck({ filter: { kind: 'Component' } }), - ]), + buildConfig({ + title: titleMetric({ filter: { kind: 'Component' } }), + lifecycle: lifecycleMetric({ filter: { kind: 'Component' } }), + }), ), ); expect(provider?.getCatalogFilter()).toEqual({ @@ -504,18 +504,15 @@ describe('CatalogRequiredAttributesMetricProvider', () => { it('should return multi-kind filter for different kinds', () => { const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader( - buildConfig([ - titleCheck({ filter: { kind: 'Component' } }), - titleCheck({ - metric: { - id: 'templateOwner', - title: 'Owner', - description: 'desc', - }, + buildConfig({ + title: titleMetric({ filter: { kind: 'Component' } }), + templateOwner: titleMetric({ + title: 'Owner', + description: 'desc', filter: { kind: 'Template' }, field: 'spec.owner', }), - ]), + }), ), ); const catalogFilter = provider?.getCatalogFilter(); @@ -527,17 +524,18 @@ describe('CatalogRequiredAttributesMetricProvider', () => { ).toEqual(['component', 'template']); }); - it('should return empty filter when any check has no kind filter', () => { + it('should return empty filter when any metric has no kind filter', () => { const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader( - buildConfig([ - titleCheck({ filter: { kind: 'Component' } }), - titleCheck({ - metric: { id: 'allTitle', title: 'T', description: 'D' }, + buildConfig({ + title: titleMetric({ filter: { kind: 'Component' } }), + allTitle: titleMetric({ + title: 'T', + description: 'D', filter: {}, field: 'metadata.title', }), - ]), + }), ), ); expect(provider?.getCatalogFilter()).toEqual({}); @@ -547,16 +545,14 @@ describe('CatalogRequiredAttributesMetricProvider', () => { describe('calculateMetrics', () => { it('should return "found" status code for existing field', async () => { const provider = createCatalogRequiredAttributesMetricProvider( - new ConfigReader(buildConfig([titleCheck()])), + new ConfigReader(buildConfig({ title: titleMetric() })), ); const result = await provider?.calculateMetrics(componentEntity); // The metric value is a numeric code mapping to "found" const metrics = provider?.getMetrics(); - const titleMetric = metrics?.find(m => m.id === 'catalog.title'); - const foundRule = titleMetric?.thresholds.rules.find( - r => r.key === 'found', - ); + const titleMet = metrics?.find(m => m.id === 'catalog.title'); + const foundRule = titleMet?.thresholds.rules.find(r => r.key === 'found'); const expectedCode = Number(foundRule?.expression.replace('==', '')); expect(result?.get('catalog.title')).toBe(expectedCode); @@ -570,13 +566,13 @@ describe('CatalogRequiredAttributesMetricProvider', () => { spec: { type: 'service', lifecycle: 'prod', owner: 'team-a' }, }; const provider = createCatalogRequiredAttributesMetricProvider( - new ConfigReader(buildConfig([titleCheck()])), + new ConfigReader(buildConfig({ title: titleMetric() })), ); const result = await provider?.calculateMetrics(entityWithoutTitle); const metrics = provider?.getMetrics(); - const titleMetric = metrics?.find(m => m.id === 'catalog.title'); - const missedRule = titleMetric?.thresholds.rules.find( + const titleMet = metrics?.find(m => m.id === 'catalog.title'); + const missedRule = titleMet?.thresholds.rules.find( r => r.key === 'missed', ); const expectedCode = Number(missedRule?.expression.replace('==', '')); @@ -584,10 +580,12 @@ describe('CatalogRequiredAttributesMetricProvider', () => { expect(result?.get('catalog.title')).toBe(expectedCode); }); - it('should skip checks for non-matching entities', async () => { + it('should skip metrics for non-matching entities', async () => { const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader( - buildConfig([titleCheck({ filter: { kind: 'Component' } })]), + buildConfig({ + title: titleMetric({ filter: { kind: 'Component' } }), + }), ), ); const result = await provider?.calculateMetrics(templateEntity); @@ -597,7 +595,7 @@ describe('CatalogRequiredAttributesMetricProvider', () => { it('should return "ok" for valid lifecycle value', async () => { const provider = createCatalogRequiredAttributesMetricProvider( - new ConfigReader(buildConfig([lifecycleCheck()])), + new ConfigReader(buildConfig({ lifecycle: lifecycleMetric() })), ); const result = await provider?.calculateMetrics(componentEntity); @@ -615,7 +613,7 @@ describe('CatalogRequiredAttributesMetricProvider', () => { spec: { ...componentEntity.spec, lifecycle: 'experimental' }, }; const provider = createCatalogRequiredAttributesMetricProvider( - new ConfigReader(buildConfig([lifecycleCheck()])), + new ConfigReader(buildConfig({ lifecycle: lifecycleMetric() })), ); const result = await provider?.calculateMetrics(entity); @@ -635,7 +633,7 @@ describe('CatalogRequiredAttributesMetricProvider', () => { spec: { type: 'service', owner: 'team-a' }, }; const provider = createCatalogRequiredAttributesMetricProvider( - new ConfigReader(buildConfig([lifecycleCheck()])), + new ConfigReader(buildConfig({ lifecycle: lifecycleMetric() })), ); const result = await provider?.calculateMetrics(entity); @@ -655,14 +653,14 @@ describe('CatalogRequiredAttributesMetricProvider', () => { metadata: { ...componentEntity.metadata, title: '' }, }; const provider = createCatalogRequiredAttributesMetricProvider( - new ConfigReader(buildConfig([titleCheck()])), + new ConfigReader(buildConfig({ title: titleMetric() })), ); const result = await provider?.calculateMetrics(entity); // Default mapping: emptyString → 'missed' const metrics = provider?.getMetrics(); - const titleMetric = metrics?.find(m => m.id === 'catalog.title'); - const missedRule = titleMetric?.thresholds.rules.find( + const titleMet = metrics?.find(m => m.id === 'catalog.title'); + const missedRule = titleMet?.thresholds.rules.find( r => r.key === 'missed', ); const expectedCode = Number(missedRule?.expression.replace('==', '')); @@ -677,16 +675,13 @@ describe('CatalogRequiredAttributesMetricProvider', () => { }; const provider = createCatalogRequiredAttributesMetricProvider( new ConfigReader( - buildConfig([ - titleCheck({ - metric: { - id: 'tags', - title: 'Tags', - description: 'Tags should exist', - }, + buildConfig({ + tags: titleMetric({ + title: 'Tags', + description: 'Tags should exist', field: 'metadata.tags', }), - ]), + }), ), ); const result = await provider?.calculateMetrics(entity); @@ -701,9 +696,14 @@ describe('CatalogRequiredAttributesMetricProvider', () => { expect(result?.get('catalog.tags')).toBe(expectedCode); }); - it('should handle multiple checks on the same entity', async () => { + it('should handle multiple metrics on the same entity', async () => { const provider = createCatalogRequiredAttributesMetricProvider( - new ConfigReader(buildConfig([titleCheck(), lifecycleCheck()])), + new ConfigReader( + buildConfig({ + title: titleMetric(), + lifecycle: lifecycleMetric(), + }), + ), ); const result = await provider?.calculateMetrics(componentEntity); @@ -711,35 +711,38 @@ describe('CatalogRequiredAttributesMetricProvider', () => { expect(result?.has('catalog.lifecycle')).toBe(true); }); - it('should apply options-level status mapping to all checks', async () => { + it('should apply options-level status mapping to all metrics', async () => { const config = new ConfigReader( - buildConfig([titleCheck()], { - exists: 'present', - missed: 'absent', - emptyString: 'absent', - emptyArray: 'absent', - empty: 'absent', - }), + buildConfig( + { title: titleMetric() }, + { + exists: 'present', + missed: 'absent', + emptyString: 'absent', + emptyArray: 'absent', + empty: 'absent', + }, + ), ); const provider = createCatalogRequiredAttributesMetricProvider(config); const metrics = provider?.getMetrics(); - const titleMetric = metrics?.find(m => m.id === 'catalog.title'); - const keys = titleMetric?.thresholds.rules.map(r => r.key); + const titleMet = metrics?.find(m => m.id === 'catalog.title'); + const keys = titleMet?.thresholds.rules.map(r => r.key); expect(keys).toContain('present'); expect(keys).toContain('absent'); }); - it('should override options-level mapping with check-level mapping', async () => { + it('should override options-level mapping with metric-level mapping', async () => { const config = new ConfigReader( buildConfig( - [ - titleCheck({ + { + title: titleMetric({ statusMapping: { - exists: 'check-present', + exists: 'metric-present', }, }), - ], + }, { exists: 'options-present', }, @@ -748,15 +751,15 @@ describe('CatalogRequiredAttributesMetricProvider', () => { const provider = createCatalogRequiredAttributesMetricProvider(config); const metrics = provider?.getMetrics(); - const titleMetric = metrics?.find(m => m.id === 'catalog.title'); - const keys = titleMetric?.thresholds.rules.map(r => r.key); - expect(keys).toContain('check-present'); + const titleMet = metrics?.find(m => m.id === 'catalog.title'); + const keys = titleMet?.thresholds.rules.map(r => r.key); + expect(keys).toContain('metric-present'); expect(keys).not.toContain('options-present'); }); - it('should apply check filter with empty filter matching all entities', async () => { + it('should apply metric filter with empty filter matching all entities', async () => { const provider = createCatalogRequiredAttributesMetricProvider( - new ConfigReader(buildConfig([titleCheck({ filter: {} })])), + new ConfigReader(buildConfig({ title: titleMetric({ filter: {} }) })), ); const componentResult = await provider?.calculateMetrics(componentEntity); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.ts index 9d846445c47..6aa8b84ecb0 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-catalog/src/metricProviders/CatalogRequiredAttributesMetricProvider.ts @@ -24,6 +24,7 @@ import { import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; import { type CatalogRequiredAttributesConfig, + type MetricConfig, type StatusMapping, parseCatalogRequiredAttributesConfig, } from './CatalogRequiredAttributesConfig'; @@ -203,7 +204,7 @@ function getDefaultIcon(status: string): string { export class CatalogRequiredAttributesMetricProvider implements MetricProvider<'number'> { - private readonly catalogRequiredAttributesConfig: CatalogRequiredAttributesConfig; + private readonly metricConfigs: MetricConfig[]; private readonly statusCodeMappings: Map< string, { statusToCode: Map; thresholds: ThresholdConfig } @@ -212,12 +213,12 @@ export class CatalogRequiredAttributesMetricProvider constructor( catalogRequiredAttributesConfig: CatalogRequiredAttributesConfig, ) { - this.catalogRequiredAttributesConfig = catalogRequiredAttributesConfig; + this.metricConfigs = catalogRequiredAttributesConfig.metrics; this.statusCodeMappings = new Map(); - for (const check of catalogRequiredAttributesConfig.checks) { + for (const metric of this.metricConfigs) { this.statusCodeMappings.set( - check.metric.id, - buildStatusCodeMapping(check.statusMapping), + metric.id, + buildStatusCodeMapping(metric.statusMapping), ); } } @@ -231,12 +232,12 @@ export class CatalogRequiredAttributesMetricProvider } getMetrics(): Metric<'number'>[] { - return this.catalogRequiredAttributesConfig.checks.map(check => { - const mapping = this.statusCodeMappings.get(check.metric.id)!; + return this.metricConfigs.map(metric => { + const mapping = this.statusCodeMappings.get(metric.id)!; return { - id: `catalog.${check.metric.id}`, - title: check.metric.title, - description: check.metric.description, + id: `catalog.${metric.id}`, + title: metric.title, + description: metric.description, type: 'number' as const, thresholds: mapping.thresholds, }; @@ -244,14 +245,14 @@ export class CatalogRequiredAttributesMetricProvider } getCatalogFilter(): Record { - // Aggregate kind filters from all checks for efficient catalog querying. - // If any check has no filter or does not filter by kind, return an + // Aggregate kind filters from all metrics for efficient catalog querying. + // If any metric has no filter or does not filter by kind, return an // empty filter (all entities). const kinds = new Set(); let allHaveKind = true; - for (const check of this.catalogRequiredAttributesConfig.checks) { - const kindValue = check.filter.kind; + for (const metric of this.metricConfigs) { + const kindValue = metric.filter.kind; if (kindValue) { kinds.add(kindValue.toLowerCase()); } else { @@ -266,29 +267,29 @@ export class CatalogRequiredAttributesMetricProvider return { kind: [...kinds] }; } - // If not all checks filter by kind, return empty filter (all entities) + // If not all metrics filter by kind, return empty filter (all entities) return {}; } async calculateMetrics(entity: Entity): Promise> { const results = new Map(); - for (const check of this.catalogRequiredAttributesConfig.checks) { - // Apply per-check filter - if (!entityMatchesFilter(entity, check.filter)) { + for (const metric of this.metricConfigs) { + // Apply per-metric filter + if (!entityMatchesFilter(entity, metric.filter)) { continue; } const status = evaluateFieldStatus( entity, - check.field, - check.statusMapping, + metric.field, + metric.statusMapping, ); - const mapping = this.statusCodeMappings.get(check.metric.id)!; + const mapping = this.statusCodeMappings.get(metric.id)!; const code = mapping.statusToCode.get(status); if (code !== undefined) { - results.set(`catalog.${check.metric.id}`, code); + results.set(`catalog.${metric.id}`, code); } } @@ -298,7 +299,7 @@ export class CatalogRequiredAttributesMetricProvider /** * Creates a CatalogRequiredAttributesMetricProvider from root Backstage config. - * Returns undefined if no checks are configured. + * Returns undefined if no metrics are configured. */ export function createCatalogRequiredAttributesMetricProvider( config: Config,