From fa00b1aa775c2f5aa3617203111334bfcd7c9650 Mon Sep 17 00:00:00 2001 From: RaghavChamadiya Date: Mon, 7 Sep 2026 22:34:23 +0530 Subject: [PATCH 1/2] feat(health): lead with two figures and mark history as something to watch The Code Health page led with one number that half comes from git history, so it fell through a week of good refactoring and never said why. It now leads with two: Code health, footed by "Structure 8.4. History pulls it to 7.0.", and Maintainability, which is code shape only and the number a refactor moves. Neither changes value when the map lens changes; the lens highlights one. History findings are watch items, not work. In the drawer they wear a neutral chip with an explainer instead of the pillar's orange, and in both AI prompts they move out of the fix list into the file's behaviour-over-time section with an explicit instruction not to try to fix them. The drawer's leading cause now passes over a history lead for the strongest code-shape finding, and says so when a file's whole deduction is history. Only the three canonical bands are named. The lede and the file drawer had a five-band vocabulary of their own, so a 6.9 read "Good" in green beside marks calling it "Warning" in amber; the map's four colour steps borrowed band words for ranges that did not match, and invented a "Fair" band the product never returns, so they name their score ranges instead. The map lens, the coverage and test table headers and the file page follow the headline to "Code health". A production/all-code toggle in the page header narrows the overview, map, trend, files, findings and work queue. Scope rides in the cache key as well as the query, so a narrowed population cannot render under the previous label. The trend gains a maintainability line and a shaded band for what history costs, both drawn only across the snapshots that recorded them. Server changes are the ones the screens could not be built without: the two halves on the overview summary, a maintainability column on health_snapshots with the three figures exposed per trend point, and scope on the findings route. The trend route's response model dropped the new per-point fields after serialization, which reached the browser as nulls with every unit test green, so two route-level tests now cover the model itself. Removes HealthKpiCards, the dashboard barrel and the legacy /health redirect, none of which had a caller in either repository, and moves HealthScoreRing beside its only consumer. --- docs/architecture/code-health.md | 1 - packages/api-client/src/code-health.ts | 15 +- .../cli/commands/health_cmd/persist.py | 1 + .../src/repowise/cli/commands/upgrade_flow.py | 1 + .../repowise/core/analysis/health/trends.py | 16 + .../core/persistence/_interfaces/_analysis.py | 1 + .../core/persistence/crud/analysis/health.py | 23 +- .../src/repowise/core/persistence/models.py | 4 + .../core/persistence/stores/_sql_analysis.py | 2 + .../src/repowise/core/pipeline/persist.py | 1 + .../routers/code_health/findings_routes.py | 9 + .../repowise/server/schemas/code_health.py | 7 + packages/types/src/generated/http.ts | 3 + packages/types/src/health.ts | 23 ++ packages/ui/README.md | 2 +- packages/ui/__tests__/c4/sidebar.test.tsx | 2 +- .../__tests__/health/code-health-map.test.tsx | 8 +- .../health/file-health-prompt.test.tsx | 2 +- .../health/health-file-drawer.test.tsx | 53 ++- packages/ui/package.json | 1 - .../{dashboard => c4}/health-score-ring.tsx | 0 packages/ui/src/c4/panels/ArchNodeInfo.tsx | 2 +- packages/ui/src/dashboard/index.ts | 21 - packages/ui/src/files/file-health-tab.tsx | 4 +- packages/ui/src/files/file-page-header.tsx | 2 +- packages/ui/src/files/file-page-tabs.ts | 2 +- packages/ui/src/health/ai-prompt-builder.ts | 54 ++- packages/ui/src/health/biomarker-glossary.ts | 42 +- packages/ui/src/health/code-health-adapter.ts | 2 + packages/ui/src/health/code-health-lede.tsx | 155 ++++--- packages/ui/src/health/coverage-view.tsx | 4 +- packages/ui/src/health/health-file-drawer.tsx | 68 ++- packages/ui/src/health/index.ts | 1 - .../ui/src/health/inferred-tests-view.tsx | 2 +- packages/ui/src/health/kpi-cards.tsx | 389 ------------------ packages/ui/src/health/map/lens.ts | 18 +- packages/ui/src/health/tokens.ts | 13 + packages/ui/src/health/trend-chart.tsx | 90 +++- packages/ui/src/health/triage-view.tsx | 1 + packages/ui/src/shared/page-lede.tsx | 117 ++++-- .../src/app/repos/[id]/code-health/page.tsx | 70 +++- .../web/src/app/repos/[id]/health/page.tsx | 11 - .../components/code-health/findings-tab.tsx | 22 +- .../src/components/code-health/triage-tab.tsx | 19 +- tests/unit/server/test_health_excludes.py | 4 + tests/unit/server/test_health_trend_route.py | 63 +++ 46 files changed, 762 insertions(+), 589 deletions(-) rename packages/ui/src/{dashboard => c4}/health-score-ring.tsx (100%) delete mode 100644 packages/ui/src/dashboard/index.ts delete mode 100644 packages/ui/src/health/kpi-cards.tsx delete mode 100644 packages/web/src/app/repos/[id]/health/page.tsx diff --git a/docs/architecture/code-health.md b/docs/architecture/code-health.md index 61fcaee284..948f18e572 100644 --- a/docs/architecture/code-health.md +++ b/docs/architecture/code-health.md @@ -171,7 +171,6 @@ server/src/repowise/server/ ``` packages/ui/src/health/ # shared React components (used by web + future hosted frontend) -├── kpi-cards.tsx ├── file-table.tsx ├── biomarker-list.tsx ├── coverage-bar.tsx diff --git a/packages/api-client/src/code-health.ts b/packages/api-client/src/code-health.ts index f54b6ea473..2c651e703d 100644 --- a/packages/api-client/src/code-health.ts +++ b/packages/api-client/src/code-health.ts @@ -21,6 +21,7 @@ import type { PerformanceOpportunityQuery, HealthWorkQueueQuery, HealthWorkQueueResponse, + HealthScope, } from "@repowise-dev/types/health"; import type { Paginated } from "@repowise-dev/types"; import { apiGet, apiPatch } from "./client"; @@ -50,6 +51,7 @@ export type { HealthMapSelection, HealthModuleRow, HealthOverviewResponse, + HealthScope, HealthTrendResponse, HealthWorkItem, HealthWorkQueueQuery, @@ -73,10 +75,11 @@ export type { export async function getHealthOverview( repoId: string, limit = 25, + scope?: HealthScope, ): Promise { return apiGet( `/api/repos/${repoId}/health/overview`, - { limit }, + { limit, scope }, ); } @@ -88,6 +91,7 @@ export async function listHealthFindings( min_severity?: string; dimension?: string; limit?: number; + scope?: HealthScope; }, ): Promise { return apiGet(`/api/repos/${repoId}/health/findings`, opts); @@ -153,6 +157,7 @@ export async function getHealthMap( return apiGet(`/api/repos/${repoId}/health/map`, { cap: opts.cap, active: opts.active?.length ? opts.active.join(",") : undefined, + scope: opts.scope, }); } @@ -176,8 +181,12 @@ export async function getHealthFileBreakdown( ); } -export async function getHealthTrend(repoId: string, limit = 20): Promise { - return apiGet(`/api/repos/${repoId}/health/trend`, { limit }); +export async function getHealthTrend( + repoId: string, + limit = 20, + scope?: HealthScope, +): Promise { + return apiGet(`/api/repos/${repoId}/health/trend`, { limit, scope }); } export async function updateFindingStatus( diff --git a/packages/cli/src/repowise/cli/commands/health_cmd/persist.py b/packages/cli/src/repowise/cli/commands/health_cmd/persist.py index b3030a53cb..f30f574944 100644 --- a/packages/cli/src/repowise/cli/commands/health_cmd/persist.py +++ b/packages/cli/src/repowise/cli/commands/health_cmd/persist.py @@ -172,6 +172,7 @@ async def _do() -> None: structure_average=kpis.get("structure_average"), history_average=kpis.get("history_average"), production_average=kpis.get("production_average"), + maintainability_average=kpis.get("maintainability_average"), ) except Exception as exc: console.print(f"[yellow]Snapshot write skipped: {exc}[/yellow]") diff --git a/packages/cli/src/repowise/cli/commands/upgrade_flow.py b/packages/cli/src/repowise/cli/commands/upgrade_flow.py index 7240a4cd02..c286ab2466 100644 --- a/packages/cli/src/repowise/cli/commands/upgrade_flow.py +++ b/packages/cli/src/repowise/cli/commands/upgrade_flow.py @@ -420,6 +420,7 @@ async def _run_upgrade( structure_average=kpis.get("structure_average"), history_average=kpis.get("history_average"), production_average=kpis.get("production_average"), + maintainability_average=kpis.get("maintainability_average"), ) console.print( f"Code health recomputed at FULL tier: " diff --git a/packages/core/src/repowise/core/analysis/health/trends.py b/packages/core/src/repowise/core/analysis/health/trends.py index eba98a990b..acb9afde8b 100644 --- a/packages/core/src/repowise/core/analysis/health/trends.py +++ b/packages/core/src/repowise/core/analysis/health/trends.py @@ -319,11 +319,20 @@ def drop_unscoped_fields(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: "hotspot_health": None, "worst_performer_path": None, "worst_performer_score": None, + "structure_average": None, + "history_average": None, + "maintainability_average": None, } for row in rows ] +def _point(snap: Any, attr: str) -> float | None: + """One optional snapshot column, rounded for the wire.""" + value = getattr(snap, attr, None) + return round(float(value), 2) if value is not None else None + + def recent_kpis(history: list[Any], limit: int = 10) -> list[dict[str, Any]]: """Serialize the most-recent *limit* snapshots for CLI / API consumers. @@ -347,6 +356,13 @@ def recent_kpis(history: list[Any], limit: int = 10) -> list[dict[str, Any]]: if snap.worst_performer_score is not None else None ), + # The headline's two halves and the maintainability pillar at + # the same instant. NULL on snapshots taken before each was + # recorded, which is what lets a reader see where the series + # starts rather than reading a gap as a zero. + "structure_average": _point(snap, "structure_average"), + "history_average": _point(snap, "history_average"), + "maintainability_average": _point(snap, "maintainability_average"), } ) return rows diff --git a/packages/core/src/repowise/core/persistence/_interfaces/_analysis.py b/packages/core/src/repowise/core/persistence/_interfaces/_analysis.py index 80bf75506e..5627d7534c 100644 --- a/packages/core/src/repowise/core/persistence/_interfaces/_analysis.py +++ b/packages/core/src/repowise/core/persistence/_interfaces/_analysis.py @@ -225,6 +225,7 @@ async def save_health_snapshot( structure_average: float | None = None, history_average: float | None = None, production_average: float | None = None, + maintainability_average: float | None = None, taken_at: datetime | None = None, ) -> HealthSnapshot: ... diff --git a/packages/core/src/repowise/core/persistence/crud/analysis/health.py b/packages/core/src/repowise/core/persistence/crud/analysis/health.py index 1cb60bcaa0..92dcc1467d 100644 --- a/packages/core/src/repowise/core/persistence/crud/analysis/health.py +++ b/packages/core/src/repowise/core/persistence/crud/analysis/health.py @@ -26,6 +26,7 @@ worst_metric, ) from ....analysis.health.rows import detail_map +from ....analysis.health.scoring import nloc_weighted_attr from ....analysis.health.scope import scores_language from ....test_paths import is_test_related_path from ...models import ( @@ -566,6 +567,11 @@ async def get_perf_coverage(session: AsyncSession, repository_id: str) -> PerfCo return coverage_for_metrics(metrics, lang_by_path) +def _rounded(value: float | None) -> float | None: + """Two-decimal wire value, passing ``None`` through as "not measured".""" + return round(value, 2) if value is not None else None + + async def get_health_summary( session: AsyncSession, repository_id: str, @@ -598,6 +604,8 @@ async def get_health_summary( "open_findings": 0, "maintainability_average": None, "performance_average": None, + "structure_average": None, + "history_average": None, "maintainability_findings": 0, "performance_findings": 0, "performance_findings_density": None, @@ -692,12 +700,13 @@ async def get_health_summary( "worst_performer_path": worst.file_path, "worst_performer_score": round(worst.score, 2), "open_findings": len(findings), - "maintainability_average": ( - round(maintainability_average, 2) if maintainability_average is not None else None - ), - "performance_average": ( - round(performance_average, 2) if performance_average is not None else None - ), + "maintainability_average": _rounded(maintainability_average), + "performance_average": _rounded(performance_average), + # The headline's two halves, in deduction points. Both come off columns + # already on the rows in hand, so this costs no query — and without them + # the page can show a score falling without saying which half moved. + "structure_average": _rounded(nloc_weighted_attr(metrics, "structure_deduction")), + "history_average": _rounded(nloc_weighted_attr(metrics, "history_deduction")), "maintainability_findings": by_dim.get("maintainability", 0), "performance_findings": performance_findings, "performance_findings_density": performance_findings_density, @@ -759,6 +768,7 @@ async def save_health_snapshot( structure_average: float | None = None, history_average: float | None = None, production_average: float | None = None, + maintainability_average: float | None = None, taken_at: datetime | None = None, ) -> HealthSnapshot: """Append a snapshot; prune oldest rows past ``HEALTH_SNAPSHOT_RETENTION``. @@ -791,6 +801,7 @@ async def save_health_snapshot( structure_average=structure_average, history_average=history_average, production_average=production_average, + maintainability_average=maintainability_average, ) session.add(snap) await session.flush() diff --git a/packages/core/src/repowise/core/persistence/models.py b/packages/core/src/repowise/core/persistence/models.py index b43f4f5584..c05bc8d4c2 100644 --- a/packages/core/src/repowise/core/persistence/models.py +++ b/packages/core/src/repowise/core/persistence/models.py @@ -1860,6 +1860,10 @@ class HealthSnapshot(Base): # the line counts that weight them, so a narrowed trend rebuilt at read # time would be a differently-weighted number wearing the same name. production_average: Mapped[float | None] = mapped_column(Float, nullable=True) + # The maintainability pillar at the same instant as ``average_health``, so + # the trend can draw the number a refactor is meant to move beside the one + # history drags on. NULL on snapshots taken before it was recorded. + maintainability_average: Mapped[float | None] = mapped_column(Float, nullable=True) class CoverageFile(Base): diff --git a/packages/core/src/repowise/core/persistence/stores/_sql_analysis.py b/packages/core/src/repowise/core/persistence/stores/_sql_analysis.py index 2e4d9abe81..d25997dd92 100644 --- a/packages/core/src/repowise/core/persistence/stores/_sql_analysis.py +++ b/packages/core/src/repowise/core/persistence/stores/_sql_analysis.py @@ -262,6 +262,7 @@ async def save_health_snapshot( structure_average: float | None = None, history_average: float | None = None, production_average: float | None = None, + maintainability_average: float | None = None, taken_at: datetime | None = None, ) -> HealthSnapshot: return await crud.save_health_snapshot( @@ -276,6 +277,7 @@ async def save_health_snapshot( structure_average=structure_average, history_average=history_average, production_average=production_average, + maintainability_average=maintainability_average, taken_at=taken_at, ) diff --git a/packages/core/src/repowise/core/pipeline/persist.py b/packages/core/src/repowise/core/pipeline/persist.py index c277914899..d608523436 100644 --- a/packages/core/src/repowise/core/pipeline/persist.py +++ b/packages/core/src/repowise/core/pipeline/persist.py @@ -1615,6 +1615,7 @@ async def snapshot_health_from_store(session: Any, repo_id: str) -> None: structure_average=kpis.get("structure_average"), history_average=kpis.get("history_average"), production_average=kpis.get("production_average"), + maintainability_average=kpis.get("maintainability_average"), ) except Exception as exc: logger.warning("health_snapshot_skipped", error=str(exc)) 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 683ba91833..f8c5d5e80c 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,7 @@ from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession +from repowise.core.analysis.health.scope import parse_scope from repowise.core.persistence import crud from repowise.server.deps import get_db_session from repowise.server.schemas import ( @@ -15,6 +16,7 @@ from ._router import router from .loaders import _attach_symbol_ids +from .scope import ScopeQuery, narrow from .serializers import _finding_to_dict @@ -29,6 +31,7 @@ async def list_health_findings( min_severity: str | None = Query(None), dimension: str | None = Query(None), limit: int = Query(100, ge=1, le=1000), + scope: str = ScopeQuery, session: AsyncSession = Depends(get_db_session), ) -> list[dict]: """Open findings, ranked by health impact. @@ -48,6 +51,12 @@ async def list_health_findings( dimension=dimension, exclude_dimensions=("performance",), ) + # A finding carries a path, not ``is_test``, so narrowing it needs the + # metric rows that do. Read them only when the answer depends on them: + # the default scope returns the same list either way. + if parse_scope(scope) == "production": + metrics = await crud.get_health_metrics(session, repo_id) + _, findings = narrow(scope, metrics, findings) 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/schemas/code_health.py b/packages/server/src/repowise/server/schemas/code_health.py index f93ebbfd1a..4c31633c92 100644 --- a/packages/server/src/repowise/server/schemas/code_health.py +++ b/packages/server/src/repowise/server/schemas/code_health.py @@ -92,6 +92,13 @@ class HealthTrendKpiRow(BaseModel): average_health: float worst_performer_path: str | None = None worst_performer_score: float | None = None + #: The headline's two halves in deduction points, and the maintainability + #: pillar, at this snapshot. ``None`` before each was recorded and under a + #: narrowed scope, so a series starts partway along the axis rather than + #: reading an unrecorded point as a zero. + structure_average: float | None = None + history_average: float | None = None + maintainability_average: float | None = None class HealthTrendSummary(BaseModel): diff --git a/packages/types/src/generated/http.ts b/packages/types/src/generated/http.ts index 6cc6c147de..d083199b2c 100644 --- a/packages/types/src/generated/http.ts +++ b/packages/types/src/generated/http.ts @@ -1555,6 +1555,9 @@ export interface HealthTrendKpiRow { average_health: number; worst_performer_path?: string | null; worst_performer_score?: number | null; + structure_average?: number | null; + history_average?: number | null; + maintainability_average?: number | null; } export interface HealthTrendResponse { diff --git a/packages/types/src/health.ts b/packages/types/src/health.ts index e920405a48..8b4df46fd6 100644 --- a/packages/types/src/health.ts +++ b/packages/types/src/health.ts @@ -536,6 +536,14 @@ export interface HealthOverviewSummary { * (a clean repo returns `null` rather than a misleading "worst" at 10.0). */ worst_performance_path?: string | null; worst_performance_score?: number | null; + /** + * `average_health`'s two halves, in deduction points: what the code's own + * shape costs, and what its git history costs. They sum to the total + * deduction, so ten minus both is the unclamped score. `null`/absent until + * the rows carry the split. + */ + structure_average?: number | null; + history_average?: number | null; } export interface HealthOverviewResponse { @@ -566,6 +574,8 @@ export interface HealthFilesResponse { } export interface HealthFilesQuery { + /** Which half of the repository to describe. Defaults to `"all"`. */ + scope?: HealthScope; limit?: number; offset?: number; sort?: string; @@ -672,6 +682,8 @@ export interface HealthMapQuery { cap?: number; /** Paths guaranteed a node, admitted before any other band. */ active?: string[]; + /** Which half of the repository to describe. Defaults to `"all"`. */ + scope?: HealthScope; } /* ------------------------------------------------------------------ * @@ -807,6 +819,15 @@ export interface HealthTrendResponse { average_health: number; worst_performer_path: string | null; worst_performer_score: number | null; + /** + * The headline's two halves in deduction points, and the maintainability + * pillar, at this snapshot. `null` before each was recorded and under a + * narrowed scope, so a series can start partway along the axis rather than + * reading an unrecorded point as a zero. + */ + structure_average?: number | null; + history_average?: number | null; + maintainability_average?: number | null; }>; summary: { /** `null` under a narrowed scope: only the average covers both populations. */ @@ -1050,6 +1071,8 @@ export interface HealthWorkQueueQuery { min_severity?: string; max_effort?: string; sort?: "impact_per_effort" | "total_impact" | "score" | "finding_count"; + /** Which half of the repository to describe. Defaults to `"all"`. */ + scope?: HealthScope; } /** @deprecated Use HealthWorkItem; this is a file triage row, not a plan. */ diff --git a/packages/ui/README.md b/packages/ui/README.md index b75f46ed50..2ab6a41612 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -22,7 +22,7 @@ src/ git/ hotspot-table, ownership-table/treemap, churn viz, … graph/ graph-flow (Sigma), toolbar, legend, panels (+ sigma/) graph-primitives/ - health/ file-table, kpi-cards, markers, score tokens + health/ file-table, markers, score tokens hooks/ use-debounce, … jobs/ generation-progress, job-log modules/ module health detail diff --git a/packages/ui/__tests__/c4/sidebar.test.tsx b/packages/ui/__tests__/c4/sidebar.test.tsx index 9068c92c98..f998822440 100644 --- a/packages/ui/__tests__/c4/sidebar.test.tsx +++ b/packages/ui/__tests__/c4/sidebar.test.tsx @@ -7,7 +7,7 @@ import { ArchNodeInfo } from "../../src/c4/panels/ArchNodeInfo"; import { FileExplorer } from "../../src/c4/panels/FileExplorer"; import { createMockView } from "./fixtures"; -vi.mock("../../src/dashboard/health-score-ring", () => ({ +vi.mock("../../src/c4/health-score-ring", () => ({ HealthScoreRing: ({ score }: { score: number }) => (
{score}
), diff --git a/packages/ui/__tests__/health/code-health-map.test.tsx b/packages/ui/__tests__/health/code-health-map.test.tsx index f3238f251b..6ebaf5adcf 100644 --- a/packages/ui/__tests__/health/code-health-map.test.tsx +++ b/packages/ui/__tests__/health/code-health-map.test.tsx @@ -136,8 +136,12 @@ describe("CodeHealthMap", () => { it("shows the on-canvas health legend", () => { const { getByText } = render(); - expect(getByText("Health")).toBeInTheDocument(); + expect(getByText("Code health")).toBeInTheDocument(); expect(getByText(/galaxy = module/i)).toBeInTheDocument(); + // The four-step ramp names score ranges. Band words would claim a + // vocabulary the three-band scale beside it does not share. + expect(getByText("8 and above")).toBeInTheDocument(); + expect(getByText("4 to 6")).toBeInTheDocument(); }); it("renders the coverage legend under the coverage lens", () => { @@ -264,7 +268,7 @@ describe("map chrome, off canvas", () => { , ); expect(getByRole("radiogroup", { name: "Map lens" })).toBeInTheDocument(); - expect(getByRole("radio", { name: "Health" })).toBeChecked(); + expect(getByRole("radio", { name: "Code health" })).toBeChecked(); fireEvent.click(getByRole("radio", { name: "Performance" })); expect(onOverlayChange).toHaveBeenCalledWith("performance"); }); diff --git a/packages/ui/__tests__/health/file-health-prompt.test.tsx b/packages/ui/__tests__/health/file-health-prompt.test.tsx index 53ad072e3e..0dcfc1a50e 100644 --- a/packages/ui/__tests__/health/file-health-prompt.test.tsx +++ b/packages/ui/__tests__/health/file-health-prompt.test.tsx @@ -49,7 +49,7 @@ describe("buildFileHealthAiPrompt", () => { it("leads with the file and all three scored dimensions", () => { const out = buildFileHealthAiPrompt({ file, findings: [finding()] }); expect(out).toContain("packages/cli/doctor_cmd.py"); - expect(out).toContain("Defect risk: **1.0/10**"); + expect(out).toContain("Code health: **1.0/10**"); expect(out).toContain("Maintainability: **4.2/10**"); expect(out).toContain("Performance: **8.1/10**"); }); diff --git a/packages/ui/__tests__/health/health-file-drawer.test.tsx b/packages/ui/__tests__/health/health-file-drawer.test.tsx index 0067c0e402..6e6bf786ec 100644 --- a/packages/ui/__tests__/health/health-file-drawer.test.tsx +++ b/packages/ui/__tests__/health/health-file-drawer.test.tsx @@ -372,10 +372,12 @@ describe("HealthFileDrawer metrics", () => { expect(cellValue("Duplication")).toBe("not measured"); }); - it("leads with the file's own score and band", () => { + it("leads with the file's own score and a canonical band", () => { render( {}} metric={metric({ score: 1.0 })} />); expect(screen.getByText("1.0")).toBeInTheDocument(); - expect(screen.getByText("Critical")).toBeInTheDocument(); + // One of the three canonical bands, never a five-step word: this pill sits + // beside marks that all derive from `bandForScore`. + expect(screen.getByText("Alert")).toBeInTheDocument(); }); it("offers one link to the full page", () => { @@ -615,3 +617,50 @@ describe("HealthFileDrawer under the performance lens", () => { expect(empty).toContain("not a measurement that it is fast"); }); }); + +describe("HealthFileDrawer leading cause", () => { + it("passes over a history lead for the strongest code-shape finding", () => { + render( + {}} + metric={metric({ primary_biomarker: "co_change_scatter", primary_reason: "edits scatter" })} + findings={[ + finding({ biomarker_type: "co_change_scatter", health_impact: 1.8 }), + finding({ biomarker_type: "brain_method", health_impact: 0.4 }), + ]} + />, + ); + // The server's lead is the history marker; the drawer must not present it + // as the thing to fix. + expect(screen.getByText(/Brain method\./)).toBeInTheDocument(); + expect(screen.queryByText(/Co-change scatter\./)).not.toBeInTheDocument(); + }); + + it("says so when a file's whole deduction is history", () => { + render( + {}} + metric={metric({ primary_biomarker: "co_change_scatter", primary_reason: "edits scatter" })} + findings={[finding({ biomarker_type: "co_change_scatter", health_impact: 1.8 })]} + />, + ); + expect(screen.getByText(/Its deduction is all\s+history/)).toBeInTheDocument(); + }); + + it("marks a history finding as a watch item, not a pillar", () => { + render( + {}} + metric={metric()} + findings={[finding({ biomarker_type: "prior_defect", health_impact: 1.0 })]} + />, + ); + expect(screen.getAllByText("Watch").length).toBeGreaterThan(0); + // No pillar chip: the pillar colours mark where work belongs, and this is + // not work. The title is what distinguishes the chip from the score label. + expect(document.querySelector('[title="Code health pillar"]')).toBeNull(); + }); +}); diff --git a/packages/ui/package.json b/packages/ui/package.json index c8d53c6814..45f61cb0a5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -62,7 +62,6 @@ "./wiki/*": "./src/wiki/*.tsx", "./chat": "./src/chat/index.ts", "./chat/*": "./src/chat/*.tsx", - "./dashboard": "./src/dashboard/index.ts", "./dashboard/attention-href": "./src/dashboard/attention-href.ts", "./dashboard/*": "./src/dashboard/*.tsx", "./stats": "./src/stats/index.ts", diff --git a/packages/ui/src/dashboard/health-score-ring.tsx b/packages/ui/src/c4/health-score-ring.tsx similarity index 100% rename from packages/ui/src/dashboard/health-score-ring.tsx rename to packages/ui/src/c4/health-score-ring.tsx diff --git a/packages/ui/src/c4/panels/ArchNodeInfo.tsx b/packages/ui/src/c4/panels/ArchNodeInfo.tsx index 87f7eb73e2..dd417f85b2 100644 --- a/packages/ui/src/c4/panels/ArchNodeInfo.tsx +++ b/packages/ui/src/c4/panels/ArchNodeInfo.tsx @@ -4,7 +4,7 @@ import { X, Code, MapPin, Layers, ExternalLink, Folder, CornerDownRight } from " import { useArchitectureStore } from "../store/use-architecture-store"; import { getTone } from "../../graph-primitives/tone-styles"; import { THEME } from "../theme/theme-variables"; -import { HealthScoreRing } from "../../dashboard/health-score-ring"; +import { HealthScoreRing } from "../health-score-ring"; import { Section, Title, Sub, KVList, ActionRow, ActionButton, Badge, Pill } from "./panel-atoms"; export interface ArchNodeHealth { diff --git a/packages/ui/src/dashboard/index.ts b/packages/ui/src/dashboard/index.ts deleted file mode 100644 index 6b9c4da1c2..0000000000 --- a/packages/ui/src/dashboard/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -export * from "./attention-panel"; -export * from "./kpi-strip"; -export * from "./overview-grid"; -export * from "./commits-mini"; -export * from "./decisions-timeline"; -export * from "./health-overview-card"; -export * from "./savings-mini"; -export * from "./index-storage-mini"; -export * from "./dependency-heatmap"; -export * from "./execution-flows-panel"; -export * from "./health-score-ring"; -export * from "./hotspots-mini"; -export * from "./language-donut"; -export * from "./module-minimap"; -export * from "./module-overview-grid"; -export * from "./ownership-treemap"; -export * from "./active-job-banner"; -export * from "./quick-actions"; -export * from "./community-summary-grid"; -export * from "./explore-cards"; -export * from "./coupling-mini-card"; diff --git a/packages/ui/src/files/file-health-tab.tsx b/packages/ui/src/files/file-health-tab.tsx index 60a562e0fa..0d6b015ac8 100644 --- a/packages/ui/src/files/file-health-tab.tsx +++ b/packages/ui/src/files/file-health-tab.tsx @@ -137,7 +137,7 @@ export function FileHealthTab({ const pillars: RibbonStat[] = []; if (metric) { - // Defect risk is deliberately absent: the header's lede carries it at 44px + // Code health is deliberately absent: the header's lede carries it at 44px // with its band, on screen from whichever tab you arrive on. Repeating it // here at a quarter the size is the same number twice. if (metric.maintainability_score != null) { @@ -171,7 +171,7 @@ export function FileHealthTab({ title="The three signals" description={ <> - Defect risk is the calibrated number in the header. Maintainability and performance + Code health is the calibrated number in the header. Maintainability and performance are co-equal signals rather than a blend of it, and they are banded the same way — healthy at 8 and above, alert below 4.{" "} {metric.has_test_file diff --git a/packages/ui/src/files/file-page-header.tsx b/packages/ui/src/files/file-page-header.tsx index e9b1492577..ea0e7a99ac 100644 --- a/packages/ui/src/files/file-page-header.tsx +++ b/packages/ui/src/files/file-page-header.tsx @@ -91,7 +91,7 @@ export function FilePageHeader({ {score != null ? ( = { export const FILE_TAB_BLURB: Record = { overview: "The shape of the file: what it holds, what it touches, and what needs attention.", doc: "What Repowise has written about this file.", - health: "Defect risk, the biomarkers deducted from it, and how each function churns.", + health: "Code health, the markers deducted from it, and how each function churns.", history: "What git knows about this file — how often it changes, who changes it, and what moves with it.", decisions: "Architectural decisions recorded against this file.", graph: "Where this file sits in the indexed dependency graph.", diff --git a/packages/ui/src/health/ai-prompt-builder.ts b/packages/ui/src/health/ai-prompt-builder.ts index c996510fad..45567bb82a 100644 --- a/packages/ui/src/health/ai-prompt-builder.ts +++ b/packages/ui/src/health/ai-prompt-builder.ts @@ -20,7 +20,7 @@ import type { RefactoringPlan, } from "@repowise-dev/types/refactoring"; -import { biomarkerInfo, CATEGORY_LABEL } from "./biomarker-glossary"; +import { biomarkerInfo, CATEGORY_LABEL, splitByOrigin } from "./biomarker-glossary"; import type { HealthWorkItem } from "./refactoring-card"; import { blastFiles, @@ -130,6 +130,27 @@ function bulletList(items: (string | null | undefined | false)[]): string { return items.filter(Boolean).map((s) => `- ${s}`).join("\n"); } +/** + * History findings, stated as context rather than as work. + * + * They are scored, so leaving them out would not explain the file's number, + * but they are measured from the commit log: an agent handed them in a fix + * list will either edit the file until it gives up or invent a change that + * cannot move them. They get their own section and an explicit instruction. + */ +function historyContextBlock( + findings: { biomarker_type: string; reason: string; health_impact: number }[], +): string | null { + if (findings.length === 0) return null; + const ranked = findings.slice().sort((a, b) => b.health_impact - a.health_impact); + const cost = ranked.reduce((sum, f) => sum + f.health_impact, 0); + return [ + bulletList(ranked.map((f) => `**${biomarkerInfo(f.biomarker_type).label}** - ${f.reason}`)), + "", + `These are measured from this file's git history, not its code, and account for -${cost.toFixed(2)} points of its score. **Do not try to fix them.** No edit to this file will clear one; they move only as its commit history moves. Read them as background on how this code behaves over time, and let them raise your care where the structural work above touches the same regions.`, + ].join("\n"); +} + function biomarkerExtraContext( biomarkerType: string, details: Record | null | undefined, @@ -216,7 +237,7 @@ export function buildAiPrompt({ const t = target; const repoLine = repoName ? ` (\`${repoName}\`)` : ""; - const findings = ( + const allFindings = ( t.all_findings && t.all_findings.length > 0 ? t.all_findings : [ @@ -234,13 +255,18 @@ export function buildAiPrompt({ .slice() .sort((a, b) => b.health_impact - a.health_impact); + // History markers are scored but unfixable, so they belong in context, not + // in a list titled "issues to fix". The split preserves the ranking above. + const { codeShape: fixable, history: historyFindings } = splitByOrigin(allFindings); + const historyBlock = historyContextBlock(historyFindings); + // Cap the detailed findings so a file with dozens of hits doesn't produce a // multi-thousand-token prompt. The top findings (by impact) are spelled out // in full; the long tail is rolled up into a single grouped line so the agent // still knows what's left without paying for every description. const MAX_DETAILED_FINDINGS = 8; - const detailed = findings.slice(0, MAX_DETAILED_FINDINGS); - const remainder = findings.slice(MAX_DETAILED_FINDINGS); + const detailed = fixable.slice(0, MAX_DETAILED_FINDINGS); + const remainder = fixable.slice(MAX_DETAILED_FINDINGS); const findingsBlock = detailed .map((f, i) => { @@ -317,11 +343,11 @@ export function buildAiPrompt({ t.module ? `Module: \`${t.module}\`` : null, ]), "", - "## Issues to fix (ranked by impact)", - "", - findingsBlock, - remainderLine ?? "", + detailed.length > 0 + ? ["## Issues to fix (ranked by impact)", "", findingsBlock, remainderLine ?? ""].join("\n") + : "## Issues to fix\n\nNothing in this file's own code is currently scored. Its deduction is entirely history, listed below; there is no structural work to do here.", "", + historyBlock ? ["## How this file behaves over time", "", historyBlock, ""].join("\n") : "", t.primary_suggestion ? ["## Suggested direction", "", t.primary_suggestion, ""].join("\n") : "", @@ -2006,12 +2032,16 @@ export function buildFileHealthAiPrompt({ const open = findings.filter( (f) => f.status !== "resolved" && f.status !== "false_positive", ); - const ranked = open.slice().sort((a, b) => b.health_impact - a.health_impact); + // Split before ranking: history markers are scored but cannot be fixed from + // this file, so they go to context rather than into a list of open work. + const { codeShape, history: historyFindings } = splitByOrigin(open); + const historyBlock = historyContextBlock(historyFindings); + const ranked = codeShape.slice().sort((a, b) => b.health_impact - a.health_impact); const detailed = ranked.slice(0, MAX_FILE_HEALTH_FINDINGS); const remainder = ranked.slice(MAX_FILE_HEALTH_FINDINGS); const pillars = bulletList([ - file.defect_score != null ? `Defect risk: **${file.defect_score.toFixed(1)}/10**` : null, + file.defect_score != null ? `Code health: **${file.defect_score.toFixed(1)}/10**` : null, file.maintainability_score != null ? `Maintainability: **${file.maintainability_score.toFixed(1)}/10**` : null, @@ -2196,7 +2226,9 @@ export function buildFileHealthAiPrompt({ detailed.length > 0 ? `\n## Open findings (ranked by impact)\n\n${findingsBlock}` : "", remainderLine ? `\n${remainderLine}` : "", causeBlock ? `\n## Open performance causes\n\n${causeBlock}` : "", - signalLines ? `\n## How this file behaves over time\n\n${signalLines}` : "", + signalLines || historyBlock + ? `\n## How this file behaves over time\n\n${[signalLines, historyBlock].filter(Boolean).join("\n\n")}` + : "", // These carry their own leading blank line, because the filter below that // drops absent sections also drops any bare "" used as a separator. `\n## Hard constraints\n`, diff --git a/packages/ui/src/health/biomarker-glossary.ts b/packages/ui/src/health/biomarker-glossary.ts index 7f6f2afbd6..86cdc4e539 100644 --- a/packages/ui/src/health/biomarker-glossary.ts +++ b/packages/ui/src/health/biomarker-glossary.ts @@ -420,7 +420,7 @@ export function biomarkerDimension(name: string): BiomarkerDimension { } export const DIMENSION_LABEL: Record = { - defect: "Defect risk", + defect: "Code health", maintainability: "Maintainability", performance: "Performance", }; @@ -431,3 +431,43 @@ export const DIMENSION_CHIP: Record = { maintainability: "bg-[var(--color-accent-secondary)]/10 text-[var(--color-accent-secondary)]", performance: "bg-[var(--color-info)]/10 text-[var(--color-info)]", }; + +/** + * The category whose evidence is git history rather than the file's code. + * Mirrors `HISTORY_CATEGORY` / `split_by_origin` in core's health scoring: + * the two must agree, or a finding excluded from the score's structural half + * still reads as something to go and fix. + */ +export const HISTORY_CATEGORY: BiomarkerCategory = "organizational"; + +export function isHistoryBiomarker(name: string): boolean { + return biomarkerInfo(name).category === HISTORY_CATEGORY; +} + +/** + * Partition findings into the ones a reader can act on by editing the file and + * the ones they cannot. The TS mirror of core's `split_by_origin`. + */ +export function splitByOrigin( + findings: T[], +): { codeShape: T[]; history: T[] } { + const codeShape: T[] = []; + const history: T[] = []; + for (const finding of findings) { + (isHistoryBiomarker(finding.biomarker_type) ? history : codeShape).push(finding); + } + return { codeShape, history }; +} + +/** + * History findings are watch items, not work items, so they take a neutral + * chip. The pillar colours mark where work belongs; painting a signal nobody + * can act on in the same ink sends a reader to edit a file over its commit log. + */ +export const HISTORY_CHIP = + "bg-[var(--color-bg-elevated)] text-[var(--color-text-secondary)]"; + +export const HISTORY_LABEL = "Watch"; + +export const HISTORY_EXPLAINER = + "Measured from this file's git history, not its code. Editing the file will not clear it."; diff --git a/packages/ui/src/health/code-health-adapter.ts b/packages/ui/src/health/code-health-adapter.ts index 3ac682a524..42a71823a4 100644 --- a/packages/ui/src/health/code-health-adapter.ts +++ b/packages/ui/src/health/code-health-adapter.ts @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import type { HealthCoverageResponse, HealthFilesQuery, + HealthScope, HealthFilesResponse, HealthFinding, HealthOverviewResponse, @@ -26,6 +27,7 @@ export interface CodeHealthFindingsQuery { min_severity?: string; dimension?: string; limit?: number; + scope?: HealthScope; } export type FindingStatusValue = diff --git a/packages/ui/src/health/code-health-lede.tsx b/packages/ui/src/health/code-health-lede.tsx index 1d3aea9138..0dcc34b15b 100644 --- a/packages/ui/src/health/code-health-lede.tsx +++ b/packages/ui/src/health/code-health-lede.tsx @@ -1,44 +1,51 @@ /** - * The Code Health page's opening read: one figure large enough to lead, and the - * sentences that make it mean something. + * The Code Health page's opening read: the two figures that lead, and the + * sentences that make them mean something. * - * It replaces four separate containers that used to stack above the map — a - * collapsible "can you trust this score?" banner, three bordered signal tiles, - * and a bordered strip of operational stats. Between them they carried six - * numbers at near-identical weight behind five uppercase labels, which is the - * box-soup failure: everything claims the same importance, so nothing leads, - * and the page needed borders to produce the structure a type scale should have - * given it for free. + * 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. * - * The prose is not decoration here. "329 risks" reads as alarming on its own; + * The prose is not decoration. "329 risks" reads as alarming on its own; * "329 static performance risks, scored separately and never blended into the - * defect 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 + * 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. */ -import type { - DefectAccuracy, - HealthDistribution, - HealthOverviewSummary, +import { + bandForScore, + HEALTH_BAND_LABEL, + type DefectAccuracy, + type HealthDistribution, + type HealthOverviewSummary, } from "@repowise-dev/types/health"; -import { PageLede } from "../shared/page-lede"; +import { LedeFigure, PageLede } from "../shared/page-lede"; import { StatRibbon, type RibbonStat } from "../stats/stat-ribbon"; -// Bands come from the one shared function on purpose. Two surfaces disagreeing -// about where "Good" starts is worse than the duplication that would avoid it. -import { healthBand } from "../overview/health-lede"; import { formatNumber } from "../lib/format"; -import { scoreTextColor } from "./tokens"; +import { healthBandColor, scoreTextColor } from "./tokens"; import { HealthDistributionBar } from "./health-distribution-bar"; +/** Which of the two figures the page's current selection describes. */ +export type LedePillar = "health" | "maintainability"; + export interface CodeHealthLedeProps { summary: HealthOverviewSummary; /** Null when the repo lacks the defect history to make an honest claim. */ accuracy?: DefectAccuracy | null; /** NLOC-weighted split across the bands, shown under the score. */ distribution?: HealthDistribution | null; - /** Rendered under the prose — the host's pillar deep-links. */ + /** + * The figure the map is currently coloured by. It highlights one of the two + * and never changes either value: a lens is a way of looking at the repo, + * not a different repo. + */ + pillar?: LedePillar; + /** Rendered under the prose, for the host's pillar deep-links. */ action?: React.ReactNode; } @@ -48,37 +55,33 @@ function windowLabel(days: number): string { return months === 1 ? "month" : `${months} months`; } +/** A score as a canonical three-band chip. */ +function bandChip(score: number): { label: string; color: string } { + const band = bandForScore(score); + return { label: HEALTH_BAND_LABEL[band], color: healthBandColor(band) }; +} + export function CodeHealthLede({ summary, accuracy, distribution, + pillar = "health", action, }: CodeHealthLedeProps) { - const band = healthBand(summary.average_health); + const health = summary.average_health; const maint = summary.maintainability_average; const perf = summary.performance_average; const perfFindings = summary.performance_findings ?? 0; const hotspot = summary.hotspot_health; - - // Assembled rather than interpolated inline: a repo can have measured one - // pillar and not the other, and the naive version produces "The three are - // scored separately" when there are two of them. - const pillars: string[] = []; - if (maint != null) pillars.push(`maintainability ${maint.toFixed(1)}`); - if (perf != null) pillars.push(`static performance risk ${perf.toFixed(1)}`); + const structure = summary.structure_average; + const healthChip = bandChip(health); const stats: RibbonStat[] = [ { label: "Files", value: formatNumber(summary.file_count) }, - { - label: "Maintainability", - value: maint == null ? "" : `${maint.toFixed(1)}`, - valueColor: maint == null ? undefined : scoreTextColor(maint), - hint: "Smells that raise change-cost without predicting bugs. Scored on its own, never blended into the defect number.", - }, { 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.", + 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.", }, { label: "Hotspot health", @@ -92,21 +95,53 @@ export function CodeHealthLede({ return (
- ) : undefined + <> + {distribution && } + {structure != null && ( +

+ Structure{" "} + + {(10 - structure).toFixed(1)} + + . History pulls it to{" "} + + {health.toFixed(1)} + + . +

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

+ } + /> } >

