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
10 changes: 8 additions & 2 deletions packages/api-client/src/code-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
HealthWorkQueueQuery,
HealthWorkQueueResponse,
HealthScope,
HealthCounts,
} from "@repowise-dev/types/health";
import type { Paginated } from "@repowise-dev/types";
import { apiGet, apiPatch } from "./client";
Expand Down Expand Up @@ -51,6 +52,7 @@
HealthMapSelection,
HealthModuleRow,
HealthOverviewResponse,
HealthCounts,
HealthScope,
HealthTrendResponse,
HealthWorkItem,
Expand All @@ -72,18 +74,19 @@
RefactoringTargetsResponse,
} from "@repowise-dev/types/health";

export async function getHealthOverview(

Check warning on line 77 in packages/api-client/src/code-health.ts

View check run for this annotation

Repowise Bot / Repowise / code health

Signature changed: getHealthOverview

2 callers outside this PR call `getHealthOverview`: `packages/vscode/src/core/webviewApi.ts::createHostApi`, `packages/web/src/components/code-health/coverage-tab.tsx::CoverageTab`. They are not part of this change, so nothing in this diff proves they still compile or still pass the right arguments.
repoId: string,
limit = 25,
scope?: HealthScope,
counts?: HealthCounts,
): Promise<HealthOverviewResponse> {
return apiGet<HealthOverviewResponse>(
`/api/repos/${repoId}/health/overview`,
{ limit, scope },
{ limit, scope, counts },
);
}

export async function listHealthFindings(

Check warning on line 89 in packages/api-client/src/code-health.ts

View check run for this annotation

Repowise Bot / Repowise / code health

Signature changed: listHealthFindings

3 callers outside this PR call `listHealthFindings`: `packages/vscode/src/core/fileSignals.ts::getFileFindings`, `packages/web/src/components/code-health/coverage-tab.tsx::CoverageTab`, `packages/web/src/components/code-health/performance-tab.tsx::PerformanceTab`. They are not part of this change, so nothing in this diff proves they still compile or still pass the right arguments.
repoId: string,
opts?: {
biomarker_type?: string;
Expand All @@ -92,6 +95,7 @@
dimension?: string;
limit?: number;
scope?: HealthScope;
counts?: HealthCounts;
},
): Promise<HealthFinding[]> {
return apiGet<HealthFinding[]>(`/api/repos/${repoId}/health/findings`, opts);
Expand Down Expand Up @@ -158,6 +162,7 @@
cap: opts.cap,
active: opts.active?.length ? opts.active.join(",") : undefined,
scope: opts.scope,
counts: opts.counts,
});
}

Expand All @@ -171,13 +176,14 @@
);
}

export async function getHealthFileBreakdown(

Check warning on line 179 in packages/api-client/src/code-health.ts

View check run for this annotation

Repowise Bot / Repowise / code health

Signature changed: getHealthFileBreakdown

2 callers outside this PR call `getHealthFileBreakdown`: `packages/vscode/src/features/hovers.ts::registerHovers`, `packages/vscode/src/features/lmTools.ts::registerLmTools`. They are not part of this change, so nothing in this diff proves they still compile or still pass the right arguments.
repoId: string,
filePath: string,
counts?: HealthCounts,
): Promise<HealthFileBreakdownResponse> {
return apiGet<HealthFileBreakdownResponse>(
`/api/repos/${repoId}/health/files/breakdown`,
{ file_path: filePath },
{ file_path: filePath, counts },
);
}

Expand Down
89 changes: 89 additions & 0 deletions packages/core/src/repowise/core/analysis/health/counts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Whether a code-health figure counts change history or only code shape.

Roughly half of a file's deduction comes from git-derived markers — churn,
co-change, ownership, prior fixes — which rise as a file is worked on. That is
correct for predicting defects and useless for answering "is my code getting
better", so the page lets a reader drop that half and score the code alone.

The projection needs no rescore: both halves are stored per file, and the
scorer's own arithmetic is ``clamp(SCORE_MAX - structure - history)``, so
leaving one out is a subtraction rather than a second pass over the findings.
"""

from __future__ import annotations

from typing import Any, Literal

from .scoring import SCORE_FLOOR, SCORE_MAX

HealthCounts = Literal["everything", "code_shape"]
DEFAULT_COUNTS: HealthCounts = "everything"
COUNTS: tuple[HealthCounts, ...] = ("everything", "code_shape")


def parse_counts(value: str | None) -> HealthCounts:
"""Read a value, falling back to the default on anything unknown.

The REST layer declares a pattern and refuses a bad value before this runs,
so the fallback is for internal callers passing a stored or computed string.
"""
return value if value in COUNTS else DEFAULT_COUNTS # type: ignore[return-value]


def code_shape_score(structure_deduction: float | None) -> float | None:
"""A file's score with the history half removed, clamped like any score.

``None`` when the split was never recorded, which is every row written
before it existed. Coercing a missing half to zero would print a confident
10.0 for a file nobody has measured.
"""
if structure_deduction is None:
return None
return max(SCORE_FLOOR, min(SCORE_MAX, SCORE_MAX - structure_deduction))


class CodeShapeMetric:
"""One metric row read with the history half of its score removed.

