Code Health: Architecture & Internals

July 25, 2026 · View on GitHub

Companion to the user-facing docs/layers/CODE_HEALTH.md. This document is for contributors: where every piece lives, how data flows from parsed source to the dashboard, and the extension points for adding markers, languages, coverage formats, or alerts.

TL;DR. Health analysis is a deterministic, zero-LLM Python pipeline: tree-sitter walks every file once -> markers vote -> scores aggregate per category -> results land in four SQLAlchemy tables. The MCP server, CLI, and Next.js dashboard all read from those tables: no JSON cache, no intermediate files, no LLM in the loop.


1. Layer overview

Code Health is the fifth intelligence layer in Repowise, alongside Graph, Git, Docs, and Decisions. It reads from Graph and Git but never modifies them. Its only writes are to its own four tables.

┌─────────────┐  parsed_files     ┌──────────────────┐
│ Ingestion   │ ────────────────► │                  │
│ (AST + git) │  git_meta_map     │  HealthAnalyzer  │ ──► HealthReport
│             │ ────────────────► │   (engine.py)    │       │
│             │  community_labels │                  │       │
└─────────────┘                   └──────────────────┘       │
                                                             │  delete/upsert

                                            ┌─────────────────────────────┐
                                            │ SQLite via SQLAlchemy       │
                                            │  • health_findings          │
                                            │  • health_file_metrics      │
                                            │  • health_snapshots         │
                                            │  • coverage_files           │
                                            └──────────────┬──────────────┘

                          ┌────────────────────────────────┼────────────────────────────────┐
                          ▼                                ▼                                ▼
                     CLI (rich)                  MCP tools (FastMCP)                  Web dashboard
                  health, status,           get_health, get_risk,                   /repos/[id]/health{,
                  health --trend            get_context, get_overview              /coverage,/refactoring-targets}

Three architectural rules govern the whole layer:

  1. Zero LLM. Every marker is AST, git, or coverage math.
  2. No JSON caches. SQLite is the single source of truth; everything else reads from it.
  3. No new runtime dependencies. Pure Python over tree-sitter (already in tree). No lizard, no jscpd, no Node.

2. Where things live

Python: packages/core/src/repowise/core/

analysis/health/
├── README.md                       # developer overview (this layer)
├── __init__.py                     # public API: HealthAnalyzer, HealthReport
├── engine.py                       # orchestrator: walker → biomarkers → scorer
├── scoring.py                      # weighted aggregation, category caps, KPIs
├── grading.py                      # 3 defect-backed bands + NLOC-weighted distribution
├── defect_accuracy.py              # "does the score find the bugs?" self-validation
├── trends.py                       # snapshot diff, Declining/Predicted alerts, per-file score series
├── signals.py                      # per-file process/people/topology join (surfacing-only)
├── churn_complexity.py             # churn × complexity scatter points (surfacing-only)
├── suggestions.py                  # deterministic refactoring text per biomarker
├── config.py                       # HealthConfig + .repowise/health-rules.json
├── models.py                       # HealthFindingData, HealthFileMetricData, HealthReport

├── complexity/                     # tree-sitter AST walker
│   ├── README.md
│   ├── walker.py                   # CCN, nesting, cognitive, bumps, params, NLOC
│   └── languages.py                # per-language control-flow node-type maps

├── coverage/                       # coverage report ingestion
│   ├── README.md
│   ├── model.py                    # CoverageReport, FileCoverage
│   ├── detector.py                 # format auto-detect + test-file heuristic
│   ├── lcov.py                     # LCOV parser (stdlib only)
│   ├── cobertura.py                # Cobertura XML parser
│   ├── clover.py                   # Clover XML parser
│   └── repowise_json.py            # normalized repowise-coverage-v1 JSON parser

├── duplication/                    # native Rabin-Karp clone detection
│   ├── README.md
│   ├── tokenizer.py                # tree-sitter token stream (ID/LIT normalized)
│   ├── rabin_karp.py               # 64-bit rolling polynomial hash
│   └── detector.py                 # clone-pair build + co-change weighting

└── biomarkers/                     # one detector per file
    ├── README.md
    ├── base.py                     # Biomarker Protocol + FileContext + BiomarkerResult
    ├── registry.py                 # detector list + detect_all()
    ├── brain_method.py
    ├── low_cohesion.py
    ├── god_class.py
    ├── nested_complexity.py
    ├── bumpy_road.py
    ├── complex_method.py
    ├── large_method.py
    ├── primitive_obsession.py
    ├── dry_violation.py
    ├── untested_hotspot.py
    ├── coverage_gap.py
    ├── coverage_gradient.py
    ├── developer_congestion.py
    ├── knowledge_loss.py
    ├── hidden_coupling.py
    ├── complex_conditional.py
    ├── function_hotspot.py
    ├── code_age_volatility.py
    ├── ownership_risk.py
    ├── churn_risk.py
    ├── change_entropy.py
    ├── co_change_scatter.py
    ├── prior_defect.py
    ├── large_assertion_block.py
    ├── duplicated_assertion_block.py
    └── error_handling.py

