Skip to content

Commit 6840326

Browse files
committed
feat(mcp): add compact threshold context for empty design checks
- include threshold_context in empty complexity, coupling, and cohesion check payloads - report measured_units and highest_below_threshold from canonical metric families - distinguish run finding thresholds from explicit requested_min filters - document the compact MCP response hint for agent-oriented triage - cover empty-check and requested-min cases with focused MCP tests
1 parent 38ec861 commit 6840326

4 files changed

Lines changed: 285 additions & 4 deletions

File tree

codeclone/mcp_service.py

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,26 @@
295295
"complexity": "complexity",
296296
"clones": "clones",
297297
}
298+
_DESIGN_CHECK_CONTEXT: Final[dict[str, dict[str, object]]] = {
299+
"complexity": {
300+
"category": CATEGORY_COMPLEXITY,
301+
"metric": "cyclomatic_complexity",
302+
"operator": ">",
303+
"default_threshold": DEFAULT_REPORT_DESIGN_COMPLEXITY_THRESHOLD,
304+
},
305+
"coupling": {
306+
"category": CATEGORY_COUPLING,
307+
"metric": "cbo",
308+
"operator": ">",
309+
"default_threshold": DEFAULT_REPORT_DESIGN_COUPLING_THRESHOLD,
310+
},
311+
"cohesion": {
312+
"category": CATEGORY_COHESION,
313+
"metric": "lcom4",
314+
"operator": ">=",
315+
"default_threshold": DEFAULT_REPORT_DESIGN_COHESION_THRESHOLD,
316+
},
317+
}
298318
_VALID_METRICS_DETAIL_FAMILIES = frozenset(
299319
{
300320
"complexity",
@@ -2045,6 +2065,13 @@ def check_complexity(
20452065
detail_level=validated_detail,
20462066
max_results=max_results,
20472067
path=path,
2068+
threshold_context=self._design_threshold_context(
2069+
record=record,
2070+
check="complexity",
2071+
path=path,
2072+
items=findings,
2073+
requested_min=min_complexity,
2074+
),
20482075
)
20492076

20502077
def check_clones(
@@ -2163,6 +2190,12 @@ def _check_design_metric(
21632190
detail_level=validated_detail,
21642191
max_results=max_results,
21652192
path=path,
2193+
threshold_context=self._design_threshold_context(
2194+
record=record,
2195+
check=check,
2196+
path=path,
2197+
items=findings,
2198+
),
21662199
)
21672200

21682201
def check_dead_code(
@@ -3615,6 +3648,7 @@ def _granular_payload(
36153648
detail_level: DetailLevel,
36163649
max_results: int,
36173650
path: str | None,
3651+
threshold_context: Mapping[str, object] | None = None,
36183652
) -> dict[str, object]:
36193653
bounded_items = [dict(item) for item in items[: max(1, max_results)]]
36203654
full_health = dict(self._as_mapping(record.summary.get("health")))
@@ -3625,7 +3659,7 @@ def _granular_payload(
36253659
if relevant_dimension and relevant_dimension in dimensions
36263660
else dict(dimensions)
36273661
)
3628-
return {
3662+
payload: dict[str, object] = {
36293663
"run_id": self._short_run_id(record.run_id),
36303664
"check": check,
36313665
"detail_level": detail_level,
@@ -3639,6 +3673,108 @@ def _granular_payload(
36393673
},
36403674
"items": bounded_items,
36413675
}
3676+
if threshold_context:
3677+
payload["threshold_context"] = dict(threshold_context)
3678+
return payload
3679+
3680+
def _design_threshold_context(
3681+
self,
3682+
*,
3683+
record: MCPRunRecord,
3684+
check: str,
3685+
path: str | None,
3686+
items: Sequence[Mapping[str, object]],
3687+
requested_min: int | None = None,
3688+
) -> dict[str, object] | None:
3689+
if items:
3690+
return None
3691+
spec = _DESIGN_CHECK_CONTEXT.get(check)
3692+
if spec is None:
3693+
return None
3694+
category = str(spec["category"])
3695+
metric = str(spec["metric"])
3696+
operator = str(spec["operator"])
3697+
normalized_path = self._normalize_relative_path(path or "")
3698+
metrics = self._as_mapping(record.report_document.get("metrics"))
3699+
families = self._as_mapping(metrics.get("families"))
3700+
family = self._as_mapping(families.get(category))
3701+
metric_items = [
3702+
self._as_mapping(item)
3703+
for item in self._as_sequence(family.get("items"))
3704+
if not normalized_path
3705+
or self._metric_item_matches_path(
3706+
self._as_mapping(item),
3707+
normalized_path,
3708+
)
3709+
]
3710+
if not metric_items:
3711+
return None
3712+
values = [_as_int(item.get(metric), 0) for item in metric_items]
3713+
finding_threshold = self._design_finding_threshold(
3714+
record=record,
3715+
check=check,
3716+
)
3717+
threshold = finding_threshold
3718+
threshold_kind = "finding_threshold"
3719+
if requested_min is not None and requested_min > finding_threshold:
3720+
threshold = requested_min
3721+
threshold_kind = "requested_min"
3722+
highest_below = self._highest_below_threshold(
3723+
values=values,
3724+
operator=operator,
3725+
threshold=threshold,
3726+
)
3727+
payload: dict[str, object] = {
3728+
"metric": metric,
3729+
"threshold": threshold,
3730+
"threshold_kind": threshold_kind,
3731+
"measured_units": len(metric_items),
3732+
}
3733+
if threshold_kind != "finding_threshold":
3734+
payload["finding_threshold"] = finding_threshold
3735+
if highest_below is not None:
3736+
payload["highest_below_threshold"] = highest_below
3737+
return payload
3738+
3739+
def _design_finding_threshold(
3740+
self,
3741+
*,
3742+
record: MCPRunRecord,
3743+
check: str,
3744+
) -> int:
3745+
spec = _DESIGN_CHECK_CONTEXT[check]
3746+
category = str(spec["category"])
3747+
default_threshold = _as_int(spec["default_threshold"])
3748+
findings = self._as_mapping(record.report_document.get("findings"))
3749+
thresholds = self._as_mapping(
3750+
self._as_mapping(findings.get("thresholds")).get("design_findings")
3751+
)
3752+
threshold_payload = self._as_mapping(thresholds.get(category))
3753+
if threshold_payload:
3754+
return _as_int(threshold_payload.get("value"), default_threshold)
3755+
request_value = {
3756+
"complexity": record.request.complexity_threshold,
3757+
"coupling": record.request.coupling_threshold,
3758+
"cohesion": record.request.cohesion_threshold,
3759+
}.get(check)
3760+
return _as_int(request_value, default_threshold)
3761+
3762+
@staticmethod
3763+
def _highest_below_threshold(
3764+
*,
3765+
values: Sequence[int],
3766+
operator: str,
3767+
threshold: int,
3768+
) -> int | None:
3769+
if operator == ">":
3770+
below = [value for value in values if value <= threshold]
3771+
elif operator == ">=":
3772+
below = [value for value in values if value < threshold]
3773+
else:
3774+
return None
3775+
if not below:
3776+
return None
3777+
return max(below)
36423778

36433779
@staticmethod
36443780
def _normalized_source_kind(value: object) -> str:

docs/book/20-mcp-interface.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ Current server characteristics:
9696
locations plus remediation
9797
- `detail_level="full"` keeps the compatibility-oriented payload,
9898
including `priority_factors`, `items`, and per-location `uri`
99+
- empty design `check_*` responses may include a compact
100+
`threshold_context` (`metric`, `threshold`, `measured_units`,
101+
`highest_below_threshold`) so agents can tell whether the run is truly
102+
quiet or just below the active threshold
99103

100104
The MCP layer does not introduce a separate analysis engine. It calls the
101105
current CodeClone pipeline and reuses the canonical report document already

docs/mcp.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ run-scoped URI templates.
116116
**Payload conventions:**
117117

118118
- `check_*` responses include only the relevant health dimension.
119+
- Empty design `check_*` responses may also include a compact
120+
`threshold_context` (`metric`, `threshold`, `measured_units`,
121+
`highest_below_threshold`) to show whether the run is genuinely quiet or
122+
simply below the active threshold.
119123
- Finding responses use short MCP IDs and relative paths by default;
120124
`detail_level=full` restores the compatibility payload with URIs.
121125
- Summary and triage projections keep interpretation compact: `health_scope`

tests/test_mcp_service.py

Lines changed: 140 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2435,9 +2435,9 @@ def _patched_get_finding(
24352435
request=MCPAnalysisRequest(
24362436
root=str(tmp_path),
24372437
respect_pyproject=False,
2438-
complexity_threshold=1,
2439-
coupling_threshold=1,
2440-
cohesion_threshold=1,
2438+
complexity_threshold=5,
2439+
coupling_threshold=5,
2440+
cohesion_threshold=4,
24412441
),
24422442
comparison_settings=(),
24432443
report_document={
@@ -2514,6 +2514,143 @@ def _patched_get_finding(
25142514
if str(finding.get("family", "")) == "design"
25152515
]
25162516
assert design_findings == []
2517+
service._runs.register(fake_design_record)
2518+
empty_complexity = service.check_complexity(
2519+
run_id="design",
2520+
path="pkg/quality.py",
2521+
detail_level="summary",
2522+
)
2523+
requested_complexity = service.check_complexity(
2524+
run_id="design",
2525+
path="pkg/quality.py",
2526+
min_complexity=8,
2527+
detail_level="summary",
2528+
)
2529+
empty_coupling = service.check_coupling(
2530+
run_id="design",
2531+
path="pkg/quality.py",
2532+
detail_level="summary",
2533+
)
2534+
empty_cohesion = service.check_cohesion(
2535+
run_id="design",
2536+
path="pkg/quality.py",
2537+
detail_level="summary",
2538+
)
2539+
assert empty_complexity["total"] == 0
2540+
assert empty_complexity["threshold_context"] == {
2541+
"metric": "cyclomatic_complexity",
2542+
"threshold": 5,
2543+
"threshold_kind": "finding_threshold",
2544+
"measured_units": 1,
2545+
"highest_below_threshold": 3,
2546+
}
2547+
assert requested_complexity["threshold_context"] == {
2548+
"metric": "cyclomatic_complexity",
2549+
"threshold": 8,
2550+
"threshold_kind": "requested_min",
2551+
"finding_threshold": 5,
2552+
"measured_units": 1,
2553+
"highest_below_threshold": 3,
2554+
}
2555+
assert empty_coupling["threshold_context"] == {
2556+
"metric": "cbo",
2557+
"threshold": 5,
2558+
"threshold_kind": "finding_threshold",
2559+
"measured_units": 1,
2560+
"highest_below_threshold": 2,
2561+
}
2562+
assert empty_cohesion["threshold_context"] == {
2563+
"metric": "lcom4",
2564+
"threshold": 4,
2565+
"threshold_kind": "finding_threshold",
2566+
"measured_units": 1,
2567+
"highest_below_threshold": 2,
2568+
}
2569+
assert (
2570+
service._design_threshold_context(
2571+
record=fake_design_record,
2572+
check="complexity",
2573+
path="pkg/quality.py",
2574+
items=({"id": "existing"},),
2575+
)
2576+
is None
2577+
)
2578+
assert (
2579+
service._design_threshold_context(
2580+
record=fake_design_record,
2581+
check="unknown",
2582+
path="pkg/quality.py",
2583+
items=(),
2584+
)
2585+
is None
2586+
)
2587+
thresholded_report_document = dict(fake_design_record.report_document)
2588+
thresholded_findings = dict(
2589+
cast("dict[str, object]", thresholded_report_document["findings"])
2590+
)
2591+
thresholded_findings["thresholds"] = {
2592+
"design_findings": {
2593+
"complexity": {
2594+
"metric": "cyclomatic_complexity",
2595+
"operator": ">",
2596+
"value": 6,
2597+
}
2598+
}
2599+
}
2600+
thresholded_report_document["findings"] = thresholded_findings
2601+
thresholded_record = replace(
2602+
fake_design_record,
2603+
report_document=thresholded_report_document,
2604+
)
2605+
assert (
2606+
service._design_finding_threshold(
2607+
record=thresholded_record,
2608+
check="complexity",
2609+
)
2610+
== 6
2611+
)
2612+
no_below_report_document = dict(fake_design_record.report_document)
2613+
no_below_metrics = dict(
2614+
cast("dict[str, object]", no_below_report_document["metrics"])
2615+
)
2616+
no_below_families = dict(cast("dict[str, object]", no_below_metrics["families"]))
2617+
no_below_families["complexity"] = {
2618+
"items": [
2619+
{
2620+
"qualname": "pkg.quality:very_hot",
2621+
"relative_path": "pkg/quality.py",
2622+
"start_line": 10,
2623+
"end_line": 20,
2624+
"cyclomatic_complexity": 9,
2625+
"nesting_depth": 2,
2626+
"risk": "high",
2627+
}
2628+
]
2629+
}
2630+
no_below_metrics["families"] = no_below_families
2631+
no_below_report_document["metrics"] = no_below_metrics
2632+
no_below_record = replace(
2633+
fake_design_record,
2634+
report_document=no_below_report_document,
2635+
)
2636+
assert service._design_threshold_context(
2637+
record=no_below_record,
2638+
check="complexity",
2639+
path="pkg/quality.py",
2640+
items=(),
2641+
) == {
2642+
"metric": "cyclomatic_complexity",
2643+
"threshold": 5,
2644+
"threshold_kind": "finding_threshold",
2645+
"measured_units": 1,
2646+
}
2647+
assert (
2648+
service._highest_below_threshold(values=(9,), operator=">", threshold=5) is None
2649+
)
2650+
assert (
2651+
service._highest_below_threshold(values=(1, 2), operator="!=", threshold=5)
2652+
is None
2653+
)
25172654
detail_payload = service._project_finding_detail(
25182655
fake_design_record,
25192656
{

0 commit comments

Comments
 (0)