A wrapper rather than a mutation. The rows handed to a route are live ORM
objects, so assigning a projected score to one would mark it dirty and
write the projection back to the store on the next flush.
"""

# ``score`` is the surfaced number every aggregate weights and
# ``defect_score`` mirrors it on the wire; both move or the page disagrees
# with itself. ``history_deduction`` reads 0.0 because that is what this
# reading counts, which keeps every figure derived from the two halves —
# the wire row's ``unclamped_score``, the summary's history average — in
# step with the score beside it rather than describing the other reading.
# Everything else falls through to the row.
__slots__ = ("_row", "defect_score", "history_deduction", "score")

def __init__(self, row: Any, score: float) -> None:
object.__setattr__(self, "_row", row)
object.__setattr__(self, "score", score)
object.__setattr__(self, "defect_score", score)
object.__setattr__(self, "history_deduction", 0.0)

def __getattr__(self, name: str) -> Any:
return getattr(self._row, name)


def project(counts: str, metrics: list[Any]) -> tuple[list[Any], int]:
"""Re-read *metrics* under *counts*, dropping rows it cannot answer for.

Returns the projected rows and how many were dropped for want of a
recorded split, so a caller can say "not measured" rather than imply the
repository shrank.
"""
if parse_counts(counts) != "code_shape":
return metrics, 0
out: list[Any] = []
unmeasured = 0
for m in metrics:
score = code_shape_score(getattr(m, "structure_deduction", None))
if score is None:
unmeasured += 1
continue
out.append(CodeShapeMetric(m, score))
return out, unmeasured
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Reading a code-health response with or without its change-history half."""

from __future__ import annotations

from typing import Any

from fastapi import Query

from repowise.core.analysis.health.counts import COUNTS, DEFAULT_COUNTS, parse_counts
from repowise.core.analysis.health.counts import project as project_metrics
from repowise.core.analysis.health.models import split_by_origin

CountsQuery = Query(
DEFAULT_COUNTS,
description=(
"What the score counts: 'everything' (code shape and change history, as "
"calibrated) or 'code_shape' (the git-derived half removed). Change "
"history rises as a file is worked on, so it answers what a repository "
"has been through rather than what its code is like."
),
pattern=f"^({'|'.join(COUNTS)})$",
)


def project(
counts: str, metrics: list[Any], *findings: list[Any]
) -> tuple[Any, ...]:
"""Re-score *metrics* under *counts*, returning ``(rows, ..., unscored)``.

The findings a reading stops counting are dropped with it: under
``code_shape`` a history finding contributes to no figure on the page, so
leaving it in the lists would show work that sums past the score it sits
under. The count of rows the reading could not answer for is returned
alongside rather than recomputed, which on a large repo is a second walk of
the whole table building a second set of wrappers.
"""
projected, unscored = project_metrics(counts, metrics)
if parse_counts(counts) != "code_shape":
return (projected, *findings, unscored)
return (projected, *(split_by_origin(rows)[0] for rows in findings), unscored)
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from ._router import router
from .breakdown import _score_breakdown_from_findings
from .counts import CountsQuery, project
from .loaders import _attach_symbol_ids, _load_file_signals
from .scope import ScopeQuery, narrow
from .serializers import (
Expand Down Expand Up @@ -60,12 +61,16 @@ async def list_health_files(
),
),
scope: str = ScopeQuery,
counts: str = CountsQuery,
session: AsyncSession = Depends(get_db_session),
) -> dict:
if sort not in _SORT_FIELDS:
sort = "score"
metrics = await crud.get_health_metrics(session, repo_id)
(metrics,) = narrow(scope, metrics)
# Projected before every filter and the sort, so `total` and the ranking
# describe the same population the score does.
metrics, _unscored = project(counts, metrics)

hotspot_paths: set[str] = set()
if only_hotspots:
Expand Down Expand Up @@ -146,14 +151,19 @@ def _key(m: Any):
async def file_score_breakdown(
repo_id: str,
file_path: str = Query(..., description="File path to break down"),
counts: str = CountsQuery,
session: AsyncSession = Depends(get_db_session),
) -> dict:
repo = await crud.get_repository(session, repo_id)
if repo is None:
raise HTTPException(status_code=404, detail="Repository not found")
metrics = await crud.get_health_metrics(session, repo_id, file_paths=[file_path])
metric = metrics[0] if metrics else None
findings = await crud.get_health_findings(session, repo_id, file_path=file_path)
# This drawer opens from a row the reader just saw a score on. Reading it
# under the other counts would answer a click on 8.5 with a 2.0 and list
# the findings the page had just said were excluded.
metrics, findings, _unscored = project(counts, metrics, findings)
metric = metrics[0] if metrics else None
breakdown = _score_breakdown_from_findings(findings)
finding_dicts = await _attach_symbol_ids(
session, repo_id, [_finding_to_dict(f) for f in findings]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession

from repowise.core.analysis.health.counts import parse_counts
from repowise.core.analysis.health.models import split_by_origin
from repowise.core.analysis.health.scope import parse_scope
from repowise.core.persistence import crud
from repowise.server.deps import get_db_session
Expand All @@ -15,6 +17,7 @@
)