Persistence

core/persistence/
├── models.py                       # HealthFinding, HealthFileMetric, HealthSnapshot, CoverageFile
└── crud.py                         # save_/upsert_/get_ health functions
core/alembic/versions/
└── 000X_health_tables.py           # migration that created the four tables

Pipeline wiring

core/pipeline/
├── orchestrator.py                 # _run_health_analysis(): builds module_map, runs analyzer
└── persist.py                      # persist_pipeline_result(): writes findings/metrics/snapshot

CLI

cli/src/repowise/cli/commands/
├── health_cmd.py                   # repowise health [--trend|--refactoring-targets|--module]
├── status_cmd.py                   # `Health: 7.4 (avg) · 6.2 (hotspots) · 2.1 (worst: ...)`
└── update_cmd.py                   # incremental path: HealthAnalyzer.analyze(changed_files=...)

Server: MCP + REST

server/src/repowise/server/
├── mcp_server/
│   ├── tool_health.py              # @mcp.tool get_health(targets, include, repo, limit)
│   ├── tool_risk.py                # enriched: health_score, top_biomarkers, coverage_pct
│   ├── tool_context.py             # include=["health"]: score, top 2 biomarkers, suggestion
│   └── tool_overview.py            # code_health block with KPIs
└── routers/
    └── code_health.py              # /api/repos/{id}/health/{overview,files,coverage,
                                    # refactoring-targets,modules,findings}

Web dashboard

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
├── module-coverage-list.tsx
├── untested-hotspot-warning.tsx
├── refactoring-card.tsx
├── refactoring-target-list.tsx
├── health-badge.tsx               # score pill, colored by the 3 health bands
├── health-distribution-bar.tsx    # NLOC-weighted Alert/Warning/Healthy split
├── trend-chart.tsx                # repo KPI history (3 series)
├── file-trend-chart.tsx           # single file's score-over-time + delta + declining flag
├── sparkline.tsx                  # compact inline series (drawer trend)
└── module-rollup-list.tsx

packages/web/src/app/repos/[id]/health/
├── page.tsx                        # KPIs + lowest-scoring files + per-module rollup
├── coverage/page.tsx               # /health/coverage view
└── refactoring-targets/page.tsx    # /health/refactoring-targets view

packages/web/src/components/health/
└── health-risks-panel.tsx          # sidecar panel on Hotspots/Ownership/Graph pages

Tests

tests/unit/health/                  # 99+ tests
├── test_complexity_walker.py       # per-language CCN/nesting assertions
├── test_biomarkers.py
├── test_structural_biomarkers.py   # bumpy_road, large_method, primitive_obsession
├── test_coverage_biomarkers.py     # untested_hotspot, coverage_gap
├── test_organizational_biomarkers.py
├── test_dry_violation.py
├── test_duplication.py             # tokenizer, hash, detector
├── test_coverage_parsers.py        # LCOV/Cobertura/Clover/JSON
├── test_scoring.py                 # category caps, clamping
├── test_scoring_snapshot.py        # stability snapshot: locks caps + deductions
├── test_health_config.py           # .repowise/health-rules.json
├── test_trends.py                  # diff_snapshots, declining/predicted alerts
├── test_signals.py                 # file_signals join + no-signal/normalization
├── test_churn_complexity.py        # churn × complexity point shaping + sort + filtering
└── test_suggestions.py

tests/integration/
├── test_health_coverage_integration.py
└── test_health_perf_benchmark.py   # 30 s budget on 3,000-file synthetic repo (slow)

3. The pipeline (init path)

repowise init runs run_pipeline() in core/pipeline/orchestrator.py. _run_health_analysis() is a phase in that orchestrator, called between _run_dead_code_analysis() and _run_decision_extraction(). It does four things:

  1. Builds a {file_path: community label} map from the graph's community detection so HealthFileMetric.module is populated (module rollups are never NULL).
  2. Loads per-file override rules from .repowise/health-rules.json via HealthConfig.load(repo_path) (a no-op when the file is absent) and resolves them to per-file disabled sets with to_analyzer_config().
  3. Constructs the HealthAnalyzer with everything it needs: the NetworkX graph (for dependents), git_meta_map (hotspot bit, owners, co-change, bus factor), the parsed_files from the AST phase, and the module map.
  4. Picks the sync or parallel path by repo size. tree-sitter releases the GIL during parsing, so on repos with >= 500 parsed files analyze_async() (asyncio gather over worker threads) gives a real wall-clock speedup; smaller repos run analyze() on a single thread.