@@ -116,17 +151,16 @@ export function CodeHealthLede({ , this codebase scores{" "} - {summary.average_health.toFixed(1)} out of 10 + {health.toFixed(1)} out of 10 {" "} - on defect risk, weighted by lines of code and built from complexity, + for code health, weighted by lines of code and built from complexity, duplication, coverage, churn and ownership. We rate that{" "} - {band.label.toLowerCase()}. - {pillars.length > 0 && ( + {healthChip.label.toLowerCase()}. + {perf != null && ( <> {" "} - It also scores {pillars.join(" and ")} out of 10;{" "} - {pillars.length === 1 ? "the two are" : "the three are"} measured - separately and never blended into one number. + Static performance risk is scored separately at {perf.toFixed(1)} out + of 10 and never blended into either figure. )}

@@ -157,10 +191,13 @@ export function CodeHealthLede({ {hotspot != null && (

The files you change most average{" "} - + {hotspot.toFixed(1)} - , {describeGap(hotspot, summary.average_health)} + , {describeGap(hotspot, health)}

)}
@@ -175,7 +212,7 @@ export function CodeHealthLede({ * * Worth a sentence rather than a delta chip: hotspot health below the repo * average is the finding that actually changes what someone does next, and - * "6.2 (−1.1)" does not say which direction is bad. + * "6.2 (-1.1)" does not say which direction is bad. */ function describeGap(hotspot: number, average: number): string { const gap = hotspot - average; diff --git a/packages/ui/src/health/coverage-view.tsx b/packages/ui/src/health/coverage-view.tsx index f9bcd56768..665af4e036 100644 --- a/packages/ui/src/health/coverage-view.tsx +++ b/packages/ui/src/health/coverage-view.tsx @@ -328,7 +328,7 @@ function CoverageBody({ }, { key: "health_score", - header: "Health", + header: "Code health", priority: 2, align: "right", sortable: true, @@ -557,7 +557,7 @@ const gapColumns: ResponsiveColumn[] = [ }, { key: "health_score", - header: "Health", + header: "Code health", priority: 2, align: "right", render: (f) => diff --git a/packages/ui/src/health/health-file-drawer.tsx b/packages/ui/src/health/health-file-drawer.tsx index f5126e1067..d3ec343d41 100644 --- a/packages/ui/src/health/health-file-drawer.tsx +++ b/packages/ui/src/health/health-file-drawer.tsx @@ -12,6 +12,10 @@ import { CATEGORY_LABEL, DIMENSION_CHIP, DIMENSION_LABEL, + HISTORY_CHIP, + HISTORY_EXPLAINER, + HISTORY_LABEL, + isHistoryBiomarker, type BiomarkerDimension, } from "./biomarker-glossary"; import { BiomarkerDetails, type BiomarkerDetailsRecord } from "./biomarker-details"; @@ -29,11 +33,13 @@ import { SEVERITY_LABEL, deltaColor, formatDelta, + healthBandColor, type Severity, } from "./tokens"; -// Shared band function, never a local threshold: two surfaces disagreeing -// about where "Good" starts is worse than the import. -import { healthBand } from "../overview/health-lede"; +// The canonical three bands, never a local threshold: this pill sits beside +// marks that all derive from `bandForScore`, and two of them disagreeing about +// where a band starts describes one file two ways in one viewport. +import { bandForScore, HEALTH_BAND_LABEL } from "@repowise-dev/types/health"; import type { FileHealthTrend, FileSignals, @@ -187,6 +193,7 @@ export function HealthFileDrawer({ // it through props on every collapsible group. const renderFinding = (f: HealthDrawerFinding) => { const info = biomarkerInfo(f.biomarker_type); + const isHistory = isHistoryBiomarker(f.biomarker_type); return ( // A hairline row, not a card inside a card. These sat as bordered boxes // inside a bordered group inside the drawer: three frames deep for one @@ -210,6 +217,18 @@ export function HealthFileDrawer({ {CATEGORY_LABEL[info.category]} {(() => { + // A history marker wears a neutral "Watch" chip instead of its + // pillar's: it is scored, but nothing in this file will clear it. + if (isHistory) { + return ( + + {HISTORY_LABEL} + + + ); + } const dim = f.dimension === "maintainability" || f.dimension === "defect" || @@ -385,17 +404,26 @@ export function HealthFileDrawer({ .sort((a, b) => b.total - a.total); })(); - // The one reason this file scores low: prefer the server lead, else the - // worst finding. Rendered as a headline so the "why" leads (P3). + // The one reason this file scores low, and it has to be one the reader can + // act on. The server's lead is the highest-impact finding outright, which on + // a churn-heavy file is a history marker — naming that as the leading cause + // points someone at a commit log and calls it the thing to fix. So a history + // lead is passed over for the strongest code-shape finding, and a file whose + // whole deficit is history says that instead of naming a cause. const primaryLead = (() => { - if (metric?.primary_biomarker) { + const codeShape = findings.filter((f) => !isHistoryBiomarker(f.biomarker_type)); + if (metric?.primary_biomarker && !isHistoryBiomarker(metric.primary_biomarker)) { return { biomarker: metric.primary_biomarker, reason: metric.primary_reason ?? null }; } - if (findings.length === 0) return null; - const worst = findings.reduce((a, b) => (b.health_impact > a.health_impact ? b : a)); + if (codeShape.length === 0) return null; + const worst = codeShape.reduce((a, b) => (b.health_impact > a.health_impact ? b : a)); return { biomarker: worst.biomarker_type, reason: worst.reason }; })(); + // Only meaningful once the findings have loaded: an empty list before then is + // "not known yet", not "nothing to fix". + const historyOnly = primaryLead === null && findings.length > 0; + return (

- Defect risk + Code health

{metric.score.toFixed(1)} @@ -436,12 +464,12 @@ export function HealthFileDrawer({ - {healthBand(metric.score).label} + {HEALTH_BAND_LABEL[bandForScore(metric.score)]} {trend && trend.points.length >= 2 ? ( @@ -482,6 +510,16 @@ export function HealthFileDrawer({ {primaryLead.reason ? ` ${primaryLead.reason}` : ""}

+ ) : historyOnly ? ( +
+

+ Leading cause +

+

+ Nothing in this file’s code is scored. Its deduction is all + history, which no edit here will clear. +

+
) : null} {/* Two actions, and only two: read the whole file's report, @@ -872,7 +910,7 @@ function PillarScore({ v }: { v: number | null }) { return ( {v.toFixed(1)} /10 diff --git a/packages/ui/src/health/index.ts b/packages/ui/src/health/index.ts index 34d534c3bd..69b81ad5f5 100644 --- a/packages/ui/src/health/index.ts +++ b/packages/ui/src/health/index.ts @@ -1,7 +1,6 @@ export * from "./tokens"; export * from "./biomarker-glossary"; export * from "./biomarker-chip"; -export * from "./kpi-cards"; export * from "./file-table"; export * from "./biomarker-list"; export * from "./biomarker-details"; diff --git a/packages/ui/src/health/inferred-tests-view.tsx b/packages/ui/src/health/inferred-tests-view.tsx index 71fcb85ad2..2a922ae324 100644 --- a/packages/ui/src/health/inferred-tests-view.tsx +++ b/packages/ui/src/health/inferred-tests-view.tsx @@ -137,7 +137,7 @@ export function InferredTestsView({ }, { key: "health_score", - header: "Health", + header: "Code health", priority: 2, align: "right", render: (f) => diff --git a/packages/ui/src/health/kpi-cards.tsx b/packages/ui/src/health/kpi-cards.tsx deleted file mode 100644 index 70739b168a..0000000000 --- a/packages/ui/src/health/kpi-cards.tsx +++ /dev/null @@ -1,389 +0,0 @@ -import type { HealthBand, HealthDistribution } from "@repowise-dev/types/health"; -import { bandForScore, HEALTH_BAND_LABEL } from "@repowise-dev/types"; -import { HeartPulse, Wrench, Gauge } from "lucide-react"; -import { formatNumber } from "../lib/format"; -import { InfoTip } from "../shared/info-tip"; -import { - scoreTextColor, - healthBandTextColor, - healthBandSoftBadgeClass, - formatDelta, - deltaColor, -} from "./tokens"; -import { Sparkline } from "./sparkline"; -import { type SeverityBreakdown } from "./severity-distribution"; -import { HealthDistributionBar } from "./health-distribution-bar"; - -export interface HealthSummary { - file_count: number; - average_health: number; - hotspot_health?: number | null; - worst_performer_path: string | null; - worst_performer_score: number | null; - open_findings: number; - severity_breakdown?: SeverityBreakdown; - /** Repo-level band from the API; derived from average_health when absent. */ - band?: HealthBand; - /** Maintainability pillar headline (the co-surfaced second signal). `null` - * when no file carries a maintainability score yet. */ - maintainability_average?: number | null; - /** Performance pillar headline (the co-surfaced third signal: static - * performance RISK). `null` when no file carries a performance score yet. */ - performance_average?: number | null; - /** Open findings homing under each pillar — the actionable counts. */ - maintainability_findings?: number; - performance_findings?: number; -} - -export interface HealthKpiCardsProps { - summary: HealthSummary; - /** NLOC-weighted file distribution across the 3 bands. */ - distribution?: HealthDistribution | null; - /** Optional series for sparklines, newest-last. */ - averageHistory?: number[]; - hotspotHistory?: number[]; - worstHistory?: number[]; - averageDelta?: number | null; - hotspotDelta?: number | null; - /** Jump to the pillar-filtered findings view. When provided, the - * Maintainability + Performance signal tiles become interactive. */ - onSelectPillar?: (pillar: "defect" | "maintainability" | "performance") => void; -} - -const DEFECT_HINT = - "The overall health headline: the defect-risk signal, calibrated against real bugs from complexity, duplication, coverage, churn, and ownership. NLOC-weighted across the repo."; -const MAINT_HINT = - "A co-equal second signal: the smells that hurt readability and change-cost (low cohesion, brain methods, primitive obsession, duplication, error handling), scored on their own and never blended into the defect score."; -const PERF_HINT = - "A co-equal third signal: static performance RISK — I/O-in-loop / N+1 shapes (a DB, network, filesystem, or subprocess call per loop iteration), detected across function boundaries via the call graph. High precision, low recall; never blended into the defect score."; - -export function HealthKpiCards({ - summary, - distribution, - averageHistory, - hotspotHistory, - averageDelta, - hotspotDelta, - onSelectPillar, -}: HealthKpiCardsProps) { - const band = summary.band ?? bandForScore(summary.average_health); - const maint = summary.maintainability_average; - const perf = summary.performance_average; - const perfFindings = summary.performance_findings ?? 0; - const maintFindings = summary.maintainability_findings ?? 0; - - return ( -
- {/* ── The three signals: the product's health model, given top billing ── */} -
- } - label="Defect risk" - hint={DEFECT_HINT} - score={summary.average_health} - band={band} - delta={averageDelta} - sparkline={averageHistory} - > - {distribution ? ( -
- -
- ) : null} -
- - } - label="Maintainability" - hint={MAINT_HINT} - score={maint ?? null} - band={maint != null ? bandForScore(maint) : null} - footnote={ - maint != null && maintFindings > 0 - ? `${formatNumber(maintFindings)} ${maintFindings === 1 ? "finding" : "findings"}` - : maint != null - ? "No open findings" - : undefined - } - onClick={maint != null && onSelectPillar ? () => onSelectPillar("maintainability") : undefined} - /> - - onSelectPillar("performance") : undefined} - /> -
- - {/* ── Operational stats: one quiet inline strip, not a second card grid ── */} -
- - - - -
-
- ); -} - -/** A compact horizontal stat — tiny label, value, optional /10 suffix, sub-line, - * delta, and sparkline. Several sit in one slim strip below the signal tiles. */ -function InlineStat({ - label, - value, - suffix, - valueClass, - sub, - subTitle, - delta, - sparkline, - hint, -}: { - label: string; - value: string; - suffix?: string | undefined; - valueClass?: string | undefined; - sub?: string | undefined; - subTitle?: string | undefined; - delta?: number | null | undefined; - sparkline?: number[] | undefined; - hint?: string | undefined; -}) { - return ( -
- - {label} - {hint ? : null} - - - - {value} - {suffix ? ( - {suffix} - ) : null} - - {delta != null && Math.abs(delta) >= 0.005 ? ( - {formatDelta(delta)} - ) : null} - {sparkline && sparkline.length > 1 ? ( - - - - ) : null} - {sub ? ( - - {sub} - - ) : null} - -
- ); -} - -/** A hero signal tile: icon + label, a large score/10 with band badge, and an - * optional supporting line. Becomes a button when `onClick` is provided. */ -function SignalTile({ - icon, - label, - hint, - score, - band, - delta, - sparkline, - footnote, - onClick, - children, -}: { - icon: React.ReactNode; - label: string; - hint: string; - score: number | null; - band: HealthBand | null; - delta?: number | null | undefined; - sparkline?: number[] | undefined; - footnote?: string | undefined; - onClick?: (() => void) | undefined; - children?: React.ReactNode; -}) { - const inner = ( - <> -
- - {icon} - {label} - {/* z-10 keeps this info button clickable above the tile's stretched - click target (which is a sibling, never a wrapping button). */} - - - {sparkline && sparkline.length > 1 ? ( - - - - ) : null} -
- - {score == null ? ( -

- — - not measured -

- ) : ( -
- - {score.toFixed(1)} - /10 - - {band ? ( - - {HEALTH_BAND_LABEL[band]} - - ) : null} -
- )} - - {delta != null && Math.abs(delta) >= 0.005 ? ( -

- {formatDelta(delta)} vs. prior -

- ) : footnote ? ( -

{footnote}

- ) : null} - - {children} - - ); - return ( - - {inner} - - ); -} - -/** Shared chrome for a hero signal tile: a static card, or a card with a - * stretched click target when an `onClick` makes it a jump into the - * pillar-filtered view. The click target is a sibling button covering the - * card rather than a button wrapping the content, so the header's info button - * is not an (invalid) interactive descendant of another button. */ -function TileShell({ - onClick, - ariaLabel, - children, -}: { - onClick?: (() => void) | undefined; - ariaLabel?: string | undefined; - children: React.ReactNode; -}) { - const cls = - "relative flex w-full flex-col rounded-xl border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-4 text-left transition-colors"; - if (onClick) { - return ( -
- {children} -
- ); - } - return
{children}
; -} - -/** Performance is a RISK pillar — so it leads with the count of open risks, with - * the /10 score as a calm secondary read. Clean repos show "0 · all clear". */ -function PerformanceTile({ - score, - findings, - onClick, -}: { - score: number | null; - findings: number; - onClick?: (() => void) | undefined; -}) { - const interactive = !!onClick && (findings > 0 || score != null); - const band = score != null ? bandForScore(score) : null; - const clear = score != null && findings === 0; - - const inner = ( - <> - - - - - Performance - - - - {score == null ? ( -

- — - not measured -

- ) : clear ? ( - <> -
- - 0 - - - All clear - -
-

- Performance health {score.toFixed(1)}/10 (higher is healthier) -

- - ) : ( - <> -
- - {formatNumber(findings)} - - - {findings === 1 ? "risk" : "risks"} - -
-

- Performance health {score.toFixed(1)}/10 (higher is healthier) - {band ? ( - - {HEALTH_BAND_LABEL[band]} - - ) : null} -

- - )} - - ); - return ( - - {inner} - - ); -} diff --git a/packages/ui/src/health/map/lens.ts b/packages/ui/src/health/map/lens.ts index fb8b3e7015..cd89b5c119 100644 --- a/packages/ui/src/health/map/lens.ts +++ b/packages/ui/src/health/map/lens.ts @@ -24,11 +24,19 @@ const BAND_FILL: Record = { good: "var(--color-node-good)", }; +/** + * The ramp names its score ranges rather than borrowing band words. + * + * It has four steps and the canonical band scale has three, so reusing the + * words made "Warning" mean 4 to 6 here and 4 to 8 on every other mark on the + * page, and invented a fourth band, "Fair", that the product never returns. A + * range is unambiguous and needs no glossary. + */ const BAND_LABEL: { band: ScoreBand; label: string }[] = [ - { band: "critical", label: "Alert" }, - { band: "poor", label: "Warning" }, - { band: "fair", label: "Fair" }, - { band: "good", label: "Healthy" }, + { band: "critical", label: "Below 4" }, + { band: "poor", label: "4 to 6" }, + { band: "fair", label: "6 to 8" }, + { band: "good", label: "8 and above" }, ]; /** @@ -230,7 +238,7 @@ export function performanceSentence(f: CodeHealthMapFile): string { export const OVERLAY_SPECS: Record = { health: { - label: "Health", + label: "Code health", caption: "galaxy = module · size = lines of code", fill: (f) => BAND_FILL[scoreBand(f.score)], legend: BAND_LABEL.map((b) => ({ fill: BAND_FILL[b.band], label: b.label })), diff --git a/packages/ui/src/health/tokens.ts b/packages/ui/src/health/tokens.ts index f24954aaa9..bff90d4fc0 100644 --- a/packages/ui/src/health/tokens.ts +++ b/packages/ui/src/health/tokens.ts @@ -98,6 +98,19 @@ export function healthBandTextColor(band: HealthBand): string { return HEALTH_BAND_TEXT[band]; } +/* The same three colours as raw CSS values, for the places that cannot take a + * class: an inline `style`, an SVG stroke. Kept beside HEALTH_BAND_TEXT so the + * two cannot drift the way the ink and text tables once did. */ +const HEALTH_BAND_COLOR: Record = { + alert: "var(--color-error)", + warning: "var(--color-warning)", + healthy: "var(--color-success)", +}; + +export function healthBandColor(band: HealthBand): string { + return HEALTH_BAND_COLOR[band]; +} + /* Literal class strings per band so Tailwind's static scanner sees them. */ const BAND_TEXT: Record = { critical: "text-[var(--color-error)]", diff --git a/packages/ui/src/health/trend-chart.tsx b/packages/ui/src/health/trend-chart.tsx index 4efc051bad..c032551399 100644 --- a/packages/ui/src/health/trend-chart.tsx +++ b/packages/ui/src/health/trend-chart.tsx @@ -8,6 +8,13 @@ export interface TrendSeriesPoint { hotspot_health: number | null; average_health: number; worst_performer_score: number | null; + /** + * The maintainability pillar, and the deduction history costs, at this + * snapshot. Both `null` before they were recorded, so each series starts + * partway along the axis instead of reading an unrecorded point as a zero. + */ + maintainability_average?: number | null; + history_average?: number | null; } export interface TrendChartProps { @@ -16,6 +23,33 @@ export interface TrendChartProps { height?: number; } +type LineKey = + | "average_health" + | "hotspot_health" + | "worst_performer_score" + | "maintainability_average"; + +/** + * Index ranges over which *has* holds without a gap. + * + * A line may simply skip a missing point, but an area cannot: filling straight + * across a gap would draw a measurement for a snapshot that never recorded one. + */ +function runs(items: T[], has: (item: T) => boolean): number[][] { + const out: number[][] = []; + let current: number[] = []; + items.forEach((item, i) => { + if (has(item)) { + current.push(i); + return; + } + if (current.length > 0) out.push(current); + current = []; + }); + if (current.length > 0) out.push(current); + return out; +} + export function TrendChart({ history, height = 220 }: TrendChartProps) { if (!history || history.length === 0) { return ( @@ -39,7 +73,7 @@ export function TrendChart({ history, height = 220 }: TrendChartProps) { history.length === 1 ? padL + plotW / 2 : padL + (i / (history.length - 1)) * plotW; const yScale = (v: number) => padT + ((10 - v) / 10) * plotH; - const path = (key: "average_health" | "hotspot_health" | "worst_performer_score") => { + const path = (key: LineKey) => { const pts: [number, number][] = []; history.forEach((p, i) => { const v = p[key]; @@ -49,6 +83,27 @@ export function TrendChart({ history, height = 220 }: TrendChartProps) { return pts.map(([x, y], i) => (i === 0 ? `M${x},${y}` : `L${x},${y}`)).join(" "); }; + const hasMaintainability = history.some((p) => p.maintainability_average != null); + + // The band between the score history does not touch and the score itself: + // what git history costs, drawn where the lede says it in words. Only over + // runs of snapshots that recorded the split. + const historyBands = runs(history, (p) => p.history_average != null).map((run) => + [ + ...run.map((i) => { + const p = history[i]!; + // Clamped: the two halves are means of deductions while the score is + // a mean of clamped scores, so on a repo with a floored file they can + // sum past 10 and the edge would leave the plot. + return `${run[0] === i ? "M" : "L"}${xScale(i)},${yScale( + Math.min(10, p.average_health + (p.history_average as number)), + )}`; + }), + ...[...run].reverse().map((i) => `L${xScale(i)},${yScale(history[i]!.average_health)}`), + "Z", + ].join(" "), + ); + return ( // No card. The chart sits inside a section that already names it, so a // border here is a second frame around content that has one. @@ -57,8 +112,17 @@ export function TrendChart({ history, height = 220 }: TrendChartProps) {

KPI trend

-
- +
+ + {hasMaintainability && ( + + )} + {historyBands.length > 0 && ( + + + History drag + + )}
@@ -73,12 +137,32 @@ export function TrendChart({ history, height = 220 }: TrendChartProps) { ))} + {/* Under the lines: the band is context for them, not a mark of its own. */} + {historyBands.map((d, i) => ( + + ))} + {hasMaintainability && ( + + )} {history.map((p, i) => ( + {p.maintainability_average != null ? ( + + ) : null} {p.hotspot_health != null ? ( ) : null} diff --git a/packages/ui/src/health/triage-view.tsx b/packages/ui/src/health/triage-view.tsx index d6ffcf17db..dfa7f64c1b 100644 --- a/packages/ui/src/health/triage-view.tsx +++ b/packages/ui/src/health/triage-view.tsx @@ -187,6 +187,7 @@ export function TriageView({ summary={overview.summary} accuracy={overview.defect_accuracy ?? null} distribution={overview.distribution ?? null} + pillar={overlay === "maintainability" ? "maintainability" : "health"} /> -

+ footer, + highlighted, +}: LedeFigureProps) { + return ( +

+

{label}

@@ -109,14 +123,67 @@ export function PageLede({ {badge}
- {figureFooter &&
{figureFooter}
} + {footer &&
{footer}
} +
+ ); +} + +/** + * The shape a page leads with: one figure large enough to lead, a band chip + * where a band exists, and the plain-English sentence that makes the figure + * readable. + * + * Extracted from `HealthLede`, which still composes it — the arrangement was + * being copied by every surface that adopted the section style, and three + * hand-rolled copies is how the 44 / 48 / 52 sizes drift apart. + * + * The prose is not decoration. "329 risks" reads as alarming; "329 static + * performance risks across 100% of scanned lines, which we rate 9.9 out of + * 10" reads as informative. Same number. + */ +export function PageLede({ + label, + value, + valueColor, + unit, + band, + badge, + children, + action, + layout = "stacked", + figureFooter, + figureHighlighted, + figureSecondary, +}: PageLedeProps) { + const beside = layout === "beside"; + const paired = figureSecondary != null; + + const primary = ( + + ); + + const figure = paired ? ( +
+ {primary} + {figureSecondary}
+ ) : ( +
{primary}
); const prose = (
-
{figure}
+
{figure}
{prose}
); diff --git a/packages/web/src/app/repos/[id]/code-health/page.tsx b/packages/web/src/app/repos/[id]/code-health/page.tsx index bf77dba716..0e519180e3 100644 --- a/packages/web/src/app/repos/[id]/code-health/page.tsx +++ b/packages/web/src/app/repos/[id]/code-health/page.tsx @@ -45,6 +45,7 @@ import { Button } from "@repowise-dev/ui/ui/button"; import { formatDateTime } from "@repowise-dev/ui/lib/format"; import type { CodeHealthOverlay } from "@repowise-dev/ui/health"; import type { DeadCodeSummary } from "@repowise-dev/types/dead-code"; +import { HEALTH_SCOPES, type HealthScope } from "@repowise-dev/types/health"; import { TriageTab } from "@/components/code-health/triage-tab"; import { HotspotsSection } from "@/components/code-health/hotspots-section"; import { FindingsTab } from "@/components/code-health/findings-tab"; @@ -126,6 +127,19 @@ const TAB_ALIASES: Record = { */ const OVERLAYS: CodeHealthOverlay[] = ["health", "maintainability", "performance", "churn"]; +/** + * Tabs whose data honours `scope`. The routes behind the others — coverage, + * dead code, security, blast radius — have no production/test split, and + * performance carries its own execution-context control, so the toggle is + * offered where it does something rather than sitting inert on five tabs. + */ +const SCOPED_TABS: TabId[] = ["triage", "findings"]; + +const SCOPE_LABEL: Record = { + all: "All code", + production: "Production", +}; + /** * 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 @@ -172,6 +186,14 @@ export default function CodeHealthPage() { ? "performance" : "triage"); + const rawScope = searchParams.get("scope"); + const scope: HealthScope = (HEALTH_SCOPES as readonly string[]).includes(rawScope ?? "") + ? (rawScope as HealthScope) + : "all"; + // Only the default population needs no key suffix, so an existing cache entry + // stays valid and the narrowed one gets its own. + const scopeKey = scope === "all" ? "" : `:${scope}`; + const rawLens = searchParams.get("lens"); const overlay: CodeHealthOverlay = (OVERLAYS as readonly string[]).includes(rawLens ?? "") ? (rawLens as CodeHealthOverlay) @@ -187,8 +209,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}`, - () => getHealthOverview(repoId, 25), + `code-health-overview:${repoId}${scopeKey}`, + () => getHealthOverview(repoId, 25, scope), { revalidateOnFocus: false }, ); const meta = overview?.meta; @@ -213,9 +235,11 @@ export default function CodeHealthPage() { data: trend, isLoading: trendLoading, error: trendError, - } = useSWR(`code-health-trend:${repoId}`, () => getHealthTrend(repoId, 20), { - revalidateOnFocus: false, - }); + } = useSWR( + `code-health-trend:${repoId}${scopeKey}`, + () => getHealthTrend(repoId, 20, scope), + { revalidateOnFocus: false }, + ); // The opportunity a link arrived with, so its files can be guaranteed a node // and marked. One bounded request, and only when the link carries an id. @@ -243,11 +267,12 @@ export default function CodeHealthPage() { [selectedPath, highlightPaths], ); const { data: mapFeed } = useSWR( - `code-health-map:${repoId}:${activePaths.join(",")}`, + `code-health-map:${repoId}${scopeKey}:${activePaths.join(",")}`, () => getHealthMap(repoId, { cap: MAP_CAP, ...(activePaths.length ? { active: activePaths } : {}), + scope, }), { revalidateOnFocus: false, keepPreviousData: true }, ); @@ -332,6 +357,17 @@ export default function CodeHealthPage() { [router, searchParams], ); + const setScope = useCallback( + (next: string) => { + const sp = new URLSearchParams(searchParams.toString()); + if (next === "all") sp.delete("scope"); + else sp.set("scope", next); + const qs = sp.toString(); + router.replace(qs ? `?${qs}` : "?", { scroll: false }); + }, + [router, searchParams], + ); + const setOverlay = useCallback( (next: CodeHealthOverlay) => { const sp = new URLSearchParams(searchParams.toString()); @@ -355,10 +391,21 @@ export default function CodeHealthPage() { // goes entirely to the field, which is this page's whole subject. maxWidth="wide" actions={ - +
+ {SCOPED_TABS.includes(activeTab) && ( + ({ id, label: SCOPE_LABEL[id] }))} + value={scope} + onValueChange={setScope} + /> + )} + +
} > {meta ? ( @@ -396,6 +443,7 @@ export default function CodeHealthPage() { selectedPath={selectedPath} onSelectPath={setSelectedPath} highlightPaths={highlightPaths} + scope={scope} hotspotsSlot={} trendSlot={ )} - {activeTab === "findings" && } + {activeTab === "findings" && } {activeTab === "performance" && } {activeTab === "coverage" && } {activeTab === "dead-code" && } diff --git a/packages/web/src/app/repos/[id]/health/page.tsx b/packages/web/src/app/repos/[id]/health/page.tsx deleted file mode 100644 index b7e9e81b49..0000000000 --- a/packages/web/src/app/repos/[id]/health/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { redirect } from "next/navigation"; - -/** Legacy route — redirects into the consolidated IA. */ -export default async function LegacyRedirect({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id } = await params; - redirect(`/repos/${id}/code-health`); -} diff --git a/packages/web/src/components/code-health/findings-tab.tsx b/packages/web/src/components/code-health/findings-tab.tsx index e3527835bc..141e06f01d 100644 --- a/packages/web/src/components/code-health/findings-tab.tsx +++ b/packages/web/src/components/code-health/findings-tab.tsx @@ -24,20 +24,30 @@ import { getFileOpportunity, refactoringOpportunityHref, } from "@/lib/api/file-opportunity"; +import type { HealthScope } from "@repowise-dev/types/health"; -export function FindingsTab({ repoId: id }: { repoId: string }) { +export function FindingsTab({ + repoId: id, + scope, +}: { + repoId: string; + /** Which half of the repository every figure here describes. */ + scope?: HealthScope; +}) { const router = useRouter(); const prefix = `/repos/${id}`; const adapter: CodeHealthAdapter = { - cacheKey: id, - getOverview: (limit) => getHealthOverview(id, limit), - listFindings: (opts) => listHealthFindings(id, opts), + cacheKey: scope && scope !== "all" ? `${id}:${scope}` : id, + getOverview: (limit) => getHealthOverview(id, limit, scope), + listFindings: (opts) => + listHealthFindings(id, { ...opts, ...(scope ? { scope } : {}) }), getFileOpportunity: (filePath) => getFileOpportunity(id, filePath), refactoringOpportunityHref: (opportunityId) => refactoringOpportunityHref(id, opportunityId), - listFiles: (opts) => listHealthFiles(id, opts), - getHealthWorkQueue: (opts) => getHealthWorkQueue(id, opts), + listFiles: (opts) => listHealthFiles(id, { ...opts, ...(scope ? { scope } : {}) }), + getHealthWorkQueue: (opts) => + getHealthWorkQueue(id, { ...opts, ...(scope ? { scope } : {}) }), updateFindingStatus: (findingId, status) => updateFindingStatus(id, findingId, status), getCoverage: (opts) => getHealthCoverage(id, opts), diff --git a/packages/web/src/components/code-health/triage-tab.tsx b/packages/web/src/components/code-health/triage-tab.tsx index 1726b481c7..007e327267 100644 --- a/packages/web/src/components/code-health/triage-tab.tsx +++ b/packages/web/src/components/code-health/triage-tab.tsx @@ -25,6 +25,7 @@ import { type HealthTrendResponse, type HealthMapFeed, } from "@/lib/api/code-health"; +import type { HealthScope } from "@repowise-dev/types/health"; import { HealthFileDrawerHost } from "@/components/health/health-file-drawer-host"; export function TriageTab({ @@ -40,6 +41,7 @@ export function TriageTab({ highlightPaths, hotspotsSlot, trendSlot, + scope, }: { repoId: string; /** Trend fetched once at the page level. */ @@ -61,16 +63,23 @@ export function TriageTab({ /** Sections composed by the page and rendered under the map. */ hotspotsSlot?: ReactNode; trendSlot?: ReactNode; + /** Which half of the repository every figure here describes. */ + scope?: HealthScope; }) { const router = useRouter(); const prefix = `/repos/${id}`; + // Scope rides in the cache key as well as the query: the views key their SWR + // off it, so narrowing has to make a different key or the first population + // stays on screen under the second one's label. const adapter: CodeHealthAdapter = { - cacheKey: id, - getOverview: (limit) => getHealthOverview(id, limit), - listFindings: (opts) => listHealthFindings(id, opts), - listFiles: (opts) => listHealthFiles(id, opts), - getHealthWorkQueue: (opts) => getHealthWorkQueue(id, opts), + cacheKey: scope && scope !== "all" ? `${id}:${scope}` : id, + getOverview: (limit) => getHealthOverview(id, limit, scope), + listFindings: (opts) => + listHealthFindings(id, { ...opts, ...(scope ? { scope } : {}) }), + listFiles: (opts) => listHealthFiles(id, { ...opts, ...(scope ? { scope } : {}) }), + getHealthWorkQueue: (opts) => + getHealthWorkQueue(id, { ...opts, ...(scope ? { scope } : {}) }), updateFindingStatus: (findingId, status) => updateFindingStatus(id, findingId, status), getCoverage: (opts) => getHealthCoverage(id, opts), diff --git a/tests/unit/server/test_health_excludes.py b/tests/unit/server/test_health_excludes.py index 7cedb401c2..62a6e12906 100644 --- a/tests/unit/server/test_health_excludes.py +++ b/tests/unit/server/test_health_excludes.py @@ -94,6 +94,10 @@ async def test_health_reads_honor_repo_settings_excludes(session, tmp_path) -> N "open_findings": 1, "maintainability_average": None, "performance_average": None, + # None because this fixture's rows predate the split, not because the + # summary stopped reporting it. + "structure_average": None, + "history_average": None, "maintainability_findings": 0, "performance_findings": 0, "performance_findings_density": None, diff --git a/tests/unit/server/test_health_trend_route.py b/tests/unit/server/test_health_trend_route.py index 5eb3670bfa..69cf5ae858 100644 --- a/tests/unit/server/test_health_trend_route.py +++ b/tests/unit/server/test_health_trend_route.py @@ -152,3 +152,66 @@ async def test_a_single_snapshot_reports_no_movement(client, session, tmp_path) assert body["file_deltas"] == [] assert body["file_deltas_total"] == 0 assert body["snapshot_count"] == 1 + + +async def test_history_rows_carry_the_split_and_the_maintainability_pillar( + client, session, tmp_path +) -> None: + """The per-point series the chart draws must survive the response model. + + These three are stored per snapshot and serialized by ``recent_kpis``, but + the route declares a response model, so a field missing from that model is + dropped after serialization and reaches the client as ``null`` — with the + core unit tests still green. + """ + repo_id = await _repo(client, session, tmp_path) + await save_health_snapshot( + session, + repo_id, + hotspot_health=5.0, + average_health=7.0, + worst_performer_path=None, + worst_performer_score=None, + per_file_scores={"a.py": 7.0}, + structure_average=1.6, + history_average=1.4, + maintainability_average=8.0, + ) + await session.commit() + + body = (await client.get(f"/api/repos/{repo_id}/health/trend")).json() + + assert body["history"][0]["structure_average"] == 1.6 + assert body["history"][0]["history_average"] == 1.4 + assert body["history"][0]["maintainability_average"] == 8.0 + + +async def test_a_narrowed_trend_blanks_the_figures_it_never_recorded( + client, session, tmp_path +) -> None: + """A production-scoped snapshot recorded only its own average. The rest + describes the whole repository, so it is blanked rather than served under a + label that would make it read as a production measurement.""" + repo_id = await _repo(client, session, tmp_path) + await save_health_snapshot( + session, + repo_id, + hotspot_health=5.0, + average_health=7.0, + worst_performer_path=None, + worst_performer_score=None, + per_file_scores={"a.py": 7.0}, + structure_average=1.6, + history_average=1.4, + maintainability_average=8.0, + production_average=6.0, + ) + await session.commit() + + body = (await client.get(f"/api/repos/{repo_id}/health/trend?scope=production")).json() + + row = body["history"][0] + assert row["average_health"] == 6.0 + assert row["structure_average"] is None + assert row["history_average"] is None + assert row["maintainability_average"] is None From 84595732606438f7479cb949a2a7e08acc1b4a96 Mon Sep 17 00:00:00 2001 From: RaghavChamadiya Date: Tue, 8 Sep 2026 17:47:59 +0530 Subject: [PATCH 2/2] fix(health): sort an import block and follow the headline rename in a test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are CI failures from the change that renamed the code-health headline. The import block lost its order when a symbol was added to it, and the VS Code webview test still looked for "Defect risk" on a lede that now says "Code health" — and the new label collides with the page title, so the assertion names the figure's own label rather than the first match. --- .../src/repowise/core/persistence/crud/analysis/health.py | 2 +- packages/vscode/webview/src/views/health/App.test.tsx | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/core/src/repowise/core/persistence/crud/analysis/health.py b/packages/core/src/repowise/core/persistence/crud/analysis/health.py index 92dcc1467d..f7f0788930 100644 --- a/packages/core/src/repowise/core/persistence/crud/analysis/health.py +++ b/packages/core/src/repowise/core/persistence/crud/analysis/health.py @@ -26,8 +26,8 @@ worst_metric, ) from ....analysis.health.rows import detail_map -from ....analysis.health.scoring import nloc_weighted_attr from ....analysis.health.scope import scores_language +from ....analysis.health.scoring import nloc_weighted_attr from ....test_paths import is_test_related_path from ...models import ( GraphNode, diff --git a/packages/vscode/webview/src/views/health/App.test.tsx b/packages/vscode/webview/src/views/health/App.test.tsx index 1ce4178c5e..6fc6ed5a5e 100644 --- a/packages/vscode/webview/src/views/health/App.test.tsx +++ b/packages/vscode/webview/src/views/health/App.test.tsx @@ -162,8 +162,12 @@ describe("Health dashboard", () => { />, ); - // The lede leads with the defect score, as the web code-health page does. - expect(await screen.findByText("Defect risk")).toBeTruthy(); + // The lede leads with the health score, as the web code-health page does. + // The page title carries the same words, so this asserts the figure's own + // label rather than the first match. + expect( + (await screen.findAllByText("Code health")).length, + ).toBeGreaterThan(1); // The figure, and again inside the sentence that makes it mean something. expect(screen.getAllByText("7.4").length).toBeGreaterThan(0);