Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions workspaces/scorecard/.changeset/old-kings-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-sonarqube': patch
'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira': patch
'@red-hat-developer-hub/backstage-plugin-scorecard-backend': patch
'@red-hat-developer-hub/backstage-plugin-scorecard-node': patch
'@red-hat-developer-hub/backstage-plugin-scorecard': patch
---

Implemented threshold interval validation for Scorecard. joint coverage on the real line, gap detection and error messages, overlaps versus rule order.
2 changes: 1 addition & 1 deletion workspaces/scorecard/app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ scorecard:
expression: '>=80'
color: '#6bb300' # green
- key: warning
expression: '30-79'
expression: '30-80'
color: 'rgb(224, 189, 108)' # light orange
- key: error
expression: '<30'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ export const openPrsWeightedAggregatedResponse = {
total: 10,
timestamp: '2026-01-24T14:10:32.858Z',
thresholds: DEFAULT_NUMBER_THRESHOLDS,
averageScore: 0.5,
averageScore: 50,
averageWeightedSum: 500,
averageMaxPossible: 1000,
aggregationChartDisplayColor: 'rgb(224, 189, 108)',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ export function newEntityComponent(
export function newThresholdsConfig(): ThresholdConfig {
return {
rules: [
{ key: 'success', expression: '<3' },
{ key: 'warning', expression: '11-32' },
{ key: 'error', expression: '>33' },
{ key: 'success', expression: '<5' },
{ key: 'warning', expression: '5-25' },
{ key: 'error', expression: '>25' },
],
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ describe('SonarQubeNumberMetricProvider', () => {

it('should return custom thresholds when provided', () => {
const custom: ThresholdConfig = {
rules: [{ key: 'ok', expression: '<5', color: '#00ff00', icon: 'ok' }],
rules: [
{ key: 'ok', expression: '<5', color: '#00ff00', icon: 'ok' },
{ key: 'rest', expression: '>=5', color: '#ff0000', icon: 'bad' },
],
};
const mockConfiWithCustomThresholds = new ConfigReader({
scorecard: {
Expand Down
6 changes: 3 additions & 3 deletions workspaces/scorecard/plugins/scorecard-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,9 @@ Thresholds define conditions to assign metric values to specific visual categori
- **App Configuration**: Override defaults through `app-config.yaml`
- **Entity Annotations**: Override specific thresholds per entity using catalog annotations

Thresholds are evaluated in order, and the first matching rule determines the category. The plugin supports various operators for number metrics (`>`, `>=`, `<`, `<=`, `==`, `!=`, `-` (range)) and boolean metrics (`==`, `!=`).
Thresholds are evaluated in order, and the first matching rule determines the category. The plugin supports various operators for number metrics (`>`, `>=`, `<`, `<=`, `==`, `!=`, `-` (range)) and boolean metrics (`==`, `!=`). For **number** metrics, configurations loaded through validated paths must cover the **entire real line** when two or more rules are defined (no gaps between intervals); **`average`** KPI **`options.thresholds`** follow the same rule.

For comprehensive threshold configuration guide, examples, best practices, and **aggregation KPI result thresholds** for **`type: average`**, see [thresholds.md](./docs/thresholds.md).
For comprehensive threshold configuration guide, examples, best practices, interval validation, and **aggregation KPI result thresholds** for **`type: average`**, see [thresholds.md](./docs/thresholds.md).

## Aggregation KPIs (homepage and `GET /aggregations`)

Expand Down Expand Up @@ -154,7 +154,7 @@ scorecard:
expression: '>=75'
color: success.main
- key: warning
expression: '10-74'
expression: '10-75'
color: warning.main
- key: error
expression: '<10'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ For **`average`**:

**`scorecard.aggregationKPIs`** is validated when the backend plugin starts. Invalid entries (unknown **`type`**, missing **`options`** for **`average`**, empty **`statusScores`**, unknown **`metricId`**, invalid threshold expressions, etc.) cause startup to **fail with an error** so misconfiguration is caught early. Fix app-config and redeploy.

For **`type: average`**, optional **`options.thresholds`** must satisfy the same **number interval / gap** rules as metric thresholds when multiple rules apply (union must cover the full real line with no gaps). Errors mention an approximate **first uncovered region**. See [Joint coverage (number metrics)](./thresholds.md#joint-coverage-number-metrics).

Schema reference for config discovery (IDE / `backstage-cli config:schema`): see **`config.d.ts`** on the backend package (`aggregationKPIs` and nested **`options`**).

## API Endpoint
Expand Down
63 changes: 51 additions & 12 deletions workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,42 @@ Thresholds are evaluated in order and the **first matching** threshold rule is a
- **`color`** (optional): The color to display for this threshold in the UI (see [Threshold Colors](#threshold-colors))
- **`icon`** (optional): The icon to display for this threshold in the UI (see [Threshold Icons](#threshold-icons))

## Joint coverage (number metrics)

For **number** metrics with **two or more** rules, validation requires that the **union** of all rule expressions covers the **entire real line** (from negative infinity to positive infinity). This matches how the backend evaluates values: numeric ranges use **inclusive** endpoints. Comparison operators keep their usual strict or non-strict boundaries.

**Overlaps:** Overlapping intervals **do not** fail validation—the check only verifies that no **gap** remains. Rule **order** does not affect coverage. It only affects **which key wins** when more than one rule matches ([first-match semantics](#thresholdevaluator)).

### How validation works

For each rule, the validator parses the expression and maps it to one or more intervals on the real line:

- **Range** syntax `min-max` → closed interval `[min, max]`.
- **Comparisons** (`>`, `>=`, `<`, `<=`) → half-lines or bounded rays.
- **`==`** → a single point.
- **`!=`** → **two** intervals (everything except that point), which can help close gaps near a boundary without adding an extra rule.

All intervals from every rule are merged (overlapping or touching intervals combine). The configuration **passes** only if the merged result is a single interval covering **(-∞, +∞)** with both ends included.

### Gap detection and error messages

If the merged union leaves any real number uncovered, validation throws `ThresholdConfigFormatError` with a message similar to:

`Number threshold rules do not cover the entire real line. First uncovered region (approximately): …`

The reported interval is an **approximate** description of the **first** gap found (for example `(10, 11)`, `(-∞, 10)`, or `(74, 75)`). Adjust boundaries so adjacent rules meet or overlap—for example use **`10-20`** instead of **`11-20`** next to **`'<10'`**, or **`10-75`** with **`>=75`** instead of **`10-74`** with **`>=75`**.

**Gap example:** `success: '<10'`, `warning: '11-20'`, `error: '>20'` leaves **`10`** and **`(10, 11)`** uncovered. Prefer **`'<10'`**, **`'10-20'`**, **`'>20'`**, or widen ranges so neighbors touch (**`51-79`** with **`'<51'`** and **`'>=80'`**).

### When full-line coverage is skipped

Validation **does not** require full coverage when:

- The metric type is not **number**, or
- **`rules`** is empty, or
- There is **at most one** rule (for example validating a single expression in isolation), or
- **Every** rule is a numeric **`==`** expression (discrete metrics such as Sonar ratings **`==1`** … **`==5`**).

## Threshold Configuration Options

### 1. Provider Default Thresholds
Expand Down Expand Up @@ -45,7 +81,8 @@ Duplicated threshold keys are not allowed (throws `ConfigFormatError`).
```typescript
import {
MetricProvider,
validateThresholds,
validateThresholdsForMetric,
getThresholdsFromConfig,
} from '@red-hat-developer-hub/backstage-plugin-scorecard-node';

export class MyMetricProvider implements MetricProvider<'number'> {
Expand All @@ -58,12 +95,12 @@ export class MyMetricProvider implements MetricProvider<'number'> {

static fromConfig(config: Config): MyMetricProvider {
const configPath = 'scorecard.plugins.myDatasource.myMetric.thresholds';
const configuredThresholds = config.getOptional(configPath);

if (configuredThresholds) {
// validate threshold configuration is correct, throws ConfigFormatError if not
validateThresholds(configuredThresholds, 'number');
}
// Validates structure, colors/icons, expressions, and number interval coverage (when applicable)
const configuredThresholds = getThresholdsFromConfig(
config,
configPath,
'number',
);

return new MyMetricProvider(configuredThresholds);
}
Expand All @@ -74,6 +111,8 @@ export class MyMetricProvider implements MetricProvider<'number'> {
}
```

You can also call **`validateThresholdsForMetric(configuredThresholds, 'number')`** directly if you already have a config object (not reading from `Config`).

**Example App Configuration:**

```yaml
Expand Down Expand Up @@ -135,7 +174,7 @@ These thresholds are **not** per-entity metric rules. They apply only to homepag

**Defaults:** If **`thresholds`** is omitted from app-config under **`options`**, it is not injected at config-parse time. **`AverageAggregationStrategy`** applies **`DEFAULT_AVERAGE_KPI_RESULT_THRESHOLDS`** from [`src/constants/aggregationKPIs.ts`](../src/constants/aggregationKPIs.ts) when serving an aggregation: **`<30`** → error, **`30-79`** → warning, **`>=80`** → success (higher percentage = better). When that default path is used, the strategy logs at **info** that the built-in 0–100% scale is in effect.

**Startup validation:** Invalid rules or expressions are caught when the backend plugin loads, together with the rest of **`scorecard.aggregationKPIs`**. See [aggregation.md — Configuration validation](./aggregation.md#configuration-validation).
**Startup validation:** Invalid rules or expressions are caught when the backend plugin loads, together with the rest of **`scorecard.aggregationKPIs`**. Average KPI **`options.thresholds`** must also satisfy **joint full-line coverage** for number expressions (see [Joint coverage (number metrics)](#joint-coverage-number-metrics)), for example ensure ranges and comparison rules meet at boundaries (**`10-75`** with **`>=75`** and **`<10`**, not **`10-74`** with **`>=75`**, which would leave **`(74, 75)`** uncovered). See [aggregation.md — Configuration validation](./aggregation.md#configuration-validation).

**Further reading:** [Entity Aggregation](./aggregation.md) (`average` algorithm, API, drill-down); [Scorecard backend README — Aggregation KPIs](../README.md#aggregation-kpis-homepage-and-get-aggregations) (full **`aggregationKPIs`** example including **`statusScores`**).

Expand Down Expand Up @@ -204,7 +243,7 @@ finalRules: [

### Number Metric

Supports operators: `>, >=, <, <=, ==, !=, -`.
Supports operators: `>, >=, <, <=, ==, !=, -`. The **`!=`** operator matches every value except the given number; in coverage validation it contributes **two** intervals, which can help eliminate small gaps at boundaries when combined with other rules.

Example:

Expand Down Expand Up @@ -375,7 +414,7 @@ The `ThresholdEvaluator` service processes threshold rules and determines which
1. **Order-dependent evaluation**: Rules are evaluated in the order they appear. If provider supports overriding defaults through [app configuration](#App-Configuration-Thresholds), you can change the evaluation order by specifying threshold keys in a different order. Entity annotations cannot alter the evaluation order, which is determined by either the [app configuration](#Provider-Default-Thresholds) or, if not specified, the [default provider configuration](#Provider-Default-Thresholds).
2. **First-match wins**: Returns the first threshold rule whose condition the value satisfies
3. **Type-safe**: Validates expressions against metric types
4. **Error handling**: You should validate your expressions loaded from config in your custom providers using `validateThresholds` from `backstage-plugin-scorecard-node`. Invalid expressions will result in evaluation error.
4. **Error handling**: Validate expressions loaded from config in custom providers using **`validateThresholdsForMetric`** or **`getThresholdsFromConfig`** from `@red-hat-developer-hub/backstage-plugin-scorecard-node`. Invalid expressions or gap configurations fail at validation time; unchecked configs may error at evaluation time.

### Best Practices

Expand All @@ -395,9 +434,9 @@ rules:
expression: '>30'
```

### 2. Avoid Overlapping Ranges
### 2. Overlaps vs gaps

Ensure threshold ranges don't create gaps or unexpected behavior.
**Joint coverage** allows overlapping intervals—only **gaps** cause validation to fail. Overlaps do affect **runtime** behavior: the **first** matching rule in list order wins ([Logical Ordering](#1-logical-ordering)). If overlaps are unintentional, reorder or narrow rules so the intended category matches first.

## Related documentation

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const DEFAULT_AVERAGE_KPI_RESULT_THRESHOLDS: ThresholdConfig = {
},
{
key: 'warning',
expression: '30-79',
expression: '30-80',
color: ScorecardThresholdRuleColors.WARNING,
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ describe('AggregationsService', () => {
const aggregationResult = result.result as AggregatedMetricAverageResult;

expect(result.metadata?.aggregationType).toBe(aggregationTypes.average);
expect(aggregationResult.averageScore).toBeCloseTo(0.5, 5);
expect(aggregationResult.averageScore).toBe(50);
expect(aggregationResult.averageWeightedSum).toBe(150);
expect(aggregationResult.averageMaxPossible).toBe(300);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,14 @@ export class AverageAggregationStrategy implements AggregationStrategy {
weightedSum,
);

const scorePercent = averageScore * 100;

const aggregationChartDisplayColor = this.getAggregationChartDisplayColor(
scorePercent,
averageScore,
headlineThresholds,
);

if (!aggregationChartDisplayColor) {
throw new Error(
`The color for percentage '${scorePercent}' metric '${metric.id}' is not configured. Check the 'scorecard.aggregationKPIs.${aggregationConfig.id}.options.thresholds' configuration.`,
`The color for percentage '${averageScore}' metric '${metric.id}' is not configured. Check the 'scorecard.aggregationKPIs.${aggregationConfig.id}.options.thresholds' configuration.`,
);
}

Expand Down Expand Up @@ -162,11 +160,9 @@ export class AverageAggregationStrategy implements AggregationStrategy {

const maxPossibleScore = maxScore * numberOfEntities;

const precision = 1000;

const averageScore =
numberOfEntities > 0 && maxPossibleScore > 0
? Math.round((weightedSum / maxPossibleScore) * precision) / precision
? Math.round((weightedSum / maxPossibleScore) * 1000) / 10
: 0;

return { averageScore, maxPossibleScore };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ describe('AverageAggregationStrategy', () => {
calculationErrorCount: 2,
averageWeightedSum: 150,
averageMaxPossible: 300,
averageScore: 0.5,
averageScore: 50,
aggregationChartDisplayColor: 'warning.main',
}),
);
Expand Down Expand Up @@ -197,7 +197,7 @@ describe('AverageAggregationStrategy', () => {
calculationErrorCount: 1,
averageWeightedSum: 100,
averageMaxPossible: 300,
averageScore: 0.333,
averageScore: 33.3,
}),
);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* 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 { validateThresholdNumberIntervals } from '@red-hat-developer-hub/backstage-plugin-scorecard-node';
import { ThresholdEvaluator } from './ThresholdEvaluator';

describe('numberThresholdCoverage vs ThresholdEvaluator', () => {
const evaluator = new ThresholdEvaluator();

it('should allow sampled values to match some rule when coverage passes', () => {
const thresholds = {
rules: [
{ key: 'low', expression: '<10' },
{ key: 'mid', expression: '10-20' },
{ key: 'high', expression: '>20' },
],
};

expect(() =>
validateThresholdNumberIntervals(thresholds.rules, 'number'),
).not.toThrow();

for (const x of [-1e6, -1, 0, 9.5, 10, 15, 20.001, 1e6]) {
expect(
evaluator.getFirstMatchingThreshold(x, 'number', thresholds),
).toBeDefined();
}
});

it('should throw error when interval validation fails before evaluation', () => {
const thresholds = {
rules: [
{ key: 'low', expression: '<10' },
{ key: 'mid', expression: '11-20' },
{ key: 'high', expression: '>20' },
],
};

expect(() =>
validateThresholdNumberIntervals(thresholds.rules, 'number'),
).toThrow(/do not cover the entire real line/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ describe('buildAggregationConfig', () => {
thresholds: {
rules: [
{ key: 'success', expression: '>=75', color: 'success.main' },
{ key: 'warning', expression: '10-74', color: 'warning.main' },
{ key: 'warning', expression: '10-75', color: 'warning.main' },
{ key: 'error', expression: '<10', color: 'error.main' },
],
},
Expand All @@ -90,7 +90,7 @@ describe('buildAggregationConfig', () => {

expect(result.options?.thresholds?.rules).toEqual([
{ key: 'success', expression: '>=75', color: 'success.main' },
{ key: 'warning', expression: '10-74', color: 'warning.main' },
{ key: 'warning', expression: '10-75', color: 'warning.main' },
{ key: 'error', expression: '<10', color: 'error.main' },
]);
});
Expand Down
Loading
Loading