The returned report rides on PipelineResult.health_report. Then core/pipeline/persist.py writes it in one session: save_health_metrics, save_health_findings (only when there are findings), and a save_health_snapshot carrying the three KPIs plus a {path: score} map for trend tracking (rolling 50-row window per repo).


4. Inside HealthAnalyzer.analyze()

A single pass over the parsed file list. For each file the analyzer:

  1. Walks the AST (_walk): walk_file(language, source) returns a FileComplexity of functions and classes. Each FunctionComplexity carries name, line range, nloc, ccn, max nesting, cognitive complexity, bumps, and param count; each ClassComplexity carries method count, total nloc, the method list, LCOM4, max method ccn, and field count.
  2. Populates symbol complexity (_populate_symbol_complexity): writes max(ccn) into Symbol.complexity_estimate as a side effect, so the ContextAssembler symbol ranker benefits even when a caller never touches the health tables.
  3. Evaluates the file (_evaluate_file): builds a FileContext (nloc, has_test_file, module, per-function and per-class metrics, the per-file git_meta, graph in-degree as dependents_count, the repo-wide p80 of in-degree used as the brain_method floor, coverage fields when ingested, and the file's clone slice), runs detect_all(), scores with score_file(), and attaches per-finding impacts.

After the loop, compute_kpis() runs over the metrics and the set of hotspot paths (git_meta_map[path]["is_hotspot"]), and the analyzer returns a HealthReport(findings, metrics, kpis).

Duplication runs once up-front (it is cross-file by nature); each FileContext gets a slice of the global clone report. The dry_violation marker reads ctx.clones and ranks pairs by co-change frequency from git_meta_map[path]["co_change_partners_json"], so active clones rank higher than dormant ones.


5. The 26 markers and their categories

Each marker is a stateless class implementing the Biomarker Protocol from biomarkers/base.py: a name ("brain_method", "nested_complexity", ...), a category (see scoring.CATEGORY_CAPS), and a detect(ctx: FileContext) method returning a list of BiomarkerResults.

CategoryCapMarkers
Organizational−3.5developer_congestion, knowledge_loss, hidden_coupling, function_hotspot, code_age_volatility, ownership_risk, churn_risk, change_entropy, co_change_scatter, prior_defect
Structural complexity−2.5brain_method, low_cohesion, god_class, nested_complexity, bumpy_road, complex_conditional
Test coverage−2.0untested_hotspot, coverage_gap
Test coverage (cont.)−2.0coverage_gradient
Size & complexity−1.5complex_method, large_method, primitive_obsession
Duplication−1.0dry_violation
Test quality−0.5large_assertion_block, duplicated_assertion_block
Error handling−0.5error_handling

large_assertion_block and duplicated_assertion_block are the two test-quality smells (see §5.3). They fire only on test files and sit in a deliberately small category so a noisy test can't dominate its own score. large_method is now gated on a minimal CCN floor (≥ 2) so a long-but-flat body (a big data literal) reads as layout, not a complexity smell: a small step toward decoupling the score from raw file size.

ownership_risk (long-run minor-contributor dispersion, Bird et al.) and churn_risk (size-normalized relative churn, Nagappan-Ball) are git-only process signals computed from top_authors_json / lines_added_90d / churn_percentile: fields the git indexer already produces. change_entropy (Hassan's History Complexity Metric) and co_change_scatter (breadth of co-change coupling, D'Ambros) are likewise git-only and read the change_entropy / change_entropy_pct fields (see §5.1) and co_change_partners_json. knowledge_loss is activity-gated so abandoned-but-stable files (the survivor effect) no longer fire.

prior_defect (recent bug-fix history, Ostrand-Weyuker / Kim's "bug cache") is the other git-only process signal: the count of bug-fix commits touching a file in the trailing ~6-month window, read from prior_defect_count. The git indexer classifies a commit as a fix with the same keyword rule the defect benchmark labels fixes with (_constants.is_fix_commit), counts only non-merge commits inside the window, and anchors the window to the index's as_of reference (REPOWISE_GIT_WINDOW_ANCHOR): so scoring a historical T0 checkout measures the fixes before T0, never leaking the post-T0 fixes that form the benchmark's labels. It carries a neutral (1.0) weight by design: on the calibration corpus prior-defect history is largely redundant with the existing process signals (correlation ≈ +0.59 with change_entropy, +0.38 with churn; calibrated coefficient ≈ +0.02, effort-aware Popt gain within bootstrap noise), so it is not boosted as a predictor. It ships for its explanatory value, not for a measured accuracy lift: "this file was bug-fixed N times recently" is immediately actionable, and it uniquely flags a few files the other signals miss.

coverage_gradient makes the test-coverage signal continuous. The two binary coverage gates (untested_hotspot, coverage_gap) only fire below hard thresholds (≈40–60% line coverage), so on a well-tested codebase (where most files sit at 85–99%) the score is effectively blind to coverage even though the uncovered fraction still carries defect signal. coverage_gradient deducts health in direct proportion to that fraction: 4.0 × (1 − line_coverage_pct/100) health points, clamped by its category cap (binding at ≥50% uncovered). It uses the deduction override on BiomarkerResult (a continuous magnitude that replaces the discrete severity-to-deduction table for that finding) so it stays linear and per-finding attributable (the health_impact contract holds). It is silent when no coverage report was ingested (line_coverage_pct is None): absent coverage is never imputed as uncovered. It lives in its own capped category (test_coverage_gradient, −2.0) so the additive continuous signal neither squeezes nor is squeezed by the binary gates, and it skips test files. Calibrated offline against the defect corpus, it recovers +0.043 corpus AUC [95% CI +0.023, +0.061] on the covered subset (≈65% of the continuous-feature ceiling), Popt-neutral, and is exactly zero on repos without ingested coverage: a purely additive improvement.

low_cohesion (LCOM4) and god_class are the two class-level structural smells. They read ctx.class_metrics, the per-class aggregates the walker now emits alongside per-function metrics (see §5.2). brain_method's centrality gate is language-agnostic: instead of a fixed dependents ≥ 8, it fires when a file is in the repo's top quintile of connected files (repo_dependents_p80, computed once per analyze) or clears the absolute hub bar of 8: so it no longer goes silent on sparse-graph languages (TS barrels, Rust) whose in-degrees are lower than Python's.

5.1 Change-entropy git-layer fields

change_entropy is computed during the single FULL-tier co-change walk (ingestion/git_indexer/co_change.py::compute_co_changes_and_entropy): no extra git log subprocess. For each commit touching a set of tracked files F (with 2 ≤ |F| ≤ 30; wider commits are dropped as noise, Hassan's filter), the commit's entropy is log2(|F|), distributed uniformly (1/|F| per file) and decayed with the same τ=180d half-life as co-change. The decay-weighted sum per file is git_meta["change_entropy"]. enrich.compute_percentiles then derives change_entropy_pct by ranking only files with positive entropy (zero-entropy files, the ESSENTIAL tier or files only ever changed alone, keep pct 0.0 so the marker stays silent). Both fields are persisted on git_metadata (migration 0025) and the additive-reconcile path back-fills them on legacy DBs.

5.2 Class-level walker metrics (LCOM4)

The complexity walker emits a ClassComplexity per class-like node for languages that opt in (LanguageNodeMap.class_kinds non-empty: Python, TS/JS, Java, Kotlin, Rust impl, C++, C#; Go has no grouping node). LCOM4 is the number of connected components in the graph whose nodes are the class's methods and whose edges link methods that share an instance field or call one another. Member references are detected per-language via self/this/$this member-access nodes. Safety valve: a class with no detected member references (a static utility, or an unmapped language) reports lcom4 = 1 ("no signal") rather than len(methods), so adding a language can only turn signal on, never produce a false-positive flood. See complexity/README.md for the full heuristic and its limits.

error_handling is the advisory maintainability marker: swallowed catches (an empty/comment-only catch / except: pass body), Python catch-all except: / except Exception:, Rust .unwrap() / .expect() / panic-family macros, and Go's empty if err != nil {} or blank-identifier discard of a call's error. The walker collects each occurrence (with its line) in a whole-tree pass (module-level code included) reusing the LanguageNodeMap catch kinds for the seven catch-shaped languages and dedicated recognizers for Rust/Go; an unsupported language or parse failure yields no hits ("no signal", never a guess). The marker emits one LOW finding per occurrence (0.15 after its floored 0.5 weight) in its own error_handling category capped at −0.5, mirroring test_quality's advisory framing. It is deliberately excluded from the defect-weight calibration: on the 21-repo / 9-language T0 benchmark it is AUC-neutral (OOF delta ≈ 0, CI crosses zero) but size-orthogonal and the least redundant signal tested, and it ships because users expect except: pass flagged: bounded so it can never move a file by more than half a point.

5.3 Assertion-block walker metrics (test-quality)

The same single walker pass records assertion_blocks on each FunctionComplexity: runs of ≥ 2 consecutive assertion statements, each (start_line, end_line, count). A statement counts as an assertion when it is a bare assert (LanguageNodeMap.assert_kinds) or its expression is a call (assert_call_kinds) whose callee name starts with assert or expect: covering assertEqual / assert_eq! / expect(...).toBe(...) across xUnit and BDD styles. Opt-in per language, with all nine full-tier languages (Python, TS/JS, Java, Kotlin, Go, Rust, C++, C#) mapped; a language that maps neither field simply emits no blocks. large_assertion_block flags a single run ≥ 15; duplicated_assertion_block intersects the clone report with assertion spans. Both gate on coverage.is_test_file(path) so production code is never touched.

biomarkers/registry.py is an explicit list, not auto-discovery: keeps the registration order deterministic and lets tests inject extras via registered_biomarkers(extra=...).


6. Scoring (scoring.py)

Every file starts at 10.0. Each finding contributes a per-severity deduction (low=0.3, medium=0.7, high=1.2, critical=2.0), scaled by the marker's calibrated weight multiplier (§6.1). score_file() then groups the weighted findings by category, sums the raw deductions per category, and either accepts the sum or, when it exceeds the cap, scales every per-finding deduction in that category proportionally so the total equals the cap. This keeps the UI's "this finding cost you X points" honest after capping. The final score is clamped to [1.0, 10.0]. So even ten critical structural findings can drive structural complexity down by at most 3.5 points, not 20.

The per-finding scaled deduction lands on HealthFinding.health_impact via attach_impacts(): that's what the dashboard's "−2.0" badge shows.

Snapshot tests in tests/unit/health/test_scoring_snapshot.py lock the category caps, severity deductions, marker-to-category mapping, and two known-fixture scores. A retune intentionally requires updating the snapshot in the same PR.

6.1 Calibrated weight multipliers

scoring._BIOMARKER_WEIGHT_MULTIPLIER lets the strongest empirical predictors deduct more than the uniform severity table alone allows. The multipliers are calibrated offline against a defect corpus, not hand-tuned: each file is scored at the pre-window commit (T0, no leakage) and an L2-logistic regression, with NLOC as an explicit control, fits each marker's defect lift beyond file size. The runtime stays deterministic; only the learned constants ship. The full calibration, with confidence intervals, is published in the benchmark report and reproduced by local-stash/calibrate_health_weights.py.

WeightMarkersRationale
1.8co_change_scatterStrongest calibrated predictor.
1.51change_entropyHistory Complexity Metric; second strongest.
1.38ownership_riskLong-run minor-contributor dispersion.
1.34nested_complexityStrongest structural predictor.
1.1–1.33remaining structural complexity / size markersModerate calibrated lift.
1.3 / 1.2 / 1.1untested_hotspot / churn_risk / code_age_volatilityCoverage-dependent and rarely-firing; keep prior weights the corpus could not fairly measure.
1.0prior_defectNeutral by design: largely redundant with the other process signals, kept for its explanatory value.
0.5 (floored)developer_congestion, dry_violation, low_cohesion, brain_method, primitive_obsession, bumpy_roadFire widely but proved weak under leakage-free scoring; kept as maintainability and parity signals, not disabled.
0.4 (de-rated)knowledge_lossWeakest of the floored set.

The same marker stream feeds the maintainability signal under an independent, expert-set weight table (the floored smells deduct at full weight there), and the performance signal under its own bounded performance category. The three signals share one scoring kernel against separate weight/category/cap tables and never feed back into each other; see the user guide for what each signal surfaces and why the overall score stays the defect score.


7. KPIs

Three repo-level numbers, computed in compute_kpis():

  • Hotspot Health: NLOC-weighted average over files where git_meta_map[path]["is_hotspot"] is true.
  • Average Health: NLOC-weighted average over all files.
  • Worst Performer: lowest-scoring file + its score.

These flow into HealthSnapshot rows (rolling 50 per repo) and feed the CLI status one-liner, the get_overview() MCP block, and the dashboard KPI cards.


State-free: callers pass an oldest-first list of snapshot rows. Two alerts:

  • Declining Health: current is ≥ DECLINE_THRESHOLD (default 0.5) below the snapshot DECLINE_LOOKBACK (5) positions back. Fires on the 6th+ snapshot.
  • Predicted Decline: the three most recent snapshots are each strictly below the one before. Magnitude is not required; direction is the signal.

recent_kpis(history, limit=10) returns a newest-first serialised view for the CLI table and MCP get_health(include=["trend"]) response.

Per-file trajectory

Snapshots also store a compact {path: score} map (per_file_scores_json), so the same window yields a single file's score-over-time series:

  • file_score_series(history, path): oldest-first FileTrendPoints, skipping snapshots that don't carry the file. Returns [] below two points (silent on thin history). This is the exact function the PR bot reuses for its in-comment sparkline.
  • file_trend(history, path): wraps the series with current / previous / delta and a declining flag (the per-file mirror of the alerts above: ≥ DECLINE_THRESHOLD below the lookback point, or PREDICTED_DECLINE_CONSECUTIVE consecutive drops). snapshot_count is the full window size so a young repo is distinguishable from a file missing in older snapshots.

Both are state-free; the server serialises FileTrend via _file_trend_to_dict and embeds it in the file-detail health block, the health-breakdown response, and the standalone trend route (§13).

Per-file signals (signals.py)

The same state-free pattern, applied to the git-layer + topology fields we already persist but only buried inside marker detail cards (or omitted entirely). file_signals(git_meta, degrees) joins one GitMetadata row with the file's graph degree into a FileSignals grouped as Process (prior_defect_count, change_entropy_pct normalized 0-100, 90-day line churn, age_days), People (recent vs all-time owner + commit share), and Topology (in_degree / out_degree). No recompute: pure surfacing.

The honesty rule mirrors the trend: a field is None only when its source row is absent (no git history means process/people silent; not a graph node means topology silent), never imputed; a genuine prior_defect_count of 0 is kept as a real signal. The server serialises it via _file_signals_to_dict and embeds it in the file-detail health block and the breakdown response (§13); MCP attaches a null-dropped copy to the get_context health block (§12). Mirrored as FileSignals in @repowise-dev/types/health; rendered by the shared file-signals-panel.tsx in both the drawer and the file-page Health tab.


9. Incremental analysis: the repowise update path

Full re-analysis would be wasteful on commit-sized diffs, so HealthAnalyzer.analyze() accepts a changed_files set. When it is present: duplication still runs full-repo (a changed file's clone partner may be unchanged); the per-file loop skips files not in changed_files; and the KPIs are not recomputed, since the subset would bias them. The dashboard recomputes KPIs from the merged DB rows instead.

update_cmd.py builds the changed-files set from change_detector.get_changed_files(), runs the analyzer, and persists through a helper that uses the upsert variants (upsert_health_metrics, upsert_health_findings, scoped to the changed paths) so unchanged files keep their existing rows. The full-init writers (save_health_findings, save_health_metrics) still use delete-then-insert: simpler, and the cost is amortised across the whole repowise init.


10. Persistence schema

Four tables, all in the repo's .repowise/wiki.db. Foreign-keyed to repositories.id with ON DELETE CASCADE.

health_findings

One row per marker hit. Lifecycle: open → acknowledged | resolved | false_positive (matches Dead Code). Bulk-deleted-and-rewritten on full init; selectively upserted on repowise update.

ColumnNotes
idUUID PK
repository_idFK
file_pathindexed
biomarker_typebrain_method, nested_complexity, ...
severitylow / medium / high / critical
function_namenullable for file-level findings
line_start, line_endnullable
details_jsonper-marker evidence (CCN values, clone span, etc.)
health_impactper-finding scaled deduction
reasonone-line summary string
statuslifecycle
created_at, updated_atdatetime

health_file_metrics

One row per file (unique on (repository_id, file_path)). Read directly by the dashboard's file table.

ColumnNotes
score1.0–10.0 final
max_ccn, max_nesting, nlocaggregate function metrics
duplication_pctpercent of NLOC covered by clones; nullable
has_test_filepaired or heuristic
line_coverage_pct, branch_coverage_pctnullable
modulecommunity label from graph; falls back to top-level dir
updated_atdatetime

health_snapshots

KPI + per-file score history. Rolling delete on insert keeps the latest 50 per repo (HEALTH_SNAPSHOT_RETENTION in crud.py).

coverage_files

Per-file coverage, overwritten on every coverage add run. Carries the explicit covered_lines_json array so the coverage_gap marker can flag the exact uncovered surface, not just the percent.


11. CLI surface

packages/cli/src/repowise/cli/commands/health_cmd.py. Mirrors the dead-code command's Click structure.

repowise health                            # KPIs + lowest-scoring files + findings
repowise health --file path/to/x.py        # deep-dive one file
repowise health --module packages/server   # restrict to a directory prefix
repowise health --refactoring-targets      # ranked by impact / effort
repowise health --trend                    # last 10 snapshots + active alerts
repowise coverage add coverage.lcov        # ingest coverage; can repeat
repowise coverage add coverage.xml --format cobertura
repowise health --format json | jq ...

repowise status queries the same tables for a one-line summary:

Health: 7.4 (avg) · 6.2 (hotspots) · 2.1 (worst: packages/server/.../app.py)

repowise update is unchanged from the user's perspective: health is silently re-scored for changed files only.


12. MCP surface

get_health(targets?, include?, repo?, limit?)

Defined in tool_health.py. Modes:

  • Dashboard mode (targets=None): returns repo-level KPIs (with the repo band) + the NLOC-weighted distribution across the 3 bands + worst_files (top N lowest-scoring) + top_findings + a per-module modules rollup.
  • Targeted mode (targets=[...]): returns full metrics + findings for the listed paths, plus a per-file trends block (compact score series + current + delta + declining) for any target with at least two snapshots of history. Targets prefixed module:foo expand to the file set in that module.

include flags layer richer data:

FlagAdds
"biomarkers"full findings list (already present in target mode)
"coverage"per-file coverage rows + summary
"refactoring"deterministic suggestion text on every finding
"trend"snapshot diff + alerts + last 10 KPI rows

Enrichments on existing tools

  • get_risk(targets): each per-target row carries health_score, top_biomarkers, coverage_pct, branch_coverage_pct.
  • get_context(targets, include=["health"]): per-file score, max_ccn, max_nesting, nloc, module, duplication_pct, top 2 markers (each with a suggestion string), a coverage block, and a null-dropped signals block (process/people/topology, see §8).
  • get_overview(): adds a code_health block: avg, repo band, hotspot, worst performer, open finding count, and the NLOC-weighted distribution.

Every response carries the standard _meta envelope via build_meta().


13. REST surface

packages/server/src/repowise/server/routers/code_health.py. All under /api/repos/{repo_id}/health/:

RouteReturns
GET /overviewsummary (with repo band) + distribution + lowest-scoring files + top findings + module rollup
GET /badge.svgself-rendered flat SVG health badge (color + N.N/10, no letter)
GET /badge.jsonShields.io endpoint-badge payload (schemaVersion/label/message/color/band)
GET /filesper-file metrics
GET /files/breakdownone file's metric + score breakdown + findings + suggestions + per-file trend + signals
GET /files/trendone file's score-over-time series + current delta + declining flag (?file_path=)
GET /trendrepo KPI history + alerts + last-two-snapshot per-file deltas
GET /findingsfindings list (filterable by biomarker_type, severity, file_path)
GET /coveragecoverage summary + per-file rows
POST /coverageingest a coverage report (used by some CI integrations)
GET /refactoring-targetsranked by total_impact / effort_bucket
GET /churn-complexitychurn × complexity scatter points (one per churned file: commit_count_90d, max_ccn, nloc, score, churn_percentile)
GET /modulesNLOC-weighted module rollup table

Auth is the standard verify_api_key dependency from server/deps.py.


14. Web dashboard

Three routes under /repos/[id]/health/:

RouteWhat it shows
/healthKPI cards, lowest-scoring file table, top findings, per-module rollup (added in Phase 4)
/health/coverageCoverage summary, untested-hotspot warnings, module-level bars, per-file drill-down
/health/refactoring-targetsCards sorted by impact-per-effort, each with severity, marker, score, NLOC, effort bucket, deterministic suggestion

Plus a sidecar HealthRisksPanel on the Hotspots, Ownership, and Graph pages: surfaces the lowest-scoring files inline without touching the shared table/graph components. The Hotspots & churn tab carries the ChurnComplexityQuadrant (fed by GET /churn-complexity), toggleable in place with the existing churn × bus-factor scatter; the file Health tab carries the per-function "Functions by churn" blame table.

All visual primitives live in packages/ui/src/health/ so the hosted frontend/ repo (separate git checkout) can reuse them: port is mostly data fetching + auth.


15. CLAUDE.md integration

The auto-generated CLAUDE.md includes a ## Code health section when the health tables are populated. The block is intentionally short; filter rules in core/generation/editor_files/data.py:

  • Score ≤ 5.0 and file is a hotspot
  • Any Brain Method in a file with > 10 dependents
  • Any Untested Hotspot
  • DRY violations > 70 % similarity
  • Declining trend (> 1.0 drop in last 5 snapshots)

Everything else is filtered out so the CLAUDE.md doesn't drown a fresh agent in noise. The Jinja stanza lives in core/generation/templates/claude_md.j2.


16. Configuration: .repowise/health-rules.json

.repowise/health-rules.json is user-authored (the only JSON file in the layer) and is loaded by HealthConfig.load(repo_path). It carries repo-wide and per-path disabled_biomarkers and severity_overrides, keyed by an fnmatch glob over the repo-relative POSIX path (path, with path_glob and glob as accepted aliases). to_analyzer_config(file_paths) resolves the globs to per-file disabled sets, which the engine honors in _evaluate_file(). The schema and examples are in the user guide.


17. Performance

Plan §4 P4.6 targets < 30 s on a 3,000-file synthetic repo. The parallel path in HealthAnalyzer.analyze_async() parallelises tree-sitter parsing across worker threads (asyncio.gather + asyncio.to_thread). tree-sitter releases the GIL on parse, so this scales on single-process CPython.

The orchestrator chooses the parallel path automatically when len(parsed_files) >= 500. The benchmark lives at tests/integration/test_health_perf_benchmark.py and is marked slow (opt-in via pytest -m slow or make health-bench).

Other perf notes:

  • Duplication is O(total_tokens). Bucket walk is near-linear on repos with low duplication.
  • Walker re-parses files because ParsedFile doesn't retain a tree-sitter Tree across the ingestion boundary. Acceptable (~1 ms per file); switching to a shared parse cache is a Phase 5 stretch.
  • No N² loops in scoring. Category aggregation is O(findings).

18. Testing

SuiteWhat it locks
tests/unit/health/test_complexity_walker.pyPer-language CCN, nesting, cognitive assertions on handcrafted fixtures
tests/unit/health/test_<biomarker>.pyEach marker: positive in two languages + one negative
tests/unit/health/test_duplication.pyTokenizer normalization, rolling-hash determinism, co-change weighting
tests/unit/health/test_coverage_parsers.pyLCOV / Cobertura / Clover / repowise-JSON happy paths + edge cases
tests/unit/health/test_scoring.pyDeduction caps, clamping, KPI math
tests/unit/health/test_scoring_snapshot.pyStability guard: caps, severity table, marker-to-category mapping, two known fixture scores
tests/unit/health/test_trends.pyDeclining + predicted alerts, ordering, per-file series + file_trend
tests/unit/health/test_signals.pyfile_signals join: no-signal vs real-zero, entropy 0-1 to 0-100, owner handoff
tests/unit/health/test_churn_complexity.pychurn_complexity_points: no-churn omission, complexity never filters, danger-product sort, percentile scaling
tests/unit/health/test_suggestions.pySuggestion strings keyed correctly
tests/unit/health/test_health_config.py.repowise/health-rules.json parsing + glob matching
tests/integration/test_health_coverage_integration.pyEnd-to-end LCOV -> analyzer -> coverage_gap fires
tests/integration/test_health_perf_benchmark.py30 s budget on 3,000 synthetic files (-m slow)

99 unit tests + 2 integration tests at time of writing. Run with make health-check.


19. Extension points

Add a marker

  1. New file under biomarkers/ implementing the Biomarker Protocol.
  2. Append to _DETECTOR_FACTORIES in biomarkers/registry.py.
  3. Add the marker-to-category mapping in scoring._BIOMARKER_CATEGORY.
  4. Add a suggestion template in suggestions._TEMPLATES.
  5. Add at least three test cases (two positive in different languages, one negative).
  6. Update biomarkers/README.md's "Registered v1 detectors" list.

Add a language to the complexity walker

Add one LanguageNodeMap entry to complexity/languages.py mapping the language's tree-sitter control-flow node-type names to abstract BRANCH / LOOP / TRY / BOOLEAN_OP categories. Add a fixture under tests/fixtures/lang_samples/<lang>/. No .scm files needed: those are owned by the ingestion parser.

Add a coverage format

Drop a parser under coverage/ returning a CoverageReport. Route to it from coverage/detector.parse. Stdlib-only (no extra XML libraries).

Add a per-file override

Users (not contributors) author .repowise/health-rules.json. To add a new override key (beyond disabled_biomarkers), extend HealthConfig and thread it through to_analyzer_config() -> engine._evaluate_file().


20. Where the layer deliberately stops

A short list of things the v1 layer does not do, by design. Future phases may revisit; the constraints kept v1 shippable.

  • No LLM-generated suggestions. suggestions.py is static templates. An optional LLM mode is Phase 5, gated behind an explicit flag.
  • No symbol-level scoring. Score lives at the file granularity to match how engineers think about refactor units. Symbol-level CCN still feeds the file score via function_metrics.
  • No complexity_estimate propagation backfill. The walker writes the field as a side effect during the current run; old indexes don't get touched until a re-index.
  • No predictive ML on trends. Predicted Decline is a 3-snapshot direction check, not a model. (Commit-level change risk is a separate, shipped surface: the analysis/change_risk/ package behind repowise risk scores a commit or base..head range with a calibrated logistic model.)
  • No letter grade. The 1–10 score is the single number. The only categorical layer is the 3 defect-backed bands (Healthy/Warning/Alert, grading.py); a letter on top would be a third overlapping scale with arbitrary cliffs. The legacy 4-step scoreBand in ui/health/tokens.ts is retained only as a finer color ramp for file-table pills, not a labeling scheme: surfaced band labels and the distribution use the 3 bands.

21. Quick lookup: where do I edit X?

I want to...Edit...
Tweak a category capscoring.CATEGORY_CAPS (snapshot test will fail; update it)
Tweak a severity deductionscoring._SEVERITY_DEDUCTION (ditto)
Add a new markerbiomarkers/*.py, registry.py, scoring.py, suggestions.py
Change the suggestion text for a markersuggestions._TEMPLATES
Adjust the trend-alert thresholdtrends.DECLINE_THRESHOLD / DECLINE_LOOKBACK
Change snapshot retentioncrud.HEALTH_SNAPSHOT_RETENTION
Add a new MCP include flagtool_health.py: append handling near the existing "coverage" / "refactoring" branches
Add a new REST routerouters/code_health.py: auth is wired at the router level
Add a new dashboard viewnew file under packages/web/src/app/repos/[id]/health/, primitives under packages/ui/src/health/
Add a CLI flagpackages/cli/src/repowise/cli/commands/health_cmd.py
Wire the analyzer into a new entry pointcall HealthAnalyzer.analyze() directly; persist via the upsert variants if your caller is incremental

See also