diff --git a/packages/api-client/src/code-health.ts b/packages/api-client/src/code-health.ts index 2c651e703d..8eef9d10c2 100644 --- a/packages/api-client/src/code-health.ts +++ b/packages/api-client/src/code-health.ts @@ -22,6 +22,7 @@ import type { HealthWorkQueueQuery, HealthWorkQueueResponse, HealthScope, + HealthCounts, } from "@repowise-dev/types/health"; import type { Paginated } from "@repowise-dev/types"; import { apiGet, apiPatch } from "./client"; @@ -51,6 +52,7 @@ export type { HealthMapSelection, HealthModuleRow, HealthOverviewResponse, + HealthCounts, HealthScope, HealthTrendResponse, HealthWorkItem, @@ -76,10 +78,11 @@ export async function getHealthOverview( repoId: string, limit = 25, scope?: HealthScope, + counts?: HealthCounts, ): Promise { return apiGet( `/api/repos/${repoId}/health/overview`, - { limit, scope }, + { limit, scope, counts }, ); } @@ -92,6 +95,7 @@ export async function listHealthFindings( dimension?: string; limit?: number; scope?: HealthScope; + counts?: HealthCounts; }, ): Promise { return apiGet(`/api/repos/${repoId}/health/findings`, opts); @@ -158,6 +162,7 @@ export async function getHealthMap( cap: opts.cap, active: opts.active?.length ? opts.active.join(",") : undefined, scope: opts.scope, + counts: opts.counts, }); } @@ -174,10 +179,11 @@ export async function listHealthFiles( export async function getHealthFileBreakdown( repoId: string, filePath: string, + counts?: HealthCounts, ): Promise { return apiGet( `/api/repos/${repoId}/health/files/breakdown`, - { file_path: filePath }, + { file_path: filePath, counts }, ); } diff --git a/packages/core/src/repowise/core/analysis/health/counts.py b/packages/core/src/repowise/core/analysis/health/counts.py new file mode 100644 index 0000000000..f4c52765ca --- /dev/null +++ b/packages/core/src/repowise/core/analysis/health/counts.py @@ -0,0 +1,89 @@ +"""Whether a code-health figure counts change history or only code shape. + +Roughly half of a file's deduction comes from git-derived markers — churn, +co-change, ownership, prior fixes — which rise as a file is worked on. That is +correct for predicting defects and useless for answering "is my code getting +better", so the page lets a reader drop that half and score the code alone. + +The projection needs no rescore: both halves are stored per file, and the +scorer's own arithmetic is ``clamp(SCORE_MAX - structure - history)``, so +leaving one out is a subtraction rather than a second pass over the findings. +""" + +from __future__ import annotations + +from typing import Any, Literal + +from .scoring import SCORE_FLOOR, SCORE_MAX + +HealthCounts = Literal["everything", "code_shape"] +DEFAULT_COUNTS: HealthCounts = "everything" +COUNTS: tuple[HealthCounts, ...] = ("everything", "code_shape") + + +def parse_counts(value: str | None) -> HealthCounts: + """Read a value, falling back to the default on anything unknown. + + The REST layer declares a pattern and refuses a bad value before this runs, + so the fallback is for internal callers passing a stored or computed string. + """ + return value if value in COUNTS else DEFAULT_COUNTS # type: ignore[return-value] + + +def code_shape_score(structure_deduction: float | None) -> float | None: + """A file's score with the history half removed, clamped like any score. + + ``None`` when the split was never recorded, which is every row written + before it existed. Coercing a missing half to zero would print a confident + 10.0 for a file nobody has measured. + """ + if structure_deduction is None: + return None + return max(SCORE_FLOOR, min(SCORE_MAX, SCORE_MAX - structure_deduction)) + + +class CodeShapeMetric: + """One metric row read with the history half of its score removed. + + A wrapper rather than a mutation. The rows handed to a route are live ORM + objects, so assigning a projected score to one would mark it dirty and + write the projection back to the store on the next flush. + """ + + # ``score`` is the surfaced number every aggregate weights and + # ``defect_score`` mirrors it on the wire; both move or the page disagrees + # with itself. ``history_deduction`` reads 0.0 because that is what this + # reading counts, which keeps every figure derived from the two halves — + # the wire row's ``unclamped_score``, the summary's history average — in + # step with the score beside it rather than describing the other reading. + # Everything else falls through to the row. + __slots__ = ("_row", "defect_score", "history_deduction", "score") + + def __init__(self, row: Any, score: float) -> None: + object.__setattr__(self, "_row", row) + object.__setattr__(self, "score", score) + object.__setattr__(self, "defect_score", score) + object.__setattr__(self, "history_deduction", 0.0) + + def __getattr__(self, name: str) -> Any: + return getattr(self._row, name) + + +def project(counts: str, metrics: list[Any]) -> tuple[list[Any], int]: + """Re-read *metrics* under *counts*, dropping rows it cannot answer for. + + Returns the projected rows and how many were dropped for want of a + recorded split, so a caller can say "not measured" rather than imply the + repository shrank. + """ + if parse_counts(counts) != "code_shape": + return metrics, 0 + out: list[Any] = [] + unmeasured = 0 + for m in metrics: + score = code_shape_score(getattr(m, "structure_deduction", None)) + if score is None: + unmeasured += 1 + continue + out.append(CodeShapeMetric(m, score)) + return out, unmeasured diff --git a/packages/server/src/repowise/server/routers/code_health/counts.py b/packages/server/src/repowise/server/routers/code_health/counts.py new file mode 100644 index 0000000000..b7a9209746 --- /dev/null +++ b/packages/server/src/repowise/server/routers/code_health/counts.py @@ -0,0 +1,40 @@ +"""Reading a code-health response with or without its change-history half.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import Query + +from repowise.core.analysis.health.counts import COUNTS, DEFAULT_COUNTS, parse_counts +from repowise.core.analysis.health.counts import project as project_metrics +from repowise.core.analysis.health.models import split_by_origin + +CountsQuery = Query( + DEFAULT_COUNTS, + description=( + "What the score counts: 'everything' (code shape and change history, as " + "calibrated) or 'code_shape' (the git-derived half removed). Change " + "history rises as a file is worked on, so it answers what a repository " + "has been through rather than what its code is like." + ), + pattern=f"^({'|'.join(COUNTS)})$", +) + + +def project( + counts: str, metrics: list[Any], *findings: list[Any] +) -> tuple[Any, ...]: + """Re-score *metrics* under *counts*, returning ``(rows, ..., unscored)``. + + The findings a reading stops counting are dropped with it: under + ``code_shape`` a history finding contributes to no figure on the page, so + leaving it in the lists would show work that sums past the score it sits + under. The count of rows the reading could not answer for is returned + alongside rather than recomputed, which on a large repo is a second walk of + the whole table building a second set of wrappers. + """ + projected, unscored = project_metrics(counts, metrics) + if parse_counts(counts) != "code_shape": + return (projected, *findings, unscored) + return (projected, *(split_by_origin(rows)[0] for rows in findings), unscored) diff --git a/packages/server/src/repowise/server/routers/code_health/files_routes.py b/packages/server/src/repowise/server/routers/code_health/files_routes.py index c859294903..50ce386dbc 100644 --- a/packages/server/src/repowise/server/routers/code_health/files_routes.py +++ b/packages/server/src/repowise/server/routers/code_health/files_routes.py @@ -15,6 +15,7 @@ from ._router import router from .breakdown import _score_breakdown_from_findings +from .counts import CountsQuery, project from .loaders import _attach_symbol_ids, _load_file_signals from .scope import ScopeQuery, narrow from .serializers import ( @@ -60,12 +61,16 @@ async def list_health_files( ), ), scope: str = ScopeQuery, + counts: str = CountsQuery, session: AsyncSession = Depends(get_db_session), ) -> dict: if sort not in _SORT_FIELDS: sort = "score" metrics = await crud.get_health_metrics(session, repo_id) (metrics,) = narrow(scope, metrics) + # Projected before every filter and the sort, so `total` and the ranking + # describe the same population the score does. + metrics, _unscored = project(counts, metrics) hotspot_paths: set[str] = set() if only_hotspots: @@ -146,14 +151,19 @@ def _key(m: Any): async def file_score_breakdown( repo_id: str, file_path: str = Query(..., description="File path to break down"), + counts: str = CountsQuery, session: AsyncSession = Depends(get_db_session), ) -> dict: repo = await crud.get_repository(session, repo_id) if repo is None: raise HTTPException(status_code=404, detail="Repository not found") metrics = await crud.get_health_metrics(session, repo_id, file_paths=[file_path]) - metric = metrics[0] if metrics else None findings = await crud.get_health_findings(session, repo_id, file_path=file_path) + # This drawer opens from a row the reader just saw a score on. Reading it + # under the other counts would answer a click on 8.5 with a 2.0 and list + # the findings the page had just said were excluded. + metrics, findings, _unscored = project(counts, metrics, findings) + metric = metrics[0] if metrics else None breakdown = _score_breakdown_from_findings(findings) finding_dicts = await _attach_symbol_ids( session, repo_id, [_finding_to_dict(f) for f in findings] diff --git a/packages/server/src/repowise/server/routers/code_health/findings_routes.py b/packages/server/src/repowise/server/routers/code_health/findings_routes.py index f8c5d5e80c..45fe5f5525 100644 --- a/packages/server/src/repowise/server/routers/code_health/findings_routes.py +++ b/packages/server/src/repowise/server/routers/code_health/findings_routes.py @@ -6,6 +6,8 @@ from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession +from repowise.core.analysis.health.counts import parse_counts +from repowise.core.analysis.health.models import split_by_origin from repowise.core.analysis.health.scope import parse_scope from repowise.core.persistence import crud from repowise.server.deps import get_db_session @@ -15,6 +17,7 @@ ) from ._router import router +from .counts import CountsQuery from .loaders import _attach_symbol_ids from .scope import ScopeQuery, narrow from .serializers import _finding_to_dict @@ -32,6 +35,7 @@ async def list_health_findings( dimension: str | None = Query(None), limit: int = Query(100, ge=1, le=1000), scope: str = ScopeQuery, + counts: str = CountsQuery, session: AsyncSession = Depends(get_db_session), ) -> list[dict]: """Open findings, ranked by health impact. @@ -57,6 +61,10 @@ async def list_health_findings( if parse_scope(scope) == "production": metrics = await crud.get_health_metrics(session, repo_id) _, findings = narrow(scope, metrics, findings) + # A history finding contributes nothing to a code-shape score, so listing + # it under one would show work that sums past the figure above it. + if parse_counts(counts) == "code_shape": + findings = split_by_origin(findings)[0] return await _attach_symbol_ids( session, repo_id, [_finding_to_dict(f) for f in findings[:limit]] ) diff --git a/packages/server/src/repowise/server/routers/code_health/map_routes.py b/packages/server/src/repowise/server/routers/code_health/map_routes.py index e6b50acc75..44974317e7 100644 --- a/packages/server/src/repowise/server/routers/code_health/map_routes.py +++ b/packages/server/src/repowise/server/routers/code_health/map_routes.py @@ -21,6 +21,7 @@ ) from ._router import router +from .counts import CountsQuery from .scope import ScopeQuery @@ -37,10 +38,11 @@ async def get_health_map( ), ), scope: str = ScopeQuery, + counts: str = CountsQuery, session: AsyncSession = Depends(get_db_session), ) -> dict[str, Any]: """One bounded field plus the exact scope of what the cap left out.""" feed = await HealthMapService(session, repo_id).feed( - cap=cap, active=parse_active(active), scope=scope + cap=cap, active=parse_active(active), scope=scope, counts=counts ) return feed.payload() diff --git a/packages/server/src/repowise/server/routers/code_health/overview_routes.py b/packages/server/src/repowise/server/routers/code_health/overview_routes.py index 9c67734a94..cbc954fe53 100644 --- a/packages/server/src/repowise/server/routers/code_health/overview_routes.py +++ b/packages/server/src/repowise/server/routers/code_health/overview_routes.py @@ -22,6 +22,7 @@ from repowise.server.mcp_server._meta import resolve_indexed_commit from ._router import router +from .counts import CountsQuery, project from .loaders import _attach_symbol_ids from .scope import ScopeQuery, narrow from .serializers import _finding_to_dict, _leads_by_file, _metric_to_dict @@ -51,6 +52,7 @@ async def health_overview( repo_id: str, limit: int = Query(20, ge=1, le=200), scope: str = ScopeQuery, + counts: str = CountsQuery, session: AsyncSession = Depends(get_db_session), ) -> dict: """KPIs + lowest-scoring files + per-module rollup + meta.""" @@ -65,6 +67,10 @@ async def health_overview( metrics = await crud.get_health_metrics(session, repo_id) findings = await crud.get_health_findings(session, repo_id) metrics, findings = narrow(scope, metrics, findings) + # Every figure below is computed from these two lists, so projecting here + # is what keeps the headline, the distribution, the hotspot figure and the + # work the page lists all describing the same thing. + metrics, findings, unscored = project(counts, metrics, findings) summary = await crud.get_health_summary( session, repo_id, metrics=metrics, findings=findings ) @@ -91,6 +97,10 @@ async def health_overview( **summary, "hotspot_health": hotspot_health_value, "severity_breakdown": severity_breakdown(findings), + # Echoed so a surface labels what it was sent rather than what it + # asked for; the two differ while a request is in flight. + "counts": counts, + "unscored_files": unscored, "band": band_for(float(avg)) if avg is not None else None, } distribution = health_distribution(metric_dicts) diff --git a/packages/server/src/repowise/server/routers/code_health/refactoring_routes.py b/packages/server/src/repowise/server/routers/code_health/refactoring_routes.py index 77144efb3e..3846f06d93 100644 --- a/packages/server/src/repowise/server/routers/code_health/refactoring_routes.py +++ b/packages/server/src/repowise/server/routers/code_health/refactoring_routes.py @@ -18,6 +18,7 @@ from repowise.server.schemas import HealthWorkQueueResponse from ._router import router +from .counts import CountsQuery, project from .scope import ScopeQuery, narrow _SEVERITY_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3} @@ -67,6 +68,7 @@ async def health_work_queue( "impact_per_effort", pattern="^(impact_per_effort|total_impact|score|finding_count)$" ), scope: str = ScopeQuery, + counts: str = CountsQuery, session: AsyncSession = Depends(get_db_session), ) -> dict: """Health work items ranked by impact / effort. @@ -86,6 +88,7 @@ async def health_work_queue( metrics = await crud.get_health_metrics(session, repo_id) findings = await crud.get_health_findings(session, repo_id) metrics, findings = narrow(scope, metrics, findings) + metrics, findings, _unscored = project(counts, metrics, findings) metric_by_path = {m.file_path: m for m in metrics} by_file: dict[str, list[Any]] = {} @@ -106,8 +109,14 @@ async def health_work_queue( if module and not file_path.startswith(module): continue m = metric_by_path.get(file_path) - nloc = m.nloc if m is not None else 0 - score = m.score if m is not None else 10.0 + # No metric row means this reading cannot score the file: under + # ``code_shape`` that is a row with no recorded split. Ranking it on a + # stand-in 10.0 would put an unmeasured file at the top of a list + # ordered by how bad things are. + if m is None: + continue + nloc = m.nloc + score = m.score primary = primary_finding(fs) total_impact = round(sum(x.health_impact for x in fs), 3) effort_bucket = _effort_for_nloc(nloc) diff --git a/packages/server/src/repowise/server/services/health_map.py b/packages/server/src/repowise/server/services/health_map.py index 8d7a27ad92..0d03e3da5b 100644 --- a/packages/server/src/repowise/server/services/health_map.py +++ b/packages/server/src/repowise/server/services/health_map.py @@ -20,6 +20,8 @@ from sqlalchemy.ext.asyncio import AsyncSession +from repowise.core.analysis.health.counts import DEFAULT_COUNTS +from repowise.core.analysis.health.counts import project as project_counts from repowise.core.analysis.health.perf.coverage import supported_perf_languages from repowise.core.analysis.health.scope import DEFAULT_SCOPE, parse_scope from repowise.core.persistence import crud @@ -60,10 +62,12 @@ class HealthMapFeed: performance: dict[str, Any] | None = None #: Which half of the repository the field describes. scope: str = DEFAULT_SCOPE + counts: str = DEFAULT_COUNTS def payload(self) -> dict[str, Any]: return { "scope": self.scope, + "counts": self.counts, "files": self.files, "cap": self.cap, "shown": self.shown, @@ -108,6 +112,7 @@ async def feed( cap: int = DEFAULT_MAP_CAP, active: tuple[str, ...] = (), scope: str = DEFAULT_SCOPE, + counts: str = DEFAULT_COUNTS, ) -> HealthMapFeed: session, repo_id = self._session, self._repository_id metrics = await crud.get_health_metrics(session, repo_id) @@ -122,6 +127,18 @@ async def feed( kept = {m.file_path for m in metrics} rollups = [r for r in rollups if r.file_path in kept] + # Re-marks the same field: every node keeps its size and its module, + # and only the colour moves. A file with no recorded split cannot be + # coloured on this basis, so it leaves the field rather than sitting + # there in whatever colour it last had. + # + # ``rollups`` is deliberately left whole. The page says performance is + # scored separately and never blended into health, so its totals must + # not move when the health reading does; only the per-node join below + # narrows, and a node that is not drawn simply never looks one up. + counted = len(metrics) + metrics, _ = project_counts(counts, metrics) + by_path = {m.file_path: m for m in metrics} # A zero-NLOC file cannot be sized, and the map drops it on arrival. # Counting it as eligible would promise a node that never appears. @@ -175,7 +192,7 @@ def admit(path: str) -> bool: cap=cap, shown=len(chosen), eligible_total=len(eligible), - repository_total=len(metrics), + repository_total=counted, selection={ "basis": "active_then_performance_then_nloc", "active_requested": list(active), @@ -208,6 +225,7 @@ def admit(path: str) -> bool: else self._performance_block(rollups, summary, len(performance_eligible)) ), scope=DEFAULT_SCOPE if not scope_narrowed else "production", + counts=counts, ) def _row( diff --git a/packages/types/src/health.ts b/packages/types/src/health.ts index 8b4df46fd6..523c385b01 100644 --- a/packages/types/src/health.ts +++ b/packages/types/src/health.ts @@ -48,6 +48,15 @@ export const HEALTH_DIMENSIONS: readonly HealthDimension[] = [ "performance", ] as const; +/** + * What a code-health figure counts. `everything` is the calibrated score; + * `code_shape` removes the git-derived half, which rises as a file is worked + * on and so answers what a repository has been through rather than what its + * code is like. + */ +export type HealthCounts = "everything" | "code_shape"; +export const HEALTH_COUNTS: readonly HealthCounts[] = ["everything", "code_shape"] as const; + /** * Which half of a repository a health figure describes. Tests score higher * than production code, so narrowing lowers every figure without a defect @@ -544,6 +553,10 @@ export interface HealthOverviewSummary { */ structure_average?: number | null; history_average?: number | null; + /** What this response counted. Echoed so a label cannot get ahead of its data. */ + counts?: HealthCounts; + /** Files a code-shape reading cannot answer for, having no recorded split. */ + unscored_files?: number; } export interface HealthOverviewResponse { @@ -574,6 +587,7 @@ export interface HealthFilesResponse { } export interface HealthFilesQuery { + counts?: HealthCounts; /** Which half of the repository to describe. Defaults to `"all"`. */ scope?: HealthScope; limit?: number; @@ -679,6 +693,7 @@ export interface HealthMapFeed { } export interface HealthMapQuery { + counts?: HealthCounts; cap?: number; /** Paths guaranteed a node, admitted before any other band. */ active?: string[]; @@ -1065,6 +1080,7 @@ export interface HealthWorkQueueResponse { } export interface HealthWorkQueueQuery { + counts?: HealthCounts; limit?: number; module?: string; biomarker?: string; diff --git a/packages/ui/__tests__/health/code-health-lede.test.tsx b/packages/ui/__tests__/health/code-health-lede.test.tsx new file mode 100644 index 0000000000..428a8e6759 --- /dev/null +++ b/packages/ui/__tests__/health/code-health-lede.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { HealthOverviewSummary } from "@repowise-dev/types/health"; +import { CodeHealthLede } from "../../src/health/code-health-lede.js"; + +function summary(partial: Partial = {}): HealthOverviewSummary { + return { + average_health: 7.0, + maintainability_average: 8.0, + hotspot_health: 4.5, + performance_average: 9.7, + performance_findings: 942, + file_count: 3787, + open_findings: 14755, + structure_average: 1.59, + history_average: 1.44, + ...partial, + } as HealthOverviewSummary; +} + +describe("CodeHealthLede — how many scores the first screen carries", () => { + // The page used to print Structure 8.4 beside Maintainability 8.0. Both are + // code-shape readings on the same 0-10 scale, so the screen answered "which + // number do I steer by?" twice, with two different numbers. The history half + // is a share of the deduction now, which says the same thing without minting + // a rival score. + + it("states the history share rather than a second score out of 10", () => { + render(); + expect(screen.getByText("48%")).toBeTruthy(); + expect(screen.getByText(/comes from change history, not from the code/)).toBeTruthy(); + }); + + it("no longer prints a structure figure", () => { + const { container } = render(); + expect(container.textContent).not.toContain("Structure"); + expect(container.textContent).not.toContain("8.4"); + }); + + it("carries one score out of 10, with Maintainability demoted to the ribbon", () => { + const { container } = render(); + // The headline is the only figure at lede weight; Maintainability is still + // on the page, as a ribbon stat beside the other cuts of the same scoring. + expect(container.querySelectorAll("dt")).toHaveLength(4); + expect(screen.getByText("Code health")).toBeTruthy(); + expect(screen.getByText("Maintainability")).toBeTruthy(); + expect(screen.getByText("8.0")).toBeTruthy(); + expect(screen.getAllByText("out of 10")).toHaveLength(1); + }); + + it("drops Open findings, which the tab row already counts", () => { + render(); + expect(screen.queryByText("Open findings")).toBeNull(); + }); + + it("gives every figure an explainer a reader can open", () => { + render(); + for (const label of ["Code health", "Files", "Maintainability", "Performance risk", "Hotspot health"]) { + expect(screen.getByLabelText(`What ${label} means`)).toBeTruthy(); + } + }); + + it("says nothing when the split was never recorded", () => { + const { container } = render( + , + ); + expect(container.textContent).not.toContain("comes from change history"); + }); +}); + +describe("CodeHealthLede — the two readings of one figure", () => { + it("names change history as the reason when it is counted", () => { + render(); + expect(screen.getByText(/comes from change history, not from the code/)).toBeTruthy(); + }); + + it("says what it excluded, and that the findings now add up", () => { + render(); + expect( + screen.getByText(/Change history excluded\. This scores the code alone/), + ).toBeTruthy(); + expect(screen.queryByText(/comes from change history/)).toBeNull(); + }); + + it("stops claiming churn and ownership are inputs once they are not", () => { + const { container } = render(); + expect(container.textContent).not.toContain("churn and ownership"); + }); + + it("drops the bug-prediction claim, which only the calibrated score earns", () => { + const accuracy = { hits: 20, k: 20, precision: 1, base_rate: 0.41, lift: 2.43, window_days: 180 }; + const withClaim = render( + , + ); + expect(withClaim.container.textContent).toContain("Ranked against real bug-fix history"); + withClaim.unmount(); + const withoutClaim = render( + , + ); + expect(withoutClaim.container.textContent).not.toContain("Ranked against real bug-fix history"); + }); + + it("owns up to files it cannot score on this basis", () => { + render(); + expect(screen.getByText(/287 files are not scored here/)).toBeTruthy(); + }); +}); diff --git a/packages/ui/src/git/churn-bar.tsx b/packages/ui/src/git/churn-bar.tsx index 011204792c..97e5ee74eb 100644 --- a/packages/ui/src/git/churn-bar.tsx +++ b/packages/ui/src/git/churn-bar.tsx @@ -3,15 +3,25 @@ import { cn } from "../lib/cn"; interface ChurnBarProps { percentile: number; className?: string; + /** + * `"scale"` grades the value red/amber/green. `"neutral"` draws the same + * length in one quiet colour, for a table that is already sorted by this + * number: every row of a hotspot list is high-churn by definition, so + * grading them paints the whole column red and reads as an alarm about + * something that is merely the subject of the table. + */ + tone?: "scale" | "neutral"; } -export function ChurnBar({ percentile, className }: ChurnBarProps) { +export function ChurnBar({ percentile, className, tone = "scale" }: ChurnBarProps) { const color = - percentile >= 75 - ? "bg-[var(--color-error)]" - : percentile >= 50 - ? "bg-[var(--color-warning)]" - : "bg-[var(--color-success)]"; + tone === "neutral" + ? "bg-[var(--color-text-tertiary)]" + : percentile >= 75 + ? "bg-[var(--color-error)]" + : percentile >= 50 + ? "bg-[var(--color-warning)]" + : "bg-[var(--color-success)]"; return (
diff --git a/packages/ui/src/git/hotspot-table.tsx b/packages/ui/src/git/hotspot-table.tsx index 40e9c25a85..21d5292d4f 100644 --- a/packages/ui/src/git/hotspot-table.tsx +++ b/packages/ui/src/git/hotspot-table.tsx @@ -2,12 +2,11 @@ import * as React from "react"; import { useState, useMemo } from "react"; -import { TrendingUp, TrendingDown, Search, Flame, Bug, ArrowUpDown, ArrowUp, ArrowDown, GitBranch, BookOpen, Radius, ChevronRight, ChevronDown } from "lucide-react"; +import { TrendingUp, TrendingDown, Search, Flame, Bug, ArrowUpDown, ArrowUp, ArrowDown, ChevronRight, ChevronDown } from "lucide-react"; import { Badge } from "../ui/badge"; import { Input } from "../ui/input"; import { EmptyState } from "../shared/empty-state"; import { ResultsFooter } from "../shared/results-footer"; -import { RowActions } from "../shared/row-actions"; import { AiPromptButton } from "../health/ai-prompt-button"; import { ChurnBar } from "./churn-bar"; import { formatLOC } from "../lib/format"; @@ -15,7 +14,6 @@ import { summarizeFixHistory } from "../lib/fix-history"; import { cn } from "../lib/cn"; import { useVirtualRows } from "../shared/virtualized-table"; import { clickableRowProps, CLICKABLE_ROW_CLS } from "../shared/responsive-table"; -import { docsPagePath, filePageId } from "../shared/entity/routes"; import type { Hotspot } from "@repowise-dev/types/git"; /** @@ -27,6 +25,12 @@ const ESTIMATED_ROW_HEIGHT = 44; interface HotspotTableProps { hotspots: Hotspot[]; + /** + * Kept for callers that still pass them. The rows carried a Graph / Docs / + * Blast Radius menu built from these; a table already sorted by risk did not + * need three links per row competing with the row's own click, so it is + * gone and these no longer do anything. + */ repoId?: string; linkPrefix?: string; /** @@ -78,8 +82,6 @@ function ariaSortFor(column: SortKey, sortKey: SortKey, sortDir: SortDir): "none export function HotspotTable({ hotspots, - repoId, - linkPrefix, onSelect, total, hasMore, @@ -98,7 +100,6 @@ export function HotspotTable({ return next; }); }; - const prefix = linkPrefix ?? (repoId ? `/repos/${repoId}` : undefined); const [search, setSearch] = useState(""); const [filter, setFilter] = useState("all"); const [sortKey, setSortKey] = useState("trend"); @@ -418,7 +419,7 @@ export function HotspotTable({ )}
- + {Math.round(h.churn_percentile)}% @@ -439,13 +440,15 @@ export function HotspotTable({ + {/* A number, not a badge. A tinted pill on every row + made a column of ordinary counts look like a column + of alerts; only a sole owner is worth marking, and + the colour alone says it. */} {h.bus_factor} @@ -461,7 +464,9 @@ export function HotspotTable({ e.stopPropagation()}>
- {h.is_hotspot && Hot} + {/* No "Hot" badge: every row in a hotspot table is + one, so it labelled nothing. "Stable" still earns + its place — it is the row that is not. */} {h.is_stable && Stable} {onGeneratePrompt && ( onGeneratePrompt(h)} /> )} - {prefix && ( - - )}
diff --git a/packages/ui/src/health/code-health-lede.tsx b/packages/ui/src/health/code-health-lede.tsx index 0dcc34b15b..59b72a82ff 100644 --- a/packages/ui/src/health/code-health-lede.tsx +++ b/packages/ui/src/health/code-health-lede.tsx @@ -1,20 +1,24 @@ /** - * The Code Health page's opening read: the two figures that lead, and the - * sentences that make them mean something. + * The Code Health page's opening read: one figure, and the sentences that make + * it mean something. * - * Two rather than one because they answer different questions. Code health is - * the calibrated, bug-predicting number, and roughly half of what it deducts - * comes from git history, so it can fall through a week of good refactoring - * and tell the reader nothing they can act on. Maintainability is pure code - * shape, which is why it sits at the same weight rather than in the ribbon: it - * is the number a refactor is supposed to move. + * One rather than two. It carried Code health and Maintainability side by side, + * both scores out of ten, both largely about code shape — so the screen + * answered "which number do I steer by?" twice, with two different numbers. The + * page's Counts control answers it instead: the single figure either includes + * change history or does not, and the reader picks. Maintainability keeps its + * place in the ribbon, beside the other cuts of the same scoring. + * + * Every figure carries an explainer. A number a reader cannot define is a + * number they cannot act on, and "Performance risk 942" beside "Hotspot health + * 4.5" reads as two scores when one is a count that only goes up. * * The prose is not decoration. "329 risks" reads as alarming on its own; * "329 static performance risks, scored separately and never blended into the - * health number" reads as informative. Same figure. The accuracy claim in - * particular only means anything next to its base rate: a 72% hit rate is - * excellent against a 20% baseline and unremarkable against a 70% one, so it - * is a sentence rather than a badge. + * health number" reads as informative. The accuracy claim in particular only + * means anything next to its base rate: a 72% hit rate is excellent against a + * 20% baseline and unremarkable against a 70% one, so it is a sentence rather + * than a badge. */ import { @@ -24,13 +28,51 @@ import { type HealthDistribution, type HealthOverviewSummary, } from "@repowise-dev/types/health"; -import { LedeFigure, PageLede } from "../shared/page-lede"; +import { PageLede } from "../shared/page-lede"; import { StatRibbon, type RibbonStat } from "../stats/stat-ribbon"; import { formatNumber } from "../lib/format"; import { healthBandColor, scoreTextColor } from "./tokens"; import { HealthDistributionBar } from "./health-distribution-bar"; -/** Which of the two figures the page's current selection describes. */ +const HEALTH_HINT = + "Fitted against real bug history to predict where defects appear. Built from " + + "code shape — complexity, duplication, coverage — and from what git says about " + + "each file: how often it changes, alongside what, and how many people touch it. " + + "1 to 10, higher is better."; + +const CODE_SHAPE_HINT = + "The same score with its change-history half removed: complexity, " + + "duplication and coverage, but not churn, co-change, ownership or prior " + + "fixes. It answers what the code is like rather than what the repository " + + "has been through, so it moves when you refactor. Not the calibrated " + + "bug-risk figure — the badge and the leaderboard keep reporting that one."; + +const FILES_HINT = + "Files scored. Only code is scored, so markdown, JSON, YAML, lockfiles and " + + "other non-code carry no score and are not counted here."; + +const MAINTAINABILITY_HINT = + "Code shape alone: complexity, duplication and error handling, weighted for " + + "how hard the code is to work with rather than for bug risk. It counts no " + + "tests and no change history, so it is the figure a refactor moves."; + +const PERFORMANCE_HINT = + "A count of open performance risks, not a score — it counts up as the analyzer " + + "finds more and falls only when they are fixed. Each one is a database, " + + "network, filesystem or subprocess call inside a loop, traced across function " + + "boundaries. Never blended into the health score."; + +const HOTSPOT_HINT = + "Code health averaged over the repo's churn hotspots — the files you change " + + "most often. Lower than the overall figure on most repos, because " + + "heavily-changed files carry the most change-history deduction."; + +const HOTSPOT_HINT_CODE_SHAPE = + "Code shape averaged over the repo's churn hotspots — the files you change " + + "most often. With change history excluded this asks whether the code you " + + "touch most is well built, rather than how much it has moved."; + +/** Which figure the page's current selection describes; marks it in the ribbon. */ export type LedePillar = "health" | "maintainability"; export interface CodeHealthLedeProps { @@ -73,76 +115,98 @@ export function CodeHealthLede({ const perf = summary.performance_average; const perfFindings = summary.performance_findings ?? 0; const hotspot = summary.hotspot_health; + // Read off the response, not the page's control: the two disagree while a + // request is in flight, and a figure captioned by the mode the reader just + // asked for rather than the one it was computed under is the whole bug this + // page exists to remove. + const codeShape = summary.counts === "code_shape"; + const unscored = summary.unscored_files ?? 0; const structure = summary.structure_average; - const healthChip = bandChip(health); + const historyDeduction = summary.history_average; + // How much of the deduction is git history rather than code. Stated as a + // share, not as a second score out of 10: a rival 0-10 figure beside + // Maintainability made the page answer "which number do I steer by?" twice. + const deduction = (structure ?? 0) + (historyDeduction ?? 0); + const historyShare = + structure == null || historyDeduction == null || deduction <= 0 + ? null + : Math.round((historyDeduction / deduction) * 100); + // The band words were fitted against the full, bug-predicting score, so + // they are not a verdict this projection has earned: clamp(10 - structure) + // reads systematically higher, and almost every repo would print "Healthy" + // under it. The figure and the spread still say where the repo sits. + const healthChip = codeShape ? undefined : bandChip(health); const stats: RibbonStat[] = [ - { label: "Files", value: formatNumber(summary.file_count) }, + { label: "Files", value: formatNumber(summary.file_count), hint: FILES_HINT }, + { + label: "Maintainability", + value: maint == null ? "" : maint.toFixed(1), + valueColor: maint == null ? undefined : scoreTextColor(maint), + hint: MAINTAINABILITY_HINT, + // The map's lens marks its figure here now that the lede carries one + // number. Dimming the sole headline when the lens moved would have said + // the page was describing something else. + highlighted: pillar === "maintainability", + }, { label: "Performance risk", value: perf == null ? "" : formatNumber(perfFindings), - hint: "Open static performance risks: a DB, network, filesystem or subprocess call per loop iteration, found across function boundaries. High precision, low recall. This is a count of open causes, not a score, so it counts up as the analyzer finds more and falls only when they are fixed.", + hint: PERFORMANCE_HINT, }, { label: "Hotspot health", value: hotspot == null ? "" : hotspot.toFixed(1), valueColor: hotspot == null ? undefined : scoreTextColor(hotspot), - hint: "The score averaged over the repo's churn hotspots only. How healthy is the code you touch most?", + hint: codeShape ? HOTSPOT_HINT_CODE_SHAPE : HOTSPOT_HINT, }, - { label: "Open findings", value: formatNumber(summary.open_findings) }, ]; + return (
{distribution && } - {structure != null && ( + {codeShape ? (

- Structure{" "} - - {(10 - structure).toFixed(1)} - - . History pulls it to{" "} - - {health.toFixed(1)} - - . + Change history excluded. This scores the code alone, and every + finding on this page counts toward it. + {unscored > 0 && ( + <> + {" "} + {formatNumber(unscored)} files are not scored here, having no + recorded split yet. + + )}

+ ) : ( + historyShare != null && ( +

+ + {historyShare}% + {" "} + of this comes from change history, not from the code. No edit to + these files clears it. +

+ ) )} } - figureSecondary={ - - {maint == null - ? "Not measured on this index." - : "Code shape only. This is the number that moves when you refactor."} -

- } - /> - } >

Across{" "} @@ -154,18 +218,19 @@ export function CodeHealthLede({ {health.toFixed(1)} out of 10 {" "} for code health, weighted by lines of code and built from complexity, - duplication, coverage, churn and ownership. We rate that{" "} - {healthChip.label.toLowerCase()}. + duplication, coverage + {codeShape ? "" : ", churn and ownership"}. + {healthChip ? <> We rate that {healthChip.label.toLowerCase()}. : null} {perf != null && ( <> {" "} Static performance risk is scored separately at {perf.toFixed(1)} out - of 10 and never blended into either figure. + of 10 and never blended into the health score. )}

- {accuracy && ( + {accuracy && !codeShape && (

Ranked against real bug-fix history:{" "} diff --git a/packages/ui/src/health/trend-view.tsx b/packages/ui/src/health/trend-view.tsx index 054d185577..851d592ad2 100644 --- a/packages/ui/src/health/trend-view.tsx +++ b/packages/ui/src/health/trend-view.tsx @@ -19,7 +19,7 @@ */ import { AlertTriangle } from "lucide-react"; -import type { HealthTrendResponse } from "@repowise-dev/types/health"; +import type { HealthCounts, HealthTrendResponse } from "@repowise-dev/types/health"; import { Skeleton } from "../ui/skeleton"; import { StatRibbon, type RibbonStat } from "../stats/stat-ribbon"; @@ -39,10 +39,13 @@ export function TrendView({ data, isLoading, error, + counts, }: { data: HealthTrendResponse | undefined; isLoading: boolean; error: unknown; + /** What the page's figures count, so this section cannot claim the other reading. */ + counts?: HealthCounts; }) { if (isLoading) return ; if (error || !data) { @@ -69,27 +72,41 @@ export function TrendView({ // has dropped. Say so rather than printing one. const hotspot = summary.current_hotspot_health; + // Snapshots recorded the full score, so there is no code-shape series to + // read. These two carry the same labels as the lede's figures, and showing + // the other reading of them here put two different numbers under one name on + // one screen — the exact confusion the single headline exists to end. + const otherReading = counts === "code_shape"; + const stats: RibbonStat[] = [ { label: "Average health", - value: summary.current_average_health.toFixed(1), - valueColor: scoreTextColor(summary.current_average_health), - sub: deltaSub(summary.average_delta, summary.previous_average_health), - ...(Math.abs(summary.average_delta ?? 0) >= 0.05 - ? { subColor: deltaColor(summary.average_delta) } - : {}), + value: otherReading ? "—" : summary.current_average_health.toFixed(1), + ...(otherReading + ? { sub: "recorded on the full score" } + : { + valueColor: scoreTextColor(summary.current_average_health), + sub: deltaSub(summary.average_delta, summary.previous_average_health), + ...(Math.abs(summary.average_delta ?? 0) >= 0.05 + ? { subColor: deltaColor(summary.average_delta) } + : {}), + }), }, { label: "Hotspot health", - value: hotspot == null ? "—" : hotspot.toFixed(1), - ...(hotspot == null ? {} : { valueColor: scoreTextColor(hotspot) }), - sub: - hotspot == null - ? "not measured for this scope" - : deltaSub(summary.hotspot_delta, summary.previous_hotspot_health), - ...(hotspot != null && Math.abs(summary.hotspot_delta ?? 0) >= 0.05 - ? { subColor: deltaColor(summary.hotspot_delta) } - : {}), + value: otherReading || hotspot == null ? "—" : hotspot.toFixed(1), + ...(otherReading + ? { sub: "recorded on the full score" } + : { + ...(hotspot == null ? {} : { valueColor: scoreTextColor(hotspot) }), + sub: + hotspot == null + ? "not measured for this scope" + : deltaSub(summary.hotspot_delta, summary.previous_hotspot_health), + ...(hotspot != null && Math.abs(summary.hotspot_delta ?? 0) >= 0.05 + ? { subColor: deltaColor(summary.hotspot_delta) } + : {}), + }), }, { label: "Snapshots", diff --git a/packages/ui/src/shared/page-lede.tsx b/packages/ui/src/shared/page-lede.tsx index a13efeb1bf..1702829e37 100644 --- a/packages/ui/src/shared/page-lede.tsx +++ b/packages/ui/src/shared/page-lede.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { InfoTip } from "./info-tip"; export interface PageLedeBand { label: string; @@ -10,6 +11,8 @@ export interface PageLedeBand { export interface PageLedeProps { /** Mono micro-label above the figure. */ label: string; + /** What the figure measures, on an `InfoTip` beside the label. */ + labelHint?: string | undefined; /** The figure itself, pre-formatted. */ value: string; /** Colour for the figure. Same rule as `band.color`. */ @@ -44,53 +47,43 @@ export interface PageLedeProps { * for another statistic. */ figureFooter?: React.ReactNode; - /** Marks the primary figure as the one the current selection describes. */ - figureHighlighted?: boolean | undefined; - /** - * A co-equal second figure, built with `LedeFigure` so both share one type - * scale. For a page whose subject genuinely has two headline numbers; a - * supporting statistic belongs in the ribbon, where it reads as supporting. - */ - figureSecondary?: React.ReactNode; } export interface LedeFigureProps { label: string; + /** What the figure measures, on an `InfoTip` beside the label. */ + labelHint?: string | undefined; value: string; valueColor?: string | undefined; unit?: string | undefined; band?: PageLedeBand | undefined; badge?: React.ReactNode; footer?: React.ReactNode; - /** Marks this figure as the one the page's current selection describes. */ - highlighted?: boolean | undefined; } /** - * One labelled figure at lede weight. Exported so a page with two headline - * numbers composes the second from the same source as the first, rather than - * re-deriving the 44/48px step and the chip geometry by hand. + * One labelled figure at lede weight. + * + * A page carries one. It was exported so a second could be composed at the + * same type scale, and the one page that did found the two figures answered + * the same question with different numbers; the supporting statistic belongs + * in the ribbon, where it reads as supporting. */ -export function LedeFigure({ +function LedeFigure({ label, + labelHint, value, valueColor, unit, band, badge, footer, - highlighted, }: LedeFigureProps) { return (

-

+

{label} + {labelHint && }

@@ -101,22 +94,17 @@ export function LedeFigure({ {value} {unit && {unit}} + {/* A dot and a word, not a badge. Filled and outlined, this read as a + notification demanding action — and the band is a description of + where a number sits, which is not news. The colour still carries + the reading; it just stops shouting it. */} {band && ( - + + {band.label} )} @@ -143,6 +131,7 @@ export function LedeFigure({ */ export function PageLede({ label, + labelHint, value, valueColor, unit, @@ -152,38 +141,29 @@ export function PageLede({ action, layout = "stacked", figureFooter, - figureHighlighted, - figureSecondary, }: PageLedeProps) { const beside = layout === "beside"; - const paired = figureSecondary != null; - const primary = ( ); - const figure = paired ? ( -
- {primary} - {figureSecondary} -
- ) : ( + const figure = (
{primary}
); const prose = (
-
{figure}
+
{figure}
{prose}
); diff --git a/packages/ui/src/shared/page-shell.tsx b/packages/ui/src/shared/page-shell.tsx index 8b42ab97f0..71dd4abb3e 100644 --- a/packages/ui/src/shared/page-shell.tsx +++ b/packages/ui/src/shared/page-shell.tsx @@ -69,7 +69,14 @@ export function PageShell({

)}
- {actions &&
{actions}
} + {/* Wraps rather than refusing to shrink. `shrink-0` here sized the + block to its contents and let it run off the side of a phone, + taking the whole page's horizontal scroll with it. */} + {actions && ( +
+ {actions} +
+ )} {children}
diff --git a/packages/ui/src/shared/view-tabs.tsx b/packages/ui/src/shared/view-tabs.tsx index fabcde5d0b..2507812ccd 100644 --- a/packages/ui/src/shared/view-tabs.tsx +++ b/packages/ui/src/shared/view-tabs.tsx @@ -29,6 +29,9 @@ export interface ViewTabsProps { * derived from this value so the host can name them without a callback. */ panelId?: string; className?: string; + /** Names the row when a page carries more than one, so the two are + * distinguishable to a screen reader announcing "tab list". */ + "aria-label"?: string; } /** @@ -43,6 +46,7 @@ export function ViewTabs({ children, panelId: externalPanelId, className, + "aria-label": ariaLabel, }: ViewTabsProps) { // Stable id base so each tab can be aria-labelled to the shared panel and // the panel can point back at the active tab. @@ -85,6 +89,7 @@ export function ViewTabs({
diff --git a/packages/ui/src/stats/stat-ribbon.tsx b/packages/ui/src/stats/stat-ribbon.tsx index 36556fb77c..596b75ff6f 100644 --- a/packages/ui/src/stats/stat-ribbon.tsx +++ b/packages/ui/src/stats/stat-ribbon.tsx @@ -1,8 +1,16 @@ import * as React from "react"; +import { InfoTip } from "../shared/info-tip"; + export interface RibbonStat { label: string; value: string; + /** + * What this figure means, shown on an `InfoTip` beside the label. A figure a + * reader cannot define is a figure they cannot act on, and the native `title` + * this used to render was invisible, unreachable by keyboard and absent on + * touch — an explainer nobody could find. + */ hint?: string; /** * Tailwind text-colour class for the value. Only for figures that carry a @@ -15,6 +23,8 @@ export interface RibbonStat { sub?: string | undefined; /** Tailwind text-colour class for `sub`. Same rule as `valueColor`. */ subColor?: string | undefined; + /** Marks the figure the page's current selection describes. */ + highlighted?: boolean | undefined; /** Optional jump to the page that owns this figure. Added because the * Overview replaced a strip of *linked* KPI tiles with this component, and * without it Files and Symbols lost their only entry point from that page. */ @@ -49,10 +59,8 @@ export function StatRibbon({ {shown.map((s, i) => (
@@ -88,8 +99,15 @@ export function StatRibbon({ ) : ( <> -
+
{s.label} + {s.hint && }
= { production: "Production", }; +/** + * Tabs whose data honours `counts`. The trend is deliberately absent even + * though it sits on the Overview: snapshots recorded the full score, so there + * is no code-shape series to draw and inventing one from mean deductions would + * disagree with the headline wherever a file sits at the score floor. + */ +const COUNTED_TABS: TabId[] = ["triage", "findings"]; + +/** + * A named dropdown for a view control in the page header. + * + * These are filters over everything on the page, not navigation, so they read + * as a question and its current answer rather than as a second row of tabs + * competing with the real one. + */ +function ViewSelect({ + label, + value, + onValueChange, + options, +}: { + label: string; + value: string; + onValueChange: (next: string) => void; + options: { id: string; label: string }[]; +}) { + return ( +
+ + {label} + + +
+ ); +} + +const COUNTS_LABEL: Record = { + everything: "Everything", + code_shape: "Code shape only", +}; + /** * Nodes the map draws. The server chooses which ones: the selected file first, * then every file carrying an open performance cause in rank order, then the @@ -194,6 +258,16 @@ export default function CodeHealthPage() { // stays valid and the narrowed one gets its own. const scopeKey = scope === "all" ? "" : `:${scope}`; + const rawCounts = searchParams.get("counts"); + const counts: HealthCounts = (HEALTH_COUNTS as readonly string[]).includes(rawCounts ?? "") + ? (rawCounts as HealthCounts) + : "everything"; + // Same convention as scope, and appended after it: the two suffixes have to + // compose in one fixed order or the page-level key and the view-level key + // stop matching and the overview is fetched twice. + const countsKey = counts === "everything" ? "" : `:${counts}`; + const viewKey = `${scopeKey}${countsKey}`; + const rawLens = searchParams.get("lens"); const overlay: CodeHealthOverlay = (OVERLAYS as readonly string[]).includes(rawLens ?? "") ? (rawLens as CodeHealthOverlay) @@ -209,8 +283,8 @@ export default function CodeHealthPage() { // Shares the SWR key with TriageView — the meta line and the findings count // cost no extra request. const { data: overview } = useSWR( - `code-health-overview:${repoId}${scopeKey}`, - () => getHealthOverview(repoId, 25, scope), + `code-health-overview:${repoId}${viewKey}`, + () => getHealthOverview(repoId, 25, scope, counts), { revalidateOnFocus: false }, ); const meta = overview?.meta; @@ -267,12 +341,13 @@ export default function CodeHealthPage() { [selectedPath, highlightPaths], ); const { data: mapFeed } = useSWR( - `code-health-map:${repoId}${scopeKey}:${activePaths.join(",")}`, + `code-health-map:${repoId}${viewKey}:${activePaths.join(",")}`, () => getHealthMap(repoId, { cap: MAP_CAP, ...(activePaths.length ? { active: activePaths } : {}), scope, + counts, }), { revalidateOnFocus: false, keepPreviousData: true }, ); @@ -368,6 +443,17 @@ export default function CodeHealthPage() { [router, searchParams], ); + const setCounts = useCallback( + (next: string) => { + const sp = new URLSearchParams(searchParams.toString()); + if (next === "everything") sp.delete("counts"); + else sp.set("counts", next); + const qs = sp.toString(); + router.replace(qs ? `?${qs}` : "?", { scroll: false }); + }, + [router, searchParams], + ); + const setOverlay = useCallback( (next: CodeHealthOverlay) => { const sp = new URLSearchParams(searchParams.toString()); @@ -391,12 +477,25 @@ export default function CodeHealthPage() { // goes entirely to the field, which is this page's whole subject. maxWidth="wide" actions={ -
+ // Two named dropdowns rather than two tab rows. Four choices spelled + // out in full overflowed the header on a phone and set the whole page + // scrolling sideways; a trigger shows the current answer and spends no + // width on the alternative. +
{SCOPED_TABS.includes(activeTab) && ( - ({ id, label: SCOPE_LABEL[id] }))} + ({ id, label: SCOPE_LABEL[id] }))} + /> + )} + {COUNTED_TABS.includes(activeTab) && ( + ({ id, label: COUNTS_LABEL[id] }))} /> )}