from ._router import router
from .counts import CountsQuery
from .loaders import _attach_symbol_ids
from .scope import ScopeQuery, narrow
from .serializers import _finding_to_dict
Expand All @@ -32,6 +35,7 @@ async def list_health_findings(
dimension: str | None = Query(None),
limit: int = Query(100, ge=1, le=1000),
scope: str = ScopeQuery,
counts: str = CountsQuery,
session: AsyncSession = Depends(get_db_session),
) -> list[dict]:
"""Open findings, ranked by health impact.
Expand All @@ -57,6 +61,10 @@ async def list_health_findings(
if parse_scope(scope) == "production":
metrics = await crud.get_health_metrics(session, repo_id)
_, findings = narrow(scope, metrics, findings)
# A history finding contributes nothing to a code-shape score, so listing
# it under one would show work that sums past the figure above it.
if parse_counts(counts) == "code_shape":
findings = split_by_origin(findings)[0]
return await _attach_symbol_ids(
session, repo_id, [_finding_to_dict(f) for f in findings[:limit]]
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
)

from ._router import router
from .counts import CountsQuery
from .scope import ScopeQuery


Expand All @@ -37,10 +38,11 @@ async def get_health_map(
),
),
scope: str = ScopeQuery,
counts: str = CountsQuery,
session: AsyncSession = Depends(get_db_session),
) -> dict[str, Any]:
"""One bounded field plus the exact scope of what the cap left out."""
feed = await HealthMapService(session, repo_id).feed(
cap=cap, active=parse_active(active), scope=scope
cap=cap, active=parse_active(active), scope=scope, counts=counts
)
return feed.payload()
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from repowise.server.mcp_server._meta import resolve_indexed_commit

from ._router import router
from .counts import CountsQuery, project
from .loaders import _attach_symbol_ids
from .scope import ScopeQuery, narrow
from .serializers import _finding_to_dict, _leads_by_file, _metric_to_dict
Expand Down Expand Up @@ -51,6 +52,7 @@ async def health_overview(
repo_id: str,
limit: int = Query(20, ge=1, le=200),
scope: str = ScopeQuery,
counts: str = CountsQuery,
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""KPIs + lowest-scoring files + per-module rollup + meta."""
Expand All @@ -65,6 +67,10 @@ async def health_overview(
metrics = await crud.get_health_metrics(session, repo_id)
findings = await crud.get_health_findings(session, repo_id)
metrics, findings = narrow(scope, metrics, findings)
# Every figure below is computed from these two lists, so projecting here
# is what keeps the headline, the distribution, the hotspot figure and the
# work the page lists all describing the same thing.
metrics, findings, unscored = project(counts, metrics, findings)
summary = await crud.get_health_summary(
session, repo_id, metrics=metrics, findings=findings
)
Expand All @@ -91,6 +97,10 @@ async def health_overview(
**summary,
"hotspot_health": hotspot_health_value,
"severity_breakdown": severity_breakdown(findings),
# Echoed so a surface labels what it was sent rather than what it
# asked for; the two differ while a request is in flight.
"counts": counts,
"unscored_files": unscored,
"band": band_for(float(avg)) if avg is not None else None,
}
distribution = health_distribution(metric_dicts)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from repowise.server.schemas import HealthWorkQueueResponse

from ._router import router
from .counts import CountsQuery, project
from .scope import ScopeQuery, narrow

_SEVERITY_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3}
Expand Down Expand Up @@ -67,6 +68,7 @@ async def health_work_queue(
"impact_per_effort", pattern="^(impact_per_effort|total_impact|score|finding_count)$"
),
scope: str = ScopeQuery,
counts: str = CountsQuery,
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Health work items ranked by impact / effort.
Expand All @@ -86,6 +88,7 @@ async def health_work_queue(
metrics = await crud.get_health_metrics(session, repo_id)
findings = await crud.get_health_findings(session, repo_id)
metrics, findings = narrow(scope, metrics, findings)
metrics, findings, _unscored = project(counts, metrics, findings)
metric_by_path = {m.file_path: m for m in metrics}

by_file: dict[str, list[Any]] = {}
Expand All @@ -106,8 +109,14 @@ async def health_work_queue(
if module and not file_path.startswith(module):
continue
m = metric_by_path.get(file_path)
nloc = m.nloc if m is not None else 0
score = m.score if m is not None else 10.0
# No metric row means this reading cannot score the file: under
# ``code_shape`` that is a row with no recorded split. Ranking it on a
# stand-in 10.0 would put an unmeasured file at the top of a list
# ordered by how bad things are.
if m is None:
continue
nloc = m.nloc
score = m.score
primary = primary_finding(fs)
total_impact = round(sum(x.health_impact for x in fs), 3)
effort_bucket = _effort_for_nloc(nloc)
Expand Down
Loading
Loading