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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion docs/architecture/code-health.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions packages/api-client/src/code-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -50,6 +51,7 @@ export type {
HealthMapSelection,
HealthModuleRow,
HealthOverviewResponse,
HealthScope,
HealthTrendResponse,
HealthWorkItem,
HealthWorkQueueQuery,
Expand All @@ -73,10 +75,11 @@ export type {
export async function getHealthOverview(
repoId: string,
limit = 25,
scope?: HealthScope,
): Promise<HealthOverviewResponse> {
return apiGet<HealthOverviewResponse>(
`/api/repos/${repoId}/health/overview`,
{ limit },
{ limit, scope },
);
}

Expand All @@ -88,6 +91,7 @@ export async function listHealthFindings(
min_severity?: string;
dimension?: string;
limit?: number;
scope?: HealthScope;
},
): Promise<HealthFinding[]> {
return apiGet<HealthFinding[]>(`/api/repos/${repoId}/health/findings`, opts);
Expand Down Expand Up @@ -153,6 +157,7 @@ export async function getHealthMap(
return apiGet<HealthMapFeed>(`/api/repos/${repoId}/health/map`, {
cap: opts.cap,
active: opts.active?.length ? opts.active.join(",") : undefined,
scope: opts.scope,
});
}

Expand All @@ -176,8 +181,12 @@ export async function getHealthFileBreakdown(
);
}

export async function getHealthTrend(repoId: string, limit = 20): Promise<HealthTrendResponse> {
return apiGet<HealthTrendResponse>(`/api/repos/${repoId}/health/trend`, { limit });
export async function getHealthTrend(
repoId: string,
limit = 20,
scope?: HealthScope,
): Promise<HealthTrendResponse> {
return apiGet<HealthTrendResponse>(`/api/repos/${repoId}/health/trend`, { limit, scope });
}

export async function updateFindingStatus(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]")
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/repowise/cli/commands/upgrade_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: "
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/repowise/core/analysis/health/trends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: ...

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
)
from ....analysis.health.rows import detail_map
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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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``.
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/repowise/core/persistence/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
)

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/repowise/core/pipeline/persist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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


Expand All @@ -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.
Expand All @@ -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]]
)
Expand Down
7 changes: 7 additions & 0 deletions packages/server/src/repowise/server/schemas/code_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions packages/types/src/generated/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions packages/types/src/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

/* ------------------------------------------------------------------ *
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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. */
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/__tests__/c4/sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<div data-testid="health-score-ring">{score}</div>
),
Expand Down
8 changes: 6 additions & 2 deletions packages/ui/__tests__/health/code-health-map.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,12 @@ describe("CodeHealthMap", () => {

it("shows the on-canvas health legend", () => {
const { getByText } = render(<CodeHealthMap files={[f("a.py", 30, "core")]} />);
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", () => {
Expand Down Expand Up @@ -264,7 +268,7 @@ describe("map chrome, off canvas", () => {
<MapLensSwitcher overlay="health" onOverlayChange={onOverlayChange} />,
);
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");
});
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/__tests__/health/file-health-prompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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**");
});
Expand Down
Loading
Loading