From 115930b07ff972bfb0b9e418251f98494e092528 Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Thu, 2 Apr 2026 20:54:16 +0500 Subject: [PATCH 01/15] feat(mcp): add help tool for cheaper, more guided agent workflows --- CHANGELOG.md | 8 + README.md | 7 +- benchmarks/run_docker_benchmark.sh | 2 +- codeclone/mcp_server.py | 22 ++ codeclone/mcp_service.py | 331 ++++++++++++++++++- docs/book/01-architecture-map.md | 4 + docs/book/08-report.md | 2 +- docs/book/14-compatibility-and-versioning.md | 4 + docs/book/20-mcp-interface.md | 6 +- docs/book/appendix/b-schema-layouts.md | 8 +- docs/mcp.md | 15 + pyproject.toml | 2 +- tests/test_github_action_helpers.py | 10 +- tests/test_mcp_server.py | 18 + tests/test_mcp_service.py | 99 +++++- uv.lock | 2 +- 16 files changed, 513 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f88bd7b..3def240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [2.0.0b4] + +In development. + +### MCP server + +- Add bounded MCP `help(topic=...)` as an uncertainty-recovery tool for workflow, baseline, suppressions, latest-run semantics, review state, and changed-scope routing. + ## [2.0.0b3] 2.0.0b3 is the release where CodeClone stops looking like "a strong analyzer with extras" and starts looking like a diff --git a/README.md b/README.md index dac69e6..23c7a95 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ codeclone-mcp --transport stdio codeclone-mcp --transport streamable-http --port 8000 ``` -20 tools + 10 resources — deterministic, baseline-aware, and read-only. +21 tools + 10 resources — deterministic, baseline-aware, and read-only. Never mutates source files, baselines, or repo state. Payloads are optimized for LLM context: compact summaries by default, full detail on demand. @@ -177,6 +177,9 @@ Recommended agent flow: `analyze_repository` or `analyze_changed_paths` → `get_run_summary` or `get_production_triage` → `list_hotspots` or `check_*` → `get_finding` → `get_remediation` +If workflow or contract meaning is unclear, `help(topic=...)` returns a compact +semantic guide with the safest next step and canonical doc links. + Docs: [MCP usage guide](https://orenlab.github.io/codeclone/mcp/) · @@ -277,7 +280,7 @@ Dynamic/runtime false positives are resolved via explicit inline suppressions, n { "report_schema_version": "2.2", "meta": { - "codeclone_version": "2.0.0b3", + "codeclone_version": "2.0.0b4", "project_name": "...", "scan_root": ".", "report_mode": "full", diff --git a/benchmarks/run_docker_benchmark.sh b/benchmarks/run_docker_benchmark.sh index 2c3a08a..7faf994 100755 --- a/benchmarks/run_docker_benchmark.sh +++ b/benchmarks/run_docker_benchmark.sh @@ -2,7 +2,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -IMAGE_TAG="${IMAGE_TAG:-codeclone-benchmark:2.0.0b2}" +IMAGE_TAG="${IMAGE_TAG:-codeclone-benchmark:2.0.0b4}" OUT_DIR="${OUT_DIR:-$ROOT_DIR/.cache/benchmarks}" OUTPUT_BASENAME="${OUTPUT_BASENAME:-codeclone-benchmark.json}" CPUSET="${CPUSET:-0}" diff --git a/codeclone/mcp_server.py b/codeclone/mcp_server.py index 3848ea0..4441091 100644 --- a/codeclone/mcp_server.py +++ b/codeclone/mcp_server.py @@ -34,6 +34,7 @@ "get_production_triage for the first pass. Use list_hotspots or focused " "check_* tools before broader list_findings calls, then drill into one " "finding with get_finding or get_remediation. Use " + "help(topic=...) when workflow or contract semantics are unclear. Use " "get_report_section(section='metrics_detail', family=..., limit=...) for " "bounded metrics drill-down, and prefer generate_pr_summary(format='markdown') " "unless machine JSON is required. Pass an absolute repository root to " @@ -273,6 +274,27 @@ def get_production_triage( max_suggestions=max_suggestions, ) + @tool( + title="Help", + description=( + "Return a compact semantic guide for a supported CodeClone topic, " + "with next-step routing and canonical doc links. Use this when " + "workflow or contract meaning is unclear. This is bounded guidance, " + "not a full manual. Supported topics: workflow, suppressions, " + "baseline, latest_runs, review_state, changed_scope." + ), + annotations=read_only_tool, + structured_output=True, + ) + def help( + topic: str, + detail: str = "compact", + ) -> dict[str, object]: + return service.get_help( + topic=topic, # type: ignore[arg-type] + detail=detail, # type: ignore[arg-type] + ) + @tool( title="Evaluate Gates", description=( diff --git a/codeclone/mcp_service.py b/codeclone/mcp_service.py index fd86ee4..db60921 100644 --- a/codeclone/mcp_service.py +++ b/codeclone/mcp_service.py @@ -51,6 +51,7 @@ DEFAULT_REPORT_DESIGN_COHESION_THRESHOLD, DEFAULT_REPORT_DESIGN_COMPLEXITY_THRESHOLD, DEFAULT_REPORT_DESIGN_COUPLING_THRESHOLD, + DOCS_URL, REPORT_SCHEMA_VERSION, ExitCode, ) @@ -123,6 +124,15 @@ DetailLevel = Literal["summary", "normal", "full"] ComparisonFocus = Literal["all", "clones", "structural", "metrics"] PRSummaryFormat = Literal["markdown", "json"] +HelpTopic = Literal[ + "workflow", + "suppressions", + "baseline", + "latest_runs", + "review_state", + "changed_scope", +] +HelpDetail = Literal["compact", "normal"] MetricsDetailFamily = Literal[ "complexity", "coupling", @@ -203,6 +213,17 @@ _VALID_DETAIL_LEVELS = frozenset({"summary", "normal", "full"}) _VALID_COMPARISON_FOCUS = frozenset({"all", "clones", "structural", "metrics"}) _VALID_PR_SUMMARY_FORMATS = frozenset({"markdown", "json"}) +_VALID_HELP_TOPICS = frozenset( + { + "workflow", + "suppressions", + "baseline", + "latest_runs", + "review_state", + "changed_scope", + } +) +_VALID_HELP_DETAILS = frozenset({"compact", "normal"}) DEFAULT_MCP_HISTORY_LIMIT = 4 MAX_MCP_HISTORY_LIMIT = 10 _VALID_REPORT_SECTIONS = frozenset( @@ -262,6 +283,278 @@ _SHORT_HASH_ID_LENGTH = 6 +@dataclass(frozen=True) +class MCPHelpTopicSpec: + summary: str + key_points: tuple[str, ...] + recommended_tools: tuple[str, ...] + doc_links: tuple[tuple[str, str], ...] + warnings: tuple[str, ...] = () + anti_patterns: tuple[str, ...] = () + + +_MCP_BOOK_URL: Final = f"{DOCS_URL}book/" +_MCP_GUIDE_URL: Final = f"{DOCS_URL}mcp/" +_MCP_INTERFACE_DOC_LINK: Final[tuple[str, str]] = ( + "MCP interface contract", + f"{_MCP_BOOK_URL}20-mcp-interface/", +) +_BASELINE_DOC_LINK: Final[tuple[str, str]] = ( + "Baseline contract", + f"{_MCP_BOOK_URL}06-baseline/", +) +_SUPPRESSIONS_DOC_LINK: Final[tuple[str, str]] = ( + "Inline suppressions contract", + f"{_MCP_BOOK_URL}19-inline-suppressions/", +) +_MCP_GUIDE_DOC_LINK: Final[tuple[str, str]] = ("MCP usage guide", _MCP_GUIDE_URL) +_HELP_TOPIC_SPECS: Final[dict[str, MCPHelpTopicSpec]] = { + "workflow": MCPHelpTopicSpec( + summary=( + "CodeClone MCP is triage-first and budget-aware. Start with compact " + "summary or production triage, then narrow through hotspots or " + "focused checks before opening one finding in detail." + ), + key_points=( + "Recommended first pass: analyze_repository or analyze_changed_paths.", + ( + "Use get_run_summary or get_production_triage before broad " + "finding enumeration." + ), + ( + "Prefer list_hotspots or focused check_* tools over " + "list_findings on medium or noisy repositories." + ), + ( + "Use get_finding and get_remediation only after selecting a " + "specific issue." + ), + ( + "get_report_section(section='all') is an exception path, not " + "a default exploration step." + ), + ), + recommended_tools=( + "analyze_repository", + "analyze_changed_paths", + "get_run_summary", + "get_production_triage", + "list_hotspots", + "check_clones", + "check_dead_code", + "get_finding", + "get_remediation", + ), + doc_links=(_MCP_INTERFACE_DOC_LINK, _MCP_GUIDE_DOC_LINK), + warnings=( + ( + "Broad list_findings calls can burn context quickly on large " + "or noisy repositories." + ), + ( + "Prefer generate_pr_summary(format='markdown') unless machine " + "JSON is explicitly needed." + ), + ), + anti_patterns=( + "Starting exploration with list_findings on a noisy repository.", + "Using get_report_section(section='all') as the default first step.", + ( + "Escalating detail on larger lists instead of opening one " + "finding with get_finding." + ), + ), + ), + "suppressions": MCPHelpTopicSpec( + summary=( + "CodeClone supports explicit inline suppressions for selected findings. " + "Suppressions are local policy, not analysis truth, and should stay " + "narrow and declaration-scoped." + ), + key_points=( + "Current syntax uses codeclone: ignore[rule-id,...].", + "Binding is declaration-scoped: def, async def, or class.", + ( + "Supported placement is the previous line or inline on the " + "declaration line/header." + ), + ( + "Suppressions are target-specific and do not imply file-wide " + "or cascading scope." + ), + ( + "Use suppressions for accepted dynamic or runtime false " + "positives, not to hide broad classes of debt." + ), + ), + recommended_tools=("get_finding", "get_remediation"), + doc_links=(_SUPPRESSIONS_DOC_LINK, _MCP_INTERFACE_DOC_LINK), + warnings=( + ( + "MCP explains suppression semantics but never creates or " + "updates suppressions." + ), + ), + anti_patterns=( + "Treating suppressions as file-wide or inherited state.", + ( + "Using suppressions to hide broad structural debt instead of " + "accepted false positives." + ), + ), + ), + "baseline": MCPHelpTopicSpec( + summary=( + "A baseline is CodeClone's accepted comparison snapshot for clone and " + "optional metrics state. It separates known debt from new regressions " + "and is trust-checked before use." + ), + key_points=( + ( + "Canonical baseline schema is v2.0 with meta and clone keys; " + "metrics may be embedded for unified flows." + ), + ( + "Compatibility depends on generator identity, supported " + "schema version, fingerprint version, python tag, and payload " + "integrity." + ), + ( + "Known means already present in the trusted baseline; new " + "means not accepted by baseline." + ), + ( + "In CI and gating contexts, untrusted baseline states are " + "contract errors rather than soft warnings." + ), + "MCP is read-only and does not update or rewrite baselines.", + ), + recommended_tools=("get_run_summary", "evaluate_gates", "compare_runs"), + doc_links=(_BASELINE_DOC_LINK,), + warnings=( + "Baseline trust semantics directly affect new-vs-known classification.", + ), + anti_patterns=( + "Treating baseline as mutable MCP session state.", + "Assuming an untrusted baseline is only a cosmetic warning in CI contexts.", + ), + ), + "latest_runs": MCPHelpTopicSpec( + summary=( + "latest/* resources point to the most recent analysis run stored in " + "the current MCP session. They are convenience handles, not " + "persistent truth anchors." + ), + key_points=( + "Run history is in-memory only and bounded by history-limit.", + "The latest pointer moves when a newer analyze_* call registers a run.", + "A fresh repository state requires a fresh analyze run.", + ( + "Short run ids are convenience handles derived from canonical " + "run identity." + ), + ( + "Do not assume latest/* is globally current outside the " + "active MCP session." + ), + ), + recommended_tools=( + "analyze_repository", + "analyze_changed_paths", + "get_run_summary", + "compare_runs", + ), + doc_links=(_MCP_INTERFACE_DOC_LINK, _MCP_GUIDE_DOC_LINK), + warnings=( + ( + "latest/* can point at a different repository after a later " + "analyze call in the same session." + ), + ), + anti_patterns=( + ( + "Assuming latest/* remains tied to one repository across the " + "whole client session." + ), + ( + "Using latest/* as a substitute for starting a fresh run when " + "freshness matters." + ), + ), + ), + "review_state": MCPHelpTopicSpec( + summary=( + "Reviewed state in MCP is session-local workflow state. It helps long " + "agent sessions track what has already been inspected, but it does " + "not modify canonical findings, baseline, or persisted artifacts." + ), + key_points=( + "Review markers are in-memory only.", + "They do not change report truth, finding identity, or CI semantics.", + "They are useful for triage workflows across long sessions.", + ( + "They should not be interpreted as acceptance, suppression, " + "or baseline update." + ), + ), + recommended_tools=( + "list_hotspots", + "get_finding", + "mark_finding_reviewed", + "list_reviewed_findings", + ), + doc_links=(_MCP_INTERFACE_DOC_LINK, _MCP_GUIDE_DOC_LINK), + warnings=( + "Reviewed markers disappear when the MCP session is cleared or restarted.", + ), + anti_patterns=( + "Treating reviewed state as a persistent acceptance signal.", + "Assuming reviewed findings are removed from canonical report truth.", + ), + ), + "changed_scope": MCPHelpTopicSpec( + summary=( + "Changed-scope analysis narrows review to findings that touch a " + "selected change set. It is intended for PR and patch review, not " + "as a replacement for full canonical analysis." + ), + key_points=( + ( + "Use analyze_changed_paths with explicit changed_paths or " + "git_diff_ref for review-focused runs." + ), + ( + "Changed-scope is best for asking what new issues touch " + "modified files and whether anything should block CI." + ), + "Prefer production triage and hotspot views before broad finding listing.", + "If repository-wide truth is needed, run full analysis first.", + ), + recommended_tools=( + "analyze_changed_paths", + "get_run_summary", + "get_production_triage", + "evaluate_gates", + "generate_pr_summary", + ), + doc_links=(_MCP_INTERFACE_DOC_LINK, _MCP_GUIDE_DOC_LINK), + warnings=( + ( + "Changed-scope narrows review focus; it does not replace the " + "full canonical report for repository-wide truth." + ), + ), + anti_patterns=( + "Using changed-scope as if it were the only source of repository truth.", + ( + "Starting changed-files review with broad listing instead of " + "compact triage." + ), + ), + ), +} + + def _suggestion_finding_id_payload(suggestion: object) -> str: if not hasattr(suggestion, "finding_family"): return "" @@ -1297,6 +1590,38 @@ def get_production_triage( }, } + def get_help( + self, + *, + topic: HelpTopic, + detail: HelpDetail = "compact", + ) -> dict[str, object]: + validated_topic = cast( + "HelpTopic", + self._validate_choice("topic", topic, _VALID_HELP_TOPICS), + ) + validated_detail = cast( + "HelpDetail", + self._validate_choice("detail", detail, _VALID_HELP_DETAILS), + ) + spec = _HELP_TOPIC_SPECS[validated_topic] + payload: dict[str, object] = { + "topic": validated_topic, + "detail": validated_detail, + "summary": spec.summary, + "key_points": list(spec.key_points), + "recommended_tools": list(spec.recommended_tools), + "doc_links": [ + {"title": title, "url": url} for title, url in spec.doc_links + ], + } + if validated_detail == "normal": + if spec.warnings: + payload["warnings"] = list(spec.warnings) + if spec.anti_patterns: + payload["anti_patterns"] = list(spec.anti_patterns) + return payload + def generate_pr_summary( self, *, @@ -1861,10 +2186,12 @@ def _finding_id_maps( short_to_canonical[disambiguated] = canonical_id return canonical_to_short, short_to_canonical - def _base_short_finding_id(self, canonical_id: str) -> str: + @staticmethod + def _base_short_finding_id(canonical_id: str) -> str: return _base_short_finding_id_payload(canonical_id) - def _disambiguated_short_finding_id(self, canonical_id: str) -> str: + @staticmethod + def _disambiguated_short_finding_id(canonical_id: str) -> str: return _disambiguated_short_finding_id_payload(canonical_id) def _disambiguated_short_finding_ids( diff --git a/docs/book/01-architecture-map.md b/docs/book/01-architecture-map.md index 1c92481..90cef33 100644 --- a/docs/book/01-architecture-map.md +++ b/docs/book/01-architecture-map.md @@ -46,6 +46,10 @@ Refs: - MCP may ship task-specific slim projections (for example, summary-only metrics or inventory counts) as long as canonical report data remains the source of truth and richer detail stays reachable through dedicated tools/sections. +- The same rule applies to bounded semantic routing tools such as + `help(topic=...)`: they explain contract meaning and route agents to the + safest next step, but they do not introduce a second documentation or truth + model. - The same rule applies to summary cache convenience fields such as `freshness` and to production-first triage projections built from canonical hotlists/suggestions. diff --git a/docs/book/08-report.md b/docs/book/08-report.md index f893b51..925b95e 100644 --- a/docs/book/08-report.md +++ b/docs/book/08-report.md @@ -2,7 +2,7 @@ ## Purpose -Define report contracts in `2.0.0b3`: canonical JSON (`report_schema_version=2.2`) +Define report contracts in `2.0.0b4`: canonical JSON (`report_schema_version=2.2`) plus deterministic TXT/Markdown/SARIF projections. ## Public surface diff --git a/docs/book/14-compatibility-and-versioning.md b/docs/book/14-compatibility-and-versioning.md index 3ad9656..a32ce06 100644 --- a/docs/book/14-compatibility-and-versioning.md +++ b/docs/book/14-compatibility-and-versioning.md @@ -53,6 +53,10 @@ Version bump rules: `cache.freshness` or production-first triage also do not change `report_schema_version` when they are derived from unchanged canonical report and summary data. +- The same rule applies to bounded MCP semantic guidance such as + `help(topic=...)`: package-versioned wording and routing may evolve, but they + do not change `report_schema_version` as long as canonical report semantics + and finding identities remain unchanged. - Canonical report changes such as `meta.analysis_thresholds.design_findings` or threshold-aware design finding materialization do change `report_schema_version` because they alter canonical report semantics and diff --git a/docs/book/20-mcp-interface.md b/docs/book/20-mcp-interface.md index ac3cc56..c218585 100644 --- a/docs/book/20-mcp-interface.md +++ b/docs/book/20-mcp-interface.md @@ -72,6 +72,8 @@ Current server characteristics: - the cheapest useful path is designed to be the most obvious path: `get_run_summary` / `get_production_triage` first, then `list_hotspots` or `check_*`, then `get_finding` / `get_remediation` + - `help(topic=...)` is a bounded semantic routing tool for contract/workflow + uncertainty; it is not a second manual or docs proxy - finding-list payloads: - MCP finding ids are compact projection ids; canonical report ids are unchanged - `detail_level="summary"` is the default for list/check/hotspot tools @@ -87,7 +89,7 @@ produced by the report contract. ## Tools -Current tool set: +Current tool set (`21` tools): | Tool | Key parameters | Purpose / notes | |--------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| @@ -95,6 +97,7 @@ Current tool set: | `analyze_changed_paths` | absolute `root`, `changed_paths` or `git_diff_ref`, `analysis_mode`, inline thresholds | Diff-aware fast path: analyze a repo, attach a changed-files projection, and return a compact changed-files snapshot. The intended next step is `get_report_section(section="changed")` or `get_production_triage` | | `get_run_summary` | `run_id` | Return the stored summary for the latest or specified run, with slim inventory counts instead of the full file registry; this is the cheapest run-level snapshot and `health` becomes explicit `available=false` when metrics were skipped | | `get_production_triage` | `run_id`, `max_hotspots`, `max_suggestions` | Return a compact production-first MCP projection: health, cache `freshness`, production hotspots, production suggestions, and global source-kind counters. This is the default first-pass view for large or noisy repositories | +| `help` | `topic`, `detail` | Return a bounded semantic guide for a small set of MCP topics (`workflow`, `suppressions`, `baseline`, `latest_runs`, `review_state`, `changed_scope`) with next-step routing and canonical doc links. This is for uncertainty recovery, not full manual access | | `compare_runs` | `run_id_before`, `run_id_after`, `focus` | Compare two registered runs by finding ids and run-to-run health delta; MCP returns short run ids, compact regression/improvement cards, `mixed` for conflicting signals, and `incomparable` with top-level `reason`, empty comparison cards, and `health_delta=null` when roots/settings differ | | `evaluate_gates` | `run_id`, gate thresholds/booleans | Evaluate CI/gating conditions against an existing run without exiting the process | | `get_report_section` | `run_id`, `section`, `family`, `path`, `offset`, `limit` | Return a canonical report section. Prefer targeted sections instead of `section="all"` unless the client truly needs the full canonical report. `metrics` is summary-only; `metrics_detail` is paginated/bounded and falls back to summary+hint when unfiltered | @@ -124,6 +127,7 @@ sessionful and may populate or reuse in-memory run state. The granular Budget-aware workflow is intentional: - first pass: `get_run_summary` or `get_production_triage` +- semantic clarification: `help(topic=...)` when contract or workflow meaning is unclear - targeted triage: `list_hotspots` or the relevant `check_*` - single-finding drill-down: `get_finding`, then `get_remediation` - bounded metrics drill-down: `get_report_section(section="metrics_detail", family=..., limit=...)` diff --git a/docs/book/appendix/b-schema-layouts.md b/docs/book/appendix/b-schema-layouts.md index bf2734d..9ec7e0e 100644 --- a/docs/book/appendix/b-schema-layouts.md +++ b/docs/book/appendix/b-schema-layouts.md @@ -2,14 +2,14 @@ ## Purpose -Compact structural layouts for baseline/cache/report contracts in `2.0.0b3`. +Compact structural layouts for baseline/cache/report contracts in `2.0.0b4`. ## Baseline schema (`2.0`) ```json { "meta": { - "generator": { "name": "codeclone", "version": "2.0.0b3" }, + "generator": { "name": "codeclone", "version": "2.0.0b4" }, "schema_version": "2.0", "fingerprint_version": "1", "python_tag": "cp313", @@ -83,7 +83,7 @@ Notes: { "report_schema_version": "2.2", "meta": { - "codeclone_version": "2.0.0b3", + "codeclone_version": "2.0.0b4", "project_name": "codeclone", "scan_root": ".", "analysis_mode": "full", @@ -275,7 +275,7 @@ Notes: "tool": { "driver": { "name": "codeclone", - "version": "2.0.0b3", + "version": "2.0.0b4", "rules": [ { "id": "CCLONE001", diff --git a/docs/mcp.md b/docs/mcp.md index 1278758..5c34ec2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -44,6 +44,9 @@ Run retention is bounded: default `4`, max `10` (`--history-limit`). If a tool request omits `processes`, MCP defers process-count policy to the core CodeClone runtime. +Current `b4` MCP surface: `21` tools, `7` fixed resources, and `3` +run-scoped URI templates. + ## Tool surface | Tool | Purpose | @@ -52,6 +55,7 @@ core CodeClone runtime. | `analyze_changed_paths` | Diff-aware analysis with `changed_paths` or `git_diff_ref`; returns a compact changed-files snapshot; then prefer `get_report_section(section="changed")` or `get_production_triage` before broader list calls | | `get_run_summary` | Cheapest run-level snapshot: compact health/findings/baseline summary with slim inventory counts; `health` is explicit `available=false` when metrics were skipped | | `get_production_triage` | Compact production-first view: health, cache freshness, production hotspots, production suggestions; best default first pass on noisy repos | +| `help` | Compact semantic/orientation tool for supported topics like workflow, baseline, suppressions, latest-runs semantics, review state, and changed-scope routing; use when MCP meaning or the safest next step is unclear | | `compare_runs` | Regressions, improvements, and run-to-run health delta between comparable runs; returns `mixed` for conflicting signals and `incomparable` when roots/settings differ, with empty comparison cards and `health_delta=null` in that case | | `list_findings` | Filtered, paginated finding groups with compact summary payloads by default; use after hotspots or `check_*` when you need a broader filtered list | | `get_finding` | Deep inspection of one finding by id; defaults to normal detail and accepts `detail_level`; use after `list_hotspots`, `list_findings`, or `check_*` | @@ -82,6 +86,9 @@ Inline design-threshold parameters on `analyze_repository` / `analyze_changed_paths` become part of the canonical run: they are recorded in `meta.analysis_thresholds.design_findings` and define that run's canonical design findings. +`help(topic=...)` is intentionally static and bounded: it explains meaning, +flags common anti-patterns, suggests a safe next step, and points to canonical +docs without turning MCP into a documentation proxy. Run ids in MCP payloads are short session handles (first 8 hex chars of the canonical digest). MCP tools and run-scoped resources accept both short and full @@ -130,6 +137,12 @@ analyze_repository → get_run_summary or get_production_triage → list_hotspots or check_* → get_finding → get_remediation ``` +### Semantic uncertainty recovery + +``` +help(topic="workflow" | "baseline" | "suppressions" | "latest_runs" | "review_state" | "changed_scope") +``` + ### Full repository review ``` @@ -217,6 +230,8 @@ Show regressions, resolved findings, and health delta. - Use `analyze_changed_paths` for PRs, not full analysis. - Prefer `get_run_summary` or `get_production_triage` for the first pass on a new run. +- Use `help(topic=...)` when the safest next step or contract meaning is + unclear; it is a bounded semantic guide, not a docs dump. - Prefer `list_hotspots` or the narrow `check_*` tools before broad `list_findings` calls. - Use `get_finding` / `get_remediation` for one finding instead of raising diff --git a/pyproject.toml b/pyproject.toml index 96fe2bc..2a880e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codeclone" -version = "2.0.0b3" +version = "2.0.0b4" description = "Structural code quality analysis for Python" readme = { file = "README.md", content-type = "text/markdown" } license = "MPL-2.0 AND MIT" diff --git a/tests/test_github_action_helpers.py b/tests/test_github_action_helpers.py index d9a240d..4e34a53 100644 --- a/tests/test_github_action_helpers.py +++ b/tests/test_github_action_helpers.py @@ -100,7 +100,7 @@ def test_render_pr_comment_uses_canonical_report_summary() -> None: action_impl = _load_action_impl() report = { "meta": { - "codeclone_version": "2.0.0b3", + "codeclone_version": "2.0.0b4", "baseline": {"status": "ok"}, "cache": {"used": True}, }, @@ -141,7 +141,7 @@ def test_render_pr_comment_uses_canonical_report_summary() -> None: "Structural: 15", "Dead code: 0", "Design: 3", - "`2.0.0b3`", + "`2.0.0b4`", ), ) @@ -156,7 +156,7 @@ def test_resolve_install_target_uses_repo_source_for_local_action_checkout( target = _resolve_install_target( action_path=action_path, workspace=repo_root, - package_version="2.0.0b3", + package_version="2.0.0b4", ) assert target.source == "repo" @@ -173,7 +173,7 @@ def test_resolve_install_target_uses_pypi_for_remote_checkout(tmp_path: Path) -> pinned = _resolve_install_target( action_path=action_path, workspace=workspace_root, - package_version="2.0.0b3", + package_version="2.0.0b4", ) latest = _resolve_install_target( action_path=action_path, @@ -188,7 +188,7 @@ def test_resolve_install_target_uses_pypi_for_remote_checkout(tmp_path: Path) -> latest.requirement, ) == ( "pypi-version", - "codeclone==2.0.0b3", + "codeclone==2.0.0b4", "pypi-latest", "codeclone", ) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 1b1d971..0dc0cde 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -102,12 +102,14 @@ def test_mcp_server_exposes_expected_read_only_tools() -> None: assert "prefer get_run_summary or get_production_triage" in str(server.instructions) assert "Use list_hotspots or focused check_* tools" in str(server.instructions) assert "prefer generate_pr_summary(format='markdown')" in str(server.instructions) + assert "Use help(topic=...)" in str(server.instructions) tools = {tool.name: tool for tool in asyncio.run(server.list_tools())} assert set(tools) == { "analyze_repository", "analyze_changed_paths", "clear_session_runs", + "help", "get_run_summary", "get_production_triage", "evaluate_gates", @@ -138,6 +140,7 @@ def test_mcp_server_exposes_expected_read_only_tools() -> None: "check_dead_code", "get_run_summary", "get_production_triage", + "help", "get_report_section", "list_findings", "get_finding", @@ -165,6 +168,8 @@ def test_mcp_server_exposes_expected_read_only_tools() -> None: assert "default first-pass review" in str( tools["get_production_triage"].description ) + assert "bounded guidance, not a full manual" in str(tools["help"].description) + assert "workflow, suppressions, baseline" in str(tools["help"].description) assert "Prefer list_hotspots or focused check_* tools" in str( tools["list_findings"].description ) @@ -244,6 +249,19 @@ def test_mcp_server_tool_roundtrip_and_resources(tmp_path: Path) -> None: "classes", } + help_payload = _structured_tool_result( + asyncio.run( + server.call_tool( + "help", + {"topic": "changed_scope", "detail": "normal"}, + ) + ) + ) + assert help_payload["topic"] == "changed_scope" + assert help_payload["detail"] == "normal" + assert "warnings" in help_payload + assert "recommended_tools" in help_payload + findings_result = _structured_tool_result( asyncio.run( server.call_tool( diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py index 7261ea6..d310cf3 100644 --- a/tests/test_mcp_service.py +++ b/tests/test_mcp_service.py @@ -258,15 +258,96 @@ def test_mcp_service_analyze_repository_registers_latest_run(tmp_path: Path) -> assert len(str(summary["run_id"])) == 8 assert summary["mode"] == "full" assert summary["schema"] == REPORT_SCHEMA_VERSION - latest_baseline = cast("dict[str, object]", latest["baseline"]) - latest_cache = cast("dict[str, object]", latest["cache"]) - assert latest_baseline["status"] == "missing" - assert latest_baseline["trusted"] is False - assert latest_cache["used"] is False - assert latest_cache["freshness"] == "fresh" - latest_health = cast("dict[str, object]", latest["health"]) - assert isinstance(latest_health["score"], int) - assert latest_health["grade"] + + +def test_mcp_service_help_returns_bounded_semantic_guidance() -> None: + service = CodeCloneMCPService(history_limit=4) + + compact = service.get_help(topic="workflow") + normal = service.get_help(topic="workflow", detail="normal") + + assert compact == { + "topic": "workflow", + "detail": "compact", + "summary": ( + "CodeClone MCP is triage-first and budget-aware. Start with compact " + "summary or production triage, then narrow through hotspots or " + "focused checks before opening one finding in detail." + ), + "key_points": [ + "Recommended first pass: analyze_repository or analyze_changed_paths.", + ( + "Use get_run_summary or get_production_triage before broad " + "finding enumeration." + ), + ( + "Prefer list_hotspots or focused check_* tools over " + "list_findings on medium or noisy repositories." + ), + ( + "Use get_finding and get_remediation only after selecting a " + "specific issue." + ), + ( + "get_report_section(section='all') is an exception path, not " + "a default exploration step." + ), + ], + "recommended_tools": [ + "analyze_repository", + "analyze_changed_paths", + "get_run_summary", + "get_production_triage", + "list_hotspots", + "check_clones", + "check_dead_code", + "get_finding", + "get_remediation", + ], + "doc_links": [ + { + "title": "MCP interface contract", + "url": "https://orenlab.github.io/codeclone/book/20-mcp-interface/", + }, + { + "title": "MCP usage guide", + "url": "https://orenlab.github.io/codeclone/mcp/", + }, + ], + } + assert normal["topic"] == "workflow" + assert normal["detail"] == "normal" + assert normal["summary"] == compact["summary"] + assert normal["recommended_tools"] == compact["recommended_tools"] + assert normal["doc_links"] == compact["doc_links"] + assert cast("list[str]", normal["warnings"]) == [ + ( + "Broad list_findings calls can burn context quickly on large or " + "noisy repositories." + ), + ( + "Prefer generate_pr_summary(format='markdown') unless machine JSON " + "is explicitly needed." + ), + ] + assert cast("list[str]", normal["anti_patterns"]) == [ + "Starting exploration with list_findings on a noisy repository.", + "Using get_report_section(section='all') as the default first step.", + ( + "Escalating detail on larger lists instead of opening one finding " + "with get_finding." + ), + ] + + +def test_mcp_service_help_validates_topic_and_detail() -> None: + service = CodeCloneMCPService(history_limit=4) + + with pytest.raises(MCPServiceContractError, match="Invalid value for topic"): + service.get_help(topic="gates") # type: ignore[arg-type] + + with pytest.raises(MCPServiceContractError, match="Invalid value for detail"): + service.get_help(topic="baseline", detail="full") # type: ignore[arg-type] def test_mcp_service_summary_inventory_is_compact_and_report_inventory_stays_canonical( diff --git a/uv.lock b/uv.lock index 1592546..878c998 100644 --- a/uv.lock +++ b/uv.lock @@ -278,7 +278,7 @@ wheels = [ [[package]] name = "codeclone" -version = "2.0.0b3" +version = "2.0.0b4" source = { editable = "." } dependencies = [ { name = "pygments" }, From e8fada959d7a946c7423de0dfad1491909eac72e Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Fri, 3 Apr 2026 17:54:02 +0500 Subject: [PATCH 02/15] feat(report): add report-only god modules and tighten MCP, health, and UX semantics - add canonical `metrics.families.god_modules` as a report-only layer and surface it in JSON, CLI, HTML, and MCP without affecting health, gates, or findings - tighten findings vs suggestions semantics for low-signal structural hints and keep action guidance inline where separate suggestions add no real value - align CLI and HTML scope/inventory presentation with canonical report semantics and polish overview/God Modules rhythm - add dedicated Health Score documentation, clarify phased health-model expansion, and document unified metrics-baseline behavior - refresh MCP guidance/help wording and GitHub issue templates for the current b4 surface --- .github/ISSUE_TEMPLATE/bug_report.yml | 16 +- .github/ISSUE_TEMPLATE/cfg_semantics.yml | 2 +- .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/false_positive.yml | 24 +- .github/ISSUE_TEMPLATE/feature_request.yml | 24 ++ .github/ISSUE_TEMPLATE/mcp_server.yml | 75 ++++ CHANGELOG.md | 37 +- README.md | 4 +- codeclone/_cli_summary.py | 13 + codeclone/_html_css.py | 30 ++ codeclone/_html_report/_assemble.py | 1 + codeclone/_html_report/_components.py | 3 + codeclone/_html_report/_context.py | 6 + codeclone/_html_report/_sections/_coupling.py | 38 +- codeclone/_html_report/_sections/_overview.py | 210 +++++++++- codeclone/cli.py | 35 +- codeclone/contracts.py | 2 +- codeclone/mcp_server.py | 11 +- codeclone/mcp_service.py | 17 +- codeclone/metrics/__init__.py | 2 + codeclone/metrics/god_modules.py | 387 ++++++++++++++++++ codeclone/pipeline.py | 40 ++ codeclone/report/findings.py | 38 +- codeclone/report/json_contract.py | 115 ++++++ codeclone/report/markdown.py | 60 ++- codeclone/report/serialize.py | 39 ++ codeclone/report/suggestions.py | 75 +++- codeclone/ui_messages.py | 29 +- docs/README.md | 3 +- docs/architecture.md | 2 +- docs/book/00-intro.md | 1 + docs/book/01-architecture-map.md | 1 + docs/book/02-terminology.md | 1 + docs/book/04-config-and-defaults.md | 3 + docs/book/06-baseline.md | 3 + docs/book/08-report.md | 40 +- docs/book/10-html-render.md | 2 + docs/book/13-testing-as-spec.md | 2 +- docs/book/14-compatibility-and-versioning.md | 50 ++- docs/book/15-health-score.md | 151 +++++++ docs/book/15-metrics-and-quality-gates.md | 14 +- docs/book/17-suggestions-and-clone-typing.md | 6 + docs/book/20-mcp-interface.md | 49 +-- docs/book/README.md | 1 + docs/book/appendix/b-schema-layouts.md | 30 +- docs/mcp.md | 4 +- mkdocs.yml | 1 + .../golden_expected_cli_snapshot.json | 2 +- tests/test_cli_inprocess.py | 2 + tests/test_cli_unit.py | 53 ++- tests/test_html_report.py | 164 ++++++++ tests/test_mcp_server.py | 20 + tests/test_mcp_service.py | 71 ++++ tests/test_pipeline_metrics.py | 124 ++++++ tests/test_report_branch_invariants.py | 18 +- tests/test_report_contract_coverage.py | 131 ++++++ tests/test_report_suggestions.py | 39 ++ uv.lock | 256 ++++++------ 58 files changed, 2320 insertions(+), 262 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/mcp_server.yml create mode 100644 codeclone/metrics/god_modules.py create mode 100644 docs/book/15-health-score.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 08f6869..80e478f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ name: 🐞 Bug report -description: Incorrect clone detection, crashes, or broken reports +description: Incorrect analysis results, crashes, broken reports, or contract regressions title: "[Bug]: " labels: [ "bug" ] assignees: [ ] @@ -16,7 +16,7 @@ body: attributes: label: CodeClone version description: Output of `codeclone --version` - placeholder: "1.1.0" + placeholder: "2.0.0b4" validations: required: true @@ -26,11 +26,17 @@ body: label: Affected area options: - Function clone detection (Type-2) - - Block clone detection (Type-3-lite) + - Block clone detection + - Segment clone reporting + - Structural findings + - Quality metrics / health - CFG construction - AST normalization + - Canonical report / JSON - HTML report - - CLI / baseline + - CLI / baseline / cache + - GitHub Action / CI + - MCP server validations: required: true @@ -62,4 +68,4 @@ body: id: notes attributes: label: Additional context - description: CFG structure, HTML screenshots, logs, etc. + description: Baseline/cache status, HTML screenshots, MCP payloads, logs, etc. diff --git a/.github/ISSUE_TEMPLATE/cfg_semantics.yml b/.github/ISSUE_TEMPLATE/cfg_semantics.yml index 3e209d1..a070429 100644 --- a/.github/ISSUE_TEMPLATE/cfg_semantics.yml +++ b/.github/ISSUE_TEMPLATE/cfg_semantics.yml @@ -15,7 +15,7 @@ body: id: version attributes: label: CodeClone version - placeholder: "1.1.0" + placeholder: "2.0.0b4" - type: textarea id: scenario diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..138623a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: CodeClone Docs + url: https://orenlab.github.io/codeclone/ + about: Read the contracts book, MCP guide, and interface docs before opening a generic usage question. diff --git a/.github/ISSUE_TEMPLATE/false_positive.yml b/.github/ISSUE_TEMPLATE/false_positive.yml index 663a2d3..acaa38a 100644 --- a/.github/ISSUE_TEMPLATE/false_positive.yml +++ b/.github/ISSUE_TEMPLATE/false_positive.yml @@ -15,15 +15,27 @@ body: id: version attributes: label: CodeClone version - placeholder: "1.1.0" + placeholder: "2.0.0b4" + validations: + required: true + + - type: dropdown + id: family + attributes: + label: Detection family + options: + - Function clone + - Block clone + - Segment clone (report-only) + - Structural finding validations: required: true - type: textarea id: code attributes: - label: Code snippets detected as clones - description: Paste both snippets or a reduced example + label: Minimal reproduction + description: Paste both snippets, the reduced example, or the relevant finding evidence render: python validations: required: true @@ -38,9 +50,11 @@ body: validations: required: true - - type: checkbox + - type: checkboxes id: cfg_related attributes: - label: CFG-related? + label: Additional signals options: - label: Control flow structure differs meaningfully + - label: Side effects or runtime behavior differ meaningfully + - label: The issue appears only in tests / fixtures diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 2607b7b..39a6fa5 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -18,9 +18,22 @@ body: - Clone detection logic - CFG semantics - AST normalization + - Structural findings / suggestions + - Metrics / health model + - Canonical report / contracts - HTML report / UX - CLI / CI integration + - MCP server / agent workflow - Performance + - Documentation + validations: + required: true + + - type: textarea + id: user_impact + attributes: + label: Why is this worth adding? + description: What concrete workflow, quality, or UX problem would this solve? validations: required: true @@ -44,3 +57,14 @@ body: id: alternatives attributes: label: Alternatives considered + + - type: checkboxes + id: contracts + attributes: + label: Contract-sensitive areas + description: Tick any surfaces you believe this may affect. + options: + - label: Baseline / cache compatibility + - label: Canonical report JSON schema + - label: CLI exit codes or script-facing output + - label: MCP tool or resource semantics diff --git a/.github/ISSUE_TEMPLATE/mcp_server.yml b/.github/ISSUE_TEMPLATE/mcp_server.yml new file mode 100644 index 0000000..dc0fc1b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/mcp_server.yml @@ -0,0 +1,75 @@ +name: MCP server +description: Report an MCP bug or propose an MCP workflow/tooling improvement +title: "[MCP]: " +labels: ["mcp"] +assignees: [] + +body: + - type: markdown + attributes: + value: | + Use this template for issues about the **CodeClone MCP server**: + tools, resources, payload shape, routing, session semantics, root handling, + IDE/MCP client behavior, and agent-facing workflow quality. + + - type: input + id: version + attributes: + label: CodeClone version + description: Output of `codeclone --version` + placeholder: "2.0.0b4" + validations: + required: true + + - type: dropdown + id: category + attributes: + label: MCP category + options: + - Tool behavior + - Resource behavior + - Payload size / shape + - Session / latest-runs semantics + - Root / safety semantics + - Guidance / workflow routing + - Packaging / launcher / transport + validations: + required: true + + - type: textarea + id: client + attributes: + label: MCP client or host + description: Codex, Claude Desktop, Cursor, VS Code, custom client, etc. + placeholder: "Codex desktop / stdio" + + - type: textarea + id: problem + attributes: + label: What happened? + description: Describe the current MCP behavior and why it is wrong or awkward. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What should happen instead? + description: Focus on the safest and most budget-aware expected behavior. + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Reproduction or request sequence + description: | + Include the relevant tool/resource calls when possible. + Small, exact examples are preferred over screenshots. + render: json + + - type: textarea + id: notes + attributes: + label: Additional context + description: Payload excerpts, error text, screenshots, or workflow notes. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3def240..1a2b34f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,39 @@ ## [2.0.0b4] -In development. +2.0.0b4 deepens the platform model introduced in b3: MCP becomes more self-guiding, report-only analysis expands with +module-level hotspot ranking, findings and suggestions are separated more cleanly by role, and Health Score +documentation now formalizes how new signal families can be introduced gradually without pretending the scoring model is +static. ### MCP server -- Add bounded MCP `help(topic=...)` as an uncertainty-recovery tool for workflow, baseline, suppressions, latest-run semantics, review state, and changed-scope routing. +- Add bounded MCP `help(topic=...)` as a compact uncertainty-recovery and semantic-routing tool for workflow, baseline, + suppressions, latest-run semantics, review state, and changed-scope routing. -## [2.0.0b3] +### Report contract + +- Bump canonical report schema to `2.3` and add `metrics.families.god_modules` as a project-relative, report-only + module-hotspot layer. +- Surface `God Modules` consistently across canonical JSON, text/markdown, HTML Overview + Quality projections, and + bounded MCP `metrics_detail` access without changing findings, health, gates, baseline semantics, or SARIF. +- Tighten the findings/suggestions role split: low-signal local structural `info` hints remain canonical findings, + while separate suggestion cards are reserved for action-surplus cases and structural findings can render compact + inline suggested action in HTML. + +### CLI and HTML + +- Align CLI and HTML scope summaries with canonical report-wide inventory totals. +- Polish `God Modules` presentation so report-only module-hotspot summaries read consistently across surfaces. + +### Documentation + +- Add a dedicated Health Score chapter documenting current scoring inputs, report-only / non-scoring layers, and the + phased policy for future health-model expansion. +- Explicitly document that future releases may lower a repository score because the scoring model becomes broader or + stricter, not only because the code became worse. + +## [2.0.0b3] - 20260401 2.0.0b3 is the release where CodeClone stops looking like "a strong analyzer with extras" and starts looking like a coherent platform: canonical-report-first, agent-facing, CI-native, and product-grade. @@ -57,7 +83,8 @@ coherent platform: canonical-report-first, agent-facing, CI-native, and product- ### HTML report -- Add `Hotspots by Directory` to the Overview tab, surfacing directory-level concentration for `all`, `clones`, and low-cohesion findings with scope-aware badges and compact counts. +- Add `Hotspots by Directory` to the Overview tab, surfacing directory-level concentration for `all`, `clones`, and + low-cohesion findings with scope-aware badges and compact counts. - Add IDE picker (PyCharm, IDEA, VS Code, Cursor, Fleet, Zed) with persistent selection. - Add clickable file-path deep links across all tabs and stable `finding-{id}` anchors. @@ -65,7 +92,7 @@ coherent platform: canonical-report-first, agent-facing, CI-native, and product- - Ship Composite Action v2 with configurable quality gates, SARIF upload to Code Scanning, and PR summary comments. -## [2.0.0b2] +## [2.0.0b2] - 20260328 ### Dependencies diff --git a/README.md b/README.md index 23c7a95..834b140 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,8 @@ Live sample report: - **Clone detection** — function (CFG fingerprint), block (statement windows), and segment (report-only) clones - **Structural findings** — duplicated branch families, clone guard/exit divergence and clone-cohort drift (report-only) -- **Quality metrics** — cyclomatic complexity, coupling (CBO), cohesion (LCOM4), dependency cycles, dead code, health - score +- **Quality metrics** — cyclomatic complexity, coupling (`CBO`), cohesion (`LCOM4`), dependency cycles, dead code, health + score, and report-only `God Modules` profiling - **Baseline governance** — separates accepted **legacy** debt from **new regressions** and lets CI fail **only** on what changed - **Reports** — interactive HTML, deterministic JSON/TXT plus Markdown and SARIF projections from one canonical report diff --git a/codeclone/_cli_summary.py b/codeclone/_cli_summary.py index 69b30da..8bf0e85 100644 --- a/codeclone/_cli_summary.py +++ b/codeclone/_cli_summary.py @@ -26,6 +26,10 @@ class MetricsSnapshot: health_total: int health_grade: str suppressed_dead_code_count: int = 0 + god_modules_candidates: int = 0 + god_modules_total: int = 0 + god_modules_population_status: str = "" + god_modules_top_score: float = 0.0 @dataclass(frozen=True, slots=True) @@ -132,6 +136,7 @@ def _print_metrics( dead=metrics.dead_code_count, health=metrics.health_total, grade=metrics.health_grade, + god_modules=metrics.god_modules_candidates, ) ) else: @@ -160,6 +165,14 @@ def _print_metrics( suppressed=metrics.suppressed_dead_code_count, ) ) + console.print( + ui.fmt_metrics_god_modules( + candidates=metrics.god_modules_candidates, + total=metrics.god_modules_total, + population_status=metrics.god_modules_population_status, + top_score=metrics.god_modules_top_score, + ) + ) def _print_changed_scope( diff --git a/codeclone/_html_css.py b/codeclone/_html_css.py index 8923410..b596364 100644 --- a/codeclone/_html_css.py +++ b/codeclone/_html_css.py @@ -635,6 +635,7 @@ /* Summary grid */ .overview-summary-grid{display:grid;gap:var(--sp-3);margin-bottom:var(--sp-3)} .overview-summary-grid--2col{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))} +.overview-summary-grid--3col{grid-template-columns:repeat(auto-fit,minmax(240px,1fr))} .overview-summary-item{background:var(--bg-surface);border:1px solid var(--border); border-radius:var(--radius-lg);padding:var(--sp-4)} .overview-summary-label{display:flex;align-items:center;gap:var(--sp-2); @@ -649,6 +650,13 @@ padding-left:var(--sp-3);position:relative;line-height:1.5} .overview-summary-list li::before{content:"\\2022";position:absolute;left:0;color:var(--text-muted)} .overview-summary-value{font-size:.85rem;color:var(--text-muted)} +.overview-fact-list{display:flex;flex-direction:column;gap:var(--sp-2);margin-top:var(--sp-3)} +.overview-fact-row{display:flex;align-items:baseline;justify-content:space-between;gap:var(--sp-3); + font-size:.76rem;border-bottom:1px solid color-mix(in srgb,var(--border) 45%,transparent);padding-bottom:6px} +.overview-fact-row:last-child{border-bottom:none;padding-bottom:0} +.overview-fact-label{color:var(--text-muted)} +.overview-fact-value{color:var(--text-secondary);font-weight:600;font-variant-numeric:tabular-nums; + text-align:right} /* Source breakdown bars */ .breakdown-list{display:flex;flex-direction:column;gap:var(--sp-2)} .breakdown-row{display:grid;grid-template-columns:6.5rem 2rem 1fr;align-items:center;gap:var(--sp-2)} @@ -675,6 +683,23 @@ .dir-hotspot-meta{display:flex;flex-wrap:wrap;gap:6px;font-size:.68rem;color:var(--text-muted)} .dir-hotspot-meta span{font-variant-numeric:tabular-nums} .dir-hotspot-meta-sep{opacity:.3} +.god-module-list{display:flex;flex-direction:column;gap:0} +.god-module-entry{padding:var(--sp-2) 0;border-bottom:1px solid color-mix(in srgb,var(--border) 50%,transparent)} +.god-module-entry:last-child{border-bottom:none;padding-bottom:0} +.god-module-entry:first-child{padding-top:0} +.god-module-head{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--sp-2);margin-bottom:4px} +.god-module-title{display:flex;align-items:center;flex-wrap:wrap;gap:var(--sp-2);min-width:0} +.god-module-title code{font-size:.78rem;font-weight:600;color:var(--text-primary);line-height:1.35} +.god-module-score{flex-shrink:0;font-size:.72rem;font-weight:700;font-variant-numeric:tabular-nums; + color:var(--accent-primary);background:var(--accent-muted);border-radius:999px;padding:2px 8px} +.god-module-metrics{display:flex;flex-wrap:wrap;gap:6px;font-size:.68rem;color:var(--text-muted)} +.god-module-metrics span{font-variant-numeric:tabular-nums} +.god-module-reasons,.god-module-signal-list{display:flex;flex-wrap:wrap;gap:var(--sp-1);margin-top:var(--sp-2)} +.god-module-reason-chip,.god-module-signal-pill{display:inline-flex;align-items:center;gap:5px; + font-size:.68rem;font-weight:500;color:var(--text-secondary);background:var(--bg-raised); + border:1px solid color-mix(in srgb,var(--border) 60%,transparent);border-radius:999px; + padding:2px 8px} +.god-module-signal-count{font-variant-numeric:tabular-nums;color:var(--text-muted)} /* Health radar chart */ .health-radar{display:flex;justify-content:center;padding:var(--sp-3) 0} .health-radar svg{width:100%;max-width:520px;height:auto;overflow:visible} @@ -882,6 +907,11 @@ .sf-body{padding:0 var(--sp-4) var(--sp-3);display:flex;flex-direction:column;gap:var(--sp-2)} .sf-chips{display:flex;flex-wrap:wrap;gap:var(--sp-1)} .sf-scope-text{font-size:.8rem;font-family:var(--font-mono);color:var(--text-secondary)} +.sf-inline-action{display:flex;align-items:flex-start;gap:var(--sp-2);padding:var(--sp-2) var(--sp-3); + border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg-raised)} +.sf-inline-action-label{font-size:.72rem;font-weight:600;letter-spacing:.02em;text-transform:uppercase; + color:var(--accent-primary);white-space:nowrap} +.sf-inline-action-text{font-size:.8rem;color:var(--text-secondary);line-height:1.45} /* Expandable occurrences */ .sf-details{border-top:1px solid var(--border)} diff --git a/codeclone/_html_report/_assemble.py b/codeclone/_html_report/_assemble.py index 29017d4..5de6ad8 100644 --- a/codeclone/_html_report/_assemble.py +++ b/codeclone/_html_report/_assemble.py @@ -111,6 +111,7 @@ def build_html_report( _as_int(_as_mapping(ctx.complexity_map.get("summary")).get("high_risk")) + _as_int(_as_mapping(ctx.coupling_map.get("summary")).get("high_risk")) + _as_int(_as_mapping(ctx.cohesion_map.get("summary")).get("low_cohesion")) + + _as_int(_as_mapping(ctx.god_modules_map.get("summary")).get("candidates")) ) def _tab_badge(count: int) -> str: diff --git a/codeclone/_html_report/_components.py b/codeclone/_html_report/_components.py index 57c872c..2b2a0af 100644 --- a/codeclone/_html_report/_components.py +++ b/codeclone/_html_report/_components.py @@ -51,12 +51,15 @@ def overview_cluster_header(title: str, subtitle: str | None = None) -> str: _SUMMARY_ICON_KEYS: dict[str, tuple[str, str]] = { + "scan scope": ("overview", "summary-icon summary-icon--info"), "top risks": ("top-risks", "summary-icon summary-icon--risk"), "issue breakdown": ("issue-breakdown", "summary-icon summary-icon--info"), "source breakdown": ("source-breakdown", "summary-icon summary-icon--info"), "all findings": ("all-findings", "summary-icon summary-icon--info"), "clone groups": ("clone-groups", "summary-icon summary-icon--info"), "low cohesion": ("low-cohesion", "summary-icon summary-icon--info"), + "top candidates": ("quality", "summary-icon summary-icon--info"), + "candidate profile": ("quality", "summary-icon summary-icon--info"), "health profile": ("health-profile", "summary-icon summary-icon--info"), } diff --git a/codeclone/_html_report/_context.py b/codeclone/_html_report/_context.py index a62a42f..c49a4a7 100644 --- a/codeclone/_html_report/_context.py +++ b/codeclone/_html_report/_context.py @@ -62,6 +62,7 @@ class ReportContext: cohesion_map: Mapping[str, object] dependencies_map: Mapping[str, object] dead_code_map: Mapping[str, object] + god_modules_map: Mapping[str, object] health_map: Mapping[str, object] # -- suggestions + structural -- @@ -71,6 +72,7 @@ class ReportContext: # -- derived -- overview_data: Mapping[str, object] report_document: Mapping[str, object] + inventory_map: Mapping[str, object] derived_map: Mapping[str, object] integrity_map: Mapping[str, object] @@ -175,6 +177,7 @@ def build_context( metrics_baseline_meta = _as_mapping(meta.get("metrics_baseline")) runtime_meta = _as_mapping(meta.get("runtime")) report_document_map = _as_mapping(report_document) + inventory_map = _as_mapping(report_document_map.get("inventory")) derived_map = _as_mapping(report_document_map.get("derived")) integrity_map = _as_mapping(report_document_map.get("integrity")) @@ -234,6 +237,7 @@ def build_context( cohesion_map = _as_mapping(metrics_map.get("cohesion")) dependencies_map = _as_mapping(metrics_map.get("dependencies")) dead_code_map = _as_mapping(metrics_map.get("dead_code")) + god_modules_map = _as_mapping(metrics_map.get("god_modules")) health_map = _as_mapping(metrics_map.get("health")) suggestions_tuple = tuple(suggestions or ()) @@ -278,11 +282,13 @@ def build_context( cohesion_map=cohesion_map, dependencies_map=dependencies_map, dead_code_map=dead_code_map, + god_modules_map=god_modules_map, health_map=health_map, suggestions=suggestions_tuple, structural_findings=tuple(structural_findings or ()), overview_data=overview_data, report_document=report_document_map, + inventory_map=inventory_map, derived_map=derived_map, integrity_map=integrity_map, metrics_diff=metrics_diff, diff --git a/codeclone/_html_report/_sections/_coupling.py b/codeclone/_html_report/_sections/_coupling.py index cfc7bac..d6a9751 100644 --- a/codeclone/_html_report/_sections/_coupling.py +++ b/codeclone/_html_report/_sections/_coupling.py @@ -53,10 +53,12 @@ def render_quality_panel(ctx: ReportContext) -> str: coupling_summary = _as_mapping(ctx.coupling_map.get("summary")) cohesion_summary = _as_mapping(ctx.cohesion_map.get("summary")) complexity_summary = _as_mapping(ctx.complexity_map.get("summary")) + god_modules_summary = _as_mapping(ctx.god_modules_map.get("summary")) coupling_high_risk = _as_int(coupling_summary.get("high_risk")) cohesion_low = _as_int(cohesion_summary.get("low_cohesion")) complexity_high_risk = _as_int(complexity_summary.get("high_risk")) + god_module_candidates = _as_int(god_modules_summary.get("candidates")) cc_max = _as_int(complexity_summary.get("max")) # Insight @@ -70,11 +72,12 @@ def render_quality_panel(ctx: ReportContext) -> str: f"High-complexity: {complexity_high_risk}; " f"high-coupling: {coupling_high_risk}; " f"low-cohesion: {cohesion_low}; " + f"god modules: {god_module_candidates}; " f"max CC {cc_max}; " f"max CBO {coupling_summary.get('max', 'n/a')}; " f"max LCOM4 {cohesion_summary.get('max', 'n/a')}." ) - if coupling_high_risk > 0 and cohesion_low > 0: + if god_module_candidates > 0 or (coupling_high_risk > 0 and cohesion_low > 0): tone = "risk" elif coupling_high_risk > 0 or cohesion_low > 0 or complexity_high_risk > 0: tone = "warn" @@ -149,10 +152,43 @@ def render_quality_panel(ctx: ReportContext) -> str: ctx=ctx, ) + gm_rows_data = _as_sequence(ctx.god_modules_map.get("items")) + gm_rows = [ + ( + str(_as_mapping(r).get("module", "")), + str( + _as_mapping(r).get("relative_path") + or _as_mapping(r).get("filepath") + or "" + ), + str(_as_mapping(r).get("score", "")), + str(_as_mapping(r).get("candidate_status", "")), + str(_as_mapping(r).get("loc", "")), + f"{_as_mapping(r).get('fan_in', '')}/{_as_mapping(r).get('fan_out', '')}", + str(_as_mapping(r).get("complexity_total", "")), + ) + for r in gm_rows_data[:50] + ] + gm_panel = render_rows_table( + headers=( + "Module", + "File", + "Score", + "Status", + "LOC", + "Fan-in/out", + "Complexity total", + ), + rows=gm_rows, + empty_message="God-module profiling is not available.", + ctx=ctx, + ) + sub_tabs: list[tuple[str, str, int, str]] = [ ("complexity", "Complexity", complexity_high_risk, cx_panel), ("coupling", "Coupling (CBO)", coupling_high_risk, cp_panel), ("cohesion", "Cohesion (LCOM4)", cohesion_low, ch_panel), + ("god-modules", "God Modules", god_module_candidates, gm_panel), ] return insight_block( diff --git a/codeclone/_html_report/_sections/_overview.py b/codeclone/_html_report/_sections/_overview.py index 341a054..4c52616 100644 --- a/codeclone/_html_report/_sections/_overview.py +++ b/codeclone/_html_report/_sections/_overview.py @@ -9,6 +9,7 @@ from __future__ import annotations import math +from collections import Counter from collections.abc import Mapping from typing import TYPE_CHECKING @@ -61,6 +62,12 @@ "coupling": "coupling", "dependency": "dependency", } +_GOD_MODULE_REASON_LABELS: dict[str, str] = { + "size_pressure": "size pressure", + "dependency_pressure": "dependency pressure", + "hub_like_shape": "hub-like shape", + "repeated_import_pressure": "repeated import pressure", +} def _health_gauge_html( @@ -385,6 +392,77 @@ def _dir_meta_span(val: int, label: str) -> str: _DIR_META_SEP = '\u00b7' +def _format_count(value: int | float) -> str: + if isinstance(value, float): + return f"{value:,.2f}" + return f"{int(value):,}" + + +def _overview_fact_rows_html(facts: list[tuple[str, str]]) -> str: + if not facts: + return "" + return ( + '
' + + "".join( + '
' + f'{_escape_html(label)}' + f'{_escape_html(value)}' + "
" + for label, value in facts + ) + + "
" + ) + + +def _mb(*pairs: tuple[str, object]) -> str: + """Render compact micro-badges for stat-card detail rows.""" + return "".join( + f'' + f'{_escape_html(str(v))}' + f'{_escape_html(label)}' + for label, v in pairs + if v is not None and str(v) != "n/a" + ) + + +def _run_snapshot_section(ctx: ReportContext) -> str: + inventory = _as_mapping(getattr(ctx, "inventory_map", {})) + if not inventory: + return "" + + files = _as_mapping(inventory.get("files")) + code = _as_mapping(inventory.get("code")) + total_found = _as_int(files.get("total_found")) + analyzed = _as_int(files.get("analyzed")) + cached = _as_int(files.get("cached")) + skipped = _as_int(files.get("skipped")) + source_io_skipped = _as_int(files.get("source_io_skipped")) + parsed_lines = _as_int(code.get("parsed_lines")) + functions = _as_int(code.get("functions")) + methods = _as_int(code.get("methods")) + classes = _as_int(code.get("classes")) + callable_total = functions + methods + + summary_parts = [ + f"{_format_count(total_found)} found", + f"{_format_count(analyzed)} analyzed", + f"{_format_count(cached)} cached", + f"{_format_count(skipped + source_io_skipped)} skipped", + ] + facts = [ + ("Cached files", _format_count(cached)), + ("Skipped files", _format_count(skipped + source_io_skipped)), + ("Parsed lines", _format_count(parsed_lines)), + ("Callables", _format_count(callable_total)), + ("Classes", _format_count(classes)), + ] + return ( + '
' + f"{' · '.join(summary_parts)}" + "
" + _overview_fact_rows_html(facts) + ) + + def _directory_kind_meta_parts( kind_breakdown: Mapping[str, object], *, @@ -502,6 +580,121 @@ def _directory_hotspots_section(ctx: ReportContext) -> str: ) +def _god_modules_section(ctx: ReportContext) -> str: + god_modules = _as_mapping(getattr(ctx, "god_modules_map", {})) + if not god_modules: + return "" + summary = _as_mapping(god_modules.get("summary")) + candidates = _as_int(summary.get("candidates")) + if candidates <= 0: + return "" + candidate_rows = [ + _as_mapping(item) + for item in _as_sequence(god_modules.get("items")) + if str(_as_mapping(item).get("candidate_status", "")).strip() == "candidate" + ][:5] + if not candidate_rows: + return "" + + top_rows = candidate_rows[:4] + rows_html: list[str] = [] + reason_counts: Counter[str] = Counter() + for row in candidate_rows: + for reason in _as_sequence(row.get("candidate_reasons")): + if str(reason).strip(): + reason_counts[str(reason)] += 1 + + signal_pills = "".join( + '' + f"{_escape_html(_GOD_MODULE_REASON_LABELS.get(reason, reason.replace('_', ' ')))}" + f'{count}' + "" + for reason, count in sorted( + reason_counts.items(), + key=lambda item: (-item[1], item[0]), + )[:4] + ) + + for row in top_rows: + score = _as_float(row.get("score")) + reason_labels = [ + _GOD_MODULE_REASON_LABELS.get(str(reason), str(reason).replace("_", " ")) + for reason in _as_sequence(row.get("candidate_reasons")) + if str(reason).strip() + ] + relative_path = str(row.get("relative_path", "")).strip() + if not relative_path: + relative_path = str(row.get("module", "")).replace(".", "/") + ".py" + fan_summary = f"{_as_int(row.get('fan_in'))}/{_as_int(row.get('fan_out'))}" + reason_html = ( + '
' + + "".join( + f'{_escape_html(label)}' + for label in reason_labels[:3] + ) + + "
" + if reason_labels + else "" + ) + rows_html.append( + '
' + '
' + '
' + f"{_escape_html(relative_path)}" + f"{_source_kind_badge_html(str(row.get('source_kind', 'other')))}" + "
" + f'{score:.2f}' + "
" + '
' + f"{_escape_html(_format_count(_as_int(row.get('loc'))))} LOC" + f"{_DIR_META_SEP}" + f"fan-in/out {_escape_html(fan_summary)}" + f"{_DIR_META_SEP}" + f"complexity {_escape_html(str(_as_int(row.get('complexity_total'))))}" + "
" + f"{reason_html}" + "
" + ) + + profile_facts = [("Top score", f"{_as_float(summary.get('top_score')):.2f}")] + average_score = _as_float(summary.get("average_score")) + if average_score > 0: + profile_facts.append(("Average score", f"{average_score:.2f}")) + population_status = str(summary.get("population_status", "")).strip() + if population_status: + profile_facts.append(("Population", population_status.replace("_", " "))) + + profile_html = ( + '
' + f"{candidates} candidate{'s' if candidates != 1 else ''} " + f"across {_as_int(summary.get('total'))} ranked module{'s' if _as_int(summary.get('total')) != 1 else ''}." + "
" + + _overview_fact_rows_html(profile_facts) + + ( + f'
{signal_pills}
' + if signal_pills + else "" + ) + ) + return ( + '
' + + overview_cluster_header( + "God Modules", + "Report-only module hotspots derived from project-relative implementation burden and dependency pressure.", + ) + + '
' + + overview_summary_item_html( + label="Top candidates", + body_html='
' + "".join(rows_html) + "
", + ) + + overview_summary_item_html( + label="Candidate profile", + body_html=profile_html, + ) + + "
" + ) + + def render_overview_panel(ctx: ReportContext) -> str: """Build the Overview tab panel HTML.""" complexity_summary = _as_mapping(ctx.complexity_map.get("summary")) @@ -583,16 +776,6 @@ def _answer_and_tone() -> tuple[str, Tone]: 1 for gk, _ in ctx.func_sorted if gk in ctx.new_func_keys ) + sum(1 for gk, _ in ctx.block_sorted if gk in ctx.new_block_keys) - def _mb(*pairs: tuple[str, object]) -> str: - """Render micro-badges: [label value] [label value] ...""" - return "".join( - f'' - f'{_escape_html(str(v))}' - f'{_escape_html(label)}' - for label, v in pairs - if v is not None and str(v) != "n/a" - ) - _baseline_ok = ( '\u2713 baselined' ) @@ -746,7 +929,7 @@ def _baselined_detail( "Executive Summary", "Project-wide context derived from the full scanned root.", ) - + '
' + + '
' + overview_summary_item_html( label="Issue breakdown", body_html=_issue_breakdown_html(ctx, deltas=_issue_deltas), @@ -757,6 +940,10 @@ def _baselined_detail( _as_mapping(ctx.overview_data.get("source_breakdown")) ), ) + + overview_summary_item_html( + label="Scan scope", + body_html=_run_snapshot_section(ctx), + ) + "
" ) @@ -778,6 +965,7 @@ def _baselined_detail( + "
" + executive + _directory_hotspots_section(ctx) + + _god_modules_section(ctx) + _analytics_section(ctx) ) diff --git a/codeclone/cli.py b/codeclone/cli.py index d06a3e4..d6ea960 100644 --- a/codeclone/cli.py +++ b/codeclone/cli.py @@ -202,6 +202,7 @@ class ChangedCloneGate: _as_mapping = _coerce.as_mapping +_as_int = _coerce.as_int _as_sequence = _coerce.as_sequence @@ -1438,10 +1439,22 @@ def _prepare_run_inputs() -> tuple[ files_analyzed=processing_result.files_analyzed, cache_hits=discovery_result.cache_hits, files_skipped=processing_result.files_skipped, - analyzed_lines=processing_result.analyzed_lines, - analyzed_functions=processing_result.analyzed_functions, - analyzed_methods=processing_result.analyzed_methods, - analyzed_classes=processing_result.analyzed_classes, + analyzed_lines=( + processing_result.analyzed_lines + + int(getattr(discovery_result, "cached_lines", 0)) + ), + analyzed_functions=( + processing_result.analyzed_functions + + int(getattr(discovery_result, "cached_functions", 0)) + ), + analyzed_methods=( + processing_result.analyzed_methods + + int(getattr(discovery_result, "cached_methods", 0)) + ), + analyzed_classes=( + processing_result.analyzed_classes + + int(getattr(discovery_result, "cached_classes", 0)) + ), func_clones_count=analysis_result.func_clones_count, block_clones_count=analysis_result.block_clones_count, segment_clones_count=analysis_result.segment_clones_count, @@ -1451,6 +1464,10 @@ def _prepare_run_inputs() -> tuple[ if analysis_result.project_metrics is not None: pm = analysis_result.project_metrics + god_modules_summary = _as_mapping( + _as_mapping(analysis_result.metrics_payload).get("god_modules") + ).get("summary") + god_modules_summary_map = _as_mapping(god_modules_summary) _print_metrics( console=cast("_PrinterLike", console), quiet=args.quiet, @@ -1467,6 +1484,16 @@ def _prepare_run_inputs() -> tuple[ health_total=pm.health.total, health_grade=pm.health.grade, suppressed_dead_code_count=analysis_result.suppressed_dead_code_items, + god_modules_candidates=_as_int( + god_modules_summary_map.get("candidates") + ), + god_modules_total=_as_int(god_modules_summary_map.get("total")), + god_modules_population_status=str( + god_modules_summary_map.get("population_status", "") + ), + god_modules_top_score=_coerce.as_float( + god_modules_summary_map.get("top_score") + ), ), ) diff --git a/codeclone/contracts.py b/codeclone/contracts.py index a75c22d..6f8064d 100644 --- a/codeclone/contracts.py +++ b/codeclone/contracts.py @@ -13,7 +13,7 @@ BASELINE_FINGERPRINT_VERSION: Final = "1" CACHE_VERSION: Final = "2.3" -REPORT_SCHEMA_VERSION: Final = "2.2" +REPORT_SCHEMA_VERSION: Final = "2.3" METRICS_BASELINE_SCHEMA_VERSION: Final = "1.0" DEFAULT_COMPLEXITY_THRESHOLD: Final = 20 diff --git a/codeclone/mcp_server.py b/codeclone/mcp_server.py index 4441091..d4e5bce 100644 --- a/codeclone/mcp_server.py +++ b/codeclone/mcp_server.py @@ -277,11 +277,12 @@ def get_production_triage( @tool( title="Help", description=( - "Return a compact semantic guide for a supported CodeClone topic, " - "with next-step routing and canonical doc links. Use this when " - "workflow or contract meaning is unclear. This is bounded guidance, " - "not a full manual. Supported topics: workflow, suppressions, " - "baseline, latest_runs, review_state, changed_scope." + "Explain a supported CodeClone workflow or contract topic and " + "suggest the safest next step. Return compact semantic guidance " + "with canonical doc links. Use this when workflow or contract " + "meaning is unclear. This is bounded guidance, not a full manual. " + "Supported topics: workflow, suppressions, baseline, latest_runs, " + "review_state, changed_scope." ), annotations=read_only_tool, structured_output=True, diff --git a/codeclone/mcp_service.py b/codeclone/mcp_service.py index db60921..5990cc5 100644 --- a/codeclone/mcp_service.py +++ b/codeclone/mcp_service.py @@ -139,6 +139,7 @@ "cohesion", "dependencies", "dead_code", + "god_modules", "health", ] ReportSection = Literal[ @@ -276,6 +277,7 @@ "cohesion", "dependencies", "dead_code", + "god_modules", "health", } ) @@ -4089,14 +4091,15 @@ def _metrics_detail_payload( if family is None: compact_item = {"family": family_name, **compact_item} items.append(compact_item) - items.sort( - key=lambda item: ( - str(item.get("family", family or "")), - str(item.get("path", "")), - str(item.get("qualname", "")), - _as_int(item.get("start_line", 0), 0), + if family is None: + items.sort( + key=lambda item: ( + str(item.get("family", "")), + str(item.get("path", "")), + str(item.get("qualname", "")), + _as_int(item.get("start_line", 0), 0), + ) ) - ) page = items[normalized_offset : normalized_offset + normalized_limit] return { "family": family, diff --git a/codeclone/metrics/__init__.py b/codeclone/metrics/__init__.py index bf64509..885524a 100644 --- a/codeclone/metrics/__init__.py +++ b/codeclone/metrics/__init__.py @@ -17,11 +17,13 @@ longest_chains, max_depth, ) +from .god_modules import build_god_modules_payload from .health import HealthInputs, compute_health __all__ = [ "HealthInputs", "build_dep_graph", + "build_god_modules_payload", "build_import_graph", "cohesion_risk", "compute_cbo", diff --git a/codeclone/metrics/god_modules.py b/codeclone/metrics/god_modules.py new file mode 100644 index 0000000..903afcd --- /dev/null +++ b/codeclone/metrics/god_modules.py @@ -0,0 +1,387 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +from __future__ import annotations + +from bisect import bisect_left, bisect_right +from collections import Counter, defaultdict +from collections.abc import Sequence +from math import floor + +from .._coerce import as_float, as_int, as_sequence, as_str +from ..domain.source_scope import ( + SOURCE_KIND_FIXTURES, + SOURCE_KIND_OTHER, + SOURCE_KIND_PRODUCTION, + SOURCE_KIND_TESTS, +) +from ..models import ClassMetrics, GroupItemLike, ModuleDep +from ..scanner import module_name_from_path + +_CANDIDATE = "candidate" +_NON_CANDIDATE = "non_candidate" +_RANKED_ONLY = "ranked_only" +_POPULATION_STATUS_OK = "ok" +_POPULATION_STATUS_LIMITED = "limited" +_MINIMUM_POPULATION = 20 + +_SIZE_PRESSURE = "size_pressure" +_DEPENDENCY_PRESSURE = "dependency_pressure" +_HUB_LIKE_SHAPE = "hub_like_shape" +_REPEATED_IMPORT_PRESSURE = "repeated_import_pressure" + + +def _normalize_path(value: str) -> str: + return value.replace("\\", "/").strip() + + +def _source_kind(filepath: str, *, scan_root: str) -> str: + normalized_path = _normalize_path(filepath) + normalized_root = _normalize_path(scan_root).rstrip("/") + if normalized_root: + prefix = normalized_root + "/" + if normalized_path.startswith(prefix): + normalized_path = normalized_path[len(prefix) :] + parts = [ + part for part in normalized_path.lower().split("/") if part and part != "." + ] + if not parts: + return SOURCE_KIND_OTHER + for idx, part in enumerate(parts): + if part != SOURCE_KIND_TESTS: + continue + if idx + 1 < len(parts) and parts[idx + 1] == SOURCE_KIND_FIXTURES: + return SOURCE_KIND_FIXTURES + return SOURCE_KIND_TESTS + return SOURCE_KIND_PRODUCTION + + +def _score_quantile(sorted_values: Sequence[float], q: float) -> float: + if not sorted_values: + return 0.0 + if len(sorted_values) == 1: + return float(sorted_values[0]) + clamped_q = min(1.0, max(0.0, q)) + position = clamped_q * float(len(sorted_values) - 1) + lower = floor(position) + upper = min(lower + 1, len(sorted_values) - 1) + lower_value = float(sorted_values[lower]) + upper_value = float(sorted_values[upper]) + if lower == upper: + return lower_value + fraction = position - float(lower) + return lower_value + (upper_value - lower_value) * fraction + + +def _percentile_rank(value: float, values: Sequence[float]) -> float: + if not values: + return 0.0 + if len(values) == 1: + return 1.0 + sorted_values = sorted(float(item) for item in values) + left = bisect_left(sorted_values, float(value)) + right = bisect_right(sorted_values, float(value)) + averaged_rank = (left + right - 1) / 2.0 + return round(averaged_rank / float(len(sorted_values) - 1), 4) + + +def _round_score(value: float) -> float: + return round(float(value), 4) + + +def build_god_modules_payload( + *, + scan_root: str, + source_stats_by_file: Sequence[tuple[str, int, int, int, int]], + units: Sequence[GroupItemLike], + class_metrics: Sequence[ClassMetrics], + module_deps: Sequence[ModuleDep], +) -> dict[str, object]: + del class_metrics + module_rows: dict[str, dict[str, object]] = {} + filepath_to_module: dict[str, str] = {} + + for filepath, lines, functions, methods, classes in sorted(source_stats_by_file): + module_name = module_name_from_path(scan_root, filepath) + filepath_to_module[filepath] = module_name + module_rows[module_name] = { + "module": module_name, + "filepath": filepath, + "source_kind": _source_kind(filepath, scan_root=scan_root), + "loc": max(0, lines), + "functions": max(0, functions), + "methods": max(0, methods), + "classes": max(0, classes), + "callable_count": max(0, functions + methods), + "complexity_total": 0, + "complexity_max": 0, + "fan_in": 0, + "fan_out": 0, + "total_deps": 0, + "import_edges": 0, + "reimport_edges": 0, + "reimport_ratio": 0.0, + "instability": 0.0, + "hub_balance": 0.0, + "size_score": 0.0, + "dependency_score": 0.0, + "shape_score": 0.0, + "score": 0.0, + "candidate_status": _NON_CANDIDATE, + "candidate_reasons": [], + } + + for unit in units: + filepath = as_str(unit.get("filepath")) or "" + module_key = filepath_to_module.get(filepath) + if module_key: + row = module_rows[module_key] + complexity = max(0, as_int(unit.get("cyclomatic_complexity"), 1)) + row["complexity_total"] = as_int(row.get("complexity_total")) + complexity + row["complexity_max"] = max(as_int(row.get("complexity_max")), complexity) + + if not module_rows: + return { + "summary": { + "total": 0, + "candidates": 0, + "population_status": _POPULATION_STATUS_LIMITED, + "top_score": 0.0, + "average_score": 0.0, + "candidate_score_cutoff": 0.0, + }, + "detection": { + "version": "1", + "scope": "report_only", + "strategy": "project_relative_composite", + "minimum_population": _MINIMUM_POPULATION, + "size_signals": ["loc", "callable_count", "complexity_total"], + "dependency_signals": [ + "fan_in", + "fan_out", + "total_deps", + "import_edges", + ], + "shape_signals": ["hub_balance", "reimport_ratio"], + }, + "items": [], + } + + module_names = set(module_rows) + incoming: dict[str, set[str]] = defaultdict(set) + outgoing: dict[str, set[str]] = defaultdict(set) + import_edges: Counter[str] = Counter() + + for dep in module_deps: + if dep.source in module_names and dep.target in module_names: + outgoing[dep.source].add(dep.target) + incoming[dep.target].add(dep.source) + import_edges[dep.source] += 1 + + for module_name, row in module_rows.items(): + fan_in = len(incoming[module_name]) + fan_out = len(outgoing[module_name]) + total_deps = fan_in + fan_out + edge_count = int(import_edges[module_name]) + reimport_edges = max(edge_count - fan_out, 0) + row["fan_in"] = fan_in + row["fan_out"] = fan_out + row["total_deps"] = total_deps + row["import_edges"] = edge_count + row["reimport_edges"] = reimport_edges + row["reimport_ratio"] = _round_score( + reimport_edges / float(edge_count) if edge_count > 0 else 0.0 + ) + row["instability"] = _round_score( + fan_out / float(total_deps) if total_deps > 0 else 0.0 + ) + row["hub_balance"] = _round_score( + 1.0 - (abs(fan_in - fan_out) / float(total_deps)) if total_deps > 0 else 0.0 + ) + + rows = list(module_rows.values()) + loc_values = [float(as_int(row.get("loc"))) for row in rows] + callable_values = [float(as_int(row.get("callable_count"))) for row in rows] + complexity_total_values = [ + float(as_int(row.get("complexity_total"))) for row in rows + ] + fan_in_values = [float(as_int(row.get("fan_in"))) for row in rows] + fan_out_values = [float(as_int(row.get("fan_out"))) for row in rows] + total_dep_values = [float(as_int(row.get("total_deps"))) for row in rows] + import_edge_values = [float(as_int(row.get("import_edges"))) for row in rows] + reimport_ratio_values = [as_float(row.get("reimport_ratio")) for row in rows] + + for row in rows: + loc_score = _percentile_rank(float(as_int(row.get("loc"))), loc_values) + callable_score = _percentile_rank( + float(as_int(row.get("callable_count"))), + callable_values, + ) + complexity_total_score = _percentile_rank( + float(as_int(row.get("complexity_total"))), + complexity_total_values, + ) + size_score = max(loc_score, callable_score, complexity_total_score) + + fan_in_score = _percentile_rank(float(as_int(row.get("fan_in"))), fan_in_values) + fan_out_score = _percentile_rank( + float(as_int(row.get("fan_out"))), + fan_out_values, + ) + total_dep_score = _percentile_rank( + float(as_int(row.get("total_deps"))), + total_dep_values, + ) + import_edge_score = _percentile_rank( + float(as_int(row.get("import_edges"))), + import_edge_values, + ) + dependency_score = max( + fan_in_score, + fan_out_score, + total_dep_score, + import_edge_score, + ) + hub_like_score = _round_score( + as_float(row.get("hub_balance")) * dependency_score + ) + repeated_import_score = _percentile_rank( + as_float(row.get("reimport_ratio")), + reimport_ratio_values, + ) + shape_score = max(hub_like_score, repeated_import_score) + score = _round_score( + 0.45 * size_score + 0.35 * dependency_score + 0.20 * shape_score + ) + row["size_score"] = _round_score(size_score) + row["dependency_score"] = _round_score(dependency_score) + row["shape_score"] = _round_score(shape_score) + row["score"] = score + + scores = sorted(as_float(row.get("score")) for row in rows) + q3 = _score_quantile(scores, 0.75) + q1 = _score_quantile(scores, 0.25) + iqr = max(q3 - q1, 0.0) + dynamic_score_cutoff = _round_score( + min(1.0, max(_score_quantile(scores, 0.90), q3 + (1.5 * iqr))) + ) + + population_status = ( + _POPULATION_STATUS_OK + if len(rows) >= _MINIMUM_POPULATION + else _POPULATION_STATUS_LIMITED + ) + candidate_count = 0 + for row in rows: + reasons: list[str] = [] + size_score = as_float(row.get("size_score")) + dependency_score = as_float(row.get("dependency_score")) + shape_score = as_float(row.get("shape_score")) + hub_like_score = _round_score( + as_float(row.get("hub_balance")) * dependency_score + ) + repeated_import_score = _percentile_rank( + as_float(row.get("reimport_ratio")), + reimport_ratio_values, + ) + if size_score >= 0.90: + reasons.append(_SIZE_PRESSURE) + if dependency_score >= 0.90: + reasons.append(_DEPENDENCY_PRESSURE) + if hub_like_score >= 0.75: + reasons.append(_HUB_LIKE_SHAPE) + if repeated_import_score >= 0.90: + reasons.append(_REPEATED_IMPORT_PRESSURE) + + if population_status != _POPULATION_STATUS_OK: + row["candidate_status"] = _RANKED_ONLY + else: + is_candidate = ( + size_score >= 0.90 + and dependency_score >= 0.90 + and ( + shape_score >= 0.75 + or as_float(row.get("score")) >= dynamic_score_cutoff + ) + ) + row["candidate_status"] = _CANDIDATE if is_candidate else _NON_CANDIDATE + if is_candidate: + candidate_count += 1 + row["candidate_reasons"] = reasons + + status_order = {_CANDIDATE: 0, _RANKED_ONLY: 1, _NON_CANDIDATE: 2} + rows.sort( + key=lambda row: ( + status_order[str(row.get("candidate_status", _NON_CANDIDATE))], + -as_float(row.get("score")), + -as_float(row.get("size_score")), + -as_float(row.get("dependency_score")), + str(row.get("filepath", "")), + str(row.get("module", "")), + ) + ) + + normalized_rows = [ + { + "module": str(row["module"]), + "filepath": str(row["filepath"]), + "source_kind": str(row["source_kind"]), + "loc": as_int(row["loc"]), + "functions": as_int(row["functions"]), + "methods": as_int(row["methods"]), + "classes": as_int(row["classes"]), + "callable_count": as_int(row["callable_count"]), + "complexity_total": as_int(row["complexity_total"]), + "complexity_max": as_int(row["complexity_max"]), + "fan_in": as_int(row["fan_in"]), + "fan_out": as_int(row["fan_out"]), + "total_deps": as_int(row["total_deps"]), + "import_edges": as_int(row["import_edges"]), + "reimport_edges": as_int(row["reimport_edges"]), + "reimport_ratio": _round_score(as_float(row.get("reimport_ratio"))), + "instability": _round_score(as_float(row.get("instability"))), + "hub_balance": _round_score(as_float(row.get("hub_balance"))), + "size_score": _round_score(as_float(row.get("size_score"))), + "dependency_score": _round_score(as_float(row.get("dependency_score"))), + "shape_score": _round_score(as_float(row.get("shape_score"))), + "score": _round_score(as_float(row.get("score"))), + "candidate_status": str(row["candidate_status"]), + "candidate_reasons": [ + str(reason) + for reason in as_sequence(row.get("candidate_reasons")) + if str(reason).strip() + ], + } + for row in rows + ] + + return { + "summary": { + "total": len(normalized_rows), + "candidates": candidate_count, + "population_status": population_status, + "top_score": _round_score(max(scores) if scores else 0.0), + "average_score": _round_score( + (sum(scores) / float(len(scores))) if scores else 0.0 + ), + "candidate_score_cutoff": dynamic_score_cutoff, + }, + "detection": { + "version": "1", + "scope": "report_only", + "strategy": "project_relative_composite", + "minimum_population": _MINIMUM_POPULATION, + "size_signals": ["loc", "callable_count", "complexity_total"], + "dependency_signals": [ + "fan_in", + "fan_out", + "total_deps", + "import_edges", + ], + "shape_signals": ["hub_balance", "reimport_ratio"], + }, + "items": normalized_rows, + } diff --git a/codeclone/pipeline.py b/codeclone/pipeline.py index 527fa6b..ad901f3 100644 --- a/codeclone/pipeline.py +++ b/codeclone/pipeline.py @@ -34,6 +34,7 @@ from .metrics import ( HealthInputs, build_dep_graph, + build_god_modules_payload, compute_health, find_suppressed_unused, find_unused, @@ -123,6 +124,7 @@ class DiscoveryResult: cached_functions: int = 0 cached_methods: int = 0 cached_classes: int = 0 + cached_source_stats_by_file: tuple[tuple[str, int, int, int, int], ...] = () @dataclass(frozen=True, slots=True) @@ -162,6 +164,7 @@ class ProcessingResult: source_read_failures: tuple[str, ...] referenced_qualnames: frozenset[str] = frozenset() structural_findings: tuple[StructuralFindingGroup, ...] = () + source_stats_by_file: tuple[tuple[str, int, int, int, int], ...] = () @dataclass(frozen=True, slots=True) @@ -585,6 +588,7 @@ def discover(*, boot: BootstrapResult, cache: Cache) -> DiscoveryResult: skipped_warnings, ) = _new_discovery_buffers() cached_sf: list[StructuralFindingGroup] = [] + cached_source_stats_by_file: list[tuple[str, int, int, int, int]] = [] cached_lines = 0 cached_functions = 0 cached_methods = 0 @@ -618,6 +622,9 @@ def discover(*, boot: BootstrapResult, cache: Cache) -> DiscoveryResult: cached_functions += functions cached_methods += methods cached_classes += classes + cached_source_stats_by_file.append( + (filepath, lines, functions, methods, classes) + ) cached_units.extend(cast("list[GroupItem]", cast(object, cached["units"]))) cached_blocks.extend( cast("list[GroupItem]", cast(object, cached["blocks"])) @@ -673,6 +680,9 @@ def discover(*, boot: BootstrapResult, cache: Cache) -> DiscoveryResult: cached_functions=cached_functions, cached_methods=cached_methods, cached_classes=cached_classes, + cached_source_stats_by_file=tuple( + sorted(cached_source_stats_by_file, key=lambda row: row[0]) + ), ) @@ -801,6 +811,7 @@ def process( failed_files=(), source_read_failures=(), structural_findings=discovery.cached_structural_findings, + source_stats_by_file=discovery.cached_source_stats_by_file, ) all_units: list[GroupItem] = list(discovery.cached_units) @@ -823,6 +834,12 @@ def process( all_structural_findings: list[StructuralFindingGroup] = list( discovery.cached_structural_findings ) + source_stats_by_file: dict[str, tuple[int, int, int, int]] = { + filepath: (lines, functions, methods, classes) + for filepath, lines, functions, methods, classes in ( + discovery.cached_source_stats_by_file + ) + } failed_files: list[str] = [] source_read_failures: list[str] = [] root_str = str(boot.root) @@ -883,6 +900,12 @@ def _accept_result(result: FileProcessResult) -> None: analyzed_functions += result.functions analyzed_methods += result.methods analyzed_classes += result.classes + source_stats_by_file[result.filepath] = ( + result.lines, + result.functions, + result.methods, + result.classes, + ) if result.units: all_units.extend(_unit_to_group_item(unit) for unit in result.units) @@ -995,6 +1018,10 @@ def _run_sequential(files: Sequence[str]) -> None: failed_files=tuple(sorted(failed_files)), source_read_failures=tuple(sorted(source_read_failures)), structural_findings=tuple(all_structural_findings), + source_stats_by_file=tuple( + (filepath, *stats) + for filepath, stats in sorted(source_stats_by_file.items()) + ), ) @@ -1166,9 +1193,12 @@ def compute_suggestions( def build_metrics_report_payload( *, + scan_root: str = "", project_metrics: ProjectMetrics, units: Sequence[GroupItemLike], class_metrics: Sequence[ClassMetrics], + module_deps: Sequence[ModuleDep] = (), + source_stats_by_file: Sequence[tuple[str, int, int, int, int]] = (), suppressed_dead_code: Sequence[DeadItem] = (), ) -> dict[str, object]: sorted_units = sorted( @@ -1319,6 +1349,13 @@ def _serialize_dead_item( "grade": project_metrics.health.grade, "dimensions": dict(project_metrics.health.dimensions), }, + "god_modules": build_god_modules_payload( + scan_root=scan_root, + source_stats_by_file=source_stats_by_file, + units=units, + class_metrics=class_metrics, + module_deps=module_deps, + ), } @@ -1414,9 +1451,12 @@ def analyze( scan_root=str(boot.root), ) metrics_payload = build_metrics_report_payload( + scan_root=str(boot.root), project_metrics=project_metrics, units=processing.units, class_metrics=processing.class_metrics, + module_deps=processing.module_deps, + source_stats_by_file=processing.source_stats_by_file, suppressed_dead_code=suppressed_dead_items, ) diff --git a/codeclone/report/findings.py b/codeclone/report/findings.py index 1c7d93d..6f1de87 100644 --- a/codeclone/report/findings.py +++ b/codeclone/report/findings.py @@ -31,6 +31,10 @@ report_location_from_structural_occurrence, ) from .json_contract import structural_group_id +from .suggestions import ( + structural_action_steps, + structural_has_separate_suggestion, +) if TYPE_CHECKING: from collections.abc import Sequence @@ -302,8 +306,8 @@ def _finding_matters_html( terminal, ( f"This group reports {count} branches with the same local shape " - f"({stmt_seq or 'unknown signature'}). Review whether the shared " - "branch body should stay duplicated or become a helper." + f"({stmt_seq or 'unknown signature'}). Review whether the local " + "branch logic should stay duplicated or be simplified in place." ), ) return _finding_matters_paragraph(message) @@ -338,6 +342,30 @@ def _finding_example_card_html( ) +def _finding_inline_action_html( + group: StructuralFindingGroup, + *, + occurrence_count: int, + spread_functions: int, +) -> str: + if structural_has_separate_suggestion( + group, + occurrence_count=occurrence_count, + spread_functions=spread_functions, + ): + return "" + action_steps = structural_action_steps(group) + if not action_steps: + return "" + primary_action = action_steps[0] + return ( + '
' + 'Suggested action' + f'{_escape_html(primary_action)}' + "
" + ) + + def _finding_why_template_html( group: StructuralFindingGroup, items: Sequence[StructuralFindingOccurrence], @@ -431,6 +459,11 @@ def _render_finding_card( deduped_items, scan_root=scan_root, already_deduped=True ) count = len(deduped_items) + inline_action_html = _finding_inline_action_html( + g, + occurrence_count=count, + spread_functions=spread_functions, + ) why_template_id = f"finding-why-template-{g.finding_key}" why_template_html = _finding_why_template_html( @@ -483,6 +516,7 @@ def _render_finding_card( f'
{ctx_chips}
' f'
{chips_html}
' f'
{_escape_html(scope_text)}
' + f"{inline_action_html}" "" # -- expandable occurrences -- '
' diff --git a/codeclone/report/json_contract.py b/codeclone/report/json_contract.py index 4cecd99..aeed324 100644 --- a/codeclone/report/json_contract.py +++ b/codeclone/report/json_contract.py @@ -97,6 +97,8 @@ "structural_group_id", ] +_GOD_MODULES_FAMILY = "god_modules" + def _optional_str(value: object) -> str | None: if value is None: @@ -352,6 +354,12 @@ def _collect_paths_from_metrics(metrics: Mapping[str, object]) -> set[str]: filepath = _optional_str(item_map.get("filepath")) if filepath is not None: paths.add(filepath) + god_modules = _as_mapping(metrics.get(_GOD_MODULES_FAMILY)) + for item in _as_sequence(god_modules.get("items")): + item_map = _as_mapping(item) + filepath = _optional_str(item_map.get("filepath")) + if filepath is not None: + paths.add(filepath) return paths @@ -632,11 +640,73 @@ def _normalize_suppressed_by( str(key): _as_int(value) for key, value in sorted(_as_mapping(health.get("dimensions")).items()) } + god_modules = _as_mapping(metrics_map.get(_GOD_MODULES_FAMILY)) + god_detection = _as_mapping(god_modules.get("detection")) + god_items = sorted( + ( + { + "module": str(item_map.get("module", "")).strip(), + "relative_path": _contract_path( + item_map.get("filepath", ""), + scan_root=scan_root, + )[0] + or "", + "source_kind": str(item_map.get("source_kind", SOURCE_KIND_OTHER)), + "loc": _as_int(item_map.get("loc")), + "functions": _as_int(item_map.get("functions")), + "methods": _as_int(item_map.get("methods")), + "classes": _as_int(item_map.get("classes")), + "callable_count": _as_int(item_map.get("callable_count")), + "complexity_total": _as_int(item_map.get("complexity_total")), + "complexity_max": _as_int(item_map.get("complexity_max")), + "fan_in": _as_int(item_map.get("fan_in")), + "fan_out": _as_int(item_map.get("fan_out")), + "total_deps": _as_int(item_map.get("total_deps")), + "import_edges": _as_int(item_map.get("import_edges")), + "reimport_edges": _as_int(item_map.get("reimport_edges")), + "reimport_ratio": round( + _as_float(item_map.get("reimport_ratio")), + 4, + ), + "instability": round(_as_float(item_map.get("instability")), 4), + "hub_balance": round(_as_float(item_map.get("hub_balance")), 4), + "size_score": round(_as_float(item_map.get("size_score")), 4), + "dependency_score": round( + _as_float(item_map.get("dependency_score")), + 4, + ), + "shape_score": round(_as_float(item_map.get("shape_score")), 4), + "score": round(_as_float(item_map.get("score")), 4), + "candidate_status": str( + item_map.get("candidate_status", "non_candidate") + ), + "candidate_reasons": [ + str(reason) + for reason in _as_sequence(item_map.get("candidate_reasons")) + if str(reason).strip() + ], + } + for item in _as_sequence(god_modules.get("items")) + for item_map in (_as_mapping(item),) + ), + key=lambda item: ( + {"candidate": 0, "ranked_only": 1, "non_candidate": 2}.get( + str(item["candidate_status"]), + 3, + ), + -_as_float(item["score"]), + -_as_float(item["size_score"]), + -_as_float(item["dependency_score"]), + item["relative_path"], + item["module"], + ), + ) complexity_summary = _as_mapping(complexity.get("summary")) coupling_summary = _as_mapping(coupling.get("summary")) cohesion_summary = _as_mapping(cohesion.get("summary")) dead_code_summary = _as_mapping(dead_code.get("summary")) + god_summary = _as_mapping(god_modules.get("summary")) dead_high_confidence = sum( 1 for item in dead_items @@ -712,6 +782,51 @@ def _normalize_suppressed_by( "items": [], "items_truncated": False, }, + _GOD_MODULES_FAMILY: { + "summary": { + "total": len(god_items), + "candidates": _as_int(god_summary.get("candidates")), + "population_status": str( + god_summary.get("population_status", "limited") + ), + "top_score": round(_as_float(god_summary.get("top_score")), 4), + "average_score": round( + _as_float(god_summary.get("average_score")), + 4, + ), + "candidate_score_cutoff": round( + _as_float(god_summary.get("candidate_score_cutoff")), + 4, + ), + }, + "detection": { + "version": str(god_detection.get("version", "1")), + "scope": str(god_detection.get("scope", "report_only")), + "strategy": str( + god_detection.get("strategy", "project_relative_composite") + ), + "minimum_population": _as_int( + god_detection.get("minimum_population"), + ), + "size_signals": [ + str(signal) + for signal in _as_sequence(god_detection.get("size_signals")) + if str(signal).strip() + ], + "dependency_signals": [ + str(signal) + for signal in _as_sequence(god_detection.get("dependency_signals")) + if str(signal).strip() + ], + "shape_signals": [ + str(signal) + for signal in _as_sequence(god_detection.get("shape_signals")) + if str(signal).strip() + ], + }, + "items": god_items, + "items_truncated": False, + }, } return normalized diff --git a/codeclone/report/markdown.py b/codeclone/report/markdown.py index 07a66cd..a987490 100644 --- a/codeclone/report/markdown.py +++ b/codeclone/report/markdown.py @@ -43,11 +43,15 @@ ("complexity", "Complexity", 3), ("coupling", "Coupling", 3), ("cohesion", "Cohesion", 3), + ("god-modules", "God Modules", 3), ("dependencies", "Dependencies", 3), ("dead-code-metrics", "Dead Code", 3), ("dead-code-suppressed", "Suppressed Dead Code", 3), ("integrity", "Integrity", 2), ) +_ANCHOR_MAP: dict[str, tuple[str, str, int]] = { + anchor[0]: anchor for anchor in _ANCHORS +} def _text(value: object) -> str: @@ -95,6 +99,10 @@ def _append_anchor(lines: list[str], anchor_id: str, title: str, level: int) -> lines.append("") +def _anchor(anchor_id: str) -> tuple[str, str, int]: + return _ANCHOR_MAP[anchor_id] + + def _append_kv_bullets( lines: list[str], rows: Sequence[tuple[str, object]], @@ -235,7 +243,7 @@ def render_markdown_report_document(payload: Mapping[str, object]) -> str: "", ] - _append_anchor(lines, *_ANCHORS[0]) + _append_anchor(lines, *_anchor("overview")) _append_kv_bullets( lines, ( @@ -260,7 +268,7 @@ def render_markdown_report_document(payload: Mapping[str, object]) -> str: ), ) - _append_anchor(lines, *_ANCHORS[1]) + _append_anchor(lines, *_anchor("inventory")) _append_kv_bullets( lines, ( @@ -292,7 +300,7 @@ def render_markdown_report_document(payload: Mapping[str, object]) -> str: ), ) - _append_anchor(lines, *_ANCHORS[2]) + _append_anchor(lines, *_anchor("findings-summary")) _append_kv_bullets( lines, ( @@ -330,7 +338,7 @@ def render_markdown_report_document(payload: Mapping[str, object]) -> str: ), ) - _append_anchor(lines, *_ANCHORS[3]) + _append_anchor(lines, *_anchor("top-risks")) top_risks = [_as_mapping(item) for item in _as_sequence(overview.get("top_risks"))] if top_risks: for idx, risk in enumerate(top_risks[:10], start=1): @@ -345,7 +353,7 @@ def render_markdown_report_document(payload: Mapping[str, object]) -> str: lines.append("") if suggestions: - _append_anchor(lines, *_ANCHORS[4]) + _append_anchor(lines, *_anchor("suggestions")) for suggestion in map(_as_mapping, suggestions): action = _as_mapping(suggestion.get("action")) lines.append(f"### {_text(suggestion.get('title'))}") @@ -372,8 +380,8 @@ def render_markdown_report_document(payload: Mapping[str, object]) -> str: lines.append(f" {idx}. {step}") lines.append("") - _append_anchor(lines, *_ANCHORS[5]) - _append_anchor(lines, *_ANCHORS[6]) + _append_anchor(lines, *_anchor("findings")) + _append_anchor(lines, *_anchor("clone-findings")) _append_findings_section( lines, groups=[ @@ -383,7 +391,7 @@ def render_markdown_report_document(payload: Mapping[str, object]) -> str: ], ) - _append_anchor(lines, *_ANCHORS[7]) + _append_anchor(lines, *_anchor("structural-findings")) _append_findings_section( lines, groups=_as_sequence( @@ -391,7 +399,7 @@ def render_markdown_report_document(payload: Mapping[str, object]) -> str: ), ) - _append_anchor(lines, *_ANCHORS[8]) + _append_anchor(lines, *_anchor("dead-code-findings")) _append_findings_section( lines, groups=_as_sequence( @@ -399,13 +407,13 @@ def render_markdown_report_document(payload: Mapping[str, object]) -> str: ), ) - _append_anchor(lines, *_ANCHORS[9]) + _append_anchor(lines, *_anchor("design-findings")) _append_findings_section( lines, groups=_as_sequence(_as_mapping(findings_groups.get("design")).get("groups")), ) - _append_anchor(lines, *_ANCHORS[10]) + _append_anchor(lines, *_anchor("metrics")) for anchor_id, title, summary_keys, item_keys in ( ("health", "Health", ("score", "grade"), ()), ( @@ -426,6 +434,26 @@ def render_markdown_report_document(payload: Mapping[str, object]) -> str: ("total", "average", "max", "low_cohesion"), ("lcom4", "method_count", "instance_var_count", "risk"), ), + ( + "god-modules", + "God Modules", + ( + "total", + "candidates", + "population_status", + "top_score", + "average_score", + ), + ( + "source_kind", + "score", + "candidate_status", + "loc", + "fan_in", + "fan_out", + "complexity_total", + ), + ), ( "dependencies", "Dependencies", @@ -439,7 +467,11 @@ def render_markdown_report_document(payload: Mapping[str, object]) -> str: ("kind", "confidence"), ), ): - family_key = "dead_code" if anchor_id == "dead-code-metrics" else anchor_id + family_key = ( + "dead_code" + if anchor_id == "dead-code-metrics" + else ("god_modules" if anchor_id == "god-modules" else anchor_id) + ) family_payload = _as_mapping(metrics_families.get(family_key)) family_summary_map = _as_mapping(family_payload.get("summary")) _append_anchor(lines, anchor_id, title, 3) @@ -454,14 +486,14 @@ def render_markdown_report_document(payload: Mapping[str, object]) -> str: ) dead_code_family_payload = _as_mapping(metrics_families.get("dead_code")) - _append_anchor(lines, *_ANCHORS[17]) + _append_anchor(lines, *_anchor("dead-code-suppressed")) _append_metric_items( lines, items=_as_sequence(dead_code_family_payload.get("suppressed_items")), key_order=("kind", "confidence", "suppression_rule", "suppression_source"), ) - _append_anchor(lines, *_ANCHORS[18]) + _append_anchor(lines, *_anchor("integrity")) _append_kv_bullets( lines, ( diff --git a/codeclone/report/serialize.py b/codeclone/report/serialize.py index a4c93b9..c8a5579 100644 --- a/codeclone/report/serialize.py +++ b/codeclone/report/serialize.py @@ -600,6 +600,7 @@ def render_text_report_document(payload: Mapping[str, object]) -> str: "complexity", "coupling", "cohesion", + "god_modules", "dependencies", "dead_code", "health", @@ -613,12 +614,50 @@ def render_text_report_document(payload: Mapping[str, object]) -> str: keys = ("total", "average", "max", "low_cohesion") case "dependencies": keys = ("modules", "edges", "cycles", "max_depth") + case "god_modules": + keys = ( + "total", + "candidates", + "population_status", + "top_score", + "average_score", + ) case "dead_code": keys = ("total", "high_confidence", "suppressed") case _: keys = ("score", "grade") lines.append(f"{family_name}: {_format_key_values(family_summary, keys)}") + god_modules_family = _as_mapping(metrics_families.get("god_modules")) + god_module_items = _as_sequence(god_modules_family.get("items")) + lines.extend( + [ + "", + "GOD MODULES (top 10)", + ] + ) + if not god_module_items: + lines.append("(none)") + else: + lines.extend( + "- " + + _format_key_values( + item, + ( + "module", + "relative_path", + "source_kind", + "score", + "candidate_status", + "loc", + "fan_in", + "fan_out", + "complexity_total", + ), + ) + for item in map(_as_mapping, god_module_items[:10]) + ) + lines.append("") _append_overview(lines, overview, hotlists) diff --git a/codeclone/report/suggestions.py b/codeclone/report/suggestions.py index 798eeec..3715353 100644 --- a/codeclone/report/suggestions.py +++ b/codeclone/report/suggestions.py @@ -578,7 +578,7 @@ def _structural_summary(group: StructuralFindingGroup) -> tuple[str, str]: return "Repeated branch family", "same repeated branch shape" -def _structural_steps(group: StructuralFindingGroup) -> tuple[str, ...]: +def structural_action_steps(group: StructuralFindingGroup) -> tuple[str, ...]: match group.finding_kind: case "clone_guard_exit_divergence": return ( @@ -600,6 +600,19 @@ def _structural_steps(group: StructuralFindingGroup) -> tuple[str, ...]: pass terminal = str(group.signature.get("terminal", "")).strip() + stmt_seq = str(group.signature.get("stmt_seq", "")).strip() + stmt_names = tuple(part.strip() for part in stmt_seq.split(",") if part.strip()) + if "Continue" in stmt_names: + return ( + ( + "Review whether the repeated continue guard can be merged " + "into one predicate." + ), + ( + "If separate continue checks keep the local control flow clearer, " + "keep this as a report-only hint." + ), + ) match terminal: case "raise": return ( @@ -616,14 +629,49 @@ def _structural_steps(group: StructuralFindingGroup) -> tuple[str, ...]: ) case _: return ( - "Review whether the repeated branch family should become a helper.", + "Review whether the repeated local branch can be simplified in place.", ( - "Keep this as a report-only hint if the local duplication is " - "intentional." + "If the local duplication keeps control flow clearer, keep " + "this as a report-only hint." ), ) +def structural_suggestion_severity( + group: StructuralFindingGroup, + *, + occurrence_count: int, + spread_functions: int, +) -> Severity: + severity: Severity = ( + SEVERITY_WARNING + if occurrence_count >= 4 or spread_functions > 1 + else SEVERITY_INFO + ) + if group.finding_kind in { + "clone_guard_exit_divergence", + "clone_cohort_drift", + }: + severity = SEVERITY_WARNING + return severity + + +def structural_has_separate_suggestion( + group: StructuralFindingGroup, + *, + occurrence_count: int, + spread_functions: int, +) -> bool: + return ( + structural_suggestion_severity( + group, + occurrence_count=occurrence_count, + spread_functions=spread_functions, + ) + != SEVERITY_INFO + ) + + def _structural_suggestions( structural_findings: Sequence[StructuralFindingGroup], *, @@ -639,14 +687,17 @@ def _structural_suggestions( spread_files, spread_functions = group_spread(locations) source_kind, breakdown = _source_context(locations, scan_root=scan_root) count = len(locations) - severity: Severity = ( - SEVERITY_WARNING if count >= 4 or spread_functions > 1 else SEVERITY_INFO + severity = structural_suggestion_severity( + group, + occurrence_count=count, + spread_functions=spread_functions, ) - if group.finding_kind in { - "clone_guard_exit_divergence", - "clone_cohort_drift", - }: - severity = SEVERITY_WARNING + if not structural_has_separate_suggestion( + group, + occurrence_count=count, + spread_functions=spread_functions, + ): + continue title, summary = _structural_summary(group) location_label = format_group_location_label( representative, @@ -660,7 +711,7 @@ def _structural_suggestions( category=CATEGORY_STRUCTURAL, title=title, location=location_label, - steps=_structural_steps(group), + steps=structural_action_steps(group), effort=EFFORT_MODERATE, priority=_priority(severity, EFFORT_MODERATE), finding_family=FAMILY_STRUCTURAL, diff --git a/codeclone/ui_messages.py b/codeclone/ui_messages.py index 7aca82e..24eb442 100644 --- a/codeclone/ui_messages.py +++ b/codeclone/ui_messages.py @@ -178,7 +178,7 @@ SUMMARY_COMPACT_METRICS = ( "Metrics cc={cc_avg}/{cc_max} cbo={cbo_avg}/{cbo_max}" " lcom4={lcom_avg}/{lcom_max} cycles={cycles} dead_code={dead}" - " health={health}({grade})" + " health={health}({grade}) god_modules={god_modules}" ) SUMMARY_COMPACT_CHANGED_SCOPE = ( "Changed paths={paths} findings={findings} new={new} known={known}" @@ -397,6 +397,7 @@ def fmt_summary_compact_metrics( dead: int, health: int, grade: str, + god_modules: int, ) -> str: return SUMMARY_COMPACT_METRICS.format( cc_avg=f"{cc_avg:.1f}", @@ -409,6 +410,7 @@ def fmt_summary_compact_metrics( dead=dead, health=health, grade=grade, + god_modules=god_modules, ) @@ -461,11 +463,10 @@ def fmt_summary_parsed( ) -> str | None: if lines == 0 and functions == 0 and methods == 0 and classes == 0: return None + callable_count = functions + methods parts = [f"{_vn(lines, 'bold cyan')} lines"] - if functions: - parts.append(f"{_v(functions, 'bold cyan')} functions") - if methods: - parts.append(f"{_v(methods, 'bold cyan')} methods") + if callable_count: + parts.append(f"{_v(callable_count, 'bold cyan')} callables") if classes: parts.append(f"{_v(classes, 'bold cyan')} classes") val = " \u00b7 ".join(parts) @@ -535,6 +536,24 @@ def fmt_metrics_dead_code(count: int, *, suppressed: int = 0) -> str: ) +def fmt_metrics_god_modules( + *, + candidates: int, + total: int, + population_status: str, + top_score: float, +) -> str: + parts = [f"{_v(candidates, 'bold magenta')} candidates"] + if top_score > 0: + parts.append(f"max score {top_score:.2f}") + parts.append(f"{_vn(total)} ranked") + summary = " \u00b7 ".join(parts) + note = "report-only" + if population_status and population_status != "ok": + note = f"{note}; {population_status.replace('_', ' ')} population" + return f" {'God Modules':<{_L}}{summary} [dim]({note})[/dim]" + + def fmt_changed_scope_paths(*, count: int) -> str: return f" {'Paths':<{_L}}{_v(count, 'bold cyan')} from git diff" diff --git a/docs/README.md b/docs/README.md index b08f1d1..83f7248 100644 --- a/docs/README.md +++ b/docs/README.md @@ -39,7 +39,7 @@ repository build: - [Core pipeline and invariants](book/05-core-pipeline.md) - [Baseline contract (schema v2.0)](book/06-baseline.md) - [Cache contract (schema v2.3)](book/07-cache.md) -- [Report contract (schema v2.2)](book/08-report.md) +- [Report contract (schema v2.3)](book/08-report.md) ## Interfaces @@ -56,6 +56,7 @@ repository build: ## Quality Contracts +- [Health Score model and evolution policy](book/15-health-score.md) - [Metrics mode and quality gates](book/15-metrics-and-quality-gates.md) - [Dead-code contract and test-boundary policy](book/16-dead-code-contract.md) - [Suggestions and clone typing contract](book/17-suggestions-and-clone-typing.md) diff --git a/docs/architecture.md b/docs/architecture.md index 4cea0a2..21c9697 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -144,7 +144,7 @@ gating decisions. Detected findings can be rendered as: - interactive HTML (`--html`), -- canonical JSON (`--json`, schema `2.2`), +- canonical JSON (`--json`, schema `2.3`), - deterministic text projection (`--text`), - deterministic Markdown projection (`--md`), - deterministic SARIF projection (`--sarif`). diff --git a/docs/book/00-intro.md b/docs/book/00-intro.md index c3e1cb7..58f04e2 100644 --- a/docs/book/00-intro.md +++ b/docs/book/00-intro.md @@ -90,6 +90,7 @@ Refs: [09-cli.md](09-cli.md) - Metrics governance path: [04-config-and-defaults.md](04-config-and-defaults.md) -> + [15-health-score.md](15-health-score.md) -> [15-metrics-and-quality-gates.md](15-metrics-and-quality-gates.md) -> [16-dead-code-contract.md](16-dead-code-contract.md) -> [19-inline-suppressions.md](19-inline-suppressions.md) -> diff --git a/docs/book/01-architecture-map.md b/docs/book/01-architecture-map.md index 90cef33..4a85a63 100644 --- a/docs/book/01-architecture-map.md +++ b/docs/book/01-architecture-map.md @@ -124,6 +124,7 @@ Refs: | Cache trust and fail-open behavior | [07-cache.md](07-cache.md) | | Report schema and provenance | [08-report.md](08-report.md), [10-html-render.md](10-html-render.md) | | MCP agent surface | [20-mcp-interface.md](20-mcp-interface.md) | +| Health score model | [15-health-score.md](15-health-score.md) | | Metrics gates and metrics baseline | [15-metrics-and-quality-gates.md](15-metrics-and-quality-gates.md) | | Dead-code liveness policy | [16-dead-code-contract.md](16-dead-code-contract.md) | | Suggestions and clone typing | [17-suggestions-and-clone-typing.md](17-suggestions-and-clone-typing.md) | diff --git a/docs/book/02-terminology.md b/docs/book/02-terminology.md index 8feab3d..a10dd04 100644 --- a/docs/book/02-terminology.md +++ b/docs/book/02-terminology.md @@ -35,6 +35,7 @@ Define terms exactly as used by code and tests. - **health score**: weighted blend of seven dimension scores (0–100). Dimensions: clones 25%, complexity 20%, cohesion 15%, coupling 10%, dead code 10%, dependencies 10%, coverage 10%. + Report-only layers such as `God Modules` do not currently affect the score. Grade bands: A ≥90, B ≥75, C ≥60, D ≥40, F <40. - **design finding**: metric-driven finding (complexity/coupling/cohesion) emitted by the canonical report builder when a class or function exceeds diff --git a/docs/book/04-config-and-defaults.md b/docs/book/04-config-and-defaults.md index eb7d8f5..a83593c 100644 --- a/docs/book/04-config-and-defaults.md +++ b/docs/book/04-config-and-defaults.md @@ -71,6 +71,9 @@ Metrics baseline path selection contract: - If `metrics_baseline` in `pyproject.toml` differs from parser default, that configured path is used even without explicit CLI flag. - Otherwise, metrics baseline defaults to the clone baseline path. +- In other words, metrics do **not** live in a separate file by default: + the default unified flow uses the same `codeclone.baseline.json` path for + clone and metrics comparison state. Refs: diff --git a/docs/book/06-baseline.md b/docs/book/06-baseline.md index 4547257..46c7b6a 100644 --- a/docs/book/06-baseline.md +++ b/docs/book/06-baseline.md @@ -47,6 +47,9 @@ Embedded metrics contract: - Top-level `metrics` is allowed only for baseline schema `>= 2.0`. - Clone baseline save preserves existing embedded `metrics` payload and `meta.metrics_payload_sha256`. +- The default runtime flow is unified: clone baseline and metrics baseline + usually share the same `codeclone.baseline.json` file unless the metrics path + is explicitly overridden. Integrity payload includes only: diff --git a/docs/book/08-report.md b/docs/book/08-report.md index 925b95e..2d3eb9c 100644 --- a/docs/book/08-report.md +++ b/docs/book/08-report.md @@ -2,7 +2,7 @@ ## Purpose -Define report contracts in `2.0.0b4`: canonical JSON (`report_schema_version=2.2`) +Define report contracts in `2.0.0b4`: canonical JSON (`report_schema_version=2.3`) plus deterministic TXT/Markdown/SARIF projections. ## Public surface @@ -16,7 +16,7 @@ plus deterministic TXT/Markdown/SARIF projections. ## Data model -JSON report top-level (v2.2): +JSON report top-level (v2.3): - `report_schema_version` - `meta` @@ -32,6 +32,21 @@ Canonical provenance additions: thresholds used to materialize canonical design findings for that run (`complexity > N`, `coupling > N`, `cohesion >= N`). +Canonical report-only metrics additions: + +- `metrics.families.god_modules` records project-relative module hotspot + profiles and candidate classification for `God Modules` +- the family is canonical report truth, but it does **not** participate in + findings totals, health, gates, baseline NEW/KNOWN semantics, or SARIF in + `b4` +- `God Modules` is a report-only experimental layer rather than a second + complexity metric: + - complexity reports local control-flow hotspots in functions and methods + - `God Modules` reports module-level responsibility overload and dependency + pressure + - the layer may later become scoring only after validation and explicit + health-model documentation updates + Canonical vs non-canonical split: - Canonical: `report_schema_version`, `meta`, `inventory`, `findings`, `metrics` @@ -40,8 +55,8 @@ Canonical vs non-canonical split: Derived projection layer: -- `derived.suggestions[*]` — actionable projection cards keyed back to canonical - findings via `finding_id` +- `derived.suggestions[*]` — action-surplus projection cards keyed back to + canonical findings via `finding_id` - `derived.overview` — summary-only overview facts: - `families` - `top_risks` @@ -62,6 +77,15 @@ Finding families: - `findings.groups.design.groups` - `findings.summary.suppressed.dead_code` (suppressed counter, non-active findings) +Important role split: + +- Findings explain what was detected. +- Suggestions exist only when they add action structure on top of a finding + (next step, prioritization, effort/risk framing, grouped remediation, or + review relevance). +- Low-signal local structural info hints may remain findings-only and not + appear as separate suggestion cards. + Structural finding kinds currently emitted by core/report pipeline: - `duplicated_branches` @@ -97,6 +121,9 @@ Per-group common axes (family-specific fields may extend): - `derived.overview.directory_hotspots` is a deterministic report-layer aggregation over canonical findings; HTML must render it as-is or omit it on compatibility paths without a canonical report document. +- `derived.overview.health_snapshot` is a projection over canonical + `metrics.families.health.summary`; it summarizes the current score but does + not define a second health model. - `derived.overview.directory_hotspots[*].path` is an overview-oriented directory key: runtime findings keep their parent directory, while test-only and fixture-only findings collapse to the corresponding source-scope roots @@ -116,6 +143,10 @@ Per-group common axes (family-specific fields may extend): - Dead-code suppressed candidates are carried only under metrics (`metrics.families.dead_code.suppressed_items`) and never promoted to active `findings.groups.dead_code`. +- A lower score after upgrade may reflect a broader health model, not only + worse code. Report renderers may surface the score, but health-model + expansion is documented separately in [15-health-score.md](15-health-score.md) + and compatibility notes. ## Invariants (MUST) @@ -170,6 +201,7 @@ Refs: - [07-cache.md](07-cache.md) - [09-cli.md](09-cli.md) - [10-html-render.md](10-html-render.md) +- [15-health-score.md](15-health-score.md) - [20-mcp-interface.md](20-mcp-interface.md) - [17-suggestions-and-clone-typing.md](17-suggestions-and-clone-typing.md) - [../sarif.md](../sarif.md) diff --git a/docs/book/10-html-render.md b/docs/book/10-html-render.md index 33151b9..d11bc02 100644 --- a/docs/book/10-html-render.md +++ b/docs/book/10-html-render.md @@ -78,6 +78,8 @@ Refs: - Novelty controls reflect baseline trust split note and per-group novelty flags. - Suppressed dead-code rows are rendered only from report dead-code suppression payloads and do not become active dead-code findings in UI tables. +- Structural finding cards may render a compact inline suggested action when a + low-signal local hint intentionally has no separate suggestion card. - IDE link `data-file` and `data-line` attributes are escaped via `_escape_attr` before insertion into HTML. diff --git a/docs/book/13-testing-as-spec.md b/docs/book/13-testing-as-spec.md index fe83446..192b340 100644 --- a/docs/book/13-testing-as-spec.md +++ b/docs/book/13-testing-as-spec.md @@ -34,7 +34,7 @@ The following matrix is treated as executable contract: | Baseline schema/integrity/compat gates | `tests/test_baseline.py` | | Cache v2.3 fail-open + status mapping | `tests/test_cache.py`, `tests/test_cli_inprocess.py::test_cli_reports_cache_too_large_respects_max_size_flag` | | Exit code categories and markers | `tests/test_cli_unit.py`, `tests/test_cli_inprocess.py` | -| Report schema v2.2 canonical/derived/integrity + JSON/TXT/MD/SARIF projections | `tests/test_report.py`, `tests/test_report_contract_coverage.py`, `tests/test_report_branch_invariants.py` | +| Report schema v2.3 canonical/derived/integrity + JSON/TXT/MD/SARIF projections | `tests/test_report.py`, `tests/test_report_contract_coverage.py`, `tests/test_report_branch_invariants.py` | | HTML render-only explainability + escaping | `tests/test_html_report.py` | | Scanner traversal safety | `tests/test_scanner_extra.py`, `tests/test_security.py` | diff --git a/docs/book/14-compatibility-and-versioning.md b/docs/book/14-compatibility-and-versioning.md index a32ce06..f85383e 100644 --- a/docs/book/14-compatibility-and-versioning.md +++ b/docs/book/14-compatibility-and-versioning.md @@ -21,8 +21,9 @@ Current contract versions: - `BASELINE_SCHEMA_VERSION = "2.0"` - `BASELINE_FINGERPRINT_VERSION = "1"` - `CACHE_VERSION = "2.3"` -- `REPORT_SCHEMA_VERSION = "2.2"` -- `METRICS_BASELINE_SCHEMA_VERSION = "1.0"` (standalone metrics-baseline file) +- `REPORT_SCHEMA_VERSION = "2.3"` +- `METRICS_BASELINE_SCHEMA_VERSION = "1.0"` (used only when metrics are stored + in a dedicated metrics-baseline file instead of the default unified baseline) Refs: @@ -39,7 +40,12 @@ Version bump rules: entries looking compatible to runtime validation. - Bump **report schema** for canonical report document contract changes (`report_schema_version`, consumed by JSON/TXT/Markdown/SARIF and HTML provenance/view). -- Bump **metrics-baseline schema** only for standalone metrics-baseline payload changes. +- Bump **metrics-baseline schema** only for dedicated metrics-baseline payload + changes. +- This schema does **not** imply that metrics normally live in a separate file: + the default runtime path is still the unified baseline file, and the + standalone metrics-baseline schema applies only when users opt into a + different metrics-baseline path. - MCP does not currently define a separate schema/version constant; tool names, resource shapes, and documented request/response semantics are therefore package-versioned public surface and must be documented/tested when changed. @@ -61,6 +67,13 @@ Version bump rules: or threshold-aware design finding materialization do change `report_schema_version` because they alter canonical report semantics and integrity payload. +- The same is true for additive canonical metrics families such as + `metrics.families.god_modules`: even though the layer is report-only and does + not affect health/gates/findings, it still changes canonical report schema + and integrity payload, so it requires a report-schema bump. +- CodeClone does not currently define a separate health-model version constant. + Health-score semantics are package-versioned and must be documented in the + Health Score chapter and release notes when they change. Baseline compatibility rules: @@ -74,6 +87,33 @@ Baseline regeneration rules: - Required when `python_tag` changes. - Not required for package patch/minor updates if compatibility gates still pass. +## Health model evolution + +Health Score is stable within a given scoring model, but the scoring model may +evolve across releases. + +New signal families may first appear as report-only or experimental layers. +After validation and contract hardening, selected layers may later be promoted +into scoring. + +Future CodeClone releases may expand the Health Score formula with additional +validated signal families. As a result, a repository's score may decrease after +upgrade even if the code itself did not become worse. In such cases, the change +reflects an evolved scoring model rather than a retroactive decline in code +quality. + +Short operational reminder: + +> A lower score after upgrade may reflect a broader health model, not only +> worse code. + +Contract consequence: + +- health-model expansion does not necessarily require a baseline/cache/report + schema bump; +- but it **does** require explicit documentation and release-note coverage, + because it changes user-visible scoring semantics. + ## Invariants (MUST) - Contract changes must include code updates and changelog/docs updates. @@ -93,7 +133,7 @@ Refs: | Fingerprint bump | clone IDs change; baseline regeneration required | | Cache schema bump | old caches are ignored and rebuilt automatically | | Report schema bump | downstream report consumers must update | -| Metrics-baseline schema bump | standalone metrics baseline must be regenerated | +| Metrics-baseline schema bump | dedicated metrics-baseline files must be regenerated | ## Determinism / canonicalization @@ -118,3 +158,5 @@ Refs: - Backward compatibility is not guaranteed across incompatible schema/fingerprint bumps. +- Health Score is not frozen forever as a mathematical formula; what is frozen + is the obligation to document scoring-model changes and present them honestly. diff --git a/docs/book/15-health-score.md b/docs/book/15-health-score.md new file mode 100644 index 0000000..8778f81 --- /dev/null +++ b/docs/book/15-health-score.md @@ -0,0 +1,151 @@ +# Health Score + +## Purpose + +Define the current Health Score model, the report-only layers that do **not** +yet affect it, and the policy for future scoring-model expansion. + +Health Score is a user-facing contract. It is not just an internal aggregate. + +## Public surface + +- Scoring model: `codeclone/metrics/health.py:compute_health` +- Weight assignment: `codeclone/contracts.py:HEALTH_WEIGHTS` +- Input wiring: `codeclone/pipeline.py:compute_project_metrics` +- Canonical report surface: + `codeclone/report/json_contract.py:build_report_document` +- Overview projection: + `codeclone/report/json_contract.py:_health_snapshot` +- CLI / HTML / MCP consumers: + `codeclone/_cli_summary.py`, `codeclone/_html_report/_sections/_overview.py`, + `codeclone/mcp_service.py` + +## Contracts + +- Health Score is computed only in `analysis_mode=full`. +- In `analysis_mode=clones_only`, health is intentionally unavailable rather + than fabricated from partial inputs. +- The current scoring model includes exactly seven dimensions: + `clones`, `complexity`, `coupling`, `cohesion`, `dead_code`, + `dependencies`, `coverage`. +- Only dimensions produced by `compute_health(...)` contribute to the score. +- Report-only or advisory layers must not affect the score until they are + explicitly promoted into the scoring model and documented. + +## What currently affects Health Score + +Current weights from `codeclone/contracts.py:HEALTH_WEIGHTS`: + +| Dimension | Weight | Current inputs in code | Signal type | Visible report/UI surface | +|--------------|--------|--------------------------------------------------------------------------------------|----------------------------------|------------------------------------------------------------------------------------------| +| Clones | 25% | function clone groups + block clone groups, normalized by `files_analyzed_or_cached` | aggregate project-level | `metrics.families.health.summary.dimensions.clones`, HTML `Health Profile`, CLI, MCP | +| Complexity | 20% | `complexity_avg`, `complexity_max`, `high_risk_functions` | local findings -> aggregate | `metrics.families.health.summary.dimensions.complexity`, design findings, HTML, CLI, MCP | +| Cohesion | 15% | `cohesion_avg`, `low_cohesion_classes` | local findings -> aggregate | `metrics.families.health.summary.dimensions.cohesion`, design findings, HTML, CLI, MCP | +| Coupling | 10% | `coupling_avg`, `coupling_max`, `high_risk_classes` | local findings -> aggregate | `metrics.families.health.summary.dimensions.coupling`, design findings, HTML, CLI, MCP | +| Dead code | 10% | count of active dead-code items after suppression and non-actionable filtering | local findings -> aggregate | `metrics.families.dead_code`, health dimensions, HTML, CLI, MCP | +| Dependencies | 10% | `dependency_cycles`, `dependency_max_depth` | aggregate graph-level | `metrics.families.dependencies`, health dimensions, HTML, CLI, MCP | +| Coverage | 10% | `files_analyzed_or_cached / files_found` | aggregate inventory-completeness | `metrics.families.health.summary.dimensions.coverage`, HTML `Health Profile`, MCP | + +Important clarifications: + +- `coverage` here means **analysis completeness**, not test coverage. +- The clone dimension currently uses only **function** and **block** clone + groups. Segment groups are visible in reports, but they do not currently feed + Health Score. +- Dead-code penalties use active dead-code items returned by + `find_unused(...)`. Suppressed or non-actionable candidates do not penalize + the score. +- Dependency pressure currently penalizes cycles directly and only penalizes + dependency depth beyond the safe zone (`max_depth > 6`). + +## Explainability intent + +The current health model is deterministic and explainable by design: + +- every scoring dimension is derived from explicit inputs already present in the + pipeline and canonical report; +- the canonical report exposes the score and per-dimension breakdown under + `metrics.families.health.summary`; +- overview/report projections may summarize the result, but they must not invent + extra health heuristics outside the scoring model. + +## Current non-scoring layers + +The following layers are visible today but do **not** currently affect Health +Score: + +### God Modules + +`God Modules` is currently a report-only experimental layer. + +- It surfaces module-level hotspots derived from implementation burden and + dependency pressure. +- It is visible in `metrics.families.god_modules`, HTML, Markdown/TXT, and MCP + `metrics_detail(family="god_modules")`. +- It does not currently affect Health Score, gates, baseline novelty, or SARIF. +- It is **not** a restatement of cyclomatic complexity: + complexity highlights local control-flow hotspots in functions and methods, + while `God Modules` highlights module-level responsibility overload and + dependency pressure. + +Suggested interpretation: + +> Complexity highlights local control-flow hotspots in functions and methods. +> God Modules highlights module-level responsibility overload and dependency +> pressure. Complexity may contribute to the signal, but God Modules is not a +> restatement of cyclomatic complexity. + +### Other visible non-scoring layers + +- `findings.groups.clones.segments` — canonical report-only segment-clone layer; + visible for review, excluded from baseline diff/gating/health. +- `findings.groups.structural.groups` — report-only structural findings; + visible as evidence/advisory material, excluded from health. +- `derived.suggestions` and `derived.hotlists` — advisory and routing + projections; never scoring inputs. + +## Health model evolution + +Health Score is stable within a given scoring model, but the model may evolve +across releases. + +New signal families may first appear as report-only or experimental layers. +After validation and contract hardening, selected layers may later be +introduced into scoring. + +Future CodeClone releases may expand the Health Score formula with additional +validated signal families. As a result, a repository's score may decrease after +upgrade even if the code itself did not become worse. In such cases, the change +reflects an evolved scoring model rather than a retroactive decline in code +quality. + +Promotion rules for a new scoring input: + +- the signal must be deterministic and stable enough for canonical reporting; +- the signal must be explainable in terms of explicit inputs and visible output; +- the signal must be validated on real repositories, not only synthetic cases; +- the change must be documented in release notes and in Health Score docs; +- MCP/HTML/CLI surfaces must continue to present the score honestly after the + expansion. + +Current versioning note: + +- CodeClone does **not** currently define a separate health-model version + constant. +- Health semantics are package-versioned public behavior and must therefore be + documented in this chapter, in compatibility notes, and in release notes when + they change. + +## Locked by tests + +- `tests/test_metrics_modules.py::test_health_helpers_and_compute_health_boundaries` +- `tests/test_pipeline_metrics.py::test_compute_project_metrics_respects_skip_flags` +- `tests/test_report_contract_coverage.py::test_report_contract_includes_canonical_god_modules_family` +- `tests/test_report_contract_coverage.py::test_overview_health_snapshot_handles_non_mapping_dimensions` + +## See also + +- [08-report.md](08-report.md) +- [14-compatibility-and-versioning.md](14-compatibility-and-versioning.md) +- [15-metrics-and-quality-gates.md](15-metrics-and-quality-gates.md) +- [16-dead-code-contract.md](16-dead-code-contract.md) diff --git a/docs/book/15-metrics-and-quality-gates.md b/docs/book/15-metrics-and-quality-gates.md index 7f9f760..b3f3a9b 100644 --- a/docs/book/15-metrics-and-quality-gates.md +++ b/docs/book/15-metrics-and-quality-gates.md @@ -29,12 +29,11 @@ Modes: - `analysis_mode=full`: metrics computed and suggestions enabled - `analysis_mode=clones_only`: metrics skipped -- Health score is a weighted blend: clones 25%, complexity 20%, cohesion 15%, - coupling 10%, dead code 10%, dependencies 10%, coverage 10%. -- Clone dimension uses a piecewise density curve with breakpoints at 0.05 - (score 90), 0.20 (score 50), 0.50 (score 0). Below 5% density the penalty - is mild; 5–20% is steep; above 20% is aggressive. -- Grade bands: A ≥90, B ≥75, C ≥60, D ≥40, F <40. +- Health-score semantics are defined in + [15-health-score.md](15-health-score.md). +- Metrics comparison state is unified by default: unless `--metrics-baseline` + is explicitly redirected, metrics baseline data comes from the same + `codeclone.baseline.json` path as clone baseline data. Refs: @@ -119,9 +118,12 @@ Refs: - Absolute threshold defaults are not frozen by this chapter. - Metrics scoring internals, per-dimension weighting, and the exact clone density curve may evolve if exit semantics and contract statuses stay stable. + See [15-health-score.md](15-health-score.md) for the current model and the + phased expansion policy. ## See also +- [15-health-score.md](15-health-score.md) - [04-config-and-defaults.md](04-config-and-defaults.md) - [05-core-pipeline.md](05-core-pipeline.md) - [09-cli.md](09-cli.md) diff --git a/docs/book/17-suggestions-and-clone-typing.md b/docs/book/17-suggestions-and-clone-typing.md index eac9246..a7eebeb 100644 --- a/docs/book/17-suggestions-and-clone-typing.md +++ b/docs/book/17-suggestions-and-clone-typing.md @@ -44,6 +44,10 @@ Refs: - Suggestions are generated only in full metrics mode (`skip_metrics=false`). - Suggestions are advisory only and never directly control exit code. +- Suggestions are not a one-to-one mirror of findings. They should exist only + when they add action structure beyond the canonical finding itself. +- Low-signal local structural `info` hints stay in `findings` and do not emit a + separate suggestion card. - SARIF projection is finding-driven and does not consume suggestion cards. - JSON report stores clone typing at group level: - `findings.groups.clones.[*].clone_type` @@ -60,6 +64,8 @@ Refs: - Suggestion priority formula is stable: `severity_weight / effort_weight`. +- For structural findings, separate suggestion cards are emitted only for the + actionable subset; low-signal local `info` hints remain finding-only. - Suggestion output is sorted by: `(-priority, severity, category, source_kind, location, title, subject_key)`. - Derived suggestion serialization in report JSON applies deterministic ordering by diff --git a/docs/book/20-mcp-interface.md b/docs/book/20-mcp-interface.md index c218585..28885e6 100644 --- a/docs/book/20-mcp-interface.md +++ b/docs/book/20-mcp-interface.md @@ -91,29 +91,29 @@ produced by the report contract. Current tool set (`21` tools): -| Tool | Key parameters | Purpose / notes | -|--------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `analyze_repository` | absolute `root`, `analysis_mode`, `changed_paths`, `git_diff_ref`, inline thresholds, cache/baseline paths | Run deterministic CodeClone analysis, register the latest run, and return a compact MCP summary. The intended next step is `get_run_summary` or `get_production_triage`, not broad listing by default | -| `analyze_changed_paths` | absolute `root`, `changed_paths` or `git_diff_ref`, `analysis_mode`, inline thresholds | Diff-aware fast path: analyze a repo, attach a changed-files projection, and return a compact changed-files snapshot. The intended next step is `get_report_section(section="changed")` or `get_production_triage` | -| `get_run_summary` | `run_id` | Return the stored summary for the latest or specified run, with slim inventory counts instead of the full file registry; this is the cheapest run-level snapshot and `health` becomes explicit `available=false` when metrics were skipped | -| `get_production_triage` | `run_id`, `max_hotspots`, `max_suggestions` | Return a compact production-first MCP projection: health, cache `freshness`, production hotspots, production suggestions, and global source-kind counters. This is the default first-pass view for large or noisy repositories | -| `help` | `topic`, `detail` | Return a bounded semantic guide for a small set of MCP topics (`workflow`, `suppressions`, `baseline`, `latest_runs`, `review_state`, `changed_scope`) with next-step routing and canonical doc links. This is for uncertainty recovery, not full manual access | -| `compare_runs` | `run_id_before`, `run_id_after`, `focus` | Compare two registered runs by finding ids and run-to-run health delta; MCP returns short run ids, compact regression/improvement cards, `mixed` for conflicting signals, and `incomparable` with top-level `reason`, empty comparison cards, and `health_delta=null` when roots/settings differ | -| `evaluate_gates` | `run_id`, gate thresholds/booleans | Evaluate CI/gating conditions against an existing run without exiting the process | -| `get_report_section` | `run_id`, `section`, `family`, `path`, `offset`, `limit` | Return a canonical report section. Prefer targeted sections instead of `section="all"` unless the client truly needs the full canonical report. `metrics` is summary-only; `metrics_detail` is paginated/bounded and falls back to summary+hint when unfiltered | -| `list_findings` | `family`, `category`, `severity`, `source_kind`, `novelty`, `sort_by`, `detail_level`, `changed_paths`, `git_diff_ref`, `exclude_reviewed`, pagination | Return deterministically ordered finding groups with filtering and pagination; compact summary detail is the default. Intended for broader filtered review after hotspots or `check_*`, not as the cheapest first-pass call | -| `get_finding` | `finding_id`, `run_id`, `detail_level` | Return one finding by id; defaults to `normal` detail and accepts MCP short ids. Use this after `list_hotspots`, `list_findings`, or `check_*` instead of raising detail on larger lists | -| `get_remediation` | `finding_id`, `run_id`, `detail_level` | Return just the remediation/explainability packet for one finding. Use this when the client needs the fix packet without pulling broader detail payloads | -| `list_hotspots` | `kind`, `run_id`, `detail_level`, `changed_paths`, `git_diff_ref`, `exclude_reviewed`, `limit`, `max_results` | Return one derived hotlist (`most_actionable`, `highest_spread`, `highest_priority`, `production_hotspots`, `test_fixture_hotspots`) with compact summary cards. This is the preferred first-pass triage surface before broader `list_findings` calls | -| `check_clones` | `run_id`, `root`, `path`, `clone_type`, `source_kind`, `max_results`, `detail_level` | Return clone findings from a compatible stored run; `health.dimensions` includes only `clones`. Prefer this narrower tool over `list_findings` when only clone debt is needed | -| `check_complexity` | `run_id`, `root`, `path`, `min_complexity`, `max_results`, `detail_level` | Return complexity hotspots from a compatible stored run; `health.dimensions` includes only `complexity`. Prefer this narrower tool over `list_findings` when only complexity is needed | -| `check_coupling` | `run_id`, `root`, `path`, `max_results`, `detail_level` | Return coupling hotspots from a compatible stored run; `health.dimensions` includes only `coupling`. Prefer this narrower tool over `list_findings` when only coupling is needed | -| `check_cohesion` | `run_id`, `root`, `path`, `max_results`, `detail_level` | Return cohesion hotspots from a compatible stored run; `health.dimensions` includes only `cohesion`. Prefer this narrower tool over `list_findings` when only cohesion is needed | -| `check_dead_code` | `run_id`, `root`, `path`, `min_severity`, `max_results`, `detail_level` | Return dead-code findings from a compatible stored run; `health.dimensions` includes only `dead_code`. Prefer this narrower tool over `list_findings` when only dead code is needed | -| `generate_pr_summary` | `run_id`, `changed_paths`, `git_diff_ref`, `format` | Build a PR-friendly changed-files summary in markdown or JSON. Prefer `markdown` for compact LLM-facing output and reserve `json` for machine post-processing | -| `mark_finding_reviewed` | `finding_id`, `run_id`, `note` | Mark a finding as reviewed in the in-memory MCP session | -| `list_reviewed_findings` | `run_id` | Return the current reviewed findings for the selected run | -| `clear_session_runs` | none | Clear all stored in-memory runs plus ephemeral review/gate/session caches for the current server process | +| Tool | Key parameters | Purpose / notes | +|--------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `analyze_repository` | absolute `root`, `analysis_mode`, `changed_paths`, `git_diff_ref`, inline thresholds, cache/baseline paths | Run deterministic CodeClone analysis, register the latest run, and return a compact MCP summary. The intended next step is `get_run_summary` or `get_production_triage`, not broad listing by default | +| `analyze_changed_paths` | absolute `root`, `changed_paths` or `git_diff_ref`, `analysis_mode`, inline thresholds | Diff-aware fast path: analyze a repo, attach a changed-files projection, and return a compact changed-files snapshot. The intended next step is `get_report_section(section="changed")` or `get_production_triage` | +| `get_run_summary` | `run_id` | Return the stored summary for the latest or specified run, with slim inventory counts instead of the full file registry; this is the cheapest run-level snapshot and `health` becomes explicit `available=false` when metrics were skipped | +| `get_production_triage` | `run_id`, `max_hotspots`, `max_suggestions` | Return a compact production-first MCP projection: health, cache `freshness`, production hotspots, production suggestions, and global source-kind counters. This is the default first-pass view for large or noisy repositories | +| `help` | `topic`, `detail` | Return a bounded semantic guide for a small set of MCP topics (`workflow`, `suppressions`, `baseline`, `latest_runs`, `review_state`, `changed_scope`) with next-step routing and canonical doc links. This is for uncertainty recovery, not full manual access | +| `compare_runs` | `run_id_before`, `run_id_after`, `focus` | Compare two registered runs by finding ids and run-to-run health delta; MCP returns short run ids, compact regression/improvement cards, `mixed` for conflicting signals, and `incomparable` with top-level `reason`, empty comparison cards, and `health_delta=null` when roots/settings differ | +| `evaluate_gates` | `run_id`, gate thresholds/booleans | Evaluate CI/gating conditions against an existing run without exiting the process | +| `get_report_section` | `run_id`, `section`, `family`, `path`, `offset`, `limit` | Return a canonical report section. Prefer targeted sections instead of `section="all"` unless the client truly needs the full canonical report. `metrics` is summary-only; `metrics_detail` is paginated/bounded, falls back to summary+hint when unfiltered, and can expose report-only families such as `god_modules` | +| `list_findings` | `family`, `category`, `severity`, `source_kind`, `novelty`, `sort_by`, `detail_level`, `changed_paths`, `git_diff_ref`, `exclude_reviewed`, pagination | Return deterministically ordered finding groups with filtering and pagination; compact summary detail is the default. Intended for broader filtered review after hotspots or `check_*`, not as the cheapest first-pass call | +| `get_finding` | `finding_id`, `run_id`, `detail_level` | Return one finding by id; defaults to `normal` detail and accepts MCP short ids. Use this after `list_hotspots`, `list_findings`, or `check_*` instead of raising detail on larger lists | +| `get_remediation` | `finding_id`, `run_id`, `detail_level` | Return just the remediation/explainability packet for one finding. Use this when the client needs the fix packet without pulling broader detail payloads | +| `list_hotspots` | `kind`, `run_id`, `detail_level`, `changed_paths`, `git_diff_ref`, `exclude_reviewed`, `limit`, `max_results` | Return one derived hotlist (`most_actionable`, `highest_spread`, `highest_priority`, `production_hotspots`, `test_fixture_hotspots`) with compact summary cards. This is the preferred first-pass triage surface before broader `list_findings` calls | +| `check_clones` | `run_id`, `root`, `path`, `clone_type`, `source_kind`, `max_results`, `detail_level` | Return clone findings from a compatible stored run; `health.dimensions` includes only `clones`. Prefer this narrower tool over `list_findings` when only clone debt is needed | +| `check_complexity` | `run_id`, `root`, `path`, `min_complexity`, `max_results`, `detail_level` | Return complexity hotspots from a compatible stored run; `health.dimensions` includes only `complexity`. Prefer this narrower tool over `list_findings` when only complexity is needed | +| `check_coupling` | `run_id`, `root`, `path`, `max_results`, `detail_level` | Return coupling hotspots from a compatible stored run; `health.dimensions` includes only `coupling`. Prefer this narrower tool over `list_findings` when only coupling is needed | +| `check_cohesion` | `run_id`, `root`, `path`, `max_results`, `detail_level` | Return cohesion hotspots from a compatible stored run; `health.dimensions` includes only `cohesion`. Prefer this narrower tool over `list_findings` when only cohesion is needed | +| `check_dead_code` | `run_id`, `root`, `path`, `min_severity`, `max_results`, `detail_level` | Return dead-code findings from a compatible stored run; `health.dimensions` includes only `dead_code`. Prefer this narrower tool over `list_findings` when only dead code is needed | +| `generate_pr_summary` | `run_id`, `changed_paths`, `git_diff_ref`, `format` | Build a PR-friendly changed-files summary in markdown or JSON. Prefer `markdown` for compact LLM-facing output and reserve `json` for machine post-processing | +| `mark_finding_reviewed` | `finding_id`, `run_id`, `note` | Mark a finding as reviewed in the in-memory MCP session | +| `list_reviewed_findings` | `run_id` | Return the current reviewed findings for the selected run | +| `clear_session_runs` | none | Clear all stored in-memory runs plus ephemeral review/gate/session caches for the current server process | All analysis/report tools are read-only with respect to repo state. The only mutable MCP tools are `mark_finding_reviewed` and `clear_session_runs`, and @@ -193,6 +193,9 @@ state behind `codeclone://latest/...`. - Canonical JSON remains the source of truth for report semantics. - `list_findings` and `list_hotspots` are deterministic projections over the canonical report, not a separate analysis branch. +- `metrics_detail(family="god_modules")` exposes the canonical report-only + module-hotspot layer, but does not promote it into findings, hotlists, or + gate semantics. - `get_remediation` is a deterministic MCP projection over existing suggestions/explainability data, not a second remediation engine. - `analysis_mode="clones_only"` must mirror the same metric/dependency diff --git a/docs/book/README.md b/docs/book/README.md index e995d74..44c72d0 100644 --- a/docs/book/README.md +++ b/docs/book/README.md @@ -41,6 +41,7 @@ If a statement is not enforced by code/tests, it is explicitly marked as non-con ### Quality and recommendations +- [15-health-score.md](15-health-score.md) - [15-metrics-and-quality-gates.md](15-metrics-and-quality-gates.md) - [16-dead-code-contract.md](16-dead-code-contract.md) - [17-suggestions-and-clone-typing.md](17-suggestions-and-clone-typing.md) diff --git a/docs/book/appendix/b-schema-layouts.md b/docs/book/appendix/b-schema-layouts.md index 9ec7e0e..df80c40 100644 --- a/docs/book/appendix/b-schema-layouts.md +++ b/docs/book/appendix/b-schema-layouts.md @@ -77,11 +77,11 @@ Notes: - `u` row decoder accepts both legacy 11-column rows and canonical 17-column rows (legacy rows map new structural fields to neutral defaults). -## Report schema (`2.2`) +## Report schema (`2.3`) ```json { - "report_schema_version": "2.2", + "report_schema_version": "2.3", "meta": { "codeclone_version": "2.0.0b4", "project_name": "codeclone", @@ -165,6 +165,13 @@ Notes: "total": 0, "high_confidence": 0, "suppressed": 1 + }, + "god_modules": { + "total": 0, + "candidates": 0, + "population_status": "limited", + "top_score": 0.0, + "average_score": 0.0 } }, "families": { @@ -185,6 +192,21 @@ Notes: } ] }, + "god_modules": { + "summary": { + "total": 0, + "candidates": 0, + "population_status": "limited", + "top_score": 0.0, + "average_score": 0.0 + }, + "detection": { + "version": "1", + "scope": "report_only", + "strategy": "project_relative_composite" + }, + "items": [] + }, "health": {} } }, @@ -244,7 +266,7 @@ Notes: ```text # CodeClone Report - Markdown schema: 1.0 -- Source report schema: 2.2 +- Source report schema: 2.3 ... ## Overview ## Inventory @@ -330,7 +352,7 @@ Notes: ], "properties": { "profileVersion": "1.0", - "reportSchemaVersion": "2.2" + "reportSchemaVersion": "2.3" }, "results": [ { diff --git a/docs/mcp.md b/docs/mcp.md index 5c34ec2..aa12f47 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -61,7 +61,7 @@ run-scoped URI templates. | `get_finding` | Deep inspection of one finding by id; defaults to normal detail and accepts `detail_level`; use after `list_hotspots`, `list_findings`, or `check_*` | | `get_remediation` | Structured remediation payload for one finding; defaults to normal detail; use when you only need the fix packet for a single finding | | `list_hotspots` | Derived views: highest priority, production hotspots, spread, etc., with compact summary cards; preferred first-pass triage before broader listing | -| `get_report_section` | Read canonical report sections; prefer specific sections over `section="all"`; `metrics` is summary-only, `metrics_detail` is paginated/bounded | +| `get_report_section` | Read canonical report sections; prefer specific sections over `section="all"`; `metrics` is summary-only, `metrics_detail` is paginated/bounded and can expose report-only families such as `god_modules` | | `evaluate_gates` | Preview CI/gating decisions without exiting | | `check_clones` | Clone findings from a stored run; cheaper and narrower than `list_findings` when you only need clone debt | | `check_complexity` | Complexity hotspots from a stored run; cheaper and narrower than `list_findings` when you only need complexity | @@ -78,6 +78,8 @@ run-scoped URI templates. `check_*` responses keep `health.score` and `health.grade`, but slim `health.dimensions` down to the one dimension relevant to that tool. +`metrics_detail(family="god_modules")` exposes the canonical report-only +module-hotspot layer without turning it into findings, hotlists, or gate data. List-style finding responses now use short MCP finding ids and compact relative locations by default; `normal` keeps structured `{path, line, end_line, symbol}` locations, while `full` keeps the richer compatibility payload including `uri`. diff --git a/mkdocs.yml b/mkdocs.yml index cd68046..0c7975e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -78,6 +78,7 @@ nav: - Testing as Spec: book/13-testing-as-spec.md - Compatibility and Versioning: book/14-compatibility-and-versioning.md - Quality: + - Health Score: book/15-health-score.md - Metrics and Gates: book/15-metrics-and-quality-gates.md - Dead Code: book/16-dead-code-contract.md - Suggestions and Clone Typing: book/17-suggestions-and-clone-typing.md diff --git a/tests/fixtures/golden_v2/pyproject_defaults/golden_expected_cli_snapshot.json b/tests/fixtures/golden_v2/pyproject_defaults/golden_expected_cli_snapshot.json index dc98485..f17a537 100644 --- a/tests/fixtures/golden_v2/pyproject_defaults/golden_expected_cli_snapshot.json +++ b/tests/fixtures/golden_v2/pyproject_defaults/golden_expected_cli_snapshot.json @@ -2,7 +2,7 @@ "meta": { "python_tag": "cp313" }, - "report_schema_version": "2.2", + "report_schema_version": "2.3", "project_name": "pyproject_defaults", "scan_root": ".", "baseline_status": "missing", diff --git a/tests/test_cli_inprocess.py b/tests/test_cli_inprocess.py index 8651b3f..13f61a4 100644 --- a/tests/test_cli_inprocess.py +++ b/tests/test_cli_inprocess.py @@ -3308,6 +3308,8 @@ def test_cli_summary_format_stable( assert "Summary" in out assert out.count("Summary") == 1 assert "Metrics" in out + assert "God Modules" in out + assert "callables" in out assert "Files parsed" not in out assert "Input" not in out assert _summary_metric(out, "Files found") >= 0 diff --git a/tests/test_cli_unit.py b/tests/test_cli_unit.py index 37338c9..fe80c1d 100644 --- a/tests/test_cli_unit.py +++ b/tests/test_cli_unit.py @@ -910,9 +910,10 @@ def test_compact_summary_labels_use_machine_scannable_keys() -> None: dead=1, health=85, grade="B", + god_modules=3, ) == "Metrics cc=2.8/21 cbo=0.6/8 lcom4=1.2/4" - " cycles=0 dead_code=1 health=85(B)" + " cycles=0 dead_code=1 health=85(B) god_modules=3" ) @@ -923,8 +924,7 @@ def test_ui_summary_formatters_cover_optional_branches() -> None: parsed = ui.fmt_summary_parsed(lines=1200, functions=3, methods=2, classes=1) assert parsed is not None assert "1,200" in parsed - assert "[bold cyan]3[/bold cyan] functions" in parsed - assert "[bold cyan]2[/bold cyan] methods" in parsed + assert "[bold cyan]5[/bold cyan] callables" in parsed assert "[bold cyan]1[/bold cyan] classes" in parsed clones = ui.fmt_summary_clones( @@ -944,6 +944,24 @@ def test_ui_summary_formatters_cover_optional_branches() -> None: clean_with_suppressed = ui.fmt_metrics_dead_code(0, suppressed=9) assert "✔ clean" in clean_with_suppressed assert "(9 suppressed)" in clean_with_suppressed + god_modules = ui.fmt_metrics_god_modules( + candidates=4, + total=158, + population_status="ok", + top_score=0.98, + ) + assert all( + fragment in god_modules + for fragment in ("4", "max score 0.98", "158 ranked", "(report-only)") + ) + limited_god_modules = ui.fmt_metrics_god_modules( + candidates=0, + total=12, + population_status="limited", + top_score=0.0, + ) + assert "12 ranked" in limited_god_modules + assert "report-only; limited population" in limited_god_modules changed_paths = ui.fmt_changed_scope_paths(count=45) assert "45" in changed_paths assert "from git diff" in changed_paths @@ -1005,6 +1023,35 @@ def test_print_changed_scope_uses_compact_line_in_quiet_mode( assert "known=5" in out +def test_print_metrics_in_quiet_mode_includes_god_modules( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(cli, "console", cli._make_console(no_color=True)) + cli_summary._print_metrics( + console=cast("cli_summary._Printer", cli.console), + quiet=True, + metrics=cli_summary.MetricsSnapshot( + complexity_avg=2.8, + complexity_max=20, + high_risk_count=0, + coupling_avg=0.5, + coupling_max=9, + cohesion_avg=1.2, + cohesion_max=4, + cycles_count=0, + dead_code_count=0, + health_total=85, + health_grade="B", + god_modules_candidates=3, + god_modules_total=158, + god_modules_population_status="ok", + god_modules_top_score=0.98, + ), + ) + out = capsys.readouterr().out + assert "god_modules=3" in out + + def test_configure_metrics_mode_rejects_skip_metrics_with_metrics_flags( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_html_report.py b/tests/test_html_report.py index e6f52fe..e08a0e7 100644 --- a/tests/test_html_report.py +++ b/tests/test_html_report.py @@ -493,6 +493,8 @@ def test_html_report_structural_findings_tab_uses_normalized_groups() -> None: ">1", "Repeated non-overlapping branch-body shapes", "1 function", + "Suggested action", + "Review whether the repeated local branch can be simplified in place.", ) assert "stmt seq" in html and "Expr,For" in html assert "stmt_seq=Expr" not in html @@ -1566,6 +1568,31 @@ def _metrics_payload( "grade": health_grade, "dimensions": {"coverage": 99}, }, + "god_modules": { + "summary": { + "total": 1, + "candidates": 0, + "population_status": "limited", + "top_score": 0.0, + "average_score": 0.0, + "candidate_score_cutoff": 0.0, + }, + "items": [], + "detection": { + "version": "1", + "scope": "report_only", + "strategy": "project_relative_composite", + "minimum_population": 20, + "size_signals": ["loc", "callable_count", "complexity_total"], + "dependency_signals": [ + "fan_in", + "fan_out", + "total_deps", + "import_edges", + ], + "shape_signals": ["hub_balance", "reimport_ratio"], + }, + }, } @@ -1626,6 +1653,143 @@ def test_html_report_metrics_risk_branches() -> None: ) +def test_html_report_renders_god_modules_in_quality_and_overview() -> None: + payload = _metrics_payload( + health_score=72, + health_grade="B", + complexity_max=25, + complexity_high_risk=1, + coupling_high_risk=1, + cohesion_low=1, + dep_cycles=[], + dep_max_depth=4, + dead_total=1, + dead_critical=1, + ) + god_modules = payload["god_modules"] + assert isinstance(god_modules, dict) + god_modules["summary"] = { + "total": 3, + "candidates": 1, + "population_status": "ok", + "top_score": 0.93, + "average_score": 0.42, + "candidate_score_cutoff": 0.88, + } + god_modules["items"] = [ + { + "module": "pkg.hub", + "relative_path": "pkg/hub.py", + "source_kind": "production", + "loc": 420, + "functions": 5, + "methods": 2, + "classes": 1, + "callable_count": 7, + "complexity_total": 31, + "complexity_max": 12, + "fan_in": 4, + "fan_out": 7, + "total_deps": 11, + "import_edges": 9, + "reimport_edges": 2, + "reimport_ratio": 0.2222, + "instability": 0.6364, + "hub_balance": 0.7273, + "size_score": 0.95, + "dependency_score": 0.91, + "shape_score": 0.8, + "score": 0.93, + "candidate_status": "candidate", + "candidate_reasons": [ + "size_pressure", + "dependency_pressure", + "hub_like_shape", + ], + } + ] + + html = build_html_report( + func_groups={}, + block_groups={}, + segment_groups={}, + report_meta={"scan_root": "/outside/project"}, + metrics=payload, + ) + + _assert_html_contains( + html, + "God Modules", + "pkg.hub", + "Candidate profile", + "0.93", + "hub-like shape", + "god-modules", + ) + assert "Candidate cutoff" not in html + assert "Ranked modules" not in html + + +def test_html_report_renders_run_snapshot_from_canonical_inventory() -> None: + metrics = _metrics_payload( + health_score=82, + health_grade="B", + complexity_max=12, + complexity_high_risk=0, + coupling_high_risk=0, + cohesion_low=0, + dep_cycles=[], + dep_max_depth=2, + dead_total=0, + dead_critical=0, + ) + report_document = build_report_document( + func_groups={}, + block_groups={}, + segment_groups={}, + meta={"scan_root": "/repo/project", "project_name": "project"}, + metrics=metrics, + inventory={ + "files": { + "total_found": 158, + "analyzed": 120, + "cached": 38, + "skipped": 2, + "source_io_skipped": 1, + }, + "code": { + "parsed_lines": 22320, + "functions": 180, + "methods": 40, + "classes": 12, + }, + "file_list": [ + "/repo/project/pkg/a.py", + "/repo/project/pkg/b.py", + ], + }, + ) + + html = build_html_report( + func_groups={}, + block_groups={}, + segment_groups={}, + report_meta=report_document["meta"], + metrics=report_document["metrics"], + report_document=report_document, + ) + + _assert_html_contains( + html, + "Scan scope", + "Parsed lines", + "Callables", + "Cached files", + "22,320", + "158 found · 120 analyzed · 38 cached · 3 skipped", + ) + + def test_html_report_metrics_without_health_score_uses_info_overview() -> None: html = build_html_report( func_groups={}, diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 0dc0cde..b69c51d 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -369,7 +369,27 @@ def test_mcp_server_tool_roundtrip_and_resources(tmp_path: Path) -> None: ) ) ) + god_modules_page = _structured_tool_result( + asyncio.run( + server.call_tool( + "get_report_section", + {"section": "metrics_detail", "family": "god_modules", "limit": 5}, + ) + ) + ) assert cast("list[dict[str, object]]", metrics_detail_page["items"]) + assert god_modules_page["family"] == "god_modules" + report_metrics = cast("dict[str, object]", report_payload["metrics"]) + report_families = cast("dict[str, object]", report_metrics["families"]) + report_god_modules = cast("dict[str, object]", report_families["god_modules"]) + report_god_module_items = cast( + "list[dict[str, object]]", + report_god_modules["items"], + ) + assert ( + cast("list[dict[str, object]]", god_modules_page["items"])[0]["path"] + == report_god_module_items[0]["relative_path"] + ) changed_section = _structured_tool_result( asyncio.run(server.call_tool("get_report_section", {"section": "changed"})) ) diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py index d310cf3..28df851 100644 --- a/tests/test_mcp_service.py +++ b/tests/test_mcp_service.py @@ -867,6 +867,7 @@ def test_mcp_service_metrics_sections_split_summary_and_detail( "cohesion", "dependencies", "dead_code", + "god_modules", "health", } assert "families" not in metrics_summary @@ -874,6 +875,26 @@ def test_mcp_service_metrics_sections_split_summary_and_detail( assert set(metrics_detail) == {"summary", "_hint"} assert "family" in metrics_detail_page assert cast("list[dict[str, object]]", metrics_detail_page["items"]) + god_modules_page = service.get_report_section( + run_id=run_id, + section="metrics_detail", + family="god_modules", + limit=5, + ) + assert god_modules_page["family"] == "god_modules" + god_modules_items = cast("list[dict[str, object]]", god_modules_page["items"]) + assert god_modules_items + report_record = service._runs.get(run_id) + assert report_record is not None + report_document = report_record.report_document + metrics_map = cast("dict[str, object]", report_document["metrics"]) + families_map = cast("dict[str, object]", metrics_map["families"]) + god_modules_family = cast("dict[str, object]", families_map["god_modules"]) + god_modules_report_items = cast( + "list[dict[str, object]]", + god_modules_family["items"], + ) + assert god_modules_items[0]["path"] == god_modules_report_items[0]["relative_path"] def test_mcp_service_evaluate_gates_on_existing_run(tmp_path: Path) -> None: @@ -3213,6 +3234,56 @@ def test_mcp_service_summary_and_metrics_detail_helper_fallbacks( } ], } + god_modules_payload = service._metrics_detail_payload( + metrics={ + "summary": {}, + "families": { + "god_modules": { + "items": [ + { + "relative_path": "zeta.py", + "module": "pkg.zeta", + "score": 0.99, + "candidate_status": "candidate", + }, + { + "relative_path": "alpha.py", + "module": "pkg.alpha", + "score": 0.12, + "candidate_status": "non_candidate", + }, + ] + } + }, + }, + family="god_modules", + path=None, + offset=0, + limit=5, + ) + assert god_modules_payload == { + "family": "god_modules", + "path": None, + "offset": 0, + "limit": 5, + "returned": 2, + "total": 2, + "has_more": False, + "items": [ + { + "path": "zeta.py", + "module": "pkg.zeta", + "score": 0.99, + "candidate_status": "candidate", + }, + { + "path": "alpha.py", + "module": "pkg.alpha", + "score": 0.12, + "candidate_status": "non_candidate", + }, + ], + } assert service._compact_metrics_item( {"qualname": "pkg.mod:run", "score": 10, "skip": None} ) == {"qualname": "pkg.mod:run", "score": 10} diff --git a/tests/test_pipeline_metrics.py b/tests/test_pipeline_metrics.py index 1c17c20..8d51320 100644 --- a/tests/test_pipeline_metrics.py +++ b/tests/test_pipeline_metrics.py @@ -7,6 +7,7 @@ from __future__ import annotations from codeclone.cache import CacheEntry +from codeclone.metrics import build_god_modules_payload from codeclone.models import ( ClassMetrics, DeadCandidate, @@ -178,6 +179,129 @@ def test_build_metrics_report_payload_includes_suppressed_dead_code_items() -> N ] +def test_build_metrics_report_payload_includes_god_modules_for_small_population() -> ( + None +): + payload = build_metrics_report_payload( + scan_root="/repo", + project_metrics=_project_metrics(dead_confidence="high"), + units=( + { + "qualname": "pkg.alpha:run", + "filepath": "/repo/pkg/alpha.py", + "cyclomatic_complexity": 12, + }, + { + "qualname": "tests.test_beta:run", + "filepath": "/repo/tests/test_beta.py", + "cyclomatic_complexity": 2, + }, + ), + class_metrics=(), + module_deps=( + ModuleDep( + source="pkg.alpha", + target="tests.test_beta", + import_type="import", + line=1, + ), + ), + source_stats_by_file=( + ("/repo/pkg/alpha.py", 240, 3, 1, 1), + ("/repo/tests/test_beta.py", 40, 1, 0, 0), + ), + suppressed_dead_code=(), + ) + + god_modules = payload["god_modules"] + assert isinstance(god_modules, dict) + summary = god_modules["summary"] + assert summary["total"] == 2 + assert summary["candidates"] == 0 + assert summary["population_status"] == "limited" + assert summary["top_score"] >= summary["average_score"] >= 0.0 + assert summary["candidate_score_cutoff"] <= 1.0 + assert summary["candidate_score_cutoff"] >= summary["top_score"] + items = god_modules["items"] + assert [item["module"] for item in items] == ["pkg.alpha", "tests.test_beta"] + assert items[0]["candidate_status"] == "ranked_only" + assert items[0]["candidate_reasons"] == ["size_pressure", "dependency_pressure"] + assert items[0]["source_kind"] == "production" + assert items[1]["candidate_status"] == "ranked_only" + assert items[1]["candidate_reasons"] == ["dependency_pressure"] + assert items[1]["source_kind"] == "tests" + + +def test_build_god_modules_payload_flags_project_relative_candidates() -> None: + scan_root = "/repo" + source_stats = [ + (f"{scan_root}/pkg/core.py", 2000, 24, 4, 2), + *((f"{scan_root}/pkg/mod_{idx}.py", 40 + idx, 1, 0, 0) for idx in range(20)), + ] + units = [ + *( + { + "qualname": f"pkg.core:fn_{idx}", + "filepath": f"{scan_root}/pkg/core.py", + "cyclomatic_complexity": 8 + (idx % 4), + } + for idx in range(24) + ), + *( + { + "qualname": f"pkg.mod_{idx}:fn", + "filepath": f"{scan_root}/pkg/mod_{idx}.py", + "cyclomatic_complexity": 1, + } + for idx in range(20) + ), + ] + deps = [ + *( + ModuleDep( + source=f"pkg.mod_{idx}", + target="pkg.core", + import_type="import", + line=1, + ) + for idx in range(10) + ), + *( + ModuleDep( + source="pkg.core", + target=f"pkg.mod_{idx}", + import_type="import", + line=idx + 1, + ) + for idx in range(10, 20) + ), + ] + + payload = build_god_modules_payload( + scan_root=scan_root, + source_stats_by_file=source_stats, + units=units, + class_metrics=(), + module_deps=deps, + ) + + summary = payload["summary"] + assert isinstance(summary, dict) + assert summary["population_status"] == "ok" + assert summary["candidates"] >= 1 + items = payload["items"] + assert isinstance(items, list) + first = items[0] + assert isinstance(first, dict) + assert first["module"] == "pkg.core" + assert first["candidate_status"] == "candidate" + assert first["candidate_reasons"] == [ + "size_pressure", + "dependency_pressure", + "hub_like_shape", + ] + + def test_load_cached_metrics_ignores_referenced_names_from_test_files() -> None: entry: CacheEntry = { "stat": {"mtime_ns": 1, "size": 1}, diff --git a/tests/test_report_branch_invariants.py b/tests/test_report_branch_invariants.py index ad7bbaf..a8e0e1b 100644 --- a/tests/test_report_branch_invariants.py +++ b/tests/test_report_branch_invariants.py @@ -32,8 +32,8 @@ from codeclone.report.suggestions import ( _clone_steps, _clone_summary, - _structural_steps, _structural_summary, + structural_action_steps, ) from tests._assertions import assert_contains_all @@ -135,6 +135,11 @@ def test_structural_summary_and_steps_cover_all_terminal_paths() -> None: signature={"cohort_id": "fp|20-49"}, items=(_occurrence(qualname="pkg:a", start=14, end=15),), ) + continue_group = _group( + key="continue", + signature={"terminal": "fallthrough", "stmt_seq": "Continue"}, + items=(_occurrence(qualname="pkg:a", start=16, end=16),) * 2, + ) assert _structural_summary(raise_group)[1] == ( "same repeated guard/validation branch" @@ -148,18 +153,21 @@ def test_structural_summary_and_steps_cover_all_terminal_paths() -> None: assert _structural_summary(guard_div_group)[0] == "Clone guard/exit divergence" assert _structural_summary(drift_group)[0] == "Clone cohort drift" - assert _structural_steps(raise_group)[0].startswith( + assert structural_action_steps(raise_group)[0].startswith( "Factor the repeated validation/guard path" ) - assert _structural_steps(return_group)[0].startswith( + assert structural_action_steps(return_group)[0].startswith( "Consolidate the repeated return-path logic" ) - assert _structural_steps(guard_div_group)[0].startswith( + assert structural_action_steps(guard_div_group)[0].startswith( "Compare divergent clone members" ) - assert _structural_steps(drift_group)[0].startswith( + assert structural_action_steps(drift_group)[0].startswith( "Review whether cohort drift is intentional" ) + assert structural_action_steps(continue_group)[0].startswith( + "Review whether the repeated continue guard can be merged" + ) def test_findings_occurrence_table_scope_and_dedupe_invariants() -> None: diff --git a/tests/test_report_contract_coverage.py b/tests/test_report_contract_coverage.py index 26e7a9c..0c09896 100644 --- a/tests/test_report_contract_coverage.py +++ b/tests/test_report_contract_coverage.py @@ -354,6 +354,88 @@ def _rich_report_document() -> dict[str, object]: }, } }, + "god_modules": { + "summary": { + "total": 2, + "candidates": 1, + "population_status": "ok", + "top_score": 0.94, + "average_score": 0.58, + "candidate_score_cutoff": 0.91, + }, + "detection": { + "version": "1", + "scope": "report_only", + "strategy": "project_relative_composite", + "minimum_population": 20, + "size_signals": ["loc", "callable_count", "complexity_total"], + "dependency_signals": [ + "fan_in", + "fan_out", + "total_deps", + "import_edges", + ], + "shape_signals": ["hub_balance", "reimport_ratio"], + }, + "items": [ + { + "module": "codeclone.alpha", + "filepath": "/repo/codeclone/codeclone/alpha.py", + "source_kind": "production", + "loc": 900, + "functions": 6, + "methods": 2, + "classes": 1, + "callable_count": 8, + "complexity_total": 64, + "complexity_max": 18, + "fan_in": 3, + "fan_out": 8, + "total_deps": 11, + "import_edges": 12, + "reimport_edges": 4, + "reimport_ratio": 0.3333, + "instability": 0.7273, + "hub_balance": 0.5455, + "size_score": 0.96, + "dependency_score": 0.93, + "shape_score": 0.81, + "score": 0.94, + "candidate_status": "candidate", + "candidate_reasons": [ + "size_pressure", + "dependency_pressure", + "hub_like_shape", + ], + }, + { + "module": "tests.test_alpha", + "filepath": "/repo/codeclone/tests/test_alpha.py", + "source_kind": "tests", + "loc": 120, + "functions": 2, + "methods": 0, + "classes": 0, + "callable_count": 2, + "complexity_total": 6, + "complexity_max": 4, + "fan_in": 0, + "fan_out": 1, + "total_deps": 1, + "import_edges": 1, + "reimport_edges": 0, + "reimport_ratio": 0.0, + "instability": 1.0, + "hub_balance": 0.0, + "size_score": 0.22, + "dependency_score": 0.11, + "shape_score": 0.0, + "score": 0.16, + "candidate_status": "non_candidate", + "candidate_reasons": [], + }, + ], + }, } suggestions = ( Suggestion( @@ -1120,6 +1202,48 @@ def test_markdown_render_long_list_branches() -> None: assert "... and 2 more item(s)" in markdown +def test_report_contract_renderers_include_god_modules_section() -> None: + payload = _rich_report_document() + + text = render_text_report_document(payload) + markdown = render_markdown_report_document(payload) + + assert "GOD MODULES (top 10)" in text + assert "module=codeclone.alpha" in text + assert '' in markdown + assert "### God Modules" in markdown + assert "candidate_status=candidate" in markdown + + +def test_report_contract_includes_canonical_god_modules_family() -> None: + payload = _rich_report_document() + + metrics = cast(dict[str, object], payload["metrics"]) + summary = cast(dict[str, object], metrics["summary"]) + families = cast(dict[str, object], metrics["families"]) + god_modules = cast(dict[str, object], families["god_modules"]) + god_summary = cast(dict[str, object], god_modules["summary"]) + + assert summary["god_modules"] == god_summary + assert god_summary == { + "total": 2, + "candidates": 1, + "population_status": "ok", + "top_score": 0.94, + "average_score": 0.58, + "candidate_score_cutoff": 0.91, + } + first = cast(list[dict[str, object]], god_modules["items"])[0] + assert first["module"] == "codeclone.alpha" + assert first["relative_path"] == "codeclone/alpha.py" + assert first["candidate_status"] == "candidate" + assert first["candidate_reasons"] == [ + "size_pressure", + "dependency_pressure", + "hub_like_shape", + ] + + def test_sarif_helper_level_mapping() -> None: assert _severity_to_level("critical") == "error" assert _severity_to_level("warning") == "warning" @@ -1765,6 +1889,12 @@ def test_collect_paths_from_metrics_covers_all_metric_families_and_skips_missing {"filepath": None}, ], }, + "god_modules": { + "items": [ + {"filepath": "/repo/god.py"}, + {"filepath": ""}, + ] + }, } assert _collect_paths_from_metrics(metrics) == { @@ -1772,6 +1902,7 @@ def test_collect_paths_from_metrics_covers_all_metric_families_and_skips_missing "/repo/coupling.py", "/repo/cohesion.py", "/repo/dead.py", + "/repo/god.py", "/repo/suppressed.py", } diff --git a/tests/test_report_suggestions.py b/tests/test_report_suggestions.py index 402a535..ff19acb 100644 --- a/tests/test_report_suggestions.py +++ b/tests/test_report_suggestions.py @@ -409,3 +409,42 @@ def test_structural_suggestions_raise_clone_cohort_drift_to_warning() -> None: ) assert len(suggestions) == 1 assert suggestions[0].severity == "warning" + + +def test_structural_info_hints_stay_in_findings_without_separate_suggestion() -> None: + suggestions = suggestions_mod._structural_suggestions( + ( + StructuralFindingGroup( + finding_kind="duplicated_branches", + finding_key="structural:duplicated_branches:1", + signature={ + "stmt_seq": "Expr,Return", + "terminal": "return", + "raises": "0", + "has_loop": "0", + }, + items=( + StructuralFindingOccurrence( + finding_kind="duplicated_branches", + finding_key="structural:duplicated_branches:1", + file_path="/repo/pkg/a.py", + qualname="pkg.a:alpha", + start=10, + end=12, + signature={}, + ), + StructuralFindingOccurrence( + finding_kind="duplicated_branches", + finding_key="structural:duplicated_branches:1", + file_path="/repo/pkg/a.py", + qualname="pkg.a:alpha", + start=20, + end=22, + signature={}, + ), + ), + ), + ), + scan_root="/repo", + ) + assert suggestions == [] diff --git a/uv.lock b/uv.lock index 878c998..d345f73 100644 --- a/uv.lock +++ b/uv.lock @@ -161,107 +161,107 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/8c/2c56124c6dc53a774d435f985b5973bc592f42d437be58c0c92d65ae7296/charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", size = 298751, upload-time = "2026-03-15T18:50:00.003Z" }, - { url = "https://files.pythonhosted.org/packages/86/2a/2a7db6b314b966a3bcad8c731c0719c60b931b931de7ae9f34b2839289ee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", size = 200027, upload-time = "2026-03-15T18:50:01.702Z" }, - { url = "https://files.pythonhosted.org/packages/68/f2/0fe775c74ae25e2a3b07b01538fc162737b3e3f795bada3bc26f4d4d495c/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", size = 220741, upload-time = "2026-03-15T18:50:03.194Z" }, - { url = "https://files.pythonhosted.org/packages/10/98/8085596e41f00b27dd6aa1e68413d1ddda7e605f34dd546833c61fddd709/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", size = 215802, upload-time = "2026-03-15T18:50:05.859Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ce/865e4e09b041bad659d682bbd98b47fb490b8e124f9398c9448065f64fee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", size = 207908, upload-time = "2026-03-15T18:50:07.676Z" }, - { url = "https://files.pythonhosted.org/packages/a8/54/8c757f1f7349262898c2f169e0d562b39dcb977503f18fdf0814e923db78/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", size = 194357, upload-time = "2026-03-15T18:50:09.327Z" }, - { url = "https://files.pythonhosted.org/packages/6f/29/e88f2fac9218907fc7a70722b393d1bbe8334c61fe9c46640dba349b6e66/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", size = 205610, upload-time = "2026-03-15T18:50:10.732Z" }, - { url = "https://files.pythonhosted.org/packages/4c/c5/21d7bb0cb415287178450171d130bed9d664211fdd59731ed2c34267b07d/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", size = 203512, upload-time = "2026-03-15T18:50:12.535Z" }, - { url = "https://files.pythonhosted.org/packages/a4/be/ce52f3c7fdb35cc987ad38a53ebcef52eec498f4fb6c66ecfe62cfe57ba2/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", size = 195398, upload-time = "2026-03-15T18:50:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/81/a0/3ab5dd39d4859a3555e5dadfc8a9fa7f8352f8c183d1a65c90264517da0e/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", size = 221772, upload-time = "2026-03-15T18:50:15.581Z" }, - { url = "https://files.pythonhosted.org/packages/04/6e/6a4e41a97ba6b2fa87f849c41e4d229449a586be85053c4d90135fe82d26/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", size = 205759, upload-time = "2026-03-15T18:50:17.047Z" }, - { url = "https://files.pythonhosted.org/packages/db/3b/34a712a5ee64a6957bf355b01dc17b12de457638d436fdb05d01e463cd1c/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", size = 216938, upload-time = "2026-03-15T18:50:18.44Z" }, - { url = "https://files.pythonhosted.org/packages/cb/05/5bd1e12da9ab18790af05c61aafd01a60f489778179b621ac2a305243c62/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", size = 210138, upload-time = "2026-03-15T18:50:19.852Z" }, - { url = "https://files.pythonhosted.org/packages/bd/8e/3cb9e2d998ff6b21c0a1860343cb7b83eba9cdb66b91410e18fc4969d6ab/charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", size = 144137, upload-time = "2026-03-15T18:50:21.505Z" }, - { url = "https://files.pythonhosted.org/packages/d8/8f/78f5489ffadb0db3eb7aff53d31c24531d33eb545f0c6f6567c25f49a5ff/charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", size = 154244, upload-time = "2026-03-15T18:50:22.81Z" }, - { url = "https://files.pythonhosted.org/packages/e4/74/e472659dffb0cadb2f411282d2d76c60da1fc94076d7fffed4ae8a93ec01/charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", size = 143312, upload-time = "2026-03-15T18:50:24.074Z" }, - { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, - { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, - { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, - { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, - { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, - { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, - { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, - { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, - { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, - { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, - { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, - { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, - { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, - { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, - { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, - { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, - { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, - { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, - { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, - { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, - { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, - { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, - { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, - { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, - { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, - { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, - { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, - { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, - { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, - { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, - { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, - { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, - { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, - { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, - { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, - { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, - { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, - { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, - { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, - { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, - { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, - { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, - { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, - { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, - { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, - { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, - { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, - { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, - { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, - { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, - { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, - { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, - { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, - { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, - { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, - { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, - { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, - { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, - { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, - { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, - { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, - { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, - { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, - { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, - { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] @@ -828,7 +828,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.26.0" +version = "1.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -846,9 +846,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, + { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, ] [[package]] @@ -862,11 +862,11 @@ wheels = [ [[package]] name = "more-itertools" -version = "10.8.0" +version = "11.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/24/e0acc4bf54cba50c1d432c70a72a3df96db4a321b2c4c68432a60759044f/more_itertools-11.0.1.tar.gz", hash = "sha256:fefaf25b7ab08f0b45fa9f1892cae93b9fc0089ef034d39213bce15f1cc9e199", size = 144739, upload-time = "2026-04-02T16:17:45.061Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f4/5e52c7319b8087acef603ed6e50dc325c02eaa999355414830468611f13c/more_itertools-11.0.1-py3-none-any.whl", hash = "sha256:eaf287826069452a8f61026c597eae2428b2d1ba2859083abbf240b46842ce6d", size = 72182, upload-time = "2026-04-02T16:17:43.724Z" }, ] [[package]] @@ -1581,27 +1581,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, - { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, - { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, - { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, - { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, - { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, - { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, - { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, - { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, - { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, - { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, - { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, - { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, - { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, +version = "0.15.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, ] [[package]] From b0efa4a62c99fd4fef86a3197302ff792e9b5014 Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Fri, 3 Apr 2026 22:45:28 +0500 Subject: [PATCH 03/15] feat(vscode): add a publish-ready CodeClone VS Code extension and document it as a first-class surface - add a native VS Code client over codeclone-mcp with baseline-aware, triage-first structural review and guided source-first drill-down - stabilize extension lifecycle and setup UX with local launcher verification, fallback connection handling, review-focused hotspots, and human-readable command surfaces - add CodeClone-native branding and marketplace-ready packaging assets, including a proper extension icon and validated .vsix packaging - document the new VS Code interface across README, docs, contracts book, changelog, and issue-routing guidance - update AGENTS.md to reflect the VS Code extension as a public surface and align validation/module-routing rules with the current codebase --- AGENTS.md | 49 +- CHANGELOG.md | 5 + README.md | 18 +- docs/README.md | 2 + docs/book/01-architecture-map.md | 5 + docs/book/21-vscode-extension.md | 129 + docs/book/README.md | 1 + docs/vscode-extension.md | 96 + extensions/vscode-codeclone/.vscodeignore | 6 + extensions/vscode-codeclone/CHANGELOG.md | 12 + extensions/vscode-codeclone/LICENSE | 373 +++ extensions/vscode-codeclone/README.md | 169 ++ .../vscode-codeclone/media/codeclone.svg | 35 + .../vscode-codeclone/media/icon-source.svg | 35 + extensions/vscode-codeclone/media/icon.png | Bin 0 -> 1815 bytes extensions/vscode-codeclone/package.json | 430 ++++ extensions/vscode-codeclone/src/extension.js | 2109 +++++++++++++++++ extensions/vscode-codeclone/src/mcpClient.js | 348 +++ mkdocs.yml | 2 + 19 files changed, 3811 insertions(+), 13 deletions(-) create mode 100644 docs/book/21-vscode-extension.md create mode 100644 docs/vscode-extension.md create mode 100644 extensions/vscode-codeclone/.vscodeignore create mode 100644 extensions/vscode-codeclone/CHANGELOG.md create mode 100644 extensions/vscode-codeclone/LICENSE create mode 100644 extensions/vscode-codeclone/README.md create mode 100644 extensions/vscode-codeclone/media/codeclone.svg create mode 100644 extensions/vscode-codeclone/media/icon-source.svg create mode 100644 extensions/vscode-codeclone/media/icon.png create mode 100644 extensions/vscode-codeclone/package.json create mode 100644 extensions/vscode-codeclone/src/extension.js create mode 100644 extensions/vscode-codeclone/src/mcpClient.js diff --git a/AGENTS.md b/AGENTS.md index c52de58..d237894 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,7 @@ Key artifacts: - `.cache/codeclone/cache.json` — analysis cache (integrity-checked) - `.cache/codeclone/report.html|report.json|report.md|report.sarif|report.txt` — reports - `codeclone-mcp` — optional read-only MCP server (install via `codeclone[mcp]`) +- `extensions/vscode-codeclone/` — preview VS Code extension as a native, read-only IDE client over `codeclone-mcp` - MCP runs are in-memory only; review markers are session-local and must never leak into baseline/cache/report artifacts - `docs/`, `mkdocs.yml`, `.github/workflows/docs.yml` — published documentation site and docs build pipeline @@ -88,6 +89,22 @@ If you touched the MCP surface, also run: uv run pytest -q tests/test_mcp_service.py tests/test_mcp_server.py ``` +If you touched the VS Code extension surface, also run: + +```bash +node --check extensions/vscode-codeclone/src/mcpClient.js +node --check extensions/vscode-codeclone/src/extension.js +``` + +If you touched VS Code extension packaging metadata (`package.json`, +README/changelog/license, media assets, or `.vscodeignore`), also run a package +smoke: + +```bash +cd extensions/vscode-codeclone +NPM_CONFIG_CACHE=/tmp/codeclone-vsce-cache npx @vscode/vsce package --pre-release --out /tmp/codeclone.vsix +``` + --- ## 4) Baseline contract (v2, stable) @@ -325,6 +342,8 @@ Architecture is layered, but grounded in current code (not aspirational diagrams `scripts/build_docs_example_report.py`) publishes contract docs and the live sample report. - **MCP agent interface** (`codeclone/mcp_service.py`, `codeclone/mcp_server.py`) exposes the current pipeline as a deterministic, read-only MCP server for AI agents and MCP-capable clients. +- **VS Code extension surface** (`extensions/vscode-codeclone/*`) is a native, workspace-only IDE client over + `codeclone-mcp`, with baseline-aware, triage-first, source-first review UX. - **Tests-as-spec** (`tests/`) lock behavior, contracts, determinism, and architecture boundaries. Non-negotiable interpretation: @@ -333,6 +352,7 @@ Non-negotiable interpretation: - Baseline/cache are persistence contracts, not analysis truth. - UI/report must not invent gating semantics. - MCP reuses pipeline/report contracts and must not create a second analysis truth path. +- The VS Code extension is a guided IDE view over MCP and must not introduce a second analysis or truth path. ## 13) Module map @@ -347,8 +367,8 @@ Use this map to route changes to the right owner module. - `codeclone/extractor.py` — AST extraction, CFG fingerprint input preparation, symbol/declaration collection, and per-file metrics inputs; change parsing/extraction semantics here; do not couple this module to CLI/report rendering/baseline logic. -- `codeclone/grouping.py` / `codeclone/blocks.py` / `codeclone/blockhash.py` — clone grouping and block/segment - mechanics; change grouping behavior here; do not mix in CLI/report UX concerns. +- `codeclone/grouping.py` / `codeclone/blocks.py` — clone grouping and block/segment mechanics; normalization-adjacent + statement hashing lives with `codeclone/normalize.py`; do not mix grouping behavior with CLI/report UX concerns. - `codeclone/metrics/` — metric computations and dead-code/dependency/health logic; change metric math and thresholds here; do not make metrics depend on renderer/UI concerns. - `codeclone/structural_findings.py` — structural finding extraction/normalization policy; keep it report-layer factual @@ -381,6 +401,8 @@ Use this map to route changes to the right owner module. levels); use these constants in pipeline/report/UI instead of scattering raw literals. - `docs/`, `mkdocs.yml`, `.github/workflows/docs.yml`, `scripts/build_docs_example_report.py` — docs-site source, publication workflow, and live sample-report generation; keep published docs aligned with code contracts. +- `extensions/vscode-codeclone/*` — preview VS Code extension surface; keep it baseline-aware, triage-first, + source-first, and faithful to MCP/canonical report semantics rather than building a second analyzer or report model. - `tests/` — executable specification: architecture rules, contracts, goldens, invariants, regressions. ## 14) Dependency direction @@ -427,16 +449,17 @@ Prefer explicit inline suppressions for runtime/dynamic false positives instead If you change a contract-sensitive zone, route docs/tests/approval deliberately. -| Change zone | Must update docs | Must update tests | Explicit approval required when | Contract-change trigger | -|-------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------| -| Baseline schema/trust/integrity (`codeclone/baseline.py`) | `docs/book/06-baseline.md`, `docs/book/14-compatibility-and-versioning.md`, `docs/book/appendix/b-schema-layouts.md`, `CHANGELOG.md` | `tests/test_baseline.py`, CI/CLI behavior tests (`tests/test_cli_inprocess.py`, `tests/test_cli_unit.py`) | schema/trust semantics, compatibility windows, payload integrity logic change | baseline key layout/status semantics/compat rules change | -| Cache schema/profile/integrity (`codeclone/cache.py`) | `docs/book/07-cache.md`, `docs/book/appendix/b-schema-layouts.md`, `CHANGELOG.md` | `tests/test_cache.py`, pipeline/CLI cache integration tests | cache schema/status/profile compatibility semantics change | cache payload/version/status semantics change | -| Canonical report JSON shape (`codeclone/report/json_contract.py`, report projections) | `docs/book/08-report.md` (+ `docs/book/10-html-render.md` if rendering contract impacted), `docs/sarif.md` when SARIF changes, `CHANGELOG.md` | `tests/test_report.py`, `tests/test_report_contract_coverage.py`, `tests/test_report_branch_invariants.py`, relevant report-format tests | finding/meta/summary schema changes | stable JSON fields/meaning/order guarantees change | -| CLI flags/help/exit behavior (`codeclone/cli.py`, `_cli_*`, `contracts.py`) | `docs/book/09-cli.md`, `docs/book/03-contracts-exit-codes.md`, `README.md`, `CHANGELOG.md` | `tests/test_cli_unit.py`, `tests/test_cli_inprocess.py`, `tests/test_cli_smoke.py` | exit-code semantics, script-facing behavior, flag contracts change | user-visible CLI contract changes | -| Fingerprint-adjacent analysis (`extractor/cfg/normalize/grouping`) | `docs/book/05-core-pipeline.md`, `docs/cfg.md`, `docs/book/14-compatibility-and-versioning.md`, `CHANGELOG.md` | `tests/test_fingerprint.py`, `tests/test_extractor.py`, `tests/test_cfg.py`, golden tests (`tests/test_detector_golden.py`, `tests/test_golden_v2.py`) | always (see Section 1.6) | clone identity / NEW-vs-KNOWN / fingerprint inputs change | -| Suppression semantics/reporting (`suppressions`, extractor dead-code wiring, report/UI counters) | `docs/book/19-inline-suppressions.md`, `docs/book/16-dead-code-contract.md`, `docs/book/08-report.md`, and interface docs if surfaced (`09-cli`, `10-html-render`) | `tests/test_suppressions.py`, `tests/test_extractor.py`, `tests/test_metrics_modules.py`, `tests/test_pipeline_metrics.py`, report/html/cli tests | declaration scope semantics, rule effect, or contract-visible counters/fields change | suppression changes alter active finding output or contract-visible report payload | -| MCP interface (`codeclone/mcp_service.py`, `codeclone/mcp_server.py`, packaging extra/launcher) | `README.md`, `docs/book/20-mcp-interface.md`, `docs/mcp.md`, `docs/book/01-architecture-map.md`, `docs/book/14-compatibility-and-versioning.md`, `CHANGELOG.md` | `tests/test_mcp_service.py`, `tests/test_mcp_server.py`, plus CLI/package tests if launcher/install semantics change | tool/resource shapes, read-only semantics, optional-dependency packaging behavior change | public MCP tool names, resource URIs, launcher/install behavior, or response semantics change | -| Docs site / sample report publication (`docs/`, `mkdocs.yml`, `.github/workflows/docs.yml`, `scripts/build_docs_example_report.py`) | `docs/README.md`, `docs/publishing.md`, `docs/examples/report.md`, and any contract pages surfaced by the change, `CHANGELOG.md` when user-visible behavior changes | `mkdocs build --strict`, sample-report generation smoke path, and relevant report/html tests if generated examples or embeds change | published docs navigation, sample-report generation, or Pages workflow semantics change | published documentation behavior or sample-report generation contract changes | +| Change zone | Must update docs | Must update tests | Explicit approval required when | Contract-change trigger | +|-------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------| +| Baseline schema/trust/integrity (`codeclone/baseline.py`) | `docs/book/06-baseline.md`, `docs/book/14-compatibility-and-versioning.md`, `docs/book/appendix/b-schema-layouts.md`, `CHANGELOG.md` | `tests/test_baseline.py`, CI/CLI behavior tests (`tests/test_cli_inprocess.py`, `tests/test_cli_unit.py`) | schema/trust semantics, compatibility windows, payload integrity logic change | baseline key layout/status semantics/compat rules change | +| Cache schema/profile/integrity (`codeclone/cache.py`) | `docs/book/07-cache.md`, `docs/book/appendix/b-schema-layouts.md`, `CHANGELOG.md` | `tests/test_cache.py`, pipeline/CLI cache integration tests | cache schema/status/profile compatibility semantics change | cache payload/version/status semantics change | +| Canonical report JSON shape (`codeclone/report/json_contract.py`, report projections) | `docs/book/08-report.md` (+ `docs/book/10-html-render.md` if rendering contract impacted), `docs/sarif.md` when SARIF changes, `CHANGELOG.md` | `tests/test_report.py`, `tests/test_report_contract_coverage.py`, `tests/test_report_branch_invariants.py`, relevant report-format tests | finding/meta/summary schema changes | stable JSON fields/meaning/order guarantees change | +| CLI flags/help/exit behavior (`codeclone/cli.py`, `_cli_*`, `contracts.py`) | `docs/book/09-cli.md`, `docs/book/03-contracts-exit-codes.md`, `README.md`, `CHANGELOG.md` | `tests/test_cli_unit.py`, `tests/test_cli_inprocess.py`, `tests/test_cli_smoke.py` | exit-code semantics, script-facing behavior, flag contracts change | user-visible CLI contract changes | +| Fingerprint-adjacent analysis (`extractor/cfg/normalize/grouping`) | `docs/book/05-core-pipeline.md`, `docs/cfg.md`, `docs/book/14-compatibility-and-versioning.md`, `CHANGELOG.md` | `tests/test_fingerprint.py`, `tests/test_extractor.py`, `tests/test_cfg.py`, golden tests (`tests/test_detector_golden.py`, `tests/test_golden_v2.py`) | always (see Section 1.6) | clone identity / NEW-vs-KNOWN / fingerprint inputs change | +| Suppression semantics/reporting (`suppressions`, extractor dead-code wiring, report/UI counters) | `docs/book/19-inline-suppressions.md`, `docs/book/16-dead-code-contract.md`, `docs/book/08-report.md`, and interface docs if surfaced (`09-cli`, `10-html-render`) | `tests/test_suppressions.py`, `tests/test_extractor.py`, `tests/test_metrics_modules.py`, `tests/test_pipeline_metrics.py`, report/html/cli tests | declaration scope semantics, rule effect, or contract-visible counters/fields change | suppression changes alter active finding output or contract-visible report payload | +| MCP interface (`codeclone/mcp_service.py`, `codeclone/mcp_server.py`, packaging extra/launcher) | `README.md`, `docs/book/20-mcp-interface.md`, `docs/mcp.md`, `docs/book/01-architecture-map.md`, `docs/book/14-compatibility-and-versioning.md`, `CHANGELOG.md` | `tests/test_mcp_service.py`, `tests/test_mcp_server.py`, plus CLI/package tests if launcher/install semantics change | tool/resource shapes, read-only semantics, optional-dependency packaging behavior change | public MCP tool names, resource URIs, launcher/install behavior, or response semantics change | +| VS Code extension surface (`extensions/vscode-codeclone/*`) | `README.md`, `docs/book/21-vscode-extension.md`, `docs/vscode-extension.md`, `docs/book/01-architecture-map.md`, `docs/README.md`, `CHANGELOG.md` | `node --check extensions/vscode-codeclone/src/mcpClient.js`, `node --check extensions/vscode-codeclone/src/extension.js`; package smoke when manifest/assets change | command/view UX, trust/runtime model, source-first review flow, or packaging metadata change | documented commands/views/setup/trust behavior, packaged assets, or publish metadata change | +| Docs site / sample report publication (`docs/`, `mkdocs.yml`, `.github/workflows/docs.yml`, `scripts/build_docs_example_report.py`) | `docs/README.md`, `docs/publishing.md`, `docs/examples/report.md`, and any contract pages surfaced by the change, `CHANGELOG.md` when user-visible behavior changes | `mkdocs build --strict`, sample-report generation smoke path, and relevant report/html tests if generated examples or embeds change | published docs navigation, sample-report generation, or Pages workflow semantics change | published documentation behavior or sample-report generation contract changes | Golden rule: do not “fix” failures by snapshot refresh unless the underlying contract change is intentional, documented, and approved. @@ -472,6 +495,8 @@ Policy: - Documented report projections and their machine/user-facing semantics (HTML/Markdown/SARIF/Text). - Documented MCP launcher/install behavior, tool names, resource URIs, and read-only semantics. - Session-local MCP review state semantics (`mark_finding_reviewed`, `exclude_reviewed`) as documented public behavior. +- Documented VS Code extension behavior: commands, views, setup guidance, trusted-workspace model, and its + baseline-aware triage workflow over MCP. - Documented finding families/kinds/ids and suppression-facing report fields. - Metrics baseline schema/compatibility where used by CI/gating. - Benchmark schema/outputs if consumed as a reproducible contract surface. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a2b34f..13c6a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,11 @@ static. - Explicitly document that future releases may lower a repository score because the scoring model becomes broader or stricter, not only because the code became worse. +### IDE integration + +- Add a preview VS Code extension as a native, read-only control surface over `codeclone-mcp`, with baseline-aware, + triage-first review flow, guided source-first drill-down, and explicit setup/session semantics. + ## [2.0.0b3] - 20260401 2.0.0b3 is the release where CodeClone stops looking like "a strong analyzer with extras" and starts looking like a diff --git a/README.md b/README.md index 834b140..25a4536 100644 --- a/README.md +++ b/README.md @@ -38,13 +38,16 @@ Live sample report: - **Clone detection** — function (CFG fingerprint), block (statement windows), and segment (report-only) clones - **Structural findings** — duplicated branch families, clone guard/exit divergence and clone-cohort drift (report-only) -- **Quality metrics** — cyclomatic complexity, coupling (`CBO`), cohesion (`LCOM4`), dependency cycles, dead code, health +- **Quality metrics** — cyclomatic complexity, coupling (`CBO`), cohesion (`LCOM4`), dependency cycles, dead code, + health score, and report-only `God Modules` profiling - **Baseline governance** — separates accepted **legacy** debt from **new regressions** and lets CI fail **only** on what changed - **Reports** — interactive HTML, deterministic JSON/TXT plus Markdown and SARIF projections from one canonical report - **MCP server** — optional read-only MCP surface for AI agents and IDEs, designed as a budget-aware guided control surface for agentic development +- **VS Code extension** — preview native client for CodeClone MCP with baseline-aware, triage-first structural + review inside the editor - **CI-first** — deterministic output, stable ordering, exit code contract, pre-commit support - **Fast** — incremental caching, parallel processing, warm-run optimization, and reproducible benchmark coverage @@ -185,6 +188,19 @@ Docs: · [MCP interface contract](https://orenlab.github.io/codeclone/book/20-mcp-interface/) +### VS Code Extension + +The repository also ships a preview VS Code extension in +[`extensions/vscode-codeclone/`](https://github.com/orenlab/codeclone/tree/main/extensions/vscode-codeclone). + +It is: + +- native VS Code first +- baseline-aware +- triage-first +- read-only with respect to repository state +- powered by the same `codeclone-mcp` contract surface + ## Configuration CodeClone can load project-level configuration from `pyproject.toml`: diff --git a/docs/README.md b/docs/README.md index 83f7248..6e9654a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,6 +45,7 @@ repository build: - [CLI behavior, modes, and UX](book/09-cli.md) - [MCP interface contract](book/20-mcp-interface.md) +- [VS Code extension contract](book/21-vscode-extension.md) - [HTML report rendering contract](book/10-html-render.md) ## System Properties @@ -68,6 +69,7 @@ repository build: - [Architecture narrative](architecture.md) - [CFG design and semantics](cfg.md) - [MCP integration for AI agents and clients](mcp.md) +- [VS Code extension usage guide](vscode-extension.md) - [SARIF integration for IDE/code-scanning use](sarif.md) - [Docs publishing and Pages workflow](publishing.md) diff --git a/docs/book/01-architecture-map.md b/docs/book/01-architecture-map.md index 4a85a63..2d7588b 100644 --- a/docs/book/01-architecture-map.md +++ b/docs/book/01-architecture-map.md @@ -13,6 +13,7 @@ Main ownership layers: - Contracts and persistence: baseline, metrics baseline, cache, exit semantics. - Report model and projections: canonical JSON + deterministic TXT/Markdown/SARIF + explainability facts. - MCP agent surface: read-only server layer over the same pipeline/report contracts. +- VS Code extension surface: native IDE client over the MCP layer and the same canonical report semantics. - Render layer: HTML rendering and template assets. ## Data model @@ -29,6 +30,7 @@ Main ownership layers: | Persistence | `codeclone/baseline.py`, `codeclone/metrics_baseline.py`, `codeclone/cache.py` | Baseline/cache trust/compat/integrity and atomic persistence | | Runtime orchestration | `codeclone/pipeline.py`, `codeclone/cli.py`, `codeclone/_cli_args.py`, `codeclone/_cli_paths.py`, `codeclone/_cli_summary.py`, `codeclone/_cli_config.py`, `codeclone/ui_messages.py` | CLI UX, stage orchestration, status handling, outputs, error markers | | MCP agent interface | `codeclone/mcp_service.py`, `codeclone/mcp_server.py` | Read-only MCP tools/resources over canonical analysis and report layers | +| VS Code extension | `extensions/vscode-codeclone/*` | Native VS Code control surface over MCP, with triage-first review and source-first drill-down | | Rendering | `codeclone/html_report.py`, `codeclone/_html_report/*`, `codeclone/_html_badges.py`, `codeclone/_html_js.py`, `codeclone/_html_escape.py`, `codeclone/_html_snippets.py`, `codeclone/templates.py` | HTML-only view layer over report data | Refs: @@ -43,6 +45,8 @@ Refs: recompute detection semantics. - MCP layer reuses current pipeline/report semantics and must not introduce a separate analysis truth path. +- The VS Code extension follows the same rule through MCP: it is a client + integration surface over canonical report semantics, not a separate analyzer. - MCP may ship task-specific slim projections (for example, summary-only metrics or inventory counts) as long as canonical report data remains the source of truth and richer detail stays reachable through dedicated tools/sections. @@ -124,6 +128,7 @@ Refs: | Cache trust and fail-open behavior | [07-cache.md](07-cache.md) | | Report schema and provenance | [08-report.md](08-report.md), [10-html-render.md](10-html-render.md) | | MCP agent surface | [20-mcp-interface.md](20-mcp-interface.md) | +| VS Code IDE surface | [21-vscode-extension.md](21-vscode-extension.md) | | Health score model | [15-health-score.md](15-health-score.md) | | Metrics gates and metrics baseline | [15-metrics-and-quality-gates.md](15-metrics-and-quality-gates.md) | | Dead-code liveness policy | [16-dead-code-contract.md](16-dead-code-contract.md) | diff --git a/docs/book/21-vscode-extension.md b/docs/book/21-vscode-extension.md new file mode 100644 index 0000000..4f1f95d --- /dev/null +++ b/docs/book/21-vscode-extension.md @@ -0,0 +1,129 @@ +# 21. VS Code Extension + +## Purpose + +Document the current contract and behavior of the VS Code extension shipped in +`extensions/vscode-codeclone/`. + +This chapter describes the extension as an interface layer over existing +CodeClone contracts. It does not define a second analysis truth model. + +## Position in the platform + +The VS Code extension is: + +- a native IDE client over `codeclone-mcp` +- read-only with respect to repository state +- baseline-aware and triage-first +- code-centered rather than report-dashboard-centered + +The extension exists to make the current CodeClone review workflow easier to +use inside the editor. It must not reinterpret report semantics or invent +findings outside canonical report and MCP payloads. + +## Source of truth + +The extension reads from: + +- MCP tool responses +- MCP session-local reviewed state +- canonical report semantics already exposed through MCP + +It must not: + +- run a second analysis engine in the extension layer +- recompute health or finding semantics independently +- mutate source files, baselines, cache, or report artifacts + +## Current surface + +The extension currently exposes three native VS Code views: + +- `Overview` +- `Hotspots` +- `Runs & Session` + +It also provides: + +- one workspace-level status bar item +- command palette entry points for analysis and review +- one onboarding walkthrough +- markdown detail panels for findings, remediation, help topics, setup help, + and report-only God Module detail + +## Workflow model + +The intended IDE path mirrors CodeClone MCP: + +1. `Analyze Workspace` or `Review Changes` +2. compact overview and priority review +3. review new regressions or production hotspots +4. reveal source +5. open canonical finding or remediation only when needed + +This is deliberately different from a lint-list model. The extension should +prefer guided review over broad enumeration. + +## State boundaries + +The extension must keep three state classes visibly separate: + +### Repository truth + +Comes from CodeClone analysis through MCP and canonical report semantics. + +### Current run + +Bounded by the active MCP session and the current latest run used by the +extension for a workspace. + +### Reviewed markers + +Session-local workflow markers only. + +Reviewed markers: + +- are in-memory only +- do not update baseline state +- do not rewrite findings +- do not change canonical report truth + +## Trust and runtime model + +The extension runs as a workspace extension and requires: + +- a trusted workspace +- local filesystem access +- local git access for changed-files review +- a local `codeclone-mcp` launcher, or an explicitly configured launcher + +For this reason: + +- untrusted workspaces are unsupported +- virtual workspaces are unsupported + +## UX rules + +The extension should preserve these product rules: + +- The cheapest useful path should be the most obvious path. +- First-run UX should lead to `Analyze Workspace`, not transport setup detail. +- Review actions should prefer opening source before opening deeper structured + detail. +- Report-only layers such as `God Modules` must remain visually distinct from + findings, gates, and health dimensions. +- The extension should minimize noise and avoid duplicating the HTML report in + the sidebar. + +## Relationship to other interfaces + +- CLI remains the scripting and CI surface. +- HTML remains the richest human report surface. +- MCP remains the read-only integration contract for agents and IDE clients. +- The VS Code extension is a guided IDE view over that MCP surface. + +## Non-guarantees + +- Exact view grouping and copy may evolve between beta releases. +- Internal client-side caching and view-model shaping may evolve as long as the + extension remains faithful to MCP and canonical report semantics. diff --git a/docs/book/README.md b/docs/book/README.md index 44c72d0..7c4c143 100644 --- a/docs/book/README.md +++ b/docs/book/README.md @@ -30,6 +30,7 @@ If a statement is not enforced by code/tests, it is explicitly marked as non-con - [09-cli.md](09-cli.md) - [20-mcp-interface.md](20-mcp-interface.md) +- [21-vscode-extension.md](21-vscode-extension.md) - [10-html-render.md](10-html-render.md) ### System properties diff --git a/docs/vscode-extension.md b/docs/vscode-extension.md new file mode 100644 index 0000000..00318c7 --- /dev/null +++ b/docs/vscode-extension.md @@ -0,0 +1,96 @@ +# VS Code Extension + +CodeClone ships a preview VS Code extension in +`extensions/vscode-codeclone/`. + +It is a native IDE surface over `codeclone-mcp` and is designed for +baseline-aware, triage-first structural review inside the editor. + +## What it does + +The extension helps you: + +- analyze the current workspace +- review changed files against a git diff +- focus on new regressions and production hotspots first +- jump directly to source locations +- open canonical finding or remediation detail only when needed + +It does not create a second truth model and it does not mutate the repository. + +## Install requirements + +The extension needs a local `codeclone-mcp` launcher. + +Recommended install for the preview extension: + +```bash +pip install --pre "codeclone[mcp]" +``` + +After the `2.0.0b4` line is stable, the regular install command is enough: + +```bash +pip install "codeclone[mcp]" +``` + +Verify the launcher: + +```bash +codeclone-mcp --help +``` + +## Main views + +### Overview + +Compact health, current run state, and next-best review action. + +### Hotspots + +Primary operational view for: + +- new regressions +- production hotspots +- changed-files findings +- report-only God Module candidates + +### Runs & Session + +Session-local state: + +- local server availability +- current run identity +- reviewed findings +- MCP help topics + +## First-run path + +1. Open the `CodeClone` view container. +2. Run `Analyze Workspace`. +3. Use `Review Priorities` or `Review Changes`. +4. Reveal source before opening deeper detail. + +If the launcher is missing, use `Setup Help` from the extension. + +## Trust model + +The extension requires a trusted local workspace and is not intended for +virtual workspaces. + +That is intentional: CodeClone reads repository contents, local git state, and +the local MCP launcher. + +## Source of truth + +The extension reads the same canonical analysis semantics already exposed by: + +- CodeClone CLI +- canonical report JSON +- CodeClone MCP + +For the underlying interface contract, see: + +- [MCP usage guide](mcp.md) +- [MCP interface contract](book/20-mcp-interface.md) +- [VS Code extension contract](book/21-vscode-extension.md) diff --git a/extensions/vscode-codeclone/.vscodeignore b/extensions/vscode-codeclone/.vscodeignore new file mode 100644 index 0000000..f2b7f94 --- /dev/null +++ b/extensions/vscode-codeclone/.vscodeignore @@ -0,0 +1,6 @@ +.vscode/** +.DS_Store +DESIGN.md +media/.thumb/** +media/icon-source.svg +*.vsix diff --git a/extensions/vscode-codeclone/CHANGELOG.md b/extensions/vscode-codeclone/CHANGELOG.md new file mode 100644 index 0000000..52ee2de --- /dev/null +++ b/extensions/vscode-codeclone/CHANGELOG.md @@ -0,0 +1,12 @@ +# Change Log + +## 0.2.0 + +- add a native preview VS Code extension for `codeclone-mcp` +- ship triage-first `Overview`, `Hotspots`, and `Runs & Session` views +- keep the extension read-only and canonical-report-first +- add setup guidance for local `codeclone-mcp` installation and launcher issues +- add guided review actions that prefer revealing source before opening deeper + detail +- surface report-only `God Modules` as a distinct IDE layer without promoting + them to health or gating truth diff --git a/extensions/vscode-codeclone/LICENSE b/extensions/vscode-codeclone/LICENSE new file mode 100644 index 0000000..df9d84d --- /dev/null +++ b/extensions/vscode-codeclone/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted from a particular Contributor are +reinstated (a) provisionally, unless and until such Contributor +explicitly and finally terminates Your grants, and (b) on an ongoing +basis, if such Contributor fails to notify You of the non-compliance by +some reasonable means prior to 60 days after You have come back into +compliance. Moreover, Your grants from a particular Contributor are +reinstated on an ongoing basis if such Contributor notifies You of the +non-compliance by some reasonable means, this is the first time You have +received notice of non-compliance with this License from such +Contributor, and You become compliant prior to 30 days after Your +receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/extensions/vscode-codeclone/README.md b/extensions/vscode-codeclone/README.md new file mode 100644 index 0000000..500869a --- /dev/null +++ b/extensions/vscode-codeclone/README.md @@ -0,0 +1,169 @@ +# CodeClone for VS Code + +CodeClone for VS Code is a native IDE surface for `codeclone-mcp`. + +It brings CodeClone's baseline-aware structural analysis into the editor without +creating a second truth model. The extension stays read-only with respect to +repository state and uses the same canonical report semantics as the CLI, HTML +report, and MCP server. + +This extension is published as a preview while the `2.0.0b4` line is still in +beta. + +## What it is for + +CodeClone inside VS Code is designed for: + +- triage-first structural review +- changed-files review against the current diff +- baseline-aware distinction between known debt and new regressions +- guided drill-down from hotspot to source, finding detail, and remediation + +It is not a generic linter panel and it does not try to duplicate the HTML +report inside the sidebar. + +## Product principles + +- **Canonical-report-first**: IDE views are projections over the same report + truth exposed by CodeClone. +- **Baseline-aware**: the extension prefers new and relevant findings over + broad full-repository listing. +- **Triage-first**: the default path is review, not enumeration. +- **Read-only**: the extension does not edit source files, baselines, caches, + or report artifacts. +- **Guided**: the extension should make the cheapest useful path the most + obvious path. + +## Install + +CodeClone for VS Code needs a local `codeclone-mcp` launcher. + +Recommended install for the preview extension: + +```bash +pip install --pre "codeclone[mcp]" +``` + +After the `2.0.0b4` line is stable, the regular install command is enough: + +```bash +pip install "codeclone[mcp]" +``` + +Verify the launcher: + +```bash +codeclone-mcp --help +``` + +## First run + +1. Open a trusted Python workspace. +2. Open the `CodeClone` view container. +3. Run `Analyze Workspace`. +4. Use `Review Priorities` or `Review Changes` as the first pass. + +If the local launcher is missing, use `Setup Help` from the view or command +palette. + +## Main surfaces + +### Overview + +Compact repository health, current run state, and next-best review action. + +### Hotspots + +The main operational view. It focuses on: + +- new regressions +- production hotspots +- changed-files findings +- report-only God Module candidates + +### Runs & Session + +Bounded MCP session state: + +- local server availability +- current run identity +- reviewed findings +- help topics + +Reviewed markers are session-local only and do not mutate the repository or the +canonical report. + +## Interaction model + +The extension is intentionally code-centered: + +- findings prefer `Reveal Source` as the default review action +- source locations are opened in the editor and softly highlighted +- deeper actions stay explicit: + - `Open Finding` + - `Show Remediation` + - `Mark Reviewed` + +This keeps the extension focused on review and refactoring flow instead of +opening raw JSON-like details by default. + +## Settings + +### `codeclone.mcp.command` + +Launcher used to start the local CodeClone server. Leave it as `auto` for the +default behavior. + +### `codeclone.mcp.args` + +Extra arguments passed to the configured launcher. + +### `codeclone.analysis.cachePolicy` + +Default cache policy for analysis requests. + +### `codeclone.analysis.changedDiffRef` + +Git revision used by `Review Changes`. + +### `codeclone.ui.showStatusBar` + +Show or hide the workspace-level status bar item. + +## Trust and workspace model + +This extension runs structural analysis against the current repository and uses +local filesystem and git state. For that reason: + +- untrusted workspaces are not supported +- virtual workspaces are not supported +- the extension runs as a workspace extension + +## Source of truth + +The extension is a client over `codeclone-mcp`. + +It does not: + +- recompute findings independently +- redefine health semantics +- mutate the repository +- rewrite baselines or reports + +If you need the contract-level documentation behind the extension behavior, see: + +- [CodeClone documentation](https://orenlab.github.io/codeclone/) +- [MCP usage guide](https://orenlab.github.io/codeclone/mcp/) +- [MCP interface contract](https://orenlab.github.io/codeclone/book/20-mcp-interface/) + +## Development + +Open this folder in VS Code and press `F5` to run an Extension Development +Host. + +Useful local checks: + +```bash +node --check src/mcpClient.js +node --check src/extension.js +``` diff --git a/extensions/vscode-codeclone/media/codeclone.svg b/extensions/vscode-codeclone/media/codeclone.svg new file mode 100644 index 0000000..1a5b1d7 --- /dev/null +++ b/extensions/vscode-codeclone/media/codeclone.svg @@ -0,0 +1,35 @@ + + + + + + diff --git a/extensions/vscode-codeclone/media/icon-source.svg b/extensions/vscode-codeclone/media/icon-source.svg new file mode 100644 index 0000000..1b5648f --- /dev/null +++ b/extensions/vscode-codeclone/media/icon-source.svg @@ -0,0 +1,35 @@ + + + + + + diff --git a/extensions/vscode-codeclone/media/icon.png b/extensions/vscode-codeclone/media/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b8bf9fdb445d927f98f6955c78b5263dc70f6716 GIT binary patch literal 1815 zcmbtVYfw{n7XRPN1ujT{AP8i^qy(W#D?yYE3P~tXpheIY0mnzOur0J~gaS%rLUI9l zl@_rMm1TIuVN@1~s62uwc^KG$iw)gf9)diA$Wt&N#KCoCzt~OaABcAUjP6ZLI|KH26sBO?rQ){#>0XFqBxaHW9)M~!>lnCr+aJ? zt#zVQkfrkvKl#ucu^IK=K0dJSC&^f8f3Ubt^_iYGGnD_HwZBMxEg@Adz1TRfIrQgN zI+NEY&JRUMkkN!PAK5jc@ND+Pp6}xxzfo3}-?nE03BLev+ZQ!cKZ-z+ z18;G-;s}N`QB5Q@LhX3F5%vfnwZCkl{JPXV6a(M}9yR3Yj;3aiKJ)xmE4!C|))ssi z4szr-1id-6*4tEA6unZ@g_CsMWKAS|!!rCeB)8 ze>d&V7*IBys+B}!wc3j=?&vQyN^ow*VkG!S>RQ`mkK2q+M>*MrX-6nR2a znaW9i_|2CzFz$Z)iH>)h3j_xsY7cupcJP4}WEc%F-}$b6L(%`86u#K}GL>f+vmdrj z)=CnHJbX_eh~v=L=3UghxnPnhRQ{?)Ui}S5>lh4IUf&0mFa9fL!@?+ik>KrD@EBoL zKREZAY}Ma6wCk;JlsicCTiZ>a0{)pt@ij&;MSh*A4kid{hivA3)x}4!yAj*z^hcno zGaprE{}HA{T)nLM+VWU~Vpld5Vf}EkW>M)N7`(g}_Cgu_Gva@Hr(&7@Kcju_zaJYg1E-;k_y(N5M`4@2f(WjkDBw@W7fWN| z{+J?DHrCzLHO0J+wlF(EY1|V6o3K8DuBxy)9yuNo&oR4w<~P zAlH9}tlD7ZV*fo?_r^ZNW1N5D^d9)05HJ7>u0DZ#qfO{9xQ8RRk-mUid^fICr$ zshGv!U|T9;q0dAFxprg&;0mf5Z6Ud2KQxz^hdSt5%@|XXv1{A*x!Ss98Wc*%ekpP2 zQY%QnOt?`T;1CIZ8Z&NG7YB5?T0)_=*=TN>hT!67I*u}aLW2mE|6KxbEo5}bF0NI$ z&`c2$7jd!r<^A$zElDZJ0gGRRNquUkeLFD$zULs7cqZxQ?6ES?^#h}00;lE;gGkol==7+`(iDfZAE@<*J$AQVId398aXB!^|!3BDE zsGQZE8x#RkiqPqMo&+{jo;VQLT9Kv7vcY^T&u zXAZ1)6cFLvEuE(?SAE);L4rYEnUz$>z27&EY}}R9cn~q~hd2krt&@SfZ)MiW2{9sN z$HgWTOf2cyaQ~bj^TxOT$Sg51#J6S8>koA36;*mJ;8nuXtoo?5F+(A-(uCvujwJT VhMdH{!4~h(u;7TG+n=y<{sW6`1sebW literal 0 HcmV?d00001 diff --git a/extensions/vscode-codeclone/package.json b/extensions/vscode-codeclone/package.json new file mode 100644 index 0000000..8c54698 --- /dev/null +++ b/extensions/vscode-codeclone/package.json @@ -0,0 +1,430 @@ +{ + "name": "codeclone", + "displayName": "CodeClone", + "description": "Baseline-aware, triage-first structural review for Python, powered by CodeClone MCP.", + "version": "0.2.0", + "preview": true, + "publisher": "orenlab", + "license": "MPL-2.0", + "repository": { + "type": "git", + "url": "https://github.com/orenlab/codeclone.git" + }, + "homepage": "https://orenlab.github.io/codeclone/", + "bugs": { + "url": "https://github.com/orenlab/codeclone/issues" + }, + "icon": "media/icon.png", + "keywords": [ + "python", + "code-quality", + "structural-analysis", + "mcp", + "ai-agents", + "baseline", + "refactoring" + ], + "markdown": "github", + "extensionKind": [ + "workspace" + ], + "capabilities": { + "untrustedWorkspaces": { + "supported": false, + "description": "CodeClone starts local structural analysis and should run only in trusted workspaces." + }, + "virtualWorkspaces": { + "supported": false, + "description": "CodeClone requires local filesystem and git access for repository analysis." + } + }, + "engines": { + "vscode": "^1.100.0" + }, + "scripts": { + "check": "node --check src/mcpClient.js && node --check src/extension.js" + }, + "categories": [ + "Linters", + "Testing", + "Other" + ], + "activationEvents": [ + "onView:codeclone.overview", + "onView:codeclone.hotspots", + "onView:codeclone.session", + "onCommand:codeclone.connectMcp", + "onCommand:codeclone.analyzeWorkspace", + "onCommand:codeclone.analyzeChangedFiles", + "onCommand:codeclone.refreshCurrentRun", + "onCommand:codeclone.openProductionTriage", + "onCommand:codeclone.showHelpTopic", + "onCommand:codeclone.openSetupHelp" + ], + "main": "./src/extension.js", + "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "codeclone", + "title": "CodeClone", + "icon": "media/codeclone.svg" + } + ] + }, + "views": { + "codeclone": [ + { + "id": "codeclone.overview", + "name": "Overview", + "type": "tree" + }, + { + "id": "codeclone.hotspots", + "name": "Hotspots", + "type": "tree" + }, + { + "id": "codeclone.session", + "name": "Runs & Session", + "type": "tree" + } + ] + }, + "viewsWelcome": [ + { + "view": "codeclone.overview", + "when": "!codeclone.connected && !codeclone.hasRun", + "contents": "Start with [Analyze Workspace](command:codeclone.analyzeWorkspace) to connect to CodeClone and create the first run.\n\nUse [Review Changes](command:codeclone.analyzeChangedFiles) for a review-focused pass.\n\nNeed local setup steps? Open [Setup Help](command:codeclone.openSetupHelp).\n\nUse [Verify Local Server](command:codeclone.connectMcp) only if you want to check the launcher manually." + }, + { + "view": "codeclone.overview", + "when": "codeclone.connected && !codeclone.hasRun", + "contents": "CodeClone is ready. Run [Analyze Workspace](command:codeclone.analyzeWorkspace) or [Review Changes](command:codeclone.analyzeChangedFiles).\n\nNeed a quick orientation? Open [workflow help](command:codeclone.showHelpTopic)." + }, + { + "view": "codeclone.hotspots", + "when": "!codeclone.hasRun", + "contents": "Hotspots appear after a run. Start with [Analyze Workspace](command:codeclone.analyzeWorkspace) or [Review Changes](command:codeclone.analyzeChangedFiles)." + }, + { + "view": "codeclone.session", + "when": "!codeclone.connected", + "contents": "Session state appears after the first connection. Run [Analyze Workspace](command:codeclone.analyzeWorkspace) to connect automatically, or [Verify Local Server](command:codeclone.connectMcp) to check the local launcher.\n\nIf CodeClone is not installed yet, open [Setup Help](command:codeclone.openSetupHelp)." + }, + { + "view": "codeclone.session", + "when": "codeclone.connected && !codeclone.hasRun", + "contents": "The local CodeClone server is ready. Run [Analyze Workspace](command:codeclone.analyzeWorkspace) to create the first run." + } + ], + "commands": [ + { + "command": "codeclone.connectMcp", + "title": "Verify Local Server", + "category": "CodeClone", + "icon": "$(plug)" + }, + { + "command": "codeclone.analyzeWorkspace", + "title": "Analyze Workspace", + "category": "CodeClone", + "icon": "$(run-all)" + }, + { + "command": "codeclone.analyzeChangedFiles", + "title": "Review Changes", + "category": "CodeClone", + "icon": "$(git-commit)" + }, + { + "command": "codeclone.refreshCurrentRun", + "title": "Refresh", + "category": "CodeClone", + "icon": "$(refresh)" + }, + { + "command": "codeclone.openProductionTriage", + "title": "Open Triage", + "category": "CodeClone", + "icon": "$(inspect)" + }, + { + "command": "codeclone.reviewPriorityQueue", + "title": "Review Priorities", + "category": "CodeClone", + "icon": "$(list-selection)" + }, + { + "command": "codeclone.openFinding", + "title": "Open Finding", + "category": "CodeClone", + "icon": "$(go-to-file)" + }, + { + "command": "codeclone.showRemediation", + "title": "Show Remediation", + "category": "CodeClone", + "icon": "$(wrench)" + }, + { + "command": "codeclone.markFindingReviewed", + "title": "Mark Reviewed", + "category": "CodeClone", + "icon": "$(pass)" + }, + { + "command": "codeclone.copyFindingId", + "title": "Copy Finding Id", + "category": "CodeClone", + "icon": "$(copy)" + }, + { + "command": "codeclone.revealFindingSource", + "title": "Reveal Source", + "category": "CodeClone", + "icon": "$(arrow-right)" + }, + { + "command": "codeclone.showHelpTopic", + "title": "Open Help Topic", + "category": "CodeClone", + "icon": "$(question)" + }, + { + "command": "codeclone.openSetupHelp", + "title": "Setup Help", + "category": "CodeClone", + "icon": "$(tools)" + }, + { + "command": "codeclone.openGodModule", + "title": "Open Candidate Detail", + "category": "CodeClone", + "icon": "$(symbol-module)" + }, + { + "command": "codeclone.openOverview", + "title": "Open Overview", + "category": "CodeClone" + }, + { + "command": "codeclone.clearSessionState", + "title": "Clear Session", + "category": "CodeClone", + "icon": "$(clear-all)" + } + ], + "menus": { + "commandPalette": [ + { + "command": "codeclone.openFinding", + "when": "false" + }, + { + "command": "codeclone.showRemediation", + "when": "false" + }, + { + "command": "codeclone.markFindingReviewed", + "when": "false" + }, + { + "command": "codeclone.copyFindingId", + "when": "false" + }, + { + "command": "codeclone.revealFindingSource", + "when": "false" + }, + { + "command": "codeclone.openGodModule", + "when": "false" + } + ], + "view/title": [ + { + "command": "codeclone.analyzeWorkspace", + "when": "view == codeclone.overview", + "group": "navigation@1" + }, + { + "command": "codeclone.analyzeChangedFiles", + "when": "view == codeclone.overview", + "group": "navigation@2" + }, + { + "command": "codeclone.reviewPriorityQueue", + "when": "view == codeclone.overview && codeclone.hasRun", + "group": "navigation@3" + }, + { + "command": "codeclone.refreshCurrentRun", + "when": "view == codeclone.overview && codeclone.hasRun", + "group": "navigation@4" + }, + { + "command": "codeclone.connectMcp", + "when": "view == codeclone.overview && !codeclone.connected", + "group": "navigation@5" + }, + { + "command": "codeclone.reviewPriorityQueue", + "when": "view == codeclone.hotspots && codeclone.hasRun", + "group": "navigation@1" + }, + { + "command": "codeclone.refreshCurrentRun", + "when": "view == codeclone.hotspots && codeclone.hasRun", + "group": "navigation@2" + }, + { + "command": "codeclone.openProductionTriage", + "when": "view == codeclone.hotspots && codeclone.hasRun", + "group": "navigation@3" + }, + { + "command": "codeclone.analyzeWorkspace", + "when": "view == codeclone.hotspots && !codeclone.hasRun", + "group": "navigation@4" + }, + { + "command": "codeclone.analyzeChangedFiles", + "when": "view == codeclone.hotspots", + "group": "navigation@5" + }, + { + "command": "codeclone.connectMcp", + "when": "view == codeclone.session && !codeclone.connected", + "group": "navigation@1" + }, + { + "command": "codeclone.openSetupHelp", + "when": "view == codeclone.session && !codeclone.connected", + "group": "navigation@2" + }, + { + "command": "codeclone.clearSessionState", + "when": "view == codeclone.session && codeclone.hasRun", + "group": "navigation@3" + }, + { + "command": "codeclone.showHelpTopic", + "when": "view == codeclone.session", + "group": "navigation@4" + } + ], + "view/item/context": [ + { + "command": "codeclone.revealFindingSource", + "when": "viewItem == codeclone.finding", + "group": "inline@1" + }, + { + "command": "codeclone.showRemediation", + "when": "viewItem == codeclone.finding", + "group": "inline@2" + }, + { + "command": "codeclone.openFinding", + "when": "viewItem == codeclone.finding", + "group": "navigation@1" + }, + { + "command": "codeclone.markFindingReviewed", + "when": "viewItem == codeclone.finding", + "group": "navigation@2" + }, + { + "command": "codeclone.copyFindingId", + "when": "viewItem == codeclone.finding", + "group": "navigation@3" + }, + { + "command": "codeclone.showHelpTopic", + "when": "viewItem == codeclone.helpTopic", + "group": "inline@1" + }, + { + "command": "codeclone.openGodModule", + "when": "viewItem == codeclone.godModule", + "group": "inline@1" + } + ] + }, + "walkthroughs": [ + { + "id": "codeclone.gettingStarted", + "title": "Get started with CodeClone", + "description": "Run your first structural analysis, review priorities, and inspect changed files.", + "steps": [ + { + "id": "codeclone.analyzeWorkspace", + "title": "Analyze the current workspace", + "description": "Create a fresh CodeClone run for the selected workspace folder.", + "media": { + "path": "media/codeclone.svg", + "altText": "CodeClone icon" + }, + "completionEvents": [ + "onCommand:codeclone.analyzeWorkspace" + ] + }, + { + "id": "codeclone.reviewPriorities", + "title": "Review the next best hotspot", + "description": "Use the guided priority queue instead of broad listing.", + "completionEvents": [ + "onCommand:codeclone.reviewPriorityQueue" + ] + }, + { + "id": "codeclone.reviewChanged", + "title": "Review changed files", + "description": "Run changed-files analysis against HEAD and inspect touched findings only.", + "completionEvents": [ + "onCommand:codeclone.analyzeChangedFiles" + ] + } + ] + } + ], + "configuration": { + "title": "CodeClone", + "properties": { + "codeclone.mcp.command": { + "type": "string", + "default": "auto", + "description": "Command used to launch the local CodeClone MCP server. Use 'auto' to detect a sensible local default." + }, + "codeclone.mcp.args": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Additional command-line arguments passed to the CodeClone MCP launcher." + }, + "codeclone.analysis.cachePolicy": { + "type": "string", + "enum": [ + "reuse", + "off" + ], + "default": "reuse", + "description": "Default cache policy for workspace analysis requests." + }, + "codeclone.analysis.changedDiffRef": { + "type": "string", + "default": "HEAD", + "description": "Git revision used for changed-files analysis." + }, + "codeclone.ui.showStatusBar": { + "type": "boolean", + "default": true, + "description": "Show a single workspace-level CodeClone status bar item." + } + } + } + } +} diff --git a/extensions/vscode-codeclone/src/extension.js b/extensions/vscode-codeclone/src/extension.js new file mode 100644 index 0000000..41e1453 --- /dev/null +++ b/extensions/vscode-codeclone/src/extension.js @@ -0,0 +1,2109 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const vscode = require("vscode"); + +const { CodeCloneMcpClient, MCPClientError } = require("./mcpClient"); + +const HELP_TOPICS = [ + "workflow", + "suppressions", + "baseline", + "latest_runs", + "review_state", + "changed_scope", +]; + +const HOTSPOT_GROUPS = [ + { id: "newRegressions", label: "New Regressions", icon: "diff-added" }, + { id: "productionHotspots", label: "Production Hotspots", icon: "target" }, + { id: "changedFiles", label: "Changed Files", icon: "git-commit" }, + { id: "godModules", label: "God Modules", icon: "symbol-module" }, +]; + +function number(value) { + if (typeof value !== "number" || Number.isNaN(value)) { + return "0"; + } + return value.toLocaleString("en-US"); +} + +function decimal(value, digits = 2) { + if (typeof value !== "number" || Number.isNaN(value)) { + return "0.00"; + } + return value.toFixed(digits); +} + +function compactDecimal(value) { + if (typeof value !== "number" || Number.isNaN(value)) { + return "0"; + } + return value.toFixed(2).replace(/\.?0+$/, ""); +} + +function capitalize(value) { + if (!value) { + return ""; + } + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function formatBooleanWord(value) { + return value ? "yes" : "no"; +} + +function formatBaselineState(payload) { + const entry = safeObject(payload); + const status = String(entry.status || "unknown"); + return entry.trusted ? `${status} · trusted` : `${status} · untrusted`; +} + +function formatCacheSummary(payload) { + const entry = safeObject(payload); + const usage = entry.used ? "used" : "fresh"; + const freshness = entry.freshness ? String(entry.freshness) : "unknown"; + return `${usage} · ${freshness}`; +} + +function formatRunScope(value) { + return value === "changed" ? "changed files" : "workspace"; +} + +function formatSourceKindSummary(value) { + const entries = Object.entries(safeObject(value)) + .filter(([, count]) => typeof count === "number" && count > 0) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)); + if (entries.length === 0) { + return "No production findings by source kind."; + } + return entries + .map(([key, count]) => `${capitalize(key)} ${count}`) + .join(" · "); +} + +function sameLaunchSpec(left, right) { + if (!left || !right) { + return false; + } + const leftArgs = Array.isArray(left.args) ? left.args : []; + const rightArgs = Array.isArray(right.args) ? right.args : []; + return ( + left.command === right.command && + left.cwd === right.cwd && + JSON.stringify(leftArgs) === JSON.stringify(rightArgs) + ); +} + +function formatSeverity(value) { + return capitalize(String(value || "info")); +} + +function formatNovelty(value) { + const novelty = String(value || "").trim(); + if (!novelty) { + return ""; + } + return capitalize(novelty); +} + +function formatKind(value) { + const kind = String(value || ""); + switch (kind) { + case "function_clone": + return "Function clone"; + case "block_clone": + return "Block clone"; + case "segment_clone": + return "Segment clone"; + case "class_hotspot": + return "Class hotspot"; + case "module_hotspot": + return "Module hotspot"; + case "duplicated_branches": + return "Duplicated branches"; + default: + return capitalize(kind.replace(/_/g, " ")); + } +} + +function findingIcon(severity) { + switch (String(severity || "").toLowerCase()) { + case "critical": + return new vscode.ThemeIcon( + "error", + new vscode.ThemeColor("problemsErrorIcon.foreground") + ); + case "warning": + return new vscode.ThemeIcon( + "warning", + new vscode.ThemeColor("problemsWarningIcon.foreground") + ); + default: + return new vscode.ThemeIcon( + "info", + new vscode.ThemeColor("problemsInfoIcon.foreground") + ); + } +} + +function safeArray(value) { + return Array.isArray(value) ? value : []; +} + +function safeObject(value) { + return value && typeof value === "object" ? value : {}; +} + +function normalizeLocations(value) { + if (!Array.isArray(value)) { + return []; + } + return value + .map((entry) => { + if (typeof entry === "string") { + const match = entry.match(/^(.+):(\d+)$/); + return { + path: match ? match[1] : entry, + line: match ? Number(match[2]) : null, + end_line: null, + symbol: null, + }; + } + if (entry && typeof entry === "object") { + return { + path: entry.path ? String(entry.path) : "", + line: + typeof entry.line === "number" ? entry.line : null, + end_line: + typeof entry.end_line === "number" ? entry.end_line : null, + symbol: entry.symbol ? String(entry.symbol) : null, + }; + } + return null; + }) + .filter(Boolean); +} + +function firstLocation(value) { + const locations = normalizeLocations(value); + return locations.length > 0 ? locations[0] : null; +} + +function looksLikeCodeCloneRepo(folderPath) { + return ( + fs.existsSync(path.join(folderPath, "pyproject.toml")) && + fs.existsSync(path.join(folderPath, "codeclone", "mcp_server.py")) + ); +} + +function markdownBulletList(values) { + return values.map((value) => `- ${value}`).join("\n"); +} + +function renderHelpMarkdown(topic, payload) { + const lines = [ + `# CodeClone MCP Help: ${topic}`, + "", + payload.summary || "", + "", + "## Key points", + markdownBulletList(safeArray(payload.key_points)), + "", + "## Recommended tools", + markdownBulletList(safeArray(payload.recommended_tools).map((tool) => `\`${tool}\``)), + ]; + const warnings = safeArray(payload.warnings); + if (warnings.length > 0) { + lines.push("", "## Warnings", markdownBulletList(warnings)); + } + const antiPatterns = safeArray(payload.anti_patterns); + if (antiPatterns.length > 0) { + lines.push("", "## Anti-patterns", markdownBulletList(antiPatterns)); + } + const docLinks = safeArray(payload.doc_links); + if (docLinks.length > 0) { + lines.push( + "", + "## Docs", + markdownBulletList( + docLinks.map((entry) => `[${entry.title}](${entry.url})`) + ) + ); + } + return lines.join("\n"); +} + +function renderSetupMarkdown() { + return [ + "# Set Up CodeClone MCP", + "", + "The VS Code extension needs a local `codeclone-mcp` launcher.", + "", + "## Recommended install for the preview extension", + "", + "```bash", + "pip install --pre \"codeclone[mcp]\"", + "```", + "", + "## Verify the launcher", + "", + "```bash", + "codeclone-mcp --help", + "```", + "", + "## If CodeClone lives in a custom environment", + "", + "- Set `codeclone.mcp.command` to the launcher you want VS Code to use.", + "- Set `codeclone.mcp.args` if that launcher needs extra arguments.", + "- In the CodeClone repository itself, the extension can also fall back to `uv run codeclone-mcp`.", + "", + "## What the extension expects", + "", + "- A local `codeclone-mcp` command, or an explicit custom launcher in settings.", + "- MCP support installed, not only the base `codeclone` package.", + "", + "Once that is ready, run `Analyze Workspace` again.", + ].join("\n"); +} + +function renderFindingMarkdown(payload) { + const remediation = safeObject(payload.remediation); + const locations = normalizeLocations(payload.locations); + const spread = safeObject(payload.spread); + const lines = [ + `# ${formatKind(payload.kind)}`, + "", + `- Finding id: \`${payload.id}\``, + `- Severity: ${formatSeverity(payload.severity)}`, + `- Scope: ${payload.scope || "unknown"}`, + `- Priority: ${compactDecimal(payload.priority)}`, + `- Count: ${payload.count || 0}`, + `- Spread: ${spread.files || 0} files / ${spread.functions || 0} functions`, + ]; + if (locations.length > 0) { + lines.push( + "", + "## Locations", + markdownBulletList( + locations.map((location) => { + const range = + location.line !== null && location.end_line !== null + ? `${location.line}-${location.end_line}` + : location.line !== null + ? `${location.line}` + : "?"; + const symbol = location.symbol ? ` — \`${location.symbol}\`` : ""; + return `\`${location.path}:${range}\`${symbol}`; + }) + ) + ); + } + if (Object.keys(remediation).length > 0) { + lines.push("", "## Remediation"); + if (remediation.shape) { + lines.push("", remediation.shape); + } + if (remediation.why_now) { + lines.push("", `Why now: ${remediation.why_now}`); + } + if (remediation.effort || remediation.risk) { + lines.push( + "", + `Effort: ${remediation.effort || "unknown"} · Risk: ${remediation.risk || "unknown"}` + ); + } + const steps = safeArray(remediation.steps); + if (steps.length > 0) { + lines.push("", "### Steps", markdownBulletList(steps)); + } + } + return lines.join("\n"); +} + +function renderRemediationMarkdown(payload) { + const remediation = safeObject(payload.remediation); + const lines = [ + `# Remediation: \`${payload.finding_id}\``, + "", + ]; + if (remediation.shape) { + lines.push(remediation.shape, ""); + } + lines.push( + `- Effort: ${remediation.effort || "unknown"}`, + `- Risk: ${remediation.risk || "unknown"}` + ); + if (remediation.why_now) { + lines.push("", `Why now: ${remediation.why_now}`); + } + const steps = safeArray(remediation.steps); + if (steps.length > 0) { + lines.push("", "## Steps", markdownBulletList(steps)); + } + return lines.join("\n"); +} + +function renderTriageMarkdown(state) { + const summary = safeObject(state.latestSummary); + const triage = safeObject(state.latestTriage); + const health = safeObject(summary.health); + const findings = safeObject(summary.findings); + const triageFindings = safeObject(triage.findings); + const topHotspots = safeObject(triage.top_hotspots); + const topSuggestions = safeObject(triage.top_suggestions); + const items = safeArray(topHotspots.items); + const suggestions = safeArray(topSuggestions.items); + const lines = [ + `# CodeClone Production Triage`, + "", + `- Run: \`${state.currentRunId || "n/a"}\``, + `- Workspace: \`${state.folder.name}\``, + `- Health: ${health.score || 0}/${health.grade || "?"}`, + `- Findings: ${findings.total || 0} total · ${findings.production || 0} production`, + `- Source kinds: ${formatSourceKindSummary(triageFindings.by_source_kind)}`, + ]; + if (items.length > 0) { + lines.push( + "", + "## Top production hotspots", + markdownBulletList( + items.map( + (item) => + `\`${item.id}\` — ${formatKind(item.kind)} · ${formatSeverity( + item.severity + )} · ${item.scope || "unknown"} · priority ${compactDecimal(item.priority)}` + ) + ) + ); + } else { + lines.push("", "## Top production hotspots", "", "None."); + } + if (suggestions.length > 0) { + lines.push( + "", + "## Top suggestions", + markdownBulletList( + suggestions.map((item) => `\`${item.id}\` — ${item.summary || "Suggestion"}`) + ) + ); + } + return lines.join("\n"); +} + +function renderGodModuleMarkdown(item) { + const reasons = safeArray(item.candidate_reasons); + const lines = [ + `# God Module Candidate`, + "", + `- Path: \`${item.path}\``, + `- Module: \`${item.module}\``, + `- Source kind: ${item.source_kind || "unknown"}`, + `- Score: ${decimal(item.score)}`, + `- LOC: ${number(item.loc)}`, + `- Callables: ${item.callable_count || 0}`, + `- Complexity total / max: ${item.complexity_total || 0} / ${item.complexity_max || 0}`, + `- Fan-in / fan-out: ${item.fan_in || 0} / ${item.fan_out || 0}`, + `- Total dependencies: ${item.total_deps || 0}`, + `- Import edges / reimport edges: ${item.import_edges || 0} / ${item.reimport_edges || 0}`, + `- Reimport ratio: ${decimal(item.reimport_ratio)}`, + `- Instability: ${decimal(item.instability)}`, + `- Hub balance: ${decimal(item.hub_balance)}`, + ]; + if (reasons.length > 0) { + lines.push("", "## Candidate reasons", markdownBulletList(reasons)); + } + return lines.join("\n"); +} + +class WorkspaceState { + constructor(folder) { + this.folder = folder; + this.currentRunId = null; + this.latestSummary = null; + this.metricsSummary = null; + this.latestTriage = null; + this.changedSummary = null; + this.reviewed = []; + this.lastScope = "workspace"; + this.lastUpdatedAt = null; + this.groupCache = new Map(); + } +} + +class BaseTreeProvider { + constructor(controller) { + this.controller = controller; + this.emitter = new vscode.EventEmitter(); + this.onDidChangeTreeData = this.emitter.event; + } + + refresh() { + this.emitter.fire(undefined); + } + + dispose() { + this.emitter.dispose(); + } +} + +class OverviewTreeProvider extends BaseTreeProvider { + async getTreeItem(node) { + return this.controller.createTreeItem(node); + } + + async getChildren(node) { + return this.controller.getOverviewChildren(node); + } +} + +class HotspotsTreeProvider extends BaseTreeProvider { + async getTreeItem(node) { + return this.controller.createTreeItem(node); + } + + async getChildren(node) { + return this.controller.getHotspotsChildren(node); + } +} + +class SessionTreeProvider extends BaseTreeProvider { + async getTreeItem(node) { + return this.controller.createTreeItem(node); + } + + async getChildren(node) { + return this.controller.getSessionChildren(node); + } +} + +class CodeCloneController { + constructor(context) { + this.context = context; + this.outputChannel = vscode.window.createOutputChannel("CodeClone"); + this.client = new CodeCloneMcpClient(this.outputChannel); + this.states = new Map(); + this.revealDecoration = vscode.window.createTextEditorDecorationType({ + isWholeLine: true, + borderWidth: "1px", + borderStyle: "solid", + borderColor: new vscode.ThemeColor("editor.wordHighlightStrongBorder"), + backgroundColor: new vscode.ThemeColor( + "editor.wordHighlightStrongBackground" + ), + }); + this.revealDecorationTimeout = null; + this.connectionInfo = { + connected: false, + serverInfo: null, + toolCount: 0, + launchSpec: null, + }; + this.statusBar = vscode.window.createStatusBarItem( + "codeclone.status", + vscode.StatusBarAlignment.Left, + 10 + ); + this.statusBar.command = "codeclone.openOverview"; + this.overviewProvider = new OverviewTreeProvider(this); + this.hotspotsProvider = new HotspotsTreeProvider(this); + this.sessionProvider = new SessionTreeProvider(this); + this.overviewView = vscode.window.createTreeView("codeclone.overview", { + treeDataProvider: this.overviewProvider, + showCollapseAll: false, + }); + this.hotspotsView = vscode.window.createTreeView("codeclone.hotspots", { + treeDataProvider: this.hotspotsProvider, + showCollapseAll: true, + }); + this.sessionView = vscode.window.createTreeView("codeclone.session", { + treeDataProvider: this.sessionProvider, + showCollapseAll: false, + }); + this.client.on("state", (state) => { + this.connectionInfo.connected = Boolean(state.connected); + this.connectionInfo.serverInfo = state.connected + ? state.serverInfo || null + : null; + this.connectionInfo.toolCount = state.connected + ? safeArray(state.toolNames).length + : 0; + this.connectionInfo.launchSpec = state.connected + ? state.launchSpec || this.connectionInfo.launchSpec + : null; + this.updateContextKeys(); + this.updateStatusBar(); + this.refreshAllViews(); + }); + this.client.on("exit", async () => { + await vscode.window.showWarningMessage( + "The local CodeClone server disconnected. Run Analyze Workspace to reconnect and refresh the current workspace." + ); + }); + context.subscriptions.push( + this.outputChannel, + this.statusBar, + this.revealDecoration, + this.overviewProvider, + this.hotspotsProvider, + this.sessionProvider, + this.overviewView, + this.hotspotsView, + this.sessionView, + { + dispose: () => { + void this.client.dispose(); + }, + } + ); + this.registerCommands(); + this.updateContextKeys(); + this.updateStatusBar(); + this.updateViewChrome(); + } + + registerCommands() { + const subscriptions = [ + vscode.commands.registerCommand("codeclone.connectMcp", () => + this.connectMcp() + ), + vscode.commands.registerCommand("codeclone.analyzeWorkspace", (arg) => + this.analyzeWorkspace(arg) + ), + vscode.commands.registerCommand("codeclone.analyzeChangedFiles", (arg) => + this.analyzeChangedFiles(arg) + ), + vscode.commands.registerCommand("codeclone.refreshCurrentRun", () => + this.refreshCurrentRun() + ), + vscode.commands.registerCommand("codeclone.openProductionTriage", () => + this.openProductionTriage() + ), + vscode.commands.registerCommand("codeclone.reviewPriorityQueue", () => + this.reviewPriorityQueue() + ), + vscode.commands.registerCommand("codeclone.reviewFinding", (node) => + this.reviewFinding(node) + ), + vscode.commands.registerCommand("codeclone.openFinding", (node) => + this.openFinding(node) + ), + vscode.commands.registerCommand("codeclone.showRemediation", (node) => + this.showRemediation(node) + ), + vscode.commands.registerCommand("codeclone.markFindingReviewed", (node) => + this.markFindingReviewed(node) + ), + vscode.commands.registerCommand("codeclone.copyFindingId", (node) => + this.copyFindingId(node) + ), + vscode.commands.registerCommand("codeclone.revealFindingSource", (node) => + this.revealFindingSource(node) + ), + vscode.commands.registerCommand("codeclone.showHelpTopic", (arg) => + this.showHelpTopic(arg) + ), + vscode.commands.registerCommand("codeclone.openSetupHelp", () => + this.openSetupHelp() + ), + vscode.commands.registerCommand("codeclone.openOverview", () => + this.openOverview() + ), + vscode.commands.registerCommand("codeclone.clearSessionState", () => + this.clearSessionState() + ), + vscode.commands.registerCommand("codeclone.openGodModule", (node) => + this.openGodModule(node) + ), + vscode.commands.registerCommand("codeclone.reviewGodModule", (node) => + this.reviewGodModule(node) + ), + ]; + this.context.subscriptions.push(...subscriptions); + } + + getWorkspaceState(folder) { + const key = folder.uri.toString(); + if (!this.states.has(key)) { + this.states.set(key, new WorkspaceState(folder)); + } + return this.states.get(key); + } + + getPrimaryState() { + const activeFolder = this.getPreferredFolder(); + if (activeFolder) { + const activeState = this.states.get(activeFolder.uri.toString()) || null; + if (activeState) { + return activeState; + } + } + const analyzed = Array.from(this.states.values()).find( + (state) => state.latestSummary !== null + ); + return analyzed || null; + } + + getPreferredFolder() { + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor) { + const folder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri); + if (folder) { + return folder; + } + } + return vscode.workspace.workspaceFolders?.[0] || null; + } + + async pickWorkspaceFolder(placeHolder) { + const folders = vscode.workspace.workspaceFolders || []; + if (folders.length === 0) { + await vscode.window.showErrorMessage( + "Open a workspace folder before using CodeClone." + ); + return null; + } + if (folders.length === 1) { + return folders[0]; + } + const picked = await vscode.window.showQuickPick( + folders.map((folder) => ({ + label: folder.name, + description: folder.uri.fsPath, + folder, + })), + { + placeHolder, + } + ); + return picked ? picked.folder : null; + } + + async resolveFolderFromArg(arg, prompt) { + if (arg && arg.workspaceKey && this.states.has(arg.workspaceKey)) { + return this.states.get(arg.workspaceKey).folder; + } + return this.pickWorkspaceFolder(prompt); + } + + resolveLaunchSpec(folder) { + const config = vscode.workspace.getConfiguration("codeclone", folder.uri); + const configuredCommand = config.get("mcp.command", "auto"); + const configuredArgs = config.get("mcp.args", []); + if (configuredCommand && configuredCommand !== "auto") { + return { + command: configuredCommand, + args: Array.isArray(configuredArgs) ? configuredArgs : [], + cwd: folder.uri.fsPath, + }; + } + return { + command: "codeclone-mcp", + args: Array.isArray(configuredArgs) ? configuredArgs : [], + cwd: folder.uri.fsPath, + fallback: looksLikeCodeCloneRepo(folder.uri.fsPath) + ? { + command: "uv", + args: ["run", "codeclone-mcp"], + cwd: folder.uri.fsPath, + } + : null, + }; + } + + async ensureConnected(folder) { + const launchSpec = this.resolveLaunchSpec(folder); + if (this.client.isConnected() && this.connectionInfo.launchSpec) { + const activeLaunchSpec = this.connectionInfo.launchSpec; + if ( + sameLaunchSpec(activeLaunchSpec, launchSpec) || + sameLaunchSpec(activeLaunchSpec, launchSpec.fallback) + ) { + const snapshot = this.client.getConnectionSnapshot(); + this.connectionInfo.connected = snapshot.connected; + this.connectionInfo.serverInfo = snapshot.serverInfo; + this.connectionInfo.toolCount = snapshot.toolNames.length; + this.connectionInfo.launchSpec = snapshot.launchSpec; + return snapshot; + } + } + let effectiveLaunchSpec = launchSpec; + let connection; + try { + connection = await this.client.connect(launchSpec); + } catch (error) { + if (launchSpec.fallback) { + this.outputChannel.appendLine( + "[codeclone] primary MCP launch failed, trying fallback launcher." + ); + effectiveLaunchSpec = launchSpec.fallback; + connection = await this.client.connect(effectiveLaunchSpec); + } else { + throw error; + } + } + this.connectionInfo.connected = true; + this.connectionInfo.serverInfo = connection.serverInfo || null; + this.connectionInfo.toolCount = connection.toolNames.length; + this.connectionInfo.launchSpec = effectiveLaunchSpec; + this.updateContextKeys(); + this.updateStatusBar(); + return connection; + } + + async connectMcp() { + const folder = await this.pickWorkspaceFolder("Select a workspace for CodeClone MCP"); + if (!folder) { + return; + } + try { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Verifying local CodeClone server", + }, + async () => { + await this.ensureConnected(folder); + } + ); + await vscode.window.showInformationMessage( + `Local CodeClone server is ready (${this.connectionInfo.toolCount} tools).` + ); + this.refreshAllViews(); + } catch (error) { + this.handleError(error, "Could not connect to CodeClone MCP."); + } + } + + async analyzeWorkspace(arg) { + const folder = await this.resolveFolderFromArg( + arg, + "Select a workspace to analyze with CodeClone" + ); + if (!folder) { + return; + } + await this.runAnalysis(folder, false); + } + + async analyzeChangedFiles(arg) { + const folder = await this.resolveFolderFromArg( + arg, + "Select a workspace for changed-files analysis" + ); + if (!folder) { + return; + } + await this.runAnalysis(folder, true); + } + + async refreshCurrentRun() { + const state = this.getPrimaryState(); + if (!state) { + await this.analyzeWorkspace(); + return; + } + await this.runAnalysis(state.folder, state.lastScope === "changed"); + } + + async runAnalysis(folder, changedMode) { + const state = this.getWorkspaceState(folder); + const config = vscode.workspace.getConfiguration("codeclone", folder.uri); + const cachePolicy = config.get("analysis.cachePolicy", "reuse"); + const diffRef = config.get("analysis.changedDiffRef", "HEAD"); + const title = changedMode + ? `CodeClone: Analyzing changed files in ${folder.name}` + : `CodeClone: Analyzing ${folder.name}`; + const previousText = this.statusBar.text; + this.statusBar.text = "$(loading~spin) CodeClone analyzing"; + this.statusBar.show(); + try { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title, + }, + async () => { + await this.ensureConnected(folder); + const analysisPayload = changedMode + ? await this.client.callTool("analyze_changed_paths", { + root: folder.uri.fsPath, + git_diff_ref: diffRef, + cache_policy: cachePolicy, + }) + : await this.client.callTool("analyze_repository", { + root: folder.uri.fsPath, + cache_policy: cachePolicy, + }); + const runId = String(analysisPayload.run_id); + const summary = await this.client.callTool("get_run_summary", { + run_id: runId, + }); + const triage = await this.client.callTool("get_production_triage", { + run_id: runId, + max_hotspots: 5, + max_suggestions: 5, + }); + const metrics = await this.client.callTool("get_report_section", { + run_id: runId, + section: "metrics", + }); + const reviewed = await this.client.callTool("list_reviewed_findings", { + run_id: runId, + }); + state.currentRunId = runId; + state.latestSummary = summary; + state.latestTriage = triage; + state.metricsSummary = metrics.summary || metrics; + state.changedSummary = changedMode ? analysisPayload : null; + state.reviewed = safeArray(reviewed.items); + state.lastScope = changedMode ? "changed" : "workspace"; + state.lastUpdatedAt = new Date(); + state.groupCache.clear(); + } + ); + this.updateContextKeys(); + this.updateStatusBar(); + this.refreshAllViews(); + await this.openOverview(); + } catch (error) { + this.handleError(error, "CodeClone analysis failed."); + } finally { + if (!this.connectionInfo.connected) { + this.statusBar.text = "CodeClone disconnected"; + } else if (previousText) { + this.updateStatusBar(); + } + } + } + + async openOverview() { + await vscode.commands.executeCommand("workbench.view.extension.codeclone"); + await vscode.commands.executeCommand("codeclone.overview.focus"); + } + + async focusHotspots() { + await vscode.commands.executeCommand("workbench.view.extension.codeclone"); + await vscode.commands.executeCommand("codeclone.hotspots.focus"); + } + + async openProductionTriage() { + const state = this.getPrimaryState(); + if (!state || !state.latestTriage) { + await vscode.window.showInformationMessage( + "Run Analyze Workspace first to open production triage." + ); + return; + } + await this.showMarkdownDocument(renderTriageMarkdown(state)); + } + + async reviewPriorityQueue() { + const state = this.getPrimaryState(); + if (!state || !state.currentRunId) { + await vscode.window.showInformationMessage( + "Run Analyze Workspace first to review CodeClone priorities." + ); + return; + } + try { + await this.ensureConnected(state.folder); + const queue = await this.getPriorityQueueNodes(state); + if (queue.length === 0) { + await vscode.window.showInformationMessage( + "No new or production hotspots need review in the current run." + ); + return; + } + const picked = await vscode.window.showQuickPick( + queue.map((node) => ({ + label: node.label, + description: node.description, + detail: node.tooltip, + node, + })), + { + placeHolder: "Select the next CodeClone hotspot to review", + matchOnDetail: true, + } + ); + if (picked) { + await this.reviewFinding(picked.node); + } + } catch (error) { + this.handleError(error, "Could not load the CodeClone review queue."); + } + } + + async reviewFinding(node) { + if (!node || !node.findingId || !node.runId) { + return; + } + const picked = await vscode.window.showQuickPick( + [ + { + label: "Reveal source", + description: "Recommended", + action: "reveal", + }, + { + label: "Open finding detail", + description: "Canonical finding view", + action: "detail", + }, + { + label: "Show remediation", + description: "Suggested next step", + action: "remediation", + }, + { + label: "Mark as reviewed", + description: "Hide from review-focused lists", + action: "reviewed", + }, + ], + { + placeHolder: `What do you want to do with ${node.findingId}?`, + } + ); + if (!picked) { + return; + } + if (picked.action === "reveal") { + await this.revealFindingSource(node); + return; + } + if (picked.action === "detail") { + await this.openFinding(node); + return; + } + if (picked.action === "remediation") { + await this.showRemediation(node); + return; + } + if (picked.action === "reviewed") { + await this.markFindingReviewed(node); + } + } + + async openFinding(node) { + if (!node || !node.findingId || !node.runId) { + return; + } + const state = this.states.get(node.workspaceKey); + if (!state) { + return; + } + try { + await this.ensureConnected(state.folder); + const payload = await this.client.callTool("get_finding", { + run_id: node.runId, + finding_id: node.findingId, + detail_level: "normal", + }); + await this.showMarkdownDocument(renderFindingMarkdown(payload)); + } catch (error) { + this.handleError(error, `Could not open finding ${node.findingId}.`); + } + } + + async showRemediation(node) { + if (!node || !node.findingId || !node.runId) { + return; + } + const state = this.states.get(node.workspaceKey); + if (!state) { + return; + } + try { + await this.ensureConnected(state.folder); + const payload = await this.client.callTool("get_remediation", { + run_id: node.runId, + finding_id: node.findingId, + detail_level: "normal", + }); + await this.showMarkdownDocument(renderRemediationMarkdown(payload)); + } catch (error) { + this.handleError(error, `Could not load remediation for ${node.findingId}.`); + } + } + + async markFindingReviewed(node) { + if (!node || !node.findingId || !node.runId) { + return; + } + const state = this.states.get(node.workspaceKey); + if (!state) { + return; + } + try { + await this.ensureConnected(state.folder); + await this.client.callTool("mark_finding_reviewed", { + run_id: node.runId, + finding_id: node.findingId, + }); + const reviewed = await this.client.callTool("list_reviewed_findings", { + run_id: node.runId, + }); + state.reviewed = safeArray(reviewed.items); + this.sessionProvider.refresh(); + await vscode.window.showInformationMessage( + `Marked ${node.findingId} as reviewed.` + ); + } catch (error) { + this.handleError(error, `Could not mark ${node.findingId} as reviewed.`); + } + } + + async copyFindingId(node) { + if (!node || !node.findingId) { + return; + } + await vscode.env.clipboard.writeText(String(node.findingId)); + await vscode.window.showInformationMessage( + `Copied finding id: ${node.findingId}` + ); + } + + async revealFindingSource(node) { + if (!node) { + return; + } + const state = this.states.get(node.workspaceKey); + if (!state) { + return; + } + let location = firstLocation(node.locations); + if (!location && node.findingId && node.runId) { + try { + await this.ensureConnected(state.folder); + const payload = await this.client.callTool("get_finding", { + run_id: node.runId, + finding_id: node.findingId, + detail_level: "normal", + }); + location = firstLocation(payload.locations); + } catch (error) { + this.handleError(error, "Could not resolve finding location."); + return; + } + } + if (!location || !location.path) { + await vscode.window.showInformationMessage( + "This item does not expose a source location." + ); + return; + } + await this.revealWorkspacePath( + state.folder, + location.path, + location.line, + location.end_line + ); + } + + async revealWorkspacePath(folder, relativePath, line = null, endLine = null) { + const fileUri = vscode.Uri.file(path.join(folder.uri.fsPath, relativePath)); + try { + const document = await vscode.workspace.openTextDocument(fileUri); + const editor = await vscode.window.showTextDocument(document, { + preview: true, + }); + if (typeof line === "number") { + const startLine = Math.max(line - 1, 0); + const finalLine = Math.max( + typeof endLine === "number" ? endLine - 1 : startLine, + startLine + ); + const position = new vscode.Position(startLine, 0); + const endPosition = new vscode.Position( + finalLine, + document.lineAt(finalLine).range.end.character + ); + const range = new vscode.Range(position, endPosition); + editor.selection = new vscode.Selection(position, position); + editor.revealRange(range, vscode.TextEditorRevealType.InCenter); + this.flashRevealRange(editor, range); + } + } catch (error) { + this.handleError(error, `Could not open ${relativePath}.`); + } + } + + flashRevealRange(editor, range) { + if (this.revealDecorationTimeout) { + clearTimeout(this.revealDecorationTimeout); + this.revealDecorationTimeout = null; + } + editor.setDecorations(this.revealDecoration, [range]); + this.revealDecorationTimeout = setTimeout(() => { + try { + editor.setDecorations(this.revealDecoration, []); + } catch { + // Ignore editor disposal during timeout cleanup. + } + this.revealDecorationTimeout = null; + }, 3500); + } + + async showHelpTopic(arg) { + const folder = this.getPreferredFolder(); + if (!folder) { + return; + } + const topic = + typeof arg === "string" + ? arg + : arg && typeof arg.topic === "string" + ? arg.topic + : await this.pickHelpTopic(); + if (!topic) { + return; + } + try { + await this.ensureConnected(folder); + const payload = await this.client.callTool("help", { + topic, + detail: "normal", + }); + await this.showMarkdownDocument(renderHelpMarkdown(topic, payload)); + } catch (error) { + this.handleError(error, `Could not load help for ${topic}.`); + } + } + + async openSetupHelp() { + await this.showMarkdownDocument(renderSetupMarkdown()); + } + + async openGodModule(node) { + if (!node || !node.item) { + return; + } + await this.showMarkdownDocument(renderGodModuleMarkdown(node.item)); + } + + async reviewGodModule(node) { + if (!node || !node.item || !node.workspaceKey) { + return; + } + const picked = await vscode.window.showQuickPick( + [ + { + label: "Reveal module source", + description: "Recommended", + action: "reveal", + }, + { + label: "Show report-only detail", + description: "Open God Module summary", + action: "detail", + }, + ], + { + placeHolder: `What do you want to do with ${node.item.path}?`, + } + ); + if (!picked) { + return; + } + if (picked.action === "reveal") { + const state = this.states.get(node.workspaceKey); + if (!state) { + return; + } + await this.revealWorkspacePath(state.folder, node.item.path); + return; + } + await this.openGodModule(node); + } + + async clearSessionState() { + const folder = this.getPreferredFolder(); + if (!folder) { + return; + } + try { + await this.ensureConnected(folder); + await this.client.callTool("clear_session_runs", {}); + for (const state of this.states.values()) { + state.currentRunId = null; + state.latestSummary = null; + state.metricsSummary = null; + state.latestTriage = null; + state.changedSummary = null; + state.reviewed = []; + state.groupCache.clear(); + } + this.updateContextKeys(); + this.updateStatusBar(); + this.refreshAllViews(); + await vscode.window.showInformationMessage( + "CodeClone MCP session state cleared." + ); + } catch (error) { + this.handleError(error, "Could not clear CodeClone MCP session state."); + } + } + + async pickHelpTopic() { + const picked = await vscode.window.showQuickPick( + HELP_TOPICS.map((topic) => ({ + label: topic, + description: "CodeClone MCP help topic", + })), + { + placeHolder: "Select a CodeClone MCP help topic", + } + ); + return picked ? picked.label : null; + } + + async showMarkdownDocument(markdown) { + const document = await vscode.workspace.openTextDocument({ + content: markdown, + language: "markdown", + }); + await vscode.window.showTextDocument(document, { + preview: true, + }); + } + + async getOverviewChildren(node) { + const state = this.getPrimaryState(); + if (!state || !state.latestSummary) { + return []; + } + if (!node) { + const sections = [ + { + nodeType: "section", + id: "overview.health", + label: "Structural Health", + description: `${state.latestSummary.health.score}/${state.latestSummary.health.grade}`, + icon: new vscode.ThemeIcon("heart"), + }, + { + nodeType: "section", + id: "overview.run", + label: "Current Run", + description: `${state.currentRunId} · ${state.latestSummary.cache.freshness}`, + icon: new vscode.ThemeIcon("pulse"), + }, + { + nodeType: "section", + id: "overview.triage", + label: "Priority Review", + description: `${state.latestSummary.findings.production} production · ${state.latestSummary.findings.new} new`, + icon: new vscode.ThemeIcon("inspect"), + command: { + command: "codeclone.openProductionTriage", + title: "Open Production Triage", + }, + }, + ]; + if (state.changedSummary) { + sections.push({ + nodeType: "section", + id: "overview.changed", + label: "Changed Scope", + description: `${state.changedSummary.changed_files} files · ${state.changedSummary.verdict}`, + icon: new vscode.ThemeIcon("git-commit"), + }); + } + if (safeObject(state.metricsSummary).god_modules) { + const godModules = safeObject(state.metricsSummary).god_modules; + sections.push({ + nodeType: "section", + id: "overview.god", + label: "God Modules", + description: `${godModules.candidates} candidates · top ${decimal(godModules.top_score)} (report-only)`, + icon: new vscode.ThemeIcon("symbol-module"), + }); + } + return sections; + } + if (node.id === "overview.health") { + const dimensions = safeObject(state.latestSummary.health.dimensions); + return [ + this.detailNode("Score", `${state.latestSummary.health.score}/${state.latestSummary.health.grade}`), + this.detailNode("Clones", number(dimensions.clones)), + this.detailNode("Complexity", number(dimensions.complexity)), + this.detailNode("Coupling", number(dimensions.coupling)), + this.detailNode("Cohesion", number(dimensions.cohesion)), + this.detailNode("Dead code", number(dimensions.dead_code)), + this.detailNode("Dependencies", number(dimensions.dependencies)), + this.detailNode("Coverage", number(dimensions.coverage)), + ]; + } + if (node.id === "overview.run") { + const inventory = safeObject(state.latestSummary.inventory); + return [ + this.detailNode("Workspace", state.folder.name), + this.detailNode("Run id", state.currentRunId), + this.detailNode("Files", number(inventory.files)), + this.detailNode("Parsed lines", number(inventory.lines)), + this.detailNode("Callables", number(inventory.functions)), + this.detailNode("Classes", number(inventory.classes)), + this.detailNode("Baseline", formatBaselineState(state.latestSummary.baseline)), + this.detailNode( + "Metrics baseline", + formatBaselineState(state.latestSummary.metrics_baseline) + ), + this.detailNode("Cache", formatCacheSummary(state.latestSummary.cache)), + ]; + } + if (node.id === "overview.triage") { + const triage = safeObject(state.latestTriage); + const findings = safeObject(triage.findings); + const nextAction = this.describeNextBestAction(state); + return [ + this.detailNode("Next best action", nextAction.label, { + command: nextAction.command, + title: nextAction.title, + }), + this.detailNode("New regressions", number(state.latestSummary.findings.new)), + this.detailNode("Production hotspots", number(state.latestSummary.findings.production)), + this.detailNode("Outside focus", number(findings.outside_focus)), + this.detailNode( + "Changed files", + state.changedSummary + ? `${number(state.changedSummary.changed_files)} · ${state.changedSummary.verdict}` + : "not analyzed" + ), + ]; + } + if (node.id === "overview.changed") { + return [ + this.detailNode("Changed files", number(state.changedSummary.changed_files)), + this.detailNode("Verdict", String(state.changedSummary.verdict)), + this.detailNode("New findings", number(state.changedSummary.new_findings)), + this.detailNode("Resolved findings", number(state.changedSummary.resolved_findings)), + this.detailNode( + "Health delta", + typeof state.changedSummary.health_delta === "number" + ? String(state.changedSummary.health_delta) + : "n/a" + ), + ]; + } + if (node.id === "overview.god") { + const godModules = safeObject(state.metricsSummary).god_modules; + return [ + this.detailNode("Candidates", number(godModules.candidates)), + this.detailNode("Ranked modules", number(godModules.total)), + this.detailNode("Top score", decimal(godModules.top_score)), + this.detailNode("Average score", decimal(godModules.average_score)), + this.detailNode("Population", String(godModules.population_status)), + ]; + } + return []; + } + + async getHotspotsChildren(node) { + const state = this.getPrimaryState(); + if (!state || !state.latestSummary) { + return []; + } + if (!node) { + const groups = HOTSPOT_GROUPS.filter((group) => + this.shouldShowGroup(group.id, state) + ); + if (groups.length === 0) { + return [ + { + nodeType: "message", + label: "No new or production hotspots need review in the current run.", + icon: new vscode.ThemeIcon("circle-slash"), + }, + ]; + } + return groups.map((group) => ({ + nodeType: "group", + groupId: group.id, + label: group.label, + description: this.describeGroup(group.id, state), + icon: new vscode.ThemeIcon(group.icon), + workspaceKey: state.folder.uri.toString(), + })); + } + return this.getHotspotGroupChildren(state, node.groupId); + } + + async getSessionChildren(node) { + const state = this.getPrimaryState(); + if (!node && (!state || !state.latestSummary)) { + return []; + } + if (!node) { + return [ + { + nodeType: "section", + id: "session.server", + label: "Local Server", + description: this.connectionInfo.connected ? "ready" : "unavailable", + icon: new vscode.ThemeIcon("plug"), + }, + { + nodeType: "section", + id: "session.run", + label: "Current Run", + description: state && state.currentRunId ? state.currentRunId : "none", + icon: new vscode.ThemeIcon("pulse"), + }, + { + nodeType: "section", + id: "session.reviewed", + label: "Reviewed Findings", + description: state ? `${state.reviewed.length}` : "0", + icon: new vscode.ThemeIcon("pass"), + }, + { + nodeType: "section", + id: "session.help", + label: "Help Topics", + description: `${HELP_TOPICS.length} topics`, + icon: new vscode.ThemeIcon("question"), + }, + ]; + } + if (node.id === "session.server") { + const launch = this.connectionInfo.launchSpec; + return [ + this.detailNode("Connected", formatBooleanWord(this.connectionInfo.connected)), + this.detailNode( + "Server version", + this.connectionInfo.serverInfo ? this.connectionInfo.serverInfo.version : "unknown" + ), + this.detailNode("Available tools", number(this.connectionInfo.toolCount)), + this.detailNode( + "Launcher", + launch ? `${launch.command} ${launch.args.join(" ")}`.trim() : "not started" + ), + ]; + } + if (node.id === "session.run") { + if (!state || !state.latestSummary) { + return [this.detailNode("Run", "No run available yet.")]; + } + return [ + this.detailNode("Workspace", state.folder.name), + this.detailNode("Run id", state.currentRunId), + this.detailNode("Scope", formatRunScope(state.lastScope)), + this.detailNode("Mode", state.latestSummary.mode), + this.detailNode("Cache freshness", state.latestSummary.cache.freshness), + this.detailNode("Updated", state.lastUpdatedAt ? state.lastUpdatedAt.toLocaleString() : "unknown"), + ]; + } + if (node.id === "session.reviewed") { + if (!state || !state.currentRunId || state.reviewed.length === 0) { + return [ + { + nodeType: "message", + label: "No reviewed findings in this MCP session.", + icon: new vscode.ThemeIcon("circle-slash"), + }, + ]; + } + return state.reviewed.map((entry) => { + const finding = safeObject(entry.finding); + return this.buildFindingNode( + state, + finding.id || entry.finding_id, + finding, + entry.note || null, + true + ); + }); + } + if (node.id === "session.help") { + return HELP_TOPICS.map((topic) => ({ + nodeType: "helpTopic", + topic, + label: topic, + description: "Open MCP semantic guide", + icon: new vscode.ThemeIcon("question"), + })); + } + return []; + } + + async getHotspotGroupChildren(state, groupId) { + if (state.groupCache.has(groupId)) { + return state.groupCache.get(groupId); + } + try { + await this.ensureConnected(state.folder); + const runId = state.currentRunId; + if (!runId) { + return []; + } + let nodes; + switch (groupId) { + case "newRegressions": + nodes = this.toFindingNodes( + state, + safeArray( + ( + await this.client.callTool("list_findings", { + run_id: runId, + novelty: "new", + detail_level: "summary", + sort_by: "priority", + limit: 20, + exclude_reviewed: true, + }) + ).items + ) + ); + break; + case "productionHotspots": + nodes = this.toFindingNodes( + state, + safeArray( + ( + await this.client.callTool("list_hotspots", { + run_id: runId, + kind: "production_hotspots", + detail_level: "summary", + limit: 10, + exclude_reviewed: true, + }) + ).items + ) + ); + break; + case "changedFiles": + if (!state.changedSummary) { + nodes = [ + { + nodeType: "message", + label: "Run Review Changes to load changed-scope findings.", + icon: new vscode.ThemeIcon("info"), + }, + ]; + break; + } + nodes = this.toFindingNodes( + state, + safeArray( + ( + await this.client.callTool("list_findings", { + run_id: runId, + git_diff_ref: vscode.workspace + .getConfiguration("codeclone", state.folder.uri) + .get("analysis.changedDiffRef", "HEAD"), + novelty: "new", + detail_level: "summary", + sort_by: "priority", + limit: 20, + exclude_reviewed: true, + }) + ).items + ) + ); + break; + case "godModules": { + const response = await this.client.callTool("get_report_section", { + run_id: runId, + section: "metrics_detail", + family: "god_modules", + limit: 15, + }); + nodes = safeArray(response.items).map((item) => ({ + nodeType: "godModule", + workspaceKey: state.folder.uri.toString(), + runId, + item, + label: item.path, + description: `${decimal(item.score)} · ${item.source_kind}`, + tooltip: `${item.module} · ${number(item.loc)} LOC · ${item.total_deps} deps`, + icon: new vscode.ThemeIcon("symbol-module"), + command: { + command: "codeclone.reviewGodModule", + title: "Review God Module", + arguments: [{ workspaceKey: state.folder.uri.toString(), runId, item }], + }, + })); + break; + } + default: + nodes = []; + } + if (!nodes || nodes.length === 0) { + nodes = [ + { + nodeType: "message", + label: this.emptyGroupMessage(groupId), + icon: new vscode.ThemeIcon("circle-slash"), + }, + ]; + } + state.groupCache.set(groupId, nodes); + return nodes; + } catch (error) { + return [ + { + nodeType: "message", + label: `Error: ${error.message}`, + icon: new vscode.ThemeIcon("error"), + }, + ]; + } + } + + toFindingNodes(state, items) { + return items.map((item) => + this.buildFindingNode(state, item.id, item, null, false) + ); + } + + async getPriorityQueueNodes(state) { + const runId = state.currentRunId; + if (!runId) { + return []; + } + const diffRef = vscode.workspace + .getConfiguration("codeclone", state.folder.uri) + .get("analysis.changedDiffRef", "HEAD"); + const buckets = []; + if (state.changedSummary) { + buckets.push( + safeArray( + ( + await this.client.callTool("list_findings", { + run_id: runId, + git_diff_ref: diffRef, + novelty: "new", + detail_level: "summary", + sort_by: "priority", + limit: 12, + exclude_reviewed: true, + }) + ).items + ) + ); + } + buckets.push( + safeArray( + ( + await this.client.callTool("list_hotspots", { + run_id: runId, + kind: "production_hotspots", + detail_level: "summary", + limit: 12, + exclude_reviewed: true, + }) + ).items + ) + ); + buckets.push( + safeArray( + ( + await this.client.callTool("list_findings", { + run_id: runId, + novelty: "new", + detail_level: "summary", + sort_by: "priority", + limit: 12, + exclude_reviewed: true, + }) + ).items + ) + ); + const deduped = []; + const seen = new Set(); + for (const bucket of buckets) { + for (const item of bucket) { + const id = String(item.id || ""); + if (!id || seen.has(id)) { + continue; + } + seen.add(id); + deduped.push(item); + } + } + return this.toFindingNodes(state, deduped); + } + + buildFindingNode(state, findingId, item, note, reviewed) { + const spread = safeObject(item.spread); + const novelty = formatNovelty(item.novelty); + const descriptionParts = []; + if (novelty) { + descriptionParts.push(novelty); + } + descriptionParts.push(formatSeverity(item.severity)); + descriptionParts.push(item.scope || "unknown"); + descriptionParts.push(`p${compactDecimal(item.priority || 0)}`); + return { + nodeType: "finding", + workspaceKey: state.folder.uri.toString(), + runId: state.currentRunId, + findingId, + label: formatKind(item.kind), + description: descriptionParts.join(" · "), + tooltip: + `${findingId}\n${spread.files || 0} files / ${spread.functions || 0} functions` + + (novelty ? `\nNovelty: ${novelty}` : "") + + (note ? `\nNote: ${note}` : ""), + icon: findingIcon(item.severity), + locations: item.locations || [], + contextValue: "codeclone.finding", + reviewed, + command: { + command: "codeclone.reviewFinding", + title: "Review Finding", + arguments: [ + { + workspaceKey: state.folder.uri.toString(), + runId: state.currentRunId, + findingId, + locations: item.locations || [], + novelty: item.novelty || "", + }, + ], + }, + }; + } + + describeGroup(groupId, state) { + const summary = safeObject(state.latestSummary); + const findings = safeObject(summary.findings); + const metrics = safeObject(state.metricsSummary); + switch (groupId) { + case "newRegressions": + return `${findings.new || 0} new`; + case "productionHotspots": + return `${safeObject(state.latestTriage).top_hotspots?.available || 0} prod`; + case "changedFiles": + return state.changedSummary + ? `${state.changedSummary.new_findings} new · ${state.changedSummary.verdict}` + : "not analyzed"; + case "godModules": + return `${safeObject(metrics.god_modules).candidates || 0} report-only`; + default: + return ""; + } + } + + emptyGroupMessage(groupId) { + switch (groupId) { + case "newRegressions": + return "No new regressions in the current run."; + case "productionHotspots": + return "No production hotspots need review."; + case "changedFiles": + return "No new findings touch the changed files."; + case "godModules": + return "No report-only God Module candidates are visible."; + default: + return "No items in this category."; + } + } + + shouldShowGroup(groupId, state) { + const summary = safeObject(state.latestSummary); + const findings = safeObject(summary.findings); + const metrics = safeObject(state.metricsSummary); + switch (groupId) { + case "newRegressions": + return Number(findings.new || 0) > 0; + case "productionHotspots": + return Number(safeObject(state.latestTriage).top_hotspots?.available || 0) > 0; + case "changedFiles": + return Boolean(state.changedSummary); + case "godModules": + return Number(safeObject(metrics.god_modules).candidates || 0) > 0; + default: + return false; + } + } + + describeNextBestAction(state) { + if (Number(state.latestSummary.findings.new || 0) > 0) { + return { + label: "Review new regressions", + command: "codeclone.reviewPriorityQueue", + title: "Review new regressions", + }; + } + if (Number(state.latestSummary.findings.production || 0) > 0) { + return { + label: "Review production hotspots", + command: "codeclone.reviewPriorityQueue", + title: "Review production hotspots", + }; + } + if (state.changedSummary) { + return { + label: "Inspect changed-files review", + command: "codeclone.focusHotspots", + title: "Inspect changed-files review", + }; + } + if (Number(safeObject(state.metricsSummary).god_modules?.candidates || 0) > 0) { + return { + label: "Inspect report-only God Modules", + command: "codeclone.focusHotspots", + title: "Inspect report-only God Modules", + }; + } + return { + label: "Repository looks structurally quiet", + command: "codeclone.focusHotspots", + title: "Open hotspots", + }; + } + + detailNode(label, description, command) { + return { + nodeType: "detail", + label, + description, + icon: new vscode.ThemeIcon("circle-small-filled"), + command, + }; + } + + createTreeItem(node) { + switch (node.nodeType) { + case "section": { + const item = new vscode.TreeItem( + node.label, + vscode.TreeItemCollapsibleState.Expanded + ); + item.id = node.id; + item.description = node.description; + item.iconPath = node.icon; + item.command = node.command; + return item; + } + case "group": { + const item = new vscode.TreeItem( + node.label, + vscode.TreeItemCollapsibleState.Collapsed + ); + item.id = `${node.workspaceKey}:${node.groupId}`; + item.description = node.description; + item.iconPath = node.icon; + return item; + } + case "finding": { + const item = new vscode.TreeItem( + node.label, + vscode.TreeItemCollapsibleState.None + ); + item.description = node.description; + item.tooltip = node.tooltip; + item.iconPath = node.icon; + item.contextValue = "codeclone.finding"; + item.command = node.command; + return item; + } + case "godModule": { + const item = new vscode.TreeItem( + node.label, + vscode.TreeItemCollapsibleState.None + ); + item.description = node.description; + item.tooltip = node.tooltip; + item.iconPath = node.icon; + item.contextValue = "codeclone.godModule"; + item.command = node.command; + return item; + } + case "helpTopic": { + const item = new vscode.TreeItem( + node.label, + vscode.TreeItemCollapsibleState.None + ); + item.description = node.description; + item.iconPath = node.icon; + item.contextValue = "codeclone.helpTopic"; + item.command = { + command: "codeclone.showHelpTopic", + title: "Show Help Topic", + arguments: [node.topic], + }; + return item; + } + case "detail": { + const item = new vscode.TreeItem( + node.label, + vscode.TreeItemCollapsibleState.None + ); + item.description = node.description; + item.iconPath = node.icon; + item.command = node.command; + return item; + } + case "message": + default: { + const item = new vscode.TreeItem( + node.label, + vscode.TreeItemCollapsibleState.None + ); + item.iconPath = node.icon || new vscode.ThemeIcon("info"); + item.description = node.description; + return item; + } + } + } + + refreshAllViews() { + this.overviewProvider.refresh(); + this.hotspotsProvider.refresh(); + this.sessionProvider.refresh(); + this.updateViewChrome(); + } + + updateViewChrome() { + const state = this.getPrimaryState(); + if (this.overviewView) { + this.overviewView.badge = undefined; + } + if (this.hotspotsView) { + const newCount = Number( + safeObject(state?.latestSummary).findings?.new || 0 + ); + const productionCount = Number( + safeObject(state?.latestSummary).findings?.production || 0 + ); + const changedCount = Number(state?.changedSummary?.new_findings || 0); + const actionableCount = Math.max(newCount + productionCount, changedCount); + const godModuleCount = Number( + safeObject(state?.metricsSummary).god_modules?.candidates || 0 + ); + this.hotspotsView.badge = + actionableCount > 0 + ? { + value: actionableCount, + tooltip: `${actionableCount} review items need attention`, + } + : godModuleCount > 0 + ? { + value: godModuleCount, + tooltip: `${godModuleCount} report-only God Module candidates are visible in Hotspots`, + } + : undefined; + } + if (this.sessionView) { + this.sessionView.badge = undefined; + } + } + + updateContextKeys() { + const state = this.getPrimaryState(); + void vscode.commands.executeCommand( + "setContext", + "codeclone.connected", + this.connectionInfo.connected + ); + void vscode.commands.executeCommand( + "setContext", + "codeclone.hasRun", + Boolean(state && state.latestSummary) + ); + } + + updateStatusBar() { + const showStatusBar = vscode.workspace + .getConfiguration("codeclone") + .get("ui.showStatusBar", true); + if (!showStatusBar) { + this.statusBar.hide(); + return; + } + const state = this.getPrimaryState(); + if (!this.connectionInfo.connected) { + this.statusBar.text = "CodeClone setup"; + this.statusBar.tooltip = + "Run Analyze Workspace to start CodeClone and create the first run. Use Verify Local Server only if you want to check the launcher manually."; + this.statusBar.command = "codeclone.analyzeWorkspace"; + this.statusBar.show(); + return; + } + if (!state || !state.latestSummary) { + this.statusBar.text = "CodeClone ready"; + this.statusBar.tooltip = + "The local CodeClone server is ready. Run Analyze Workspace or Review Changes."; + this.statusBar.command = "codeclone.analyzeWorkspace"; + this.statusBar.show(); + return; + } + this.statusBar.text = `CodeClone ${state.latestSummary.health.score}/${state.latestSummary.health.grade}`; + this.statusBar.command = "codeclone.openOverview"; + this.statusBar.tooltip = + `${state.folder.name}\nRun ${state.currentRunId}\n${state.latestSummary.findings.total} findings`; + this.statusBar.show(); + } + + handleError(error, fallbackMessage) { + const message = + error instanceof MCPClientError || error instanceof Error + ? error.message + : fallbackMessage; + this.outputChannel.show(true); + this.outputChannel.appendLine(`[codeclone] error: ${message}`); + if (this.isCodeCloneSetupError(message)) { + void this.showSetupGuidance(message); + return; + } + void vscode.window.showErrorMessage(message || fallbackMessage); + } + + isCodeCloneSetupError(message) { + const text = String(message || ""); + return ( + text.includes("Failed to start CodeClone MCP") || + text.includes("requires the optional 'mcp' extra") || + text.includes("spawn codeclone-mcp ENOENT") || + text.includes("spawn uv ENOENT") + ); + } + + async showSetupGuidance(message) { + const choice = await vscode.window.showErrorMessage( + message, + "Open setup help", + "Copy install command", + "Open settings" + ); + if (choice === "Open setup help") { + await this.openSetupHelp(); + return; + } + if (choice === "Copy install command") { + await vscode.env.clipboard.writeText('pip install --pre "codeclone[mcp]"'); + await vscode.window.showInformationMessage( + 'Copied: pip install --pre "codeclone[mcp]"' + ); + return; + } + if (choice === "Open settings") { + await vscode.commands.executeCommand( + "workbench.action.openSettings", + "@ext:orenlab.codeclone codeclone.mcp" + ); + } + } +} + +let controller = null; + +function activate(context) { + controller = new CodeCloneController(context); +} + +async function deactivate() { + if (controller) { + await controller.client.dispose(); + } +} + +module.exports = { + activate, + deactivate, +}; diff --git a/extensions/vscode-codeclone/src/mcpClient.js b/extensions/vscode-codeclone/src/mcpClient.js new file mode 100644 index 0000000..d793274 --- /dev/null +++ b/extensions/vscode-codeclone/src/mcpClient.js @@ -0,0 +1,348 @@ +"use strict"; + +const { spawn } = require("node:child_process"); +const { EventEmitter } = require("node:events"); + +const REQUEST_TIMEOUT_MS = 5 * 60 * 1000; + +class MCPClientError extends Error { + constructor(message) { + super(message); + this.name = "MCPClientError"; + } +} + +class CodeCloneMcpClient extends EventEmitter { + constructor(outputChannel) { + super(); + this.outputChannel = outputChannel; + this.process = null; + this.connected = false; + this.initialized = false; + this.nextId = 1; + this.pending = new Map(); + this.stdoutBuffer = ""; + this.stderrBuffer = ""; + this.diagnostics = []; + this.launchSpec = null; + this.serverInfo = null; + this.toolNames = []; + } + + isConnected() { + return this.connected; + } + + getConnectionSnapshot() { + return { + connected: this.connected, + serverInfo: this.serverInfo ? { ...this.serverInfo } : null, + toolNames: [...this.toolNames], + launchSpec: this.launchSpec + ? { + command: this.launchSpec.command, + args: [...this.launchSpec.args], + cwd: this.launchSpec.cwd, + } + : null, + }; + } + + async connect(launchSpec) { + if ( + this.process !== null && + this.connected && + this._sameLaunchSpec(launchSpec, this.launchSpec) + ) { + return { + serverInfo: this.serverInfo, + toolNames: [...this.toolNames], + }; + } + if (this.process !== null || this.connected || this.initialized) { + await this.dispose({ emitState: false }); + } + await this._spawn(launchSpec); + try { + const initializeResult = await this.request("initialize", { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { + name: "CodeClone VS Code", + version: "0.0.1", + }, + }); + this._write({ + jsonrpc: "2.0", + method: "notifications/initialized", + params: {}, + }); + const toolsResult = await this.request("tools/list", {}); + this.connected = true; + this.initialized = true; + this.serverInfo = initializeResult.serverInfo || null; + this.toolNames = Array.isArray(toolsResult.tools) + ? toolsResult.tools.map((tool) => String(tool.name)) + : []; + this.emit("state", { + connected: true, + serverInfo: this.serverInfo, + toolNames: [...this.toolNames], + launchSpec: this.getConnectionSnapshot().launchSpec, + }); + return { + serverInfo: this.serverInfo, + toolNames: [...this.toolNames], + }; + } catch (error) { + await this.dispose({ emitState: false }); + throw error; + } + } + + async callTool(name, args = {}) { + if (!this.connected) { + throw new MCPClientError("CodeClone MCP is not connected."); + } + const result = await this.request("tools/call", { + name, + arguments: args, + }); + if (result && result.isError) { + throw new MCPClientError( + `Tool ${name} returned an error response from CodeClone MCP.` + ); + } + if (result && result.structuredContent !== undefined) { + return result.structuredContent; + } + if (Array.isArray(result?.content)) { + const textChunk = result.content.find( + (entry) => entry && entry.type === "text" && typeof entry.text === "string" + ); + if (textChunk && typeof textChunk.text === "string") { + try { + return JSON.parse(textChunk.text); + } catch { + return { text: textChunk.text }; + } + } + } + return result; + } + + async dispose(options = {}) { + const emitState = options.emitState !== false; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new MCPClientError("CodeClone MCP connection closed.")); + } + this.pending.clear(); + this.stdoutBuffer = ""; + this.stderrBuffer = ""; + this.diagnostics = []; + this.connected = false; + this.initialized = false; + this.serverInfo = null; + this.toolNames = []; + this.launchSpec = null; + if (this.process) { + const child = this.process; + this.process = null; + child.removeAllListeners(); + child.stdout?.removeAllListeners(); + child.stderr?.removeAllListeners(); + child.kill(); + } + if (emitState) { + this.emit("state", { connected: false, launchSpec: null }); + } + } + + request(method, params) { + if (this.process === null) { + return Promise.reject( + new MCPClientError("CodeClone MCP process is not running.") + ); + } + const id = this.nextId++; + const payload = { + jsonrpc: "2.0", + id, + method, + params, + }; + this._write(payload); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject( + new MCPClientError( + `Timed out waiting for CodeClone MCP response to ${method}.` + ) + ); + }, REQUEST_TIMEOUT_MS); + this.pending.set(id, { resolve, reject, timer, method }); + }); + } + + async _spawn(launchSpec) { + await new Promise((resolve, reject) => { + const child = spawn(launchSpec.command, launchSpec.args, { + cwd: launchSpec.cwd, + env: process.env, + shell: false, + stdio: ["pipe", "pipe", "pipe"], + }); + this.process = child; + this.launchSpec = { ...launchSpec }; + child.once("error", (error) => { + reject( + new MCPClientError( + `Failed to start CodeClone MCP (${launchSpec.command}): ${error.message}` + ) + ); + }); + child.once("spawn", () => { + this.outputChannel.appendLine( + `[codeclone] MCP spawned: ${launchSpec.command} ${launchSpec.args.join( + " " + )}`.trim() + ); + resolve(); + }); + child.on("exit", (code, signal) => { + this.outputChannel.appendLine( + `[codeclone] MCP exited (code=${code ?? "null"}, signal=${signal ?? "null"})` + ); + const wasConnected = this.connected; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new MCPClientError(this._buildExitMessage())); + } + this.pending.clear(); + this.process = null; + this.connected = false; + this.initialized = false; + this.serverInfo = null; + this.toolNames = []; + this.launchSpec = null; + this.emit("state", { connected: false, launchSpec: null }); + if (wasConnected) { + this.emit("exit"); + } + }); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => this._handleStdout(chunk)); + child.stderr.on("data", (chunk) => this._handleStderr(chunk)); + }); + } + + _handleStdout(chunk) { + this.stdoutBuffer += chunk; + const lines = this.stdoutBuffer.split(/\r?\n/); + this.stdoutBuffer = lines.pop() || ""; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) { + continue; + } + let message; + try { + message = JSON.parse(line); + } catch { + this.outputChannel.appendLine(`[codeclone] stdout: ${line}`); + continue; + } + if ( + Object.prototype.hasOwnProperty.call(message, "id") && + (Object.prototype.hasOwnProperty.call(message, "result") || + Object.prototype.hasOwnProperty.call(message, "error")) + ) { + const pending = this.pending.get(message.id); + if (!pending) { + continue; + } + clearTimeout(pending.timer); + this.pending.delete(message.id); + if (message.error) { + pending.reject( + new MCPClientError( + this._formatRpcError(message.error, pending.method) + ) + ); + } else { + pending.resolve(message.result); + } + continue; + } + if ( + message.method === "notifications/message" && + message.params && + typeof message.params.data === "string" + ) { + this.outputChannel.appendLine( + `[codeclone] ${message.params.level || "info"}: ${message.params.data}` + ); + } + } + } + + _handleStderr(chunk) { + this.stderrBuffer += chunk; + const lines = this.stderrBuffer.split(/\r?\n/); + this.stderrBuffer = lines.pop() || ""; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (line) { + this._rememberDiagnostic(line); + this.outputChannel.appendLine(`[codeclone] stderr: ${line}`); + } + } + } + + _write(payload) { + if (!this.process || !this.process.stdin.writable) { + throw new MCPClientError("CodeClone MCP stdin is not writable."); + } + this.process.stdin.write(`${JSON.stringify(payload)}\n`); + } + + _formatRpcError(error, method) { + if (error && typeof error.message === "string") { + return `CodeClone MCP ${method} failed: ${error.message}`; + } + return `CodeClone MCP ${method} failed.`; + } + + _buildExitMessage() { + if (this.diagnostics.length > 0) { + return `CodeClone MCP process exited. ${this.diagnostics[this.diagnostics.length - 1]}`; + } + return "CodeClone MCP process exited."; + } + + _rememberDiagnostic(line) { + this.diagnostics.push(line); + if (this.diagnostics.length > 10) { + this.diagnostics.shift(); + } + } + + _sameLaunchSpec(left, right) { + if (!left || !right) { + return false; + } + return ( + left.command === right.command && + left.cwd === right.cwd && + JSON.stringify(left.args) === JSON.stringify(right.args) + ); + } +} + +module.exports = { + CodeCloneMcpClient, + MCPClientError, +}; diff --git a/mkdocs.yml b/mkdocs.yml index 0c7975e..72b8691 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -71,6 +71,7 @@ nav: - Interfaces: - CLI: book/09-cli.md - MCP Interface: book/20-mcp-interface.md + - VS Code Extension: book/21-vscode-extension.md - HTML Render: book/10-html-render.md - System Properties: - Security Model: book/11-security-model.md @@ -92,6 +93,7 @@ nav: - Architecture Narrative: architecture.md - CFG Semantics: cfg.md - MCP for AI Agents: mcp.md + - VS Code Extension: vscode-extension.md - SARIF for IDEs: sarif.md - Publishing and Docs Site: publishing.md - Examples: From bbcebaa9b82650024d56fb67314bc6c888cb450f Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Sat, 4 Apr 2026 00:45:51 +0500 Subject: [PATCH 04/15] feat(vscode): harden the VS Code surface and document it as a first-class contract - mature the preview VS Code extension into a safer, enterprise-grade MCP client with limited Restricted Mode, source-first review flow, persisted focus state, bounded transport handling, and a safer local HTML bridge - add extension-side regression coverage with Node unit tests, local extension-host smoke, and validated preview VSIX packaging - document the extension consistently across README, docs, the contracts book, changelog, and AGENTS with its current capabilities, design decisions, trust model, and limits --- AGENTS.md | 7 +- CHANGELOG.md | 14 +- README.md | 10 + docs/book/01-architecture-map.md | 5 +- docs/book/21-vscode-extension.md | 50 +- docs/vscode-extension.md | 44 +- extensions/vscode-codeclone/.vscodeignore | 1 + extensions/vscode-codeclone/README.md | 52 +- extensions/vscode-codeclone/package.json | 305 ++- extensions/vscode-codeclone/src/extension.js | 2054 ++++++++++++++--- extensions/vscode-codeclone/src/mcpClient.js | 44 +- extensions/vscode-codeclone/src/support.js | 82 + .../test/extensionHost/index.js | 49 + .../vscode-codeclone/test/mcpClient.test.js | 51 + .../vscode-codeclone/test/runExtensionHost.js | 66 + .../vscode-codeclone/test/support.test.js | 85 + 16 files changed, 2532 insertions(+), 387 deletions(-) create mode 100644 extensions/vscode-codeclone/src/support.js create mode 100644 extensions/vscode-codeclone/test/extensionHost/index.js create mode 100644 extensions/vscode-codeclone/test/mcpClient.test.js create mode 100644 extensions/vscode-codeclone/test/runExtensionHost.js create mode 100644 extensions/vscode-codeclone/test/support.test.js diff --git a/AGENTS.md b/AGENTS.md index d237894..776a578 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,8 +92,11 @@ uv run pytest -q tests/test_mcp_service.py tests/test_mcp_server.py If you touched the VS Code extension surface, also run: ```bash +node --check extensions/vscode-codeclone/src/support.js node --check extensions/vscode-codeclone/src/mcpClient.js node --check extensions/vscode-codeclone/src/extension.js +node --test extensions/vscode-codeclone/test/*.test.js +node extensions/vscode-codeclone/test/runExtensionHost.js ``` If you touched VS Code extension packaging metadata (`package.json`, @@ -102,7 +105,7 @@ smoke: ```bash cd extensions/vscode-codeclone -NPM_CONFIG_CACHE=/tmp/codeclone-vsce-cache npx @vscode/vsce package --pre-release --out /tmp/codeclone.vsix +vsce package --pre-release --out /tmp/codeclone.vsix ``` --- @@ -458,7 +461,7 @@ If you change a contract-sensitive zone, route docs/tests/approval deliberately. | Fingerprint-adjacent analysis (`extractor/cfg/normalize/grouping`) | `docs/book/05-core-pipeline.md`, `docs/cfg.md`, `docs/book/14-compatibility-and-versioning.md`, `CHANGELOG.md` | `tests/test_fingerprint.py`, `tests/test_extractor.py`, `tests/test_cfg.py`, golden tests (`tests/test_detector_golden.py`, `tests/test_golden_v2.py`) | always (see Section 1.6) | clone identity / NEW-vs-KNOWN / fingerprint inputs change | | Suppression semantics/reporting (`suppressions`, extractor dead-code wiring, report/UI counters) | `docs/book/19-inline-suppressions.md`, `docs/book/16-dead-code-contract.md`, `docs/book/08-report.md`, and interface docs if surfaced (`09-cli`, `10-html-render`) | `tests/test_suppressions.py`, `tests/test_extractor.py`, `tests/test_metrics_modules.py`, `tests/test_pipeline_metrics.py`, report/html/cli tests | declaration scope semantics, rule effect, or contract-visible counters/fields change | suppression changes alter active finding output or contract-visible report payload | | MCP interface (`codeclone/mcp_service.py`, `codeclone/mcp_server.py`, packaging extra/launcher) | `README.md`, `docs/book/20-mcp-interface.md`, `docs/mcp.md`, `docs/book/01-architecture-map.md`, `docs/book/14-compatibility-and-versioning.md`, `CHANGELOG.md` | `tests/test_mcp_service.py`, `tests/test_mcp_server.py`, plus CLI/package tests if launcher/install semantics change | tool/resource shapes, read-only semantics, optional-dependency packaging behavior change | public MCP tool names, resource URIs, launcher/install behavior, or response semantics change | -| VS Code extension surface (`extensions/vscode-codeclone/*`) | `README.md`, `docs/book/21-vscode-extension.md`, `docs/vscode-extension.md`, `docs/book/01-architecture-map.md`, `docs/README.md`, `CHANGELOG.md` | `node --check extensions/vscode-codeclone/src/mcpClient.js`, `node --check extensions/vscode-codeclone/src/extension.js`; package smoke when manifest/assets change | command/view UX, trust/runtime model, source-first review flow, or packaging metadata change | documented commands/views/setup/trust behavior, packaged assets, or publish metadata change | +| VS Code extension surface (`extensions/vscode-codeclone/*`) | `README.md`, `docs/book/21-vscode-extension.md`, `docs/vscode-extension.md`, `docs/book/01-architecture-map.md`, `docs/README.md`, `CHANGELOG.md` | `node --check extensions/vscode-codeclone/src/support.js`, `node --check extensions/vscode-codeclone/src/mcpClient.js`, `node --check extensions/vscode-codeclone/src/extension.js`, `node --test extensions/vscode-codeclone/test/*.test.js`, plus local extension-host smoke and package smoke when surface/manifest/assets change | command/view UX, trust/runtime model, source-first review flow, or packaging metadata change | documented commands/views/setup/trust behavior, packaged assets, or publish metadata change | | Docs site / sample report publication (`docs/`, `mkdocs.yml`, `.github/workflows/docs.yml`, `scripts/build_docs_example_report.py`) | `docs/README.md`, `docs/publishing.md`, `docs/examples/report.md`, and any contract pages surfaced by the change, `CHANGELOG.md` when user-visible behavior changes | `mkdocs build --strict`, sample-report generation smoke path, and relevant report/html tests if generated examples or embeds change | published docs navigation, sample-report generation, or Pages workflow semantics change | published documentation behavior or sample-report generation contract changes | Golden rule: do not “fix” failures by snapshot refresh unless the underlying contract change is intentional, documented, diff --git a/CHANGELOG.md b/CHANGELOG.md index 13c6a22..7494c28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,8 @@ ## [2.0.0b4] 2.0.0b4 deepens the platform model introduced in b3: MCP becomes more self-guiding, report-only analysis expands with -module-level hotspot ranking, findings and suggestions are separated more cleanly by role, and Health Score -documentation now formalizes how new signal families can be introduced gradually without pretending the scoring model is -static. +module-level hotspot ranking, findings and suggestions are separated more cleanly by role, Health Score documentation +now formalizes phased score-model evolution, and CodeClone gains its first native IDE surface in preview. ### MCP server @@ -36,8 +35,13 @@ static. ### IDE integration -- Add a preview VS Code extension as a native, read-only control surface over `codeclone-mcp`, with baseline-aware, - triage-first review flow, guided source-first drill-down, and explicit setup/session semantics. +- Add a preview VS Code extension as the first native IDE surface for `codeclone-mcp`, bringing baseline-aware, + triage-first structural review and guided source-first drill-down into the editor. +- Establish the initial extension interaction model, including explicit setup/session semantics, review-loop navigation, + hotspot focus persistence, lightweight Explorer decorations, safe HTML-report bridging, and accessibility/status + polish. +- Add extension-side regression coverage with Node unit tests, local extension-host smoke, and validated preview `.vsix` + packaging. ## [2.0.0b3] - 20260401 diff --git a/README.md b/README.md index 25a4536..35e552f 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,16 @@ It is: - triage-first - read-only with respect to repository state - powered by the same `codeclone-mcp` contract surface +- limited in Restricted Mode until workspace trust is granted + +It focuses on source-first structural review inside the editor: overview, +hotspots, review loop, changed-files pass, and explicit drill-down into finding, +remediation, or local HTML report when needed. + +Docs: +[VS Code extension guide](https://orenlab.github.io/codeclone/vscode-extension/) +· +[VS Code extension contract](https://orenlab.github.io/codeclone/book/21-vscode-extension/) ## Configuration diff --git a/docs/book/01-architecture-map.md b/docs/book/01-architecture-map.md index 2d7588b..cf63c71 100644 --- a/docs/book/01-architecture-map.md +++ b/docs/book/01-architecture-map.md @@ -13,7 +13,8 @@ Main ownership layers: - Contracts and persistence: baseline, metrics baseline, cache, exit semantics. - Report model and projections: canonical JSON + deterministic TXT/Markdown/SARIF + explainability facts. - MCP agent surface: read-only server layer over the same pipeline/report contracts. -- VS Code extension surface: native IDE client over the MCP layer and the same canonical report semantics. +- VS Code extension surface: native IDE client over the MCP layer and the same canonical report semantics, with + limited Restricted Mode and source-first review flow. - Render layer: HTML rendering and template assets. ## Data model @@ -30,7 +31,7 @@ Main ownership layers: | Persistence | `codeclone/baseline.py`, `codeclone/metrics_baseline.py`, `codeclone/cache.py` | Baseline/cache trust/compat/integrity and atomic persistence | | Runtime orchestration | `codeclone/pipeline.py`, `codeclone/cli.py`, `codeclone/_cli_args.py`, `codeclone/_cli_paths.py`, `codeclone/_cli_summary.py`, `codeclone/_cli_config.py`, `codeclone/ui_messages.py` | CLI UX, stage orchestration, status handling, outputs, error markers | | MCP agent interface | `codeclone/mcp_service.py`, `codeclone/mcp_server.py` | Read-only MCP tools/resources over canonical analysis and report layers | -| VS Code extension | `extensions/vscode-codeclone/*` | Native VS Code control surface over MCP, with triage-first review and source-first drill-down | +| VS Code extension | `extensions/vscode-codeclone/*` | Native VS Code control surface over MCP, with limited Restricted Mode, triage-first review, and source-first drill-down | | Rendering | `codeclone/html_report.py`, `codeclone/_html_report/*`, `codeclone/_html_badges.py`, `codeclone/_html_js.py`, `codeclone/_html_escape.py`, `codeclone/_html_snippets.py`, `codeclone/templates.py` | HTML-only view layer over report data | Refs: diff --git a/docs/book/21-vscode-extension.md b/docs/book/21-vscode-extension.md index 4f1f95d..4cd6b45 100644 --- a/docs/book/21-vscode-extension.md +++ b/docs/book/21-vscode-extension.md @@ -16,6 +16,7 @@ The VS Code extension is: - read-only with respect to repository state - baseline-aware and triage-first - code-centered rather than report-dashboard-centered +- limited in Restricted Mode and fully active only after workspace trust The extension exists to make the current CodeClone review workflow easier to use inside the editor. It must not reinterpret report semantics or invent @@ -49,7 +50,9 @@ It also provides: - command palette entry points for analysis and review - one onboarding walkthrough - markdown detail panels for findings, remediation, help topics, setup help, - and report-only God Module detail + restricted-mode guidance, and report-only God Module detail +- lightweight Explorer file decorations for review-relevant files +- editor-local CodeLens and title actions for the active review target ## Workflow model @@ -64,6 +67,23 @@ The intended IDE path mirrors CodeClone MCP: This is deliberately different from a lint-list model. The extension should prefer guided review over broad enumeration. +## Current capabilities + +The extension currently supports: + +- full-workspace analysis +- changed-files analysis against a configured git diff reference +- compact overview of structural health, current run state, and baseline drift +- review queues for new regressions, production hotspots, changed-scope + findings, and report-only `God Modules` +- source reveal, peek, canonical finding detail, remediation detail, and + session-local reviewed markers +- bounded MCP help topics inside the IDE +- explicit HTML-report bridge when a local HTML report already exists + +These capabilities must remain clients of MCP and canonical report truth rather +than parallel extension-only logic. + ## State boundaries The extension must keep three state classes visibly separate: @@ -92,16 +112,34 @@ Reviewed markers: The extension runs as a workspace extension and requires: -- a trusted workspace - local filesystem access - local git access for changed-files review - a local `codeclone-mcp` launcher, or an explicitly configured launcher For this reason: -- untrusted workspaces are unsupported +- Restricted Mode support is `limited` +- untrusted workspaces may show setup/onboarding/help surfaces only +- local analysis and local MCP startup remain disabled until trust is granted - virtual workspaces are unsupported +## Design decisions + +The extension follows these implementation rules: + +- **Native VS Code first**: tree views, status bar, Quick Pick, CodeLens, and + file decorations come before any richer custom UI. +- **Source-first review**: findings prefer `Reveal Source` over immediate + detail panels. +- **Explicit deepening**: canonical finding detail, remediation, and HTML + report bridges remain opt-in actions. +- **Report-only separation**: `God Modules` stay clearly outside findings, + gates, and health dimensions. +- **Safe local HTML bridge**: `Open in HTML Report` must verify that a local + `report.html` exists and is not obviously older than the current run. +- **Session-local workflow state**: reviewed markers may shape the review UX + but must not leak into repository truth. + ## UX rules The extension should preserve these product rules: @@ -114,6 +152,10 @@ The extension should preserve these product rules: findings, gates, and health dimensions. - The extension should minimize noise and avoid duplicating the HTML report in the sidebar. +- Restricted Mode should still explain what the extension needs, without + pretending local analysis is available before trust is granted. +- Accessibility labels should stay meaningful on tree items and status + surfaces. ## Relationship to other interfaces @@ -127,3 +169,5 @@ The extension should preserve these product rules: - Exact view grouping and copy may evolve between beta releases. - Internal client-side caching and view-model shaping may evolve as long as the extension remains faithful to MCP and canonical report semantics. +- Explorer decoration styling, review-loop polish, and other non-contract UI + details may evolve without changing the extension contract. diff --git a/docs/vscode-extension.md b/docs/vscode-extension.md index 00318c7..f3076ac 100644 --- a/docs/vscode-extension.md +++ b/docs/vscode-extension.md @@ -6,7 +6,7 @@ CodeClone ships a preview VS Code extension in It is a native IDE surface over `codeclone-mcp` and is designed for baseline-aware, triage-first structural review inside the editor. -## What it does +## What it is for The extension helps you: @@ -15,6 +15,7 @@ The extension helps you: - focus on new regressions and production hotspots first - jump directly to source locations - open canonical finding or remediation detail only when needed +- inspect report-only God Module candidates without treating them like findings It does not create a second truth model and it does not mutate the repository. @@ -44,7 +45,7 @@ codeclone-mcp --help ### Overview -Compact health, current run state, and next-best review action. +Compact health, current run state, baseline drift, and next-best review action. ### Hotspots @@ -64,6 +65,21 @@ Session-local state: - reviewed findings - MCP help topics +## Review model + +The extension stays source-first: + +- `Review Priorities` and `Next Hotspot` / `Previous Hotspot` drive the review + loop +- `Reveal Source` is the default action for findings +- editor-local actions appear only when the current file matches the active + review target +- Explorer decorations stay lightweight and focus on new, production, or + changed-scope relevance + +`Open in HTML Report` exists as an explicit bridge to the richer human report, +not as the primary IDE workflow. + ## First-run path 1. Open the `CodeClone` view container. @@ -75,12 +91,32 @@ If the launcher is missing, use `Setup Help` from the extension. ## Trust model -The extension requires a trusted local workspace and is not intended for -virtual workspaces. +The extension uses a **limited Restricted Mode**: + +- onboarding and setup help remain available in untrusted workspaces +- local analysis and the local MCP server stay disabled until workspace trust + is granted + +The extension is not intended for virtual workspaces. That is intentional: CodeClone reads repository contents, local git state, and the local MCP launcher. +## Design decisions + +- native VS Code views first, not a custom report dashboard +- baseline-aware review instead of broad lint-style listing +- report-only layers stay visually separate from findings and health +- repository truth stays in CodeClone MCP and canonical report semantics + +## Current limits + +- no always-on background analysis +- no `Problems`-panel duplication +- no persistent reviewed markers across MCP sessions +- `Open in HTML Report` opens a local HTML report only when it exists and looks + fresh enough for the current run + ## Source of truth The extension reads the same canonical analysis semantics already exposed by: diff --git a/extensions/vscode-codeclone/.vscodeignore b/extensions/vscode-codeclone/.vscodeignore index f2b7f94..1aeb973 100644 --- a/extensions/vscode-codeclone/.vscodeignore +++ b/extensions/vscode-codeclone/.vscodeignore @@ -4,3 +4,4 @@ DESIGN.md media/.thumb/** media/icon-source.svg *.vsix +test/** diff --git a/extensions/vscode-codeclone/README.md b/extensions/vscode-codeclone/README.md index 500869a..6ea7ccc 100644 --- a/extensions/vscode-codeclone/README.md +++ b/extensions/vscode-codeclone/README.md @@ -18,6 +18,7 @@ CodeClone inside VS Code is designed for: - changed-files review against the current diff - baseline-aware distinction between known debt and new regressions - guided drill-down from hotspot to source, finding detail, and remediation +- lightweight code navigation without turning the sidebar into a second report app It is not a generic linter panel and it does not try to duplicate the HTML report inside the sidebar. @@ -70,7 +71,8 @@ palette. ### Overview -Compact repository health, current run state, and next-best review action. +Compact repository health, current run state, baseline drift, and next-best +review action. ### Hotspots @@ -81,6 +83,10 @@ The main operational view. It focuses on: - changed-files findings - report-only God Module candidates +Focus mode is explicit and persisted per workspace. The extension favors +`Recommended` by default and keeps report-only candidates visually separate from +findings. + ### Runs & Session Bounded MCP session state: @@ -93,6 +99,16 @@ Bounded MCP session state: Reviewed markers are session-local only and do not mutate the repository or the canonical report. +### Editor interaction + +- `Reveal Source` is the default review action for findings +- active review targets can be stepped with `Next Hotspot` / `Previous Hotspot` +- review-relevant files receive lightweight Explorer decorations +- CodeLens and editor-title actions appear only when the current editor matches + the active review target +- `Open in HTML Report` is available as an explicit bridge, not as the primary + review surface + ## Interaction model The extension is intentionally code-centered: @@ -100,13 +116,36 @@ The extension is intentionally code-centered: - findings prefer `Reveal Source` as the default review action - source locations are opened in the editor and softly highlighted - deeper actions stay explicit: - - `Open Finding` - - `Show Remediation` - - `Mark Reviewed` + - `Open Finding` + - `Show Remediation` + - `Mark Reviewed` This keeps the extension focused on review and refactoring flow instead of opening raw JSON-like details by default. +## Product decisions + +- **Native VS Code first**: tree views, status bar, file decorations, and + editor actions come before any richer custom surface. +- **No second truth model**: health, findings, and drift come from CodeClone + MCP and canonical report semantics only. +- **Source-first**: review should move you to code before it opens deeper + detail. +- **Report-only separation**: `God Modules` are visible but intentionally kept + outside findings, gates, and health. +- **Limited Restricted Mode**: the extension keeps setup/onboarding available in + untrusted workspaces, but local analysis and MCP stay disabled until trust is + granted. + +## Current limits + +- The extension does not run background analysis on every save. +- It does not populate VS Code Problems or try to behave like a linter. +- Reviewed markers are session-local only. +- `Open in HTML Report` only uses a local `report.html` when one already exists + and looks fresh enough for the current run. +- Virtual workspaces are not supported. + ## Settings ### `codeclone.mcp.command` @@ -135,7 +174,7 @@ Show or hide the workspace-level status bar item. This extension runs structural analysis against the current repository and uses local filesystem and git state. For that reason: -- untrusted workspaces are not supported +- untrusted workspaces are supported only in a limited onboarding/setup mode - virtual workspaces are not supported - the extension runs as a workspace extension @@ -164,6 +203,9 @@ Host. Useful local checks: ```bash +node --check src/support.js node --check src/mcpClient.js node --check src/extension.js +node --test test/*.test.js +node test/runExtensionHost.js ``` diff --git a/extensions/vscode-codeclone/package.json b/extensions/vscode-codeclone/package.json index 8c54698..5480488 100644 --- a/extensions/vscode-codeclone/package.json +++ b/extensions/vscode-codeclone/package.json @@ -30,8 +30,12 @@ ], "capabilities": { "untrustedWorkspaces": { - "supported": false, - "description": "CodeClone starts local structural analysis and should run only in trusted workspaces." + "supported": "limited", + "description": "CodeClone exposes setup and onboarding in Restricted Mode, but starts local analysis and MCP only in trusted workspaces.", + "restrictedConfigurations": [ + "codeclone.mcp.command", + "codeclone.mcp.args" + ] }, "virtualWorkspaces": { "supported": false, @@ -42,7 +46,9 @@ "vscode": "^1.100.0" }, "scripts": { - "check": "node --check src/mcpClient.js && node --check src/extension.js" + "check": "node --check src/support.js && node --check src/mcpClient.js && node --check src/extension.js", + "test": "node --test test/*.test.js", + "test:host": "node test/runExtensionHost.js" }, "categories": [ "Linters", @@ -58,8 +64,18 @@ "onCommand:codeclone.analyzeChangedFiles", "onCommand:codeclone.refreshCurrentRun", "onCommand:codeclone.openProductionTriage", + "onCommand:codeclone.reviewPriorityQueue", + "onCommand:codeclone.nextReviewItem", + "onCommand:codeclone.previousReviewItem", + "onCommand:codeclone.setHotspotFocusMode", + "onCommand:codeclone.showRemediation", + "onCommand:codeclone.markFindingReviewed", "onCommand:codeclone.showHelpTopic", - "onCommand:codeclone.openSetupHelp" + "onCommand:codeclone.openSetupHelp", + "onCommand:codeclone.openGodModule", + "onCommand:codeclone.copyGodModuleBrief" + , + "onCommand:codeclone.manageWorkspaceTrust" ], "main": "./src/extension.js", "contributes": { @@ -94,28 +110,43 @@ "viewsWelcome": [ { "view": "codeclone.overview", - "when": "!codeclone.connected && !codeclone.hasRun", - "contents": "Start with [Analyze Workspace](command:codeclone.analyzeWorkspace) to connect to CodeClone and create the first run.\n\nUse [Review Changes](command:codeclone.analyzeChangedFiles) for a review-focused pass.\n\nNeed local setup steps? Open [Setup Help](command:codeclone.openSetupHelp).\n\nUse [Verify Local Server](command:codeclone.connectMcp) only if you want to check the launcher manually." + "when": "!isWorkspaceTrusted", + "contents": "CodeClone is in Restricted Mode for this workspace. Grant trust to run local structural analysis and the local MCP server.\n\nOpen [Manage Workspace Trust](command:codeclone.manageWorkspaceTrust) to continue, or [Setup Help](command:codeclone.openSetupHelp) if you want to review the local requirements first." + }, + { + "view": "codeclone.overview", + "when": "isWorkspaceTrusted && !codeclone.connected && !codeclone.hasRun", + "contents": "Start with [Analyze Workspace](command:codeclone.analyzeWorkspace) to create the first structural run for this workspace.\n\nUse [Review Changes](command:codeclone.analyzeChangedFiles) when you want a diff-focused pass.\n\nNeed local setup? Open [Setup Help](command:codeclone.openSetupHelp).\n\nUse [Verify Local Server](command:codeclone.connectMcp) only when you want to check the launcher manually." }, { "view": "codeclone.overview", - "when": "codeclone.connected && !codeclone.hasRun", - "contents": "CodeClone is ready. Run [Analyze Workspace](command:codeclone.analyzeWorkspace) or [Review Changes](command:codeclone.analyzeChangedFiles).\n\nNeed a quick orientation? Open [workflow help](command:codeclone.showHelpTopic)." + "when": "isWorkspaceTrusted && codeclone.connected && !codeclone.hasRun", + "contents": "CodeClone is ready for this workspace. Run [Analyze Workspace](command:codeclone.analyzeWorkspace) for a full structural pass, or [Review Changes](command:codeclone.analyzeChangedFiles) for a diff-focused review.\n\nNeed a quick mental model? Open [workflow help](command:codeclone.showHelpTopic)." }, { "view": "codeclone.hotspots", - "when": "!codeclone.hasRun", - "contents": "Hotspots appear after a run. Start with [Analyze Workspace](command:codeclone.analyzeWorkspace) or [Review Changes](command:codeclone.analyzeChangedFiles)." + "when": "!isWorkspaceTrusted", + "contents": "Hotspots stay unavailable in Restricted Mode because CodeClone does not start local analysis for untrusted workspaces.\n\nOpen [Manage Workspace Trust](command:codeclone.manageWorkspaceTrust) when you want to enable analysis." + }, + { + "view": "codeclone.hotspots", + "when": "isWorkspaceTrusted && !codeclone.hasRun", + "contents": "Hotspots appear after the first run. Start with [Analyze Workspace](command:codeclone.analyzeWorkspace), or use [Review Changes](command:codeclone.analyzeChangedFiles) when you want a diff-focused pass." }, { "view": "codeclone.session", - "when": "!codeclone.connected", - "contents": "Session state appears after the first connection. Run [Analyze Workspace](command:codeclone.analyzeWorkspace) to connect automatically, or [Verify Local Server](command:codeclone.connectMcp) to check the local launcher.\n\nIf CodeClone is not installed yet, open [Setup Help](command:codeclone.openSetupHelp)." + "when": "!isWorkspaceTrusted", + "contents": "Restricted Mode keeps the local CodeClone server offline for this workspace.\n\nOpen [Manage Workspace Trust](command:codeclone.manageWorkspaceTrust) to enable local MCP, or [Setup Help](command:codeclone.openSetupHelp) to review installation steps." }, { "view": "codeclone.session", - "when": "codeclone.connected && !codeclone.hasRun", - "contents": "The local CodeClone server is ready. Run [Analyze Workspace](command:codeclone.analyzeWorkspace) to create the first run." + "when": "isWorkspaceTrusted && !codeclone.connected", + "contents": "Runs and reviewed findings appear after the first successful connection. Run [Analyze Workspace](command:codeclone.analyzeWorkspace) to connect automatically, or [Verify Local Server](command:codeclone.connectMcp) to check the local launcher.\n\nIf CodeClone is not installed yet, open [Setup Help](command:codeclone.openSetupHelp)." + }, + { + "view": "codeclone.session", + "when": "isWorkspaceTrusted && codeclone.connected && !codeclone.hasRun", + "contents": "The local CodeClone server is ready. Run [Analyze Workspace](command:codeclone.analyzeWorkspace) or [Review Changes](command:codeclone.analyzeChangedFiles) to create the first run." } ], "commands": [ @@ -155,12 +186,41 @@ "category": "CodeClone", "icon": "$(list-selection)" }, + { + "command": "codeclone.focusHotspots", + "title": "Open Hotspots", + "category": "CodeClone" + }, + { + "command": "codeclone.nextReviewItem", + "title": "Next Hotspot", + "category": "CodeClone", + "icon": "$(arrow-down)" + }, + { + "command": "codeclone.previousReviewItem", + "title": "Previous Hotspot", + "category": "CodeClone", + "icon": "$(arrow-up)" + }, + { + "command": "codeclone.setHotspotFocusMode", + "title": "Set Hotspot Focus", + "category": "CodeClone", + "icon": "$(filter)" + }, { "command": "codeclone.openFinding", "title": "Open Finding", "category": "CodeClone", "icon": "$(go-to-file)" }, + { + "command": "codeclone.peekFindingLocations", + "title": "Peek Occurrences", + "category": "CodeClone", + "icon": "$(references)" + }, { "command": "codeclone.showRemediation", "title": "Show Remediation", @@ -179,6 +239,24 @@ "category": "CodeClone", "icon": "$(copy)" }, + { + "command": "codeclone.copyFindingContext", + "title": "Copy Finding Context", + "category": "CodeClone", + "icon": "$(copy)" + }, + { + "command": "codeclone.copyRefactorBrief", + "title": "Copy Refactor Brief", + "category": "CodeClone", + "icon": "$(copy)" + }, + { + "command": "codeclone.openInHtmlReport", + "title": "Open in HTML Report", + "category": "CodeClone", + "icon": "$(link-external)" + }, { "command": "codeclone.revealFindingSource", "title": "Reveal Source", @@ -197,12 +275,24 @@ "category": "CodeClone", "icon": "$(tools)" }, + { + "command": "codeclone.manageWorkspaceTrust", + "title": "Manage Workspace Trust", + "category": "CodeClone", + "icon": "$(shield)" + }, { "command": "codeclone.openGodModule", "title": "Open Candidate Detail", "category": "CodeClone", "icon": "$(symbol-module)" }, + { + "command": "codeclone.copyGodModuleBrief", + "title": "Copy Report-only Brief", + "category": "CodeClone", + "icon": "$(copy)" + }, { "command": "codeclone.openOverview", "title": "Open Overview", @@ -217,10 +307,22 @@ ], "menus": { "commandPalette": [ + { + "command": "codeclone.focusHotspots", + "when": "false" + }, + { + "command": "codeclone.reviewFinding", + "when": "false" + }, { "command": "codeclone.openFinding", "when": "false" }, + { + "command": "codeclone.peekFindingLocations", + "when": "false" + }, { "command": "codeclone.showRemediation", "when": "false" @@ -233,6 +335,18 @@ "command": "codeclone.copyFindingId", "when": "false" }, + { + "command": "codeclone.copyFindingContext", + "when": "false" + }, + { + "command": "codeclone.copyRefactorBrief", + "when": "false" + }, + { + "command": "codeclone.openInHtmlReport", + "when": "false" + }, { "command": "codeclone.revealFindingSource", "when": "false" @@ -240,106 +354,159 @@ { "command": "codeclone.openGodModule", "when": "false" + }, + { + "command": "codeclone.copyGodModuleBrief", + "when": "false" + }, + { + "command": "codeclone.reviewGodModule", + "when": "false" } ], "view/title": [ { "command": "codeclone.analyzeWorkspace", - "when": "view == codeclone.overview", + "when": "view == codeclone.overview && isWorkspaceTrusted", "group": "navigation@1" }, { "command": "codeclone.analyzeChangedFiles", - "when": "view == codeclone.overview", + "when": "view == codeclone.overview && isWorkspaceTrusted", "group": "navigation@2" }, { - "command": "codeclone.reviewPriorityQueue", - "when": "view == codeclone.overview && codeclone.hasRun", + "command": "codeclone.refreshCurrentRun", + "when": "view == codeclone.overview && isWorkspaceTrusted && codeclone.hasRun", "group": "navigation@3" }, { - "command": "codeclone.refreshCurrentRun", - "when": "view == codeclone.overview && codeclone.hasRun", - "group": "navigation@4" + "command": "codeclone.reviewPriorityQueue", + "when": "view == codeclone.overview && isWorkspaceTrusted && codeclone.hasRun", + "group": "secondary@1" }, { "command": "codeclone.connectMcp", - "when": "view == codeclone.overview && !codeclone.connected", - "group": "navigation@5" + "when": "view == codeclone.overview && isWorkspaceTrusted && !codeclone.connected", + "group": "secondary@2" + }, + { + "command": "codeclone.manageWorkspaceTrust", + "when": "view == codeclone.overview && !isWorkspaceTrusted", + "group": "navigation@1" + }, + { + "command": "codeclone.openSetupHelp", + "when": "view == codeclone.overview && !isWorkspaceTrusted", + "group": "secondary@1" }, { "command": "codeclone.reviewPriorityQueue", - "when": "view == codeclone.hotspots && codeclone.hasRun", + "when": "view == codeclone.hotspots && isWorkspaceTrusted && codeclone.hasRun", "group": "navigation@1" }, { - "command": "codeclone.refreshCurrentRun", - "when": "view == codeclone.hotspots && codeclone.hasRun", + "command": "codeclone.setHotspotFocusMode", + "when": "view == codeclone.hotspots && isWorkspaceTrusted && codeclone.hasRun", "group": "navigation@2" }, { - "command": "codeclone.openProductionTriage", - "when": "view == codeclone.hotspots && codeclone.hasRun", + "command": "codeclone.refreshCurrentRun", + "when": "view == codeclone.hotspots && isWorkspaceTrusted && codeclone.hasRun", "group": "navigation@3" }, + { + "command": "codeclone.analyzeChangedFiles", + "when": "view == codeclone.hotspots && isWorkspaceTrusted && codeclone.hasRun", + "group": "secondary@1" + }, + { + "command": "codeclone.openProductionTriage", + "when": "view == codeclone.hotspots && isWorkspaceTrusted && codeclone.hasRun", + "group": "secondary@2" + }, { "command": "codeclone.analyzeWorkspace", - "when": "view == codeclone.hotspots && !codeclone.hasRun", - "group": "navigation@4" + "when": "view == codeclone.hotspots && isWorkspaceTrusted && !codeclone.hasRun", + "group": "navigation@1" }, { - "command": "codeclone.analyzeChangedFiles", - "when": "view == codeclone.hotspots", - "group": "navigation@5" + "command": "codeclone.manageWorkspaceTrust", + "when": "view == codeclone.hotspots && !isWorkspaceTrusted", + "group": "navigation@1" }, { "command": "codeclone.connectMcp", - "when": "view == codeclone.session && !codeclone.connected", + "when": "view == codeclone.session && isWorkspaceTrusted && !codeclone.connected", "group": "navigation@1" }, { - "command": "codeclone.openSetupHelp", - "when": "view == codeclone.session && !codeclone.connected", - "group": "navigation@2" + "command": "codeclone.manageWorkspaceTrust", + "when": "view == codeclone.session && !isWorkspaceTrusted", + "group": "navigation@1" }, { "command": "codeclone.clearSessionState", - "when": "view == codeclone.session && codeclone.hasRun", - "group": "navigation@3" + "when": "view == codeclone.session && isWorkspaceTrusted && codeclone.hasRun", + "group": "navigation@2" }, { "command": "codeclone.showHelpTopic", - "when": "view == codeclone.session", - "group": "navigation@4" + "when": "view == codeclone.session && isWorkspaceTrusted", + "group": "secondary@1" + }, + { + "command": "codeclone.openSetupHelp", + "when": "view == codeclone.session && (!isWorkspaceTrusted || !codeclone.connected)", + "group": "secondary@2" } ], "view/item/context": [ { "command": "codeclone.revealFindingSource", - "when": "viewItem == codeclone.finding", + "when": "viewItem == codeclone.finding || viewItem == codeclone.reviewedFinding", "group": "inline@1" }, { - "command": "codeclone.showRemediation", - "when": "viewItem == codeclone.finding", + "command": "codeclone.peekFindingLocations", + "when": "viewItem == codeclone.finding || viewItem == codeclone.reviewedFinding", "group": "inline@2" }, { - "command": "codeclone.openFinding", + "command": "codeclone.showRemediation", "when": "viewItem == codeclone.finding", "group": "navigation@1" }, { - "command": "codeclone.markFindingReviewed", - "when": "viewItem == codeclone.finding", + "command": "codeclone.openFinding", + "when": "viewItem == codeclone.finding || viewItem == codeclone.reviewedFinding", "group": "navigation@2" }, { - "command": "codeclone.copyFindingId", + "command": "codeclone.markFindingReviewed", "when": "viewItem == codeclone.finding", "group": "navigation@3" }, + { + "command": "codeclone.copyRefactorBrief", + "when": "viewItem == codeclone.finding || viewItem == codeclone.reviewedFinding", + "group": "navigation@4" + }, + { + "command": "codeclone.openInHtmlReport", + "when": "viewItem == codeclone.finding || viewItem == codeclone.reviewedFinding", + "group": "navigation@5" + }, + { + "command": "codeclone.copyFindingContext", + "when": "viewItem == codeclone.finding || viewItem == codeclone.reviewedFinding", + "group": "navigation@6" + }, + { + "command": "codeclone.copyFindingId", + "when": "viewItem == codeclone.finding || viewItem == codeclone.reviewedFinding", + "group": "navigation@7" + }, { "command": "codeclone.showHelpTopic", "when": "viewItem == codeclone.helpTopic", @@ -349,6 +516,48 @@ "command": "codeclone.openGodModule", "when": "viewItem == codeclone.godModule", "group": "inline@1" + }, + { + "command": "codeclone.copyGodModuleBrief", + "when": "viewItem == codeclone.godModule", + "group": "navigation@1" + } + ], + "editor/title": [ + { + "command": "codeclone.previousReviewItem", + "when": "editorTextFocus && codeclone.activeReviewTargetVisibleInEditor", + "group": "navigation@1" + }, + { + "command": "codeclone.nextReviewItem", + "when": "editorTextFocus && codeclone.activeReviewTargetVisibleInEditor", + "group": "navigation@2" + }, + { + "command": "codeclone.showRemediation", + "when": "editorTextFocus && codeclone.activeReviewTargetVisibleInEditor && codeclone.activeReviewTargetIsFinding", + "group": "navigation@3" + }, + { + "command": "codeclone.markFindingReviewed", + "when": "editorTextFocus && codeclone.activeReviewTargetVisibleInEditor && codeclone.activeReviewTargetIsFinding && !codeclone.activeReviewTargetIsReviewed", + "group": "navigation@4" + }, + { + "command": "codeclone.copyRefactorBrief", + "when": "editorTextFocus && codeclone.activeReviewTargetVisibleInEditor && codeclone.activeReviewTargetIsFinding", + "group": "secondary@1" + }, + { + "command": "codeclone.openInHtmlReport", + "when": "editorTextFocus && codeclone.activeReviewTargetVisibleInEditor && codeclone.activeReviewTargetIsFinding", + "group": "secondary@2" + }, + { + "command": "codeclone.copyGodModuleBrief", + "when": "editorTextFocus && codeclone.activeReviewTargetVisibleInEditor && codeclone.activeReviewTargetIsGodModule", + "group": "secondary@1" } ] }, diff --git a/extensions/vscode-codeclone/src/extension.js b/extensions/vscode-codeclone/src/extension.js index 41e1453..44b202a 100644 --- a/extensions/vscode-codeclone/src/extension.js +++ b/extensions/vscode-codeclone/src/extension.js @@ -1,10 +1,23 @@ "use strict"; +const { execFile } = require("node:child_process"); const fs = require("node:fs"); const path = require("node:path"); +const { promisify } = require("node:util"); const vscode = require("vscode"); const { CodeCloneMcpClient, MCPClientError } = require("./mcpClient"); +const { + STALE_REASON_EDITOR, + STALE_REASON_WORKSPACE, + normalizedLaunchSpec, + parseUtcTimestamp, + resolveWorkspacePath, + signedInteger, + staleMessage, +} = require("./support"); + +const execFileAsync = promisify(execFile); const HELP_TOPICS = [ "workflow", @@ -22,6 +35,69 @@ const HOTSPOT_GROUPS = [ { id: "godModules", label: "God Modules", icon: "symbol-module" }, ]; +const HOTSPOT_FOCUS_MODES = [ + { + id: "recommended", + label: "Recommended", + description: "Show the highest-signal review surfaces for the current run.", + }, + { + id: "new", + label: "New Regressions", + description: "Focus only on baseline-new findings.", + }, + { + id: "production", + label: "Production", + description: "Focus only on production hotspots.", + }, + { + id: "changed", + label: "Changed Files", + description: "Focus only on findings touching the selected diff.", + }, + { + id: "reportOnly", + label: "Report-only", + description: "Focus only on report-only God Module candidates.", + }, + { + id: "all", + label: "All Groups", + description: "Show every hotspot group, including empty ones.", + }, +]; + +const HOTSPOT_GROUPS_BY_MODE = { + recommended: HOTSPOT_GROUPS.map((group) => group.id), + new: ["newRegressions"], + production: ["productionHotspots"], + changed: ["changedFiles"], + reportOnly: ["godModules"], + all: HOTSPOT_GROUPS.map((group) => group.id), +}; + +const REVIEW_DECORATION_THEMES = { + new: { + badge: "N", + color: "problemsErrorIcon.foreground", + tooltip: "CodeClone new regression", + }, + production: { + badge: "P", + color: "problemsWarningIcon.foreground", + tooltip: "CodeClone production hotspot", + }, + changed: { + badge: "C", + color: "charts.blue", + tooltip: "CodeClone changed-files review item", + }, +}; + +const WORKSPACE_STATE_HOTSPOT_FOCUS_MODE = "codeclone.hotspotFocusMode"; +const WORKSPACE_STATE_LAST_HELP_TOPIC = "codeclone.lastHelpTopic"; + function number(value) { if (typeof value !== "number" || Number.isNaN(value)) { return "0"; @@ -96,6 +172,18 @@ function sameLaunchSpec(left, right) { ); } +function normalizeRelativePath(value) { + return String(value || "").replace(/\\/g, "/"); +} + +function workspaceRelativePath(folder, fsPath) { + return normalizeRelativePath(path.relative(folder.uri.fsPath, fsPath)); +} + +function uniqueStrings(values) { + return [...new Set(values.filter(Boolean))]; +} + function formatSeverity(value) { return capitalize(String(value || "info")); } @@ -128,6 +216,30 @@ function formatKind(value) { } } +function focusModeSpec(modeId) { + return ( + HOTSPOT_FOCUS_MODES.find((entry) => entry.id === modeId) || + HOTSPOT_FOCUS_MODES[0] + ); +} + +function isSpecificFocusMode(modeId) { + return modeId !== "recommended" && modeId !== "all"; +} + +function reviewTargetKey(target) { + if (!target || typeof target !== "object") { + return ""; + } + if (target.nodeType === "godModule" && safeObject(target.item).path) { + return `god:${String(target.item.path)}`; + } + if (target.findingId) { + return `finding:${String(target.findingId)}`; + } + return ""; +} + function findingIcon(severity) { switch (String(severity || "").toLowerCase()) { case "critical": @@ -156,6 +268,15 @@ function safeObject(value) { return value && typeof value === "object" ? value : {}; } +function emptyReviewArtifacts() { + return { + newRegressions: [], + productionHotspots: [], + changedFiles: [], + godModules: [], + }; +} + function normalizeLocations(value) { if (!Array.isArray(value)) { return []; @@ -191,6 +312,60 @@ function firstLocation(value) { return locations.length > 0 ? locations[0] : null; } +function normalizeFindingLocations(folder, value) { + return normalizeLocations(value) + .filter((location) => location.path) + .map((location) => { + const relativePath = normalizeRelativePath(location.path); + const absolutePath = resolveWorkspacePath(folder.uri.fsPath, relativePath); + if (!absolutePath) { + return null; + } + return { + ...location, + path: relativePath, + absolutePath, + }; + }) + .filter(Boolean); +} + +function firstNormalizedLocation(folder, value) { + const locations = normalizeFindingLocations(folder, value); + return locations.length > 0 ? locations[0] : null; +} + +async function gitStdout(cwd, args) { + try { + const result = await execFileAsync("git", args, { + cwd, + maxBuffer: 1024 * 1024, + }); + return String(result.stdout || "").trim(); + } catch { + return null; + } +} + +async function captureWorkspaceGitSnapshot(folder) { + const cwd = folder.uri.fsPath; + const [head, status] = await Promise.all([ + gitStdout(cwd, ["rev-parse", "HEAD"]), + gitStdout(cwd, ["status", "--porcelain=v1", "--untracked-files=normal"]), + ]); + return { + head, + dirtySignature: status || "", + }; +} + +function sameGitSnapshot(left, right) { + return ( + safeObject(left).head === safeObject(right).head && + safeObject(left).dirtySignature === safeObject(right).dirtySignature + ); +} + function looksLikeCodeCloneRepo(folderPath) { return ( fs.existsSync(path.join(folderPath, "pyproject.toml")) && @@ -198,6 +373,17 @@ function looksLikeCodeCloneRepo(folderPath) { ); } +function readFileHead(filePath, maxBytes = 16384) { + const fd = fs.openSync(filePath, "r"); + try { + const buffer = Buffer.allocUnsafe(maxBytes); + const bytesRead = fs.readSync(fd, buffer, 0, maxBytes, 0); + return buffer.toString("utf8", 0, bytesRead); + } finally { + fs.closeSync(fd); + } +} + function markdownBulletList(values) { return values.map((value) => `- ${value}`).join("\n"); } @@ -268,6 +454,28 @@ function renderSetupMarkdown() { ].join("\n"); } +function renderRestrictedModeMarkdown(topic) { + return [ + `# CodeClone: Restricted Mode`, + "", + `The workspace is not trusted, so CodeClone keeps local analysis and the local MCP server offline.`, + "", + topic + ? `Live MCP help for \`${topic}\` becomes available after workspace trust is granted.` + : "Live MCP help topics become available after workspace trust is granted.", + "", + "## What you can do safely right now", + "", + "- Review installation and setup guidance.", + "- Inspect the extension surface and onboarding text.", + "- Grant workspace trust when you are ready to enable local analysis.", + "", + "## Next step", + "", + "Run `Manage Workspace Trust`, then open the help topic again.", + ].join("\n"); +} + function renderFindingMarkdown(payload) { const remediation = safeObject(payload.remediation); const locations = normalizeLocations(payload.locations); @@ -417,6 +625,16 @@ function renderGodModuleMarkdown(item) { return lines.join("\n"); } +function treeAccessibilityInformation(node) { + const label = String(node?.label || "").trim(); + const description = String(node?.description || "").trim(); + if (!label && !description) { + return undefined; + } + const spoken = description ? `${label}, ${description}` : label; + return { label: spoken }; +} + class WorkspaceState { constructor(folder) { this.folder = folder; @@ -429,6 +647,11 @@ class WorkspaceState { this.lastScope = "workspace"; this.lastUpdatedAt = null; this.groupCache = new Map(); + this.reviewArtifacts = emptyReviewArtifacts(); + this.gitSnapshot = null; + this.stale = false; + this.staleReason = null; + this.lastStaleCheckAt = 0; } } @@ -478,12 +701,62 @@ class SessionTreeProvider extends BaseTreeProvider { } } +class ReviewCodeLensProvider { + constructor(controller) { + this.controller = controller; + this.emitter = new vscode.EventEmitter(); + this.onDidChangeCodeLenses = this.emitter.event; + } + + refresh() { + this.emitter.fire(undefined); + } + + provideCodeLenses(document) { + return this.controller.provideReviewCodeLenses(document); + } + + dispose() { + this.emitter.dispose(); + } +} + +class ReviewFileDecorationProvider { + constructor(controller) { + this.controller = controller; + this.emitter = new vscode.EventEmitter(); + this.onDidChangeFileDecorations = this.emitter.event; + } + + refresh(uri) { + this.emitter.fire(uri); + } + + provideFileDecoration(uri) { + return this.controller.provideFileDecoration(uri); + } + + dispose() { + this.emitter.dispose(); + } +} + class CodeCloneController { constructor(context) { this.context = context; this.outputChannel = vscode.window.createOutputChannel("CodeClone"); this.client = new CodeCloneMcpClient(this.outputChannel); this.states = new Map(); + this.hotspotFocusMode = this.loadHotspotFocusMode(); + const storedHelpTopic = this.context.workspaceState.get( + WORKSPACE_STATE_LAST_HELP_TOPIC, + HELP_TOPICS[0] + ); + this.lastHelpTopic = HELP_TOPICS.includes(storedHelpTopic) + ? storedHelpTopic + : HELP_TOPICS[0]; + this.activeReviewTarget = null; + this.fileDecorations = new Map(); this.revealDecoration = vscode.window.createTextEditorDecorationType({ isWholeLine: true, borderWidth: "1px", @@ -505,10 +778,13 @@ class CodeCloneController { vscode.StatusBarAlignment.Left, 10 ); + this.statusBar.name = "CodeClone"; this.statusBar.command = "codeclone.openOverview"; this.overviewProvider = new OverviewTreeProvider(this); this.hotspotsProvider = new HotspotsTreeProvider(this); this.sessionProvider = new SessionTreeProvider(this); + this.reviewCodeLensProvider = new ReviewCodeLensProvider(this); + this.reviewFileDecorationProvider = new ReviewFileDecorationProvider(this); this.overviewView = vscode.window.createTreeView("codeclone.overview", { treeDataProvider: this.overviewProvider, showCollapseAll: false, @@ -538,7 +814,7 @@ class CodeCloneController { }); this.client.on("exit", async () => { await vscode.window.showWarningMessage( - "The local CodeClone server disconnected. Run Analyze Workspace to reconnect and refresh the current workspace." + "The local CodeClone server disconnected. Run Analyze Workspace or Review Changes to reconnect." ); }); context.subscriptions.push( @@ -548,9 +824,33 @@ class CodeCloneController { this.overviewProvider, this.hotspotsProvider, this.sessionProvider, + this.reviewCodeLensProvider, + this.reviewFileDecorationProvider, this.overviewView, this.hotspotsView, this.sessionView, + vscode.languages.registerCodeLensProvider( + { scheme: "file" }, + this.reviewCodeLensProvider + ), + vscode.window.registerFileDecorationProvider( + this.reviewFileDecorationProvider + ), + vscode.workspace.onDidChangeTextDocument((event) => + this.handleTextDocumentChanged(event) + ), + vscode.workspace.onDidSaveTextDocument((document) => + this.handleTextDocumentSaved(document) + ), + vscode.window.onDidChangeActiveTextEditor(() => + this.handleActiveEditorChanged() + ), + vscode.window.onDidChangeWindowState((state) => + this.handleWindowStateChanged(state) + ), + vscode.workspace.onDidGrantWorkspaceTrust(() => + this.handleWorkspaceTrustGranted() + ), { dispose: () => { void this.client.dispose(); @@ -565,6 +865,9 @@ class CodeCloneController { registerCommands() { const subscriptions = [ + vscode.commands.registerCommand("codeclone.manageWorkspaceTrust", () => + this.manageWorkspaceTrust() + ), vscode.commands.registerCommand("codeclone.connectMcp", () => this.connectMcp() ), @@ -583,12 +886,27 @@ class CodeCloneController { vscode.commands.registerCommand("codeclone.reviewPriorityQueue", () => this.reviewPriorityQueue() ), + vscode.commands.registerCommand("codeclone.focusHotspots", () => + this.focusHotspots() + ), + vscode.commands.registerCommand("codeclone.nextReviewItem", () => + this.moveReviewCursor(1) + ), + vscode.commands.registerCommand("codeclone.previousReviewItem", () => + this.moveReviewCursor(-1) + ), + vscode.commands.registerCommand("codeclone.setHotspotFocusMode", () => + this.setHotspotFocusMode() + ), vscode.commands.registerCommand("codeclone.reviewFinding", (node) => this.reviewFinding(node) ), vscode.commands.registerCommand("codeclone.openFinding", (node) => this.openFinding(node) ), + vscode.commands.registerCommand("codeclone.peekFindingLocations", (node) => + this.peekFindingLocations(node) + ), vscode.commands.registerCommand("codeclone.showRemediation", (node) => this.showRemediation(node) ), @@ -598,6 +916,15 @@ class CodeCloneController { vscode.commands.registerCommand("codeclone.copyFindingId", (node) => this.copyFindingId(node) ), + vscode.commands.registerCommand("codeclone.copyFindingContext", (node) => + this.copyFindingContext(node) + ), + vscode.commands.registerCommand("codeclone.copyRefactorBrief", (node) => + this.copyRefactorBrief(node) + ), + vscode.commands.registerCommand("codeclone.openInHtmlReport", (node) => + this.openInHtmlReport(node) + ), vscode.commands.registerCommand("codeclone.revealFindingSource", (node) => this.revealFindingSource(node) ), @@ -616,6 +943,9 @@ class CodeCloneController { vscode.commands.registerCommand("codeclone.openGodModule", (node) => this.openGodModule(node) ), + vscode.commands.registerCommand("codeclone.copyGodModuleBrief", (node) => + this.copyGodModuleBrief(node) + ), vscode.commands.registerCommand("codeclone.reviewGodModule", (node) => this.reviewGodModule(node) ), @@ -656,7 +986,61 @@ class CodeCloneController { return vscode.workspace.workspaceFolders?.[0] || null; } + async ensureWorkspaceTrust() { + if (vscode.workspace.isTrusted) { + return true; + } + const choice = await vscode.window.showWarningMessage( + "CodeClone requires a trusted workspace before it starts local analysis or a local MCP server.", + "Manage Workspace Trust" + ); + if (choice === "Manage Workspace Trust") { + await vscode.commands.executeCommand("workbench.trust.manage"); + } + return false; + } + + loadHotspotFocusMode() { + const stored = this.context.workspaceState.get( + WORKSPACE_STATE_HOTSPOT_FOCUS_MODE, + "recommended" + ); + const allowed = new Set(HOTSPOT_FOCUS_MODES.map((entry) => entry.id)); + return allowed.has(stored) ? stored : "recommended"; + } + + async persistHotspotFocusMode() { + await this.context.workspaceState.update( + WORKSPACE_STATE_HOTSPOT_FOCUS_MODE, + this.hotspotFocusMode + ); + } + + async persistLastHelpTopic(topic) { + this.lastHelpTopic = HELP_TOPICS.includes(topic) ? topic : HELP_TOPICS[0]; + await this.context.workspaceState.update( + WORKSPACE_STATE_LAST_HELP_TOPIC, + this.lastHelpTopic + ); + } + + async manageWorkspaceTrust() { + await vscode.commands.executeCommand("workbench.trust.manage"); + } + + handleWorkspaceTrustGranted() { + this.updateContextKeys(); + this.updateStatusBar(); + this.refreshAllViews(); + void vscode.window.showInformationMessage( + "Workspace trust granted. CodeClone analysis is now available." + ); + } + async pickWorkspaceFolder(placeHolder) { + if (!(await this.ensureWorkspaceTrust())) { + return null; + } const folders = vscode.workspace.workspaceFolders || []; if (folders.length === 0) { await vscode.window.showErrorMessage( @@ -687,29 +1071,82 @@ class CodeCloneController { return this.pickWorkspaceFolder(prompt); } + stateForDocument(document) { + if (!document || !document.uri) { + return null; + } + const folder = vscode.workspace.getWorkspaceFolder(document.uri); + if (!folder) { + return null; + } + return this.states.get(folder.uri.toString()) || null; + } + + handleTextDocumentChanged(event) { + const state = this.stateForDocument(event.document); + if (!state || !state.latestSummary) { + return; + } + state.stale = true; + state.staleReason = STALE_REASON_EDITOR; + this.updateContextKeys(); + this.updateStatusBar(); + this.refreshAllViews(); + } + + async handleTextDocumentSaved(document) { + const state = this.stateForDocument(document); + if (!state || !state.latestSummary) { + return; + } + await this.refreshStaleState(state); + } + + async handleActiveEditorChanged() { + this.reviewCodeLensProvider.refresh(); + this.updateContextKeys(); + const state = this.getPrimaryState(); + if (!state || !state.latestSummary) { + return; + } + await this.refreshStaleState(state); + } + + async handleWindowStateChanged(windowState) { + if (!windowState.focused) { + return; + } + const state = this.getPrimaryState(); + if (!state || !state.latestSummary) { + return; + } + await this.refreshStaleState(state); + } + resolveLaunchSpec(folder) { const config = vscode.workspace.getConfiguration("codeclone", folder.uri); const configuredCommand = config.get("mcp.command", "auto"); const configuredArgs = config.get("mcp.args", []); if (configuredCommand && configuredCommand !== "auto") { - return { + return normalizedLaunchSpec({ command: configuredCommand, args: Array.isArray(configuredArgs) ? configuredArgs : [], cwd: folder.uri.fsPath, - }; + }); } - return { + const primary = normalizedLaunchSpec({ command: "codeclone-mcp", args: Array.isArray(configuredArgs) ? configuredArgs : [], cwd: folder.uri.fsPath, - fallback: looksLikeCodeCloneRepo(folder.uri.fsPath) - ? { - command: "uv", - args: ["run", "codeclone-mcp"], - cwd: folder.uri.fsPath, - } - : null, - }; + }); + primary.fallback = looksLikeCodeCloneRepo(folder.uri.fsPath) + ? normalizedLaunchSpec({ + command: "uv", + args: ["run", "codeclone-mcp"], + cwd: folder.uri.fsPath, + }) + : null; + return primary; } async ensureConnected(folder) { @@ -776,6 +1213,178 @@ class CodeCloneController { } } + async refreshStaleState(state) { + if (!state || !state.latestSummary) { + return; + } + const now = Date.now(); + if (now - Number(state.lastStaleCheckAt || 0) < 750) { + return; + } + state.lastStaleCheckAt = now; + const hasDirtyEditors = vscode.workspace.textDocuments.some((document) => { + if (!document.isDirty) { + return false; + } + const folder = vscode.workspace.getWorkspaceFolder(document.uri); + return Boolean(folder && folder.uri.toString() === state.folder.uri.toString()); + }); + if (hasDirtyEditors) { + state.stale = true; + state.staleReason = STALE_REASON_EDITOR; + this.updateContextKeys(); + this.updateStatusBar(); + this.refreshAllViews(); + return; + } + const snapshot = await captureWorkspaceGitSnapshot(state.folder); + if (!sameGitSnapshot(snapshot, state.gitSnapshot)) { + state.stale = true; + state.staleReason = STALE_REASON_WORKSPACE; + } else { + state.stale = false; + state.staleReason = null; + } + this.updateContextKeys(); + this.updateStatusBar(); + this.refreshAllViews(); + } + + async refreshReviewArtifacts(state) { + if (!state || !state.currentRunId) { + if (state) { + state.reviewArtifacts = emptyReviewArtifacts(); + state.groupCache.clear(); + } + this.rebuildFileDecorations(); + return; + } + const runId = state.currentRunId; + const diffRef = vscode.workspace + .getConfiguration("codeclone", state.folder.uri) + .get("analysis.changedDiffRef", "HEAD"); + const [ + newRegressionsResponse, + productionHotspotsResponse, + changedFilesResponse, + godModulesResponse, + ] = await Promise.all([ + this.client.callTool("list_findings", { + run_id: runId, + novelty: "new", + detail_level: "summary", + sort_by: "priority", + limit: 200, + exclude_reviewed: true, + }), + this.client.callTool("list_hotspots", { + run_id: runId, + kind: "production_hotspots", + detail_level: "summary", + limit: 100, + exclude_reviewed: true, + }), + state.changedSummary + ? this.client.callTool("list_findings", { + run_id: runId, + git_diff_ref: diffRef, + novelty: "new", + detail_level: "summary", + sort_by: "priority", + limit: 200, + exclude_reviewed: true, + }) + : Promise.resolve({ items: [] }), + this.client.callTool("get_report_section", { + run_id: runId, + section: "metrics_detail", + family: "god_modules", + limit: 25, + }), + ]); + state.reviewArtifacts = { + newRegressions: safeArray(newRegressionsResponse.items), + productionHotspots: safeArray(productionHotspotsResponse.items), + changedFiles: safeArray(changedFilesResponse.items), + godModules: safeArray(godModulesResponse.items), + }; + state.groupCache.clear(); + this.rebuildFileDecorations(); + } + + rebuildFileDecorations() { + this.fileDecorations.clear(); + for (const state of this.states.values()) { + if (!state.latestSummary) { + continue; + } + const artifacts = safeObject(state.reviewArtifacts); + this.addFileDecorationRows( + state, + safeArray(artifacts.newRegressions), + "new" + ); + this.addFileDecorationRows( + state, + safeArray(artifacts.productionHotspots), + "production" + ); + this.addFileDecorationRows( + state, + safeArray(artifacts.changedFiles), + "changed" + ); + } + this.reviewFileDecorationProvider.refresh(undefined); + } + + addFileDecorationRows(state, rows, kind) { + for (const row of rows) { + for (const location of normalizeFindingLocations(state.folder, row.locations)) { + const key = vscode.Uri.file(location.absolutePath).toString(); + const entry = + this.fileDecorations.get(key) || { + kinds: new Set(), + findingIds: new Set(), + }; + entry.kinds.add(kind); + if (row.id) { + entry.findingIds.add(String(row.id)); + } + this.fileDecorations.set(key, entry); + } + } + } + + provideFileDecoration(uri) { + const entry = this.fileDecorations.get(uri.toString()); + if (!entry) { + return undefined; + } + const kinds = entry.kinds; + const theme = kinds.has("new") + ? REVIEW_DECORATION_THEMES.new + : kinds.has("production") + ? REVIEW_DECORATION_THEMES.production + : REVIEW_DECORATION_THEMES.changed; + const labels = []; + if (kinds.has("new")) { + labels.push("new regressions"); + } + if (kinds.has("production")) { + labels.push("production hotspots"); + } + if (kinds.has("changed")) { + labels.push("changed-files review items"); + } + return { + badge: theme.badge, + color: new vscode.ThemeColor(theme.color), + tooltip: `CodeClone: ${labels.join(" · ")}`, + propagate: kinds.has("new") || kinds.has("production"), + }; + } + async analyzeWorkspace(arg) { const folder = await this.resolveFolderFromArg( arg, @@ -852,6 +1461,7 @@ class CodeCloneController { const reviewed = await this.client.callTool("list_reviewed_findings", { run_id: runId, }); + const gitSnapshot = await captureWorkspaceGitSnapshot(folder); state.currentRunId = runId; state.latestSummary = summary; state.latestTriage = triage; @@ -860,9 +1470,15 @@ class CodeCloneController { state.reviewed = safeArray(reviewed.items); state.lastScope = changedMode ? "changed" : "workspace"; state.lastUpdatedAt = new Date(); + state.gitSnapshot = gitSnapshot; + state.stale = false; + state.staleReason = null; + state.lastStaleCheckAt = Date.now(); state.groupCache.clear(); + await this.refreshReviewArtifacts(state); } ); + this.clearActiveReviewTarget(); this.updateContextKeys(); this.updateStatusBar(); this.refreshAllViews(); @@ -892,44 +1508,395 @@ class CodeCloneController { const state = this.getPrimaryState(); if (!state || !state.latestTriage) { await vscode.window.showInformationMessage( - "Run Analyze Workspace first to open production triage." + "Start with Analyze Workspace or Review Changes before opening triage." ); return; } await this.showMarkdownDocument(renderTriageMarkdown(state)); } - async reviewPriorityQueue() { - const state = this.getPrimaryState(); - if (!state || !state.currentRunId) { - await vscode.window.showInformationMessage( - "Run Analyze Workspace first to review CodeClone priorities." - ); - return; + setActiveReviewTarget(target) { + this.activeReviewTarget = target || null; + this.updateContextKeys(); + this.reviewCodeLensProvider.refresh(); + } + + clearActiveReviewTarget() { + this.setActiveReviewTarget(null); + } + + activeFindingTarget(node) { + const candidate = node || this.activeReviewTarget; + if (!candidate || candidate.nodeType === "godModule" || !candidate.findingId) { + return null; } - try { - await this.ensureConnected(state.folder); - const queue = await this.getPriorityQueueNodes(state); - if (queue.length === 0) { - await vscode.window.showInformationMessage( - "No new or production hotspots need review in the current run." - ); - return; - } - const picked = await vscode.window.showQuickPick( - queue.map((node) => ({ - label: node.label, - description: node.description, - detail: node.tooltip, + return candidate; + } + + activeGodModuleTarget(node) { + const candidate = node || this.activeReviewTarget; + if (!candidate || candidate.nodeType !== "godModule" || !safeObject(candidate.item).path) { + return null; + } + return candidate; + } + + isTargetVisibleInEditor(target, editor = vscode.window.activeTextEditor) { + if (!target || !editor || !editor.document) { + return false; + } + const fsPath = editor.document.uri.fsPath; + if (target.nodeType === "godModule") { + const state = this.states.get(target.workspaceKey); + if (!state) { + return false; + } + return workspaceRelativePath(state.folder, fsPath) === normalizeRelativePath(target.item.path); + } + return safeArray(target.locations).some( + (location) => location.absolutePath === fsPath + ); + } + + async resolveFindingNode(node) { + const activeNode = this.activeFindingTarget(node); + if (!activeNode || !activeNode.findingId || !activeNode.runId) { + return null; + } + const state = this.states.get(activeNode.workspaceKey); + if (!state) { + return null; + } + let locations = normalizeFindingLocations(state.folder, activeNode.locations); + let detailPayload = null; + if (locations.length === 0) { + await this.ensureConnected(state.folder); + detailPayload = await this.client.callTool("get_finding", { + run_id: activeNode.runId, + finding_id: activeNode.findingId, + detail_level: "normal", + }); + locations = normalizeFindingLocations(state.folder, detailPayload.locations); + } + const resolved = { + ...activeNode, + nodeType: "finding", + workspaceKey: state.folder.uri.toString(), + runId: activeNode.runId, + findingId: activeNode.findingId, + locations, + detailPayload, + reviewed: Boolean(activeNode.reviewed), + }; + this.setActiveReviewTarget(resolved); + return resolved; + } + + reviewArtifactItems(state, groupId) { + if (!state) { + return []; + } + const artifacts = safeObject(state.reviewArtifacts); + switch (groupId) { + case "newRegressions": + return safeArray(artifacts.newRegressions); + case "productionHotspots": + return safeArray(artifacts.productionHotspots); + case "changedFiles": + return safeArray(artifacts.changedFiles); + case "godModules": + return safeArray(artifacts.godModules); + default: + return []; + } + } + + reviewArtifactCount(state, groupId) { + return this.reviewArtifactItems(state, groupId).length; + } + + activeHotspotGroupIds(state) { + const requested = + HOTSPOT_GROUPS_BY_MODE[this.hotspotFocusMode] || + HOTSPOT_GROUPS_BY_MODE.recommended; + if (this.hotspotFocusMode === "all") { + return requested; + } + return requested.filter((groupId) => this.shouldShowGroup(groupId, state)); + } + + baselineDrift(state) { + if (!state || !state.latestSummary) { + return { + cloneTrusted: false, + metricsTrusted: false, + newFindings: null, + newClones: null, + healthDelta: null, + }; + } + const summary = safeObject(state.latestSummary); + const baseline = safeObject(summary.baseline); + const metricsBaseline = safeObject(summary.metrics_baseline); + const diff = safeObject(summary.diff); + return { + cloneTrusted: Boolean(baseline.trusted), + metricsTrusted: Boolean(metricsBaseline.trusted), + newFindings: Boolean(baseline.trusted) + ? Number(safeObject(summary.findings).new || 0) + : null, + newClones: Boolean(baseline.trusted) + ? Number(diff.new_clones || 0) + : null, + healthDelta: + Boolean(metricsBaseline.trusted) && + typeof diff.health_delta === "number" + ? Number(diff.health_delta) + : null, + }; + } + + baselineDriftSummary(state) { + const drift = this.baselineDrift(state); + const parts = []; + if (drift.newFindings !== null) { + parts.push(`${drift.newFindings} new`); + } + if (drift.newClones !== null) { + parts.push(`${signedInteger(drift.newClones)} clones`); + } + if (drift.healthDelta !== null) { + parts.push(`${signedInteger(drift.healthDelta)} health`); + } + return parts.length > 0 ? parts.join(" · ") : "baseline unavailable"; + } + + async inspectLocalHtmlReport(state) { + const htmlPath = path.join( + state.folder.uri.fsPath, + ".cache", + "codeclone", + "report.html" + ); + if (!fs.existsSync(htmlPath)) { + return { + htmlPath, + exists: false, + stale: false, + reason: "missing", + generatedAtUtc: null, + }; + } + const stat = fs.statSync(htmlPath); + let generatedAtUtc = null; + try { + const html = readFileHead(htmlPath); + const match = html.match(/data-report-generated-at-utc="([^"]+)"/); + generatedAtUtc = match ? match[1] : null; + } catch { + generatedAtUtc = null; + } + const generatedAtMs = + parseUtcTimestamp(generatedAtUtc) ?? Number(stat.mtimeMs || 0); + const runUpdatedMs = state.lastUpdatedAt ? state.lastUpdatedAt.getTime() : null; + const staleBecauseOlderThanRun = + runUpdatedMs !== null && generatedAtMs > 0 && generatedAtMs + 1500 < runUpdatedMs; + const staleBecauseWorkspaceChanged = Boolean(state.stale); + const stale = staleBecauseOlderThanRun || staleBecauseWorkspaceChanged; + let reason = null; + if (staleBecauseWorkspaceChanged) { + reason = "workspace-changed"; + } else if (staleBecauseOlderThanRun) { + reason = "older-than-run"; + } + return { + htmlPath, + exists: true, + stale, + reason, + generatedAtUtc, + }; + } + + toGodModuleNodes(state, items) { + return items.map((item) => this.buildGodModuleNode(state, item)); + } + + buildGodModuleNode(state, item) { + return { + nodeType: "godModule", + workspaceKey: state.folder.uri.toString(), + runId: state.currentRunId, + item, + label: item.path, + description: `${decimal(item.score)} · ${item.source_kind} · report-only`, + tooltip: `${item.module} · ${number(item.loc)} LOC · ${item.total_deps} deps`, + icon: new vscode.ThemeIcon("symbol-module"), + contextValue: "codeclone.godModule", + command: { + command: "codeclone.reviewGodModule", + title: "Review God Module", + arguments: [ + { + workspaceKey: state.folder.uri.toString(), + runId: state.currentRunId, + item, + nodeType: "godModule", + }, + ], + }, + }; + } + + currentPriorityQueue(state) { + const artifacts = safeObject(state.reviewArtifacts); + const groupIds = + this.hotspotFocusMode === "recommended" + ? ["changedFiles", "newRegressions", "productionHotspots"] + : this.hotspotFocusMode === "all" + ? ["changedFiles", "newRegressions", "productionHotspots", "godModules"] + : this.activeHotspotGroupIds(state); + const queue = []; + const seen = new Set(); + for (const groupId of groupIds) { + if (groupId === "godModules") { + for (const node of this.toGodModuleNodes( + state, + safeArray(artifacts.godModules) + )) { + const key = reviewTargetKey(node); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + queue.push(node); + } + continue; + } + for (const node of this.toFindingNodes( + state, + this.reviewArtifactItems(state, groupId) + )) { + const key = reviewTargetKey(node); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + queue.push(node); + } + } + if ( + this.hotspotFocusMode === "recommended" && + queue.length === 0 && + safeArray(artifacts.godModules).length > 0 + ) { + return this.toGodModuleNodes(state, safeArray(artifacts.godModules)); + } + return queue; + } + + async moveReviewCursor(step) { + const state = this.getPrimaryState(); + if (!state || !state.currentRunId) { + await vscode.window.showInformationMessage( + "Start with Analyze Workspace or Review Changes before starting a review loop." + ); + return; + } + await this.ensureConnected(state.folder); + await this.refreshReviewArtifacts(state); + const queue = this.currentPriorityQueue(state); + if (queue.length === 0) { + await vscode.window.showInformationMessage( + "No review-ready items are visible in the current run." + ); + return; + } + const currentKey = reviewTargetKey(this.activeReviewTarget); + const currentIndex = queue.findIndex( + (node) => reviewTargetKey(node) === currentKey + ); + const nextIndex = + currentIndex < 0 + ? step > 0 + ? 0 + : queue.length - 1 + : currentIndex + step; + if (nextIndex < 0 || nextIndex >= queue.length) { + await vscode.window.showInformationMessage( + step > 0 + ? "Already at the last hotspot in the current priority queue." + : "Already at the first hotspot in the current priority queue." + ); + return; + } + const nextNode = queue[nextIndex]; + if (nextNode.nodeType === "godModule") { + await this.revealGodModuleSource(nextNode); + return; + } + await this.revealFindingSource(nextNode); + } + + async setHotspotFocusMode() { + const picked = await vscode.window.showQuickPick( + HOTSPOT_FOCUS_MODES.map((entry) => ({ + label: entry.label, + description: + entry.id === this.hotspotFocusMode ? "Current" : undefined, + detail: entry.description, + modeId: entry.id, + })), + { + placeHolder: "Select which hotspot groups CodeClone should emphasize", + matchOnDetail: true, + } + ); + if (!picked) { + return; + } + this.hotspotFocusMode = picked.modeId; + await this.persistHotspotFocusMode(); + this.updateContextKeys(); + this.refreshAllViews(); + } + + async reviewPriorityQueue() { + const state = this.getPrimaryState(); + if (!state || !state.currentRunId) { + await vscode.window.showInformationMessage( + "Start with Analyze Workspace or Review Changes before opening review priorities." + ); + return; + } + try { + await this.ensureConnected(state.folder); + await this.refreshReviewArtifacts(state); + const queue = this.currentPriorityQueue(state); + if (queue.length === 0) { + await vscode.window.showInformationMessage( + "No review-ready hotspots are visible in the current run." + ); + return; + } + const picked = await vscode.window.showQuickPick( + queue.map((node) => ({ + label: node.label, + description: node.description, + detail: node.tooltip, node, })), { - placeHolder: "Select the next CodeClone hotspot to review", + placeHolder: "Select the next CodeClone review item", matchOnDetail: true, } ); if (picked) { - await this.reviewFinding(picked.node); + if (picked.node.nodeType === "godModule") { + await this.reviewGodModule(picked.node); + } else { + await this.reviewFinding(picked.node); + } } } catch (error) { this.handleError(error, "Could not load the CodeClone review queue."); @@ -940,6 +1907,10 @@ class CodeCloneController { if (!node || !node.findingId || !node.runId) { return; } + const resolved = await this.resolveFindingNode(node); + if (!resolved) { + return; + } const picked = await vscode.window.showQuickPick( [ { @@ -947,6 +1918,11 @@ class CodeCloneController { description: "Recommended", action: "reveal", }, + { + label: "Peek occurrences", + description: "Inspect all reported locations", + action: "peek", + }, { label: "Open finding detail", description: "Canonical finding view", @@ -957,6 +1933,16 @@ class CodeCloneController { description: "Suggested next step", action: "remediation", }, + { + label: "Copy refactor brief", + description: "AI handoff", + action: "brief", + }, + { + label: "Open in HTML report", + description: "If a local HTML report exists", + action: "html", + }, { label: "Mark as reviewed", description: "Hide from review-focused lists", @@ -964,131 +1950,345 @@ class CodeCloneController { }, ], { - placeHolder: `What do you want to do with ${node.findingId}?`, + placeHolder: `What do you want to do with ${resolved.findingId}?`, } ); if (!picked) { return; } if (picked.action === "reveal") { - await this.revealFindingSource(node); + await this.revealFindingSource(resolved); + return; + } + if (picked.action === "peek") { + await this.peekFindingLocations(resolved); return; } if (picked.action === "detail") { - await this.openFinding(node); + await this.openFinding(resolved); return; } if (picked.action === "remediation") { - await this.showRemediation(node); + await this.showRemediation(resolved); + return; + } + if (picked.action === "brief") { + await this.copyRefactorBrief(resolved); + return; + } + if (picked.action === "html") { + await this.openInHtmlReport(resolved); return; } if (picked.action === "reviewed") { - await this.markFindingReviewed(node); + await this.markFindingReviewed(resolved); } } async openFinding(node) { - if (!node || !node.findingId || !node.runId) { - return; - } - const state = this.states.get(node.workspaceKey); - if (!state) { + const resolved = await this.resolveFindingNode(node); + if (!resolved) { return; } + const state = this.states.get(resolved.workspaceKey); try { await this.ensureConnected(state.folder); - const payload = await this.client.callTool("get_finding", { - run_id: node.runId, - finding_id: node.findingId, - detail_level: "normal", - }); + const payload = + resolved.detailPayload || + (await this.client.callTool("get_finding", { + run_id: resolved.runId, + finding_id: resolved.findingId, + detail_level: "normal", + })); await this.showMarkdownDocument(renderFindingMarkdown(payload)); } catch (error) { - this.handleError(error, `Could not open finding ${node.findingId}.`); + this.handleError(error, `Could not open finding ${resolved.findingId}.`); } } - async showRemediation(node) { - if (!node || !node.findingId || !node.runId) { + async peekFindingLocations(node) { + const resolved = await this.resolveFindingNode(node); + if (!resolved) { return; } - const state = this.states.get(node.workspaceKey); - if (!state) { + const state = this.states.get(resolved.workspaceKey); + const locations = safeArray(resolved.locations) + .map((location) => { + const uri = vscode.Uri.file(location.absolutePath); + const startLine = Math.max(Number(location.line || 1) - 1, 0); + const endLine = Math.max( + Number(location.end_line || location.line || 1) - 1, + startLine + ); + const start = new vscode.Position(startLine, 0); + const end = new vscode.Position(endLine, 0); + return new vscode.Location(uri, new vscode.Range(start, end)); + }) + .filter((entry) => fs.existsSync(entry.uri.fsPath)); + if (locations.length === 0) { + await vscode.window.showInformationMessage( + "This finding does not expose source locations for Peek." + ); + return; + } + const primary = locations[0]; + try { + const document = await vscode.workspace.openTextDocument(primary.uri); + await vscode.window.showTextDocument(document, { preview: true }); + await vscode.commands.executeCommand( + "editor.action.peekLocations", + primary.uri, + primary.range.start, + locations, + "peek" + ); + } catch (error) { + this.handleError(error, `Could not peek locations for ${resolved.findingId}.`); + } + } + + async showRemediation(node) { + const resolved = await this.resolveFindingNode(node); + if (!resolved) { return; } + const state = this.states.get(resolved.workspaceKey); try { await this.ensureConnected(state.folder); const payload = await this.client.callTool("get_remediation", { - run_id: node.runId, - finding_id: node.findingId, + run_id: resolved.runId, + finding_id: resolved.findingId, detail_level: "normal", }); await this.showMarkdownDocument(renderRemediationMarkdown(payload)); } catch (error) { - this.handleError(error, `Could not load remediation for ${node.findingId}.`); + this.handleError(error, `Could not load remediation for ${resolved.findingId}.`); } } async markFindingReviewed(node) { - if (!node || !node.findingId || !node.runId) { - return; - } - const state = this.states.get(node.workspaceKey); - if (!state) { + const resolved = await this.resolveFindingNode(node); + if (!resolved) { return; } + const state = this.states.get(resolved.workspaceKey); try { await this.ensureConnected(state.folder); await this.client.callTool("mark_finding_reviewed", { - run_id: node.runId, - finding_id: node.findingId, + run_id: resolved.runId, + finding_id: resolved.findingId, }); const reviewed = await this.client.callTool("list_reviewed_findings", { - run_id: node.runId, + run_id: resolved.runId, }); state.reviewed = safeArray(reviewed.items); + this.setActiveReviewTarget({ + ...resolved, + reviewed: true, + }); + await this.refreshReviewArtifacts(state); this.sessionProvider.refresh(); + this.refreshAllViews(); await vscode.window.showInformationMessage( - `Marked ${node.findingId} as reviewed.` + `Marked ${resolved.findingId} as reviewed.` ); } catch (error) { - this.handleError(error, `Could not mark ${node.findingId} as reviewed.`); + this.handleError(error, `Could not mark ${resolved.findingId} as reviewed.`); } } async copyFindingId(node) { - if (!node || !node.findingId) { + const activeNode = this.activeFindingTarget(node); + if (!activeNode || !activeNode.findingId) { return; } - await vscode.env.clipboard.writeText(String(node.findingId)); + await vscode.env.clipboard.writeText(String(activeNode.findingId)); await vscode.window.showInformationMessage( - `Copied finding id: ${node.findingId}` + `Copied finding id ${activeNode.findingId}.` ); } - async revealFindingSource(node) { - if (!node) { + async copyFindingContext(node) { + const resolved = await this.resolveFindingNode(node); + if (!resolved) { return; } - const state = this.states.get(node.workspaceKey); - if (!state) { + const state = this.states.get(resolved.workspaceKey); + try { + await this.ensureConnected(state.folder); + const payload = + resolved.detailPayload || + (await this.client.callTool("get_finding", { + run_id: resolved.runId, + finding_id: resolved.findingId, + detail_level: "normal", + })); + const spread = safeObject(payload.spread); + const locations = normalizeFindingLocations(state.folder, payload.locations); + const lines = [ + "# CodeClone Finding Context", + "", + `- Workspace: ${state.folder.name}`, + `- Run: ${resolved.runId}`, + `- Finding id: ${payload.id}`, + `- Kind: ${formatKind(payload.kind)}`, + `- Severity: ${formatSeverity(payload.severity)}`, + `- Scope: ${payload.scope || "unknown"}`, + `- Priority: ${compactDecimal(payload.priority)}`, + `- Spread: ${spread.files || 0} files / ${spread.functions || 0} functions`, + ]; + if (locations.length > 0) { + lines.push( + "", + "## Locations", + markdownBulletList( + locations.map((location) => { + const lineText = + location.line !== null && location.end_line !== null + ? `${location.line}-${location.end_line}` + : location.line !== null + ? `${location.line}` + : "?"; + return `\`${location.path}:${lineText}\``; + }) + ) + ); + } + await vscode.env.clipboard.writeText(lines.join("\n")); + await vscode.window.showInformationMessage( + `Copied finding context for ${resolved.findingId}.` + ); + } catch (error) { + this.handleError(error, `Could not copy context for ${resolved.findingId}.`); + } + } + + async copyRefactorBrief(node) { + const resolved = await this.resolveFindingNode(node); + if (!resolved) { return; } - let location = firstLocation(node.locations); - if (!location && node.findingId && node.runId) { - try { - await this.ensureConnected(state.folder); - const payload = await this.client.callTool("get_finding", { - run_id: node.runId, - finding_id: node.findingId, + const state = this.states.get(resolved.workspaceKey); + try { + await this.ensureConnected(state.folder); + const [finding, remediation] = await Promise.all([ + resolved.detailPayload || + this.client.callTool("get_finding", { + run_id: resolved.runId, + finding_id: resolved.findingId, + detail_level: "normal", + }), + this.client.callTool("get_remediation", { + run_id: resolved.runId, + finding_id: resolved.findingId, detail_level: "normal", - }); - location = firstLocation(payload.locations); - } catch (error) { - this.handleError(error, "Could not resolve finding location."); + }), + ]); + const steps = safeArray(safeObject(remediation.remediation).steps); + const lines = [ + "# CodeClone Refactor Brief", + "", + `Repository: ${state.folder.name}`, + `Finding: ${finding.id} (${formatKind(finding.kind)})`, + `Severity: ${formatSeverity(finding.severity)} · Scope: ${finding.scope || "unknown"} · Priority: ${compactDecimal(finding.priority)}`, + "", + "Treat the CodeClone finding and remediation as the canonical source of truth.", + "Keep behavior unchanged unless the remediation explicitly requires a behavioral shift.", + "", + "## Suggested shape", + safeObject(remediation.remediation).shape || "Use a minimal, behavior-preserving refactor.", + ]; + if (safeObject(remediation.remediation).why_now) { + lines.push("", `Why now: ${safeObject(remediation.remediation).why_now}`); + } + if (steps.length > 0) { + lines.push("", "## Steps", markdownBulletList(steps)); + } + await vscode.env.clipboard.writeText(lines.join("\n")); + await vscode.window.showInformationMessage( + `Copied refactor brief for ${resolved.findingId}.` + ); + } catch (error) { + this.handleError(error, `Could not build a refactor brief for ${resolved.findingId}.`); + } + } + + async openInHtmlReport(node) { + const resolved = await this.resolveFindingNode(node); + if (!resolved) { + return; + } + const state = this.states.get(resolved.workspaceKey); + const htmlState = await this.inspectLocalHtmlReport(state); + if (!htmlState.exists) { + const choice = await vscode.window.showInformationMessage( + "No local HTML report is available for this workspace yet.", + "Open finding detail", + "Reveal source" + ); + if (choice === "Open finding detail") { + await this.openFinding(resolved); + } else if (choice === "Reveal source") { + await this.revealFindingSource(resolved); + } + return; + } + let anchor = `finding-${resolved.findingId}`; + try { + await this.ensureConnected(state.folder); + const payload = + resolved.detailPayload || + (await this.client.callTool("get_finding", { + run_id: resolved.runId, + finding_id: resolved.findingId, + detail_level: "normal", + })); + if (payload && payload.html_anchor) { + anchor = String(payload.html_anchor); + } + } catch { + // Keep the deterministic fallback anchor when detail lookup fails. + } + if (htmlState.stale) { + const staleWarning = + htmlState.reason === "workspace-changed" + ? "The local HTML report may be stale because the workspace changed after this run." + : "The local HTML report looks older than the current CodeClone run."; + const generatedSuffix = htmlState.generatedAtUtc + ? ` Report generated at ${htmlState.generatedAtUtc}.` + : ""; + const choice = await vscode.window.showWarningMessage( + `${staleWarning}${generatedSuffix}`, + "Open anyway", + "Open finding detail", + "Reveal source" + ); + if (choice === "Open anyway") { + const uri = vscode.Uri.file(htmlState.htmlPath).with({ fragment: anchor }); + await vscode.env.openExternal(uri); + return; + } + if (choice === "Open finding detail") { + await this.openFinding(resolved); + return; + } + if (choice === "Reveal source") { + await this.revealFindingSource(resolved); return; } + return; + } + const uri = vscode.Uri.file(htmlState.htmlPath).with({ fragment: anchor }); + await vscode.env.openExternal(uri); + } + + async revealFindingSource(node) { + const resolved = await this.resolveFindingNode(node); + if (!resolved) { + return; } + const state = this.states.get(resolved.workspaceKey); + const location = firstNormalizedLocation(state.folder, resolved.locations); if (!location || !location.path) { await vscode.window.showInformationMessage( "This item does not expose a source location." @@ -1103,8 +2303,32 @@ class CodeCloneController { ); } + async revealGodModuleSource(node) { + const activeNode = this.activeGodModuleTarget(node); + if (!activeNode) { + return; + } + const state = this.states.get(activeNode.workspaceKey); + if (!state) { + return; + } + const resolved = { + ...activeNode, + nodeType: "godModule", + }; + this.setActiveReviewTarget(resolved); + await this.revealWorkspacePath(state.folder, activeNode.item.path); + } + async revealWorkspacePath(folder, relativePath, line = null, endLine = null) { - const fileUri = vscode.Uri.file(path.join(folder.uri.fsPath, relativePath)); + const absolutePath = resolveWorkspacePath(folder.uri.fsPath, relativePath); + if (!absolutePath) { + await vscode.window.showWarningMessage( + "CodeClone ignored a source path outside the workspace root." + ); + return; + } + const fileUri = vscode.Uri.file(absolutePath); try { const document = await vscode.workspace.openTextDocument(fileUri); const editor = await vscode.window.showTextDocument(document, { @@ -1161,6 +2385,11 @@ class CodeCloneController { if (!topic) { return; } + await this.persistLastHelpTopic(topic); + if (!vscode.workspace.isTrusted) { + await this.showMarkdownDocument(renderRestrictedModeMarkdown(topic)); + return; + } try { await this.ensureConnected(folder); const payload = await this.client.callTool("help", { @@ -1178,16 +2407,20 @@ class CodeCloneController { } async openGodModule(node) { - if (!node || !node.item) { + const activeNode = this.activeGodModuleTarget(node); + if (!activeNode) { return; } - await this.showMarkdownDocument(renderGodModuleMarkdown(node.item)); + this.setActiveReviewTarget(activeNode); + await this.showMarkdownDocument(renderGodModuleMarkdown(activeNode.item)); } async reviewGodModule(node) { - if (!node || !node.item || !node.workspaceKey) { + const activeNode = this.activeGodModuleTarget(node); + if (!activeNode) { return; } + this.setActiveReviewTarget(activeNode); const picked = await vscode.window.showQuickPick( [ { @@ -1200,23 +2433,72 @@ class CodeCloneController { description: "Open God Module summary", action: "detail", }, + { + label: "Copy report-only brief", + description: "AI handoff", + action: "brief", + }, ], { - placeHolder: `What do you want to do with ${node.item.path}?`, + placeHolder: `What do you want to do with ${activeNode.item.path}?`, } ); if (!picked) { return; } if (picked.action === "reveal") { - const state = this.states.get(node.workspaceKey); - if (!state) { - return; - } - await this.revealWorkspacePath(state.folder, node.item.path); + await this.revealGodModuleSource(activeNode); + return; + } + if (picked.action === "brief") { + await this.copyGodModuleBrief(activeNode); return; } - await this.openGodModule(node); + await this.openGodModule(activeNode); + } + + async copyGodModuleBrief(node) { + const activeNode = this.activeGodModuleTarget(node); + if (!activeNode) { + return; + } + this.setActiveReviewTarget(activeNode); + const item = activeNode.item; + const reasons = safeArray(item.candidate_reasons); + const lines = [ + "# CodeClone Report-only Module Brief", + "", + `Repository: ${this.states.get(activeNode.workspaceKey)?.folder.name || "unknown"}`, + `Module: ${item.module}`, + `Path: ${item.path}`, + `Source kind: ${item.source_kind || "unknown"}`, + `Candidate score: ${decimal(item.score)}`, + "", + "Treat this as a report-only structural signal, not as a blocking finding or gate result.", + "Focus on responsibility overload and dependency pressure before touching behavior.", + "", + "## Module profile", + `- LOC: ${number(item.loc)}`, + `- Callables: ${item.callable_count || 0}`, + `- Complexity total / max: ${item.complexity_total || 0} / ${item.complexity_max || 0}`, + `- Fan-in / fan-out: ${item.fan_in || 0} / ${item.fan_out || 0}`, + `- Total dependencies: ${item.total_deps || 0}`, + `- Import edges / reimport edges: ${item.import_edges || 0} / ${item.reimport_edges || 0}`, + `- Reimport ratio: ${decimal(item.reimport_ratio)}`, + `- Instability: ${decimal(item.instability)}`, + `- Hub balance: ${decimal(item.hub_balance)}`, + ]; + if (reasons.length > 0) { + lines.push( + "", + "## Why CodeClone highlighted this module", + markdownBulletList(reasons) + ); + } + await vscode.env.clipboard.writeText(lines.join("\n")); + await vscode.window.showInformationMessage( + `Copied report-only brief for ${item.path}.` + ); } async clearSessionState() { @@ -1234,8 +2516,14 @@ class CodeCloneController { state.latestTriage = null; state.changedSummary = null; state.reviewed = []; + state.reviewArtifacts = emptyReviewArtifacts(); + state.gitSnapshot = null; + state.stale = false; + state.staleReason = null; state.groupCache.clear(); } + this.clearActiveReviewTarget(); + this.rebuildFileDecorations(); this.updateContextKeys(); this.updateStatusBar(); this.refreshAllViews(); @@ -1251,7 +2539,8 @@ class CodeCloneController { const picked = await vscode.window.showQuickPick( HELP_TOPICS.map((topic) => ({ label: topic, - description: "CodeClone MCP help topic", + description: + topic === this.lastHelpTopic ? "Last opened" : "CodeClone MCP help topic", })), { placeHolder: "Select a CodeClone MCP help topic", @@ -1270,32 +2559,126 @@ class CodeCloneController { }); } + provideReviewCodeLenses(document) { + const target = this.activeReviewTarget; + if (!target) { + return []; + } + if (target.nodeType === "godModule") { + const state = this.states.get(target.workspaceKey); + if (!state) { + return []; + } + const relativePath = workspaceRelativePath(state.folder, document.uri.fsPath); + if (relativePath !== normalizeRelativePath(target.item.path)) { + return []; + } + const range = new vscode.Range(0, 0, 0, 0); + return [ + new vscode.CodeLens(range, { + command: "codeclone.previousReviewItem", + title: "$(arrow-up) Previous hotspot", + }), + new vscode.CodeLens(range, { + command: "codeclone.nextReviewItem", + title: "$(arrow-down) Next hotspot", + }), + new vscode.CodeLens(range, { + command: "codeclone.openGodModule", + title: "$(symbol-module) Report-only detail", + arguments: [target], + }), + new vscode.CodeLens(range, { + command: "codeclone.copyGodModuleBrief", + title: "$(copy) Copy report-only brief", + arguments: [target], + }), + ]; + } + const state = this.states.get(target.workspaceKey); + if (!state) { + return []; + } + const matchingLocations = safeArray(target.locations).filter( + (location) => location.absolutePath === document.uri.fsPath + ); + if (matchingLocations.length === 0) { + return []; + } + const primaryLocation = matchingLocations[0]; + const startLine = Math.max(Number(primaryLocation.line || 1) - 1, 0); + const range = new vscode.Range(startLine, 0, startLine, 0); + return [ + new vscode.CodeLens(range, { + command: "codeclone.previousReviewItem", + title: "$(arrow-up) Previous hotspot", + }), + new vscode.CodeLens(range, { + command: "codeclone.nextReviewItem", + title: "$(arrow-down) Next hotspot", + }), + new vscode.CodeLens(range, { + command: "codeclone.peekFindingLocations", + title: "$(references) Peek occurrences", + arguments: [target], + }), + new vscode.CodeLens(range, { + command: "codeclone.showRemediation", + title: "$(wrench) Remediation", + arguments: [target], + }), + ...(!target.reviewed + ? [ + new vscode.CodeLens(range, { + command: "codeclone.markFindingReviewed", + title: "$(pass) Mark reviewed", + arguments: [target], + }), + ] + : []), + ]; + } + async getOverviewChildren(node) { const state = this.getPrimaryState(); if (!state || !state.latestSummary) { return []; } + const reviewCounts = { + changed: this.reviewArtifactCount(state, "changedFiles"), + new: this.reviewArtifactCount(state, "newRegressions"), + production: this.reviewArtifactCount(state, "productionHotspots"), + godModules: this.reviewArtifactCount(state, "godModules"), + }; + const baselineDrift = this.baselineDrift(state); if (!node) { const sections = [ { nodeType: "section", id: "overview.health", label: "Structural Health", - description: `${state.latestSummary.health.score}/${state.latestSummary.health.grade}`, + description: + baselineDrift.healthDelta !== null + ? `${state.latestSummary.health.score}/${state.latestSummary.health.grade} · ${signedInteger( + baselineDrift.healthDelta + )} vs baseline` + : `${state.latestSummary.health.score}/${state.latestSummary.health.grade}`, icon: new vscode.ThemeIcon("heart"), }, { nodeType: "section", id: "overview.run", label: "Current Run", - description: `${state.currentRunId} · ${state.latestSummary.cache.freshness}`, + description: state.stale + ? `${state.currentRunId} · stale` + : `${state.currentRunId} · ${state.latestSummary.cache.freshness}`, icon: new vscode.ThemeIcon("pulse"), }, { nodeType: "section", id: "overview.triage", label: "Priority Review", - description: `${state.latestSummary.findings.production} production · ${state.latestSummary.findings.new} new`, + description: `${reviewCounts.production} production · ${reviewCounts.new} new`, icon: new vscode.ThemeIcon("inspect"), command: { command: "codeclone.openProductionTriage", @@ -1335,13 +2718,23 @@ class CodeCloneController { this.detailNode("Dead code", number(dimensions.dead_code)), this.detailNode("Dependencies", number(dimensions.dependencies)), this.detailNode("Coverage", number(dimensions.coverage)), + this.detailNode( + "Health delta", + baselineDrift.healthDelta !== null + ? `${signedInteger(baselineDrift.healthDelta)} vs metrics baseline` + : "metrics baseline unavailable" + ), ]; } if (node.id === "overview.run") { const inventory = safeObject(state.latestSummary.inventory); return [ this.detailNode("Workspace", state.folder.name), - this.detailNode("Run id", state.currentRunId), + this.detailNode("Run ID", state.currentRunId), + this.detailNode( + "Freshness", + state.stale ? `stale · ${state.staleReason}` : "current" + ), this.detailNode("Files", number(inventory.files)), this.detailNode("Parsed lines", number(inventory.lines)), this.detailNode("Callables", number(inventory.functions)), @@ -1351,27 +2744,34 @@ class CodeCloneController { "Metrics baseline", formatBaselineState(state.latestSummary.metrics_baseline) ), + this.detailNode("Baseline drift", this.baselineDriftSummary(state)), this.detailNode("Cache", formatCacheSummary(state.latestSummary.cache)), ]; } if (node.id === "overview.triage") { const triage = safeObject(state.latestTriage); - const findings = safeObject(triage.findings); const nextAction = this.describeNextBestAction(state); return [ this.detailNode("Next best action", nextAction.label, { command: nextAction.command, title: nextAction.title, }), - this.detailNode("New regressions", number(state.latestSummary.findings.new)), - this.detailNode("Production hotspots", number(state.latestSummary.findings.production)), - this.detailNode("Outside focus", number(findings.outside_focus)), + this.detailNode("Focus mode", focusModeSpec(this.hotspotFocusMode).label), + this.detailNode("New regressions", number(reviewCounts.new)), + this.detailNode("Production hotspots", number(reviewCounts.production)), + this.detailNode( + "New clones", + baselineDrift.newClones !== null + ? `${signedInteger(baselineDrift.newClones)} vs clone baseline` + : "baseline unavailable" + ), this.detailNode( "Changed files", state.changedSummary - ? `${number(state.changedSummary.changed_files)} · ${state.changedSummary.verdict}` + ? `${number(reviewCounts.changed)} visible · ${state.changedSummary.verdict}` : "not analyzed" ), + this.detailNode("Reviewed hidden", number(state.reviewed.length)), ]; } if (node.id === "overview.changed") { @@ -1396,6 +2796,10 @@ class CodeCloneController { this.detailNode("Top score", decimal(godModules.top_score)), this.detailNode("Average score", decimal(godModules.average_score)), this.detailNode("Population", String(godModules.population_status)), + this.detailNode( + "Review surface", + `${number(reviewCounts.godModules)} visible in Hotspots` + ), ]; } return []; @@ -1407,15 +2811,20 @@ class CodeCloneController { return []; } if (!node) { - const groups = HOTSPOT_GROUPS.filter((group) => - this.shouldShowGroup(group.id, state) - ); + const groups = this.activeHotspotGroupIds(state).map((groupId) => + HOTSPOT_GROUPS.find((group) => group.id === groupId) + ).filter(Boolean); if (groups.length === 0) { return [ { nodeType: "message", - label: "No new or production hotspots need review in the current run.", - icon: new vscode.ThemeIcon("circle-slash"), + label: + this.hotspotFocusMode === "recommended" + ? "Nothing needs review in the current run." + : `No items are visible in ${focusModeSpec(this.hotspotFocusMode).label} focus.`, + icon: new vscode.ThemeIcon( + state.stale ? "warning" : "circle-slash" + ), }, ]; } @@ -1442,14 +2851,17 @@ class CodeCloneController { nodeType: "section", id: "session.server", label: "Local Server", - description: this.connectionInfo.connected ? "ready" : "unavailable", + description: this.connectionInfo.connected ? "ready" : "not connected", icon: new vscode.ThemeIcon("plug"), }, { nodeType: "section", id: "session.run", label: "Current Run", - description: state && state.currentRunId ? state.currentRunId : "none", + description: + state && state.currentRunId + ? `${state.currentRunId}${state.stale ? " · stale" : ""}` + : "none", icon: new vscode.ThemeIcon("pulse"), }, { @@ -1485,13 +2897,22 @@ class CodeCloneController { } if (node.id === "session.run") { if (!state || !state.latestSummary) { - return [this.detailNode("Run", "No run available yet.")]; + return [ + this.detailNode( + "Run", + "Run Analyze Workspace or Review Changes to create the first run." + ), + ]; } return [ this.detailNode("Workspace", state.folder.name), - this.detailNode("Run id", state.currentRunId), + this.detailNode("Run ID", state.currentRunId), this.detailNode("Scope", formatRunScope(state.lastScope)), this.detailNode("Mode", state.latestSummary.mode), + this.detailNode( + "Freshness", + state.stale ? `stale · ${state.staleReason}` : "current" + ), this.detailNode("Cache freshness", state.latestSummary.cache.freshness), this.detailNode("Updated", state.lastUpdatedAt ? state.lastUpdatedAt.toLocaleString() : "unknown"), ]; @@ -1501,7 +2922,7 @@ class CodeCloneController { return [ { nodeType: "message", - label: "No reviewed findings in this MCP session.", + label: "Nothing has been marked reviewed in this session yet.", icon: new vscode.ThemeIcon("circle-slash"), }, ]; @@ -1535,43 +2956,21 @@ class CodeCloneController { } try { await this.ensureConnected(state.folder); - const runId = state.currentRunId; - if (!runId) { + if (!state.currentRunId) { return []; } - let nodes; + let nodes = []; switch (groupId) { case "newRegressions": nodes = this.toFindingNodes( state, - safeArray( - ( - await this.client.callTool("list_findings", { - run_id: runId, - novelty: "new", - detail_level: "summary", - sort_by: "priority", - limit: 20, - exclude_reviewed: true, - }) - ).items - ) + this.reviewArtifactItems(state, "newRegressions") ); break; case "productionHotspots": nodes = this.toFindingNodes( state, - safeArray( - ( - await this.client.callTool("list_hotspots", { - run_id: runId, - kind: "production_hotspots", - detail_level: "summary", - limit: 10, - exclude_reviewed: true, - }) - ).items - ) + this.reviewArtifactItems(state, "productionHotspots") ); break; case "changedFiles": @@ -1587,47 +2986,15 @@ class CodeCloneController { } nodes = this.toFindingNodes( state, - safeArray( - ( - await this.client.callTool("list_findings", { - run_id: runId, - git_diff_ref: vscode.workspace - .getConfiguration("codeclone", state.folder.uri) - .get("analysis.changedDiffRef", "HEAD"), - novelty: "new", - detail_level: "summary", - sort_by: "priority", - limit: 20, - exclude_reviewed: true, - }) - ).items - ) + this.reviewArtifactItems(state, "changedFiles") ); break; - case "godModules": { - const response = await this.client.callTool("get_report_section", { - run_id: runId, - section: "metrics_detail", - family: "god_modules", - limit: 15, - }); - nodes = safeArray(response.items).map((item) => ({ - nodeType: "godModule", - workspaceKey: state.folder.uri.toString(), - runId, - item, - label: item.path, - description: `${decimal(item.score)} · ${item.source_kind}`, - tooltip: `${item.module} · ${number(item.loc)} LOC · ${item.total_deps} deps`, - icon: new vscode.ThemeIcon("symbol-module"), - command: { - command: "codeclone.reviewGodModule", - title: "Review God Module", - arguments: [{ workspaceKey: state.folder.uri.toString(), runId, item }], - }, - })); + case "godModules": + nodes = this.toGodModuleNodes( + state, + this.reviewArtifactItems(state, "godModules") + ); break; - } default: nodes = []; } @@ -1659,74 +3026,6 @@ class CodeCloneController { ); } - async getPriorityQueueNodes(state) { - const runId = state.currentRunId; - if (!runId) { - return []; - } - const diffRef = vscode.workspace - .getConfiguration("codeclone", state.folder.uri) - .get("analysis.changedDiffRef", "HEAD"); - const buckets = []; - if (state.changedSummary) { - buckets.push( - safeArray( - ( - await this.client.callTool("list_findings", { - run_id: runId, - git_diff_ref: diffRef, - novelty: "new", - detail_level: "summary", - sort_by: "priority", - limit: 12, - exclude_reviewed: true, - }) - ).items - ) - ); - } - buckets.push( - safeArray( - ( - await this.client.callTool("list_hotspots", { - run_id: runId, - kind: "production_hotspots", - detail_level: "summary", - limit: 12, - exclude_reviewed: true, - }) - ).items - ) - ); - buckets.push( - safeArray( - ( - await this.client.callTool("list_findings", { - run_id: runId, - novelty: "new", - detail_level: "summary", - sort_by: "priority", - limit: 12, - exclude_reviewed: true, - }) - ).items - ) - ); - const deduped = []; - const seen = new Set(); - for (const bucket of buckets) { - for (const item of bucket) { - const id = String(item.id || ""); - if (!id || seen.has(id)) { - continue; - } - seen.add(id); - deduped.push(item); - } - } - return this.toFindingNodes(state, deduped); - } - buildFindingNode(state, findingId, item, note, reviewed) { const spread = safeObject(item.spread); const novelty = formatNovelty(item.novelty); @@ -1750,7 +3049,7 @@ class CodeCloneController { (note ? `\nNote: ${note}` : ""), icon: findingIcon(item.severity), locations: item.locations || [], - contextValue: "codeclone.finding", + contextValue: reviewed ? "codeclone.reviewedFinding" : "codeclone.finding", reviewed, command: { command: "codeclone.reviewFinding", @@ -1769,20 +3068,17 @@ class CodeCloneController { } describeGroup(groupId, state) { - const summary = safeObject(state.latestSummary); - const findings = safeObject(summary.findings); - const metrics = safeObject(state.metricsSummary); switch (groupId) { case "newRegressions": - return `${findings.new || 0} new`; + return `${this.reviewArtifactCount(state, "newRegressions")} new`; case "productionHotspots": - return `${safeObject(state.latestTriage).top_hotspots?.available || 0} prod`; + return `${this.reviewArtifactCount(state, "productionHotspots")} production`; case "changedFiles": return state.changedSummary - ? `${state.changedSummary.new_findings} new · ${state.changedSummary.verdict}` + ? `${this.reviewArtifactCount(state, "changedFiles")} visible · ${state.changedSummary.verdict}` : "not analyzed"; case "godModules": - return `${safeObject(metrics.god_modules).candidates || 0} report-only`; + return `${this.reviewArtifactCount(state, "godModules")} report-only`; default: return ""; } @@ -1791,59 +3087,82 @@ class CodeCloneController { emptyGroupMessage(groupId) { switch (groupId) { case "newRegressions": - return "No new regressions in the current run."; + return "No baseline-new regressions are visible."; case "productionHotspots": - return "No production hotspots need review."; + return "No production hotspots are visible."; case "changedFiles": - return "No new findings touch the changed files."; + return "No findings touching changed files are visible."; case "godModules": return "No report-only God Module candidates are visible."; default: - return "No items in this category."; + return "Nothing is visible in this category."; } } shouldShowGroup(groupId, state) { - const summary = safeObject(state.latestSummary); - const findings = safeObject(summary.findings); - const metrics = safeObject(state.metricsSummary); + const specificMode = isSpecificFocusMode(this.hotspotFocusMode); + if (specificMode) { + const allowed = + HOTSPOT_GROUPS_BY_MODE[this.hotspotFocusMode] || HOTSPOT_GROUPS_BY_MODE.recommended; + if (!allowed.includes(groupId)) { + return false; + } + } switch (groupId) { case "newRegressions": - return Number(findings.new || 0) > 0; + return specificMode || this.reviewArtifactCount(state, "newRegressions") > 0; case "productionHotspots": - return Number(safeObject(state.latestTriage).top_hotspots?.available || 0) > 0; + return ( + specificMode || this.reviewArtifactCount(state, "productionHotspots") > 0 + ); case "changedFiles": - return Boolean(state.changedSummary); + if (!state.changedSummary) { + return this.hotspotFocusMode === "changed"; + } + return specificMode || this.reviewArtifactCount(state, "changedFiles") > 0; case "godModules": - return Number(safeObject(metrics.god_modules).candidates || 0) > 0; + return specificMode || this.reviewArtifactCount(state, "godModules") > 0; default: return false; } } describeNextBestAction(state) { - if (Number(state.latestSummary.findings.new || 0) > 0) { + if (state.stale) { + return { + label: state.lastScope === "changed" ? "Review changes again" : "Refresh stale run", + command: + state.lastScope === "changed" + ? "codeclone.analyzeChangedFiles" + : "codeclone.refreshCurrentRun", + title: + state.lastScope === "changed" + ? "Review changes again" + : "Refresh stale run", + }; + } + if (this.reviewArtifactCount(state, "changedFiles") > 0) { + return { + label: "Review changed-files hotspots", + command: "codeclone.reviewPriorityQueue", + title: "Review changed-files hotspots", + }; + } + if (this.reviewArtifactCount(state, "newRegressions") > 0) { return { label: "Review new regressions", command: "codeclone.reviewPriorityQueue", title: "Review new regressions", }; } - if (Number(state.latestSummary.findings.production || 0) > 0) { + if (this.reviewArtifactCount(state, "productionHotspots") > 0) { return { label: "Review production hotspots", command: "codeclone.reviewPriorityQueue", title: "Review production hotspots", }; } - if (state.changedSummary) { - return { - label: "Inspect changed-files review", - command: "codeclone.focusHotspots", - title: "Inspect changed-files review", - }; - } - if (Number(safeObject(state.metricsSummary).god_modules?.candidates || 0) > 0) { + if (this.reviewArtifactCount(state, "godModules") > 0) { return { label: "Inspect report-only God Modules", command: "codeclone.focusHotspots", @@ -1868,9 +3187,10 @@ class CodeCloneController { } createTreeItem(node) { + let item; switch (node.nodeType) { case "section": { - const item = new vscode.TreeItem( + item = new vscode.TreeItem( node.label, vscode.TreeItemCollapsibleState.Expanded ); @@ -1878,32 +3198,32 @@ class CodeCloneController { item.description = node.description; item.iconPath = node.icon; item.command = node.command; - return item; + break; } case "group": { - const item = new vscode.TreeItem( + item = new vscode.TreeItem( node.label, vscode.TreeItemCollapsibleState.Collapsed ); item.id = `${node.workspaceKey}:${node.groupId}`; item.description = node.description; item.iconPath = node.icon; - return item; + break; } case "finding": { - const item = new vscode.TreeItem( + item = new vscode.TreeItem( node.label, vscode.TreeItemCollapsibleState.None ); item.description = node.description; item.tooltip = node.tooltip; item.iconPath = node.icon; - item.contextValue = "codeclone.finding"; + item.contextValue = node.contextValue || "codeclone.finding"; item.command = node.command; - return item; + break; } case "godModule": { - const item = new vscode.TreeItem( + item = new vscode.TreeItem( node.label, vscode.TreeItemCollapsibleState.None ); @@ -1912,10 +3232,10 @@ class CodeCloneController { item.iconPath = node.icon; item.contextValue = "codeclone.godModule"; item.command = node.command; - return item; + break; } case "helpTopic": { - const item = new vscode.TreeItem( + item = new vscode.TreeItem( node.label, vscode.TreeItemCollapsibleState.None ); @@ -1927,35 +3247,38 @@ class CodeCloneController { title: "Show Help Topic", arguments: [node.topic], }; - return item; + break; } case "detail": { - const item = new vscode.TreeItem( + item = new vscode.TreeItem( node.label, vscode.TreeItemCollapsibleState.None ); item.description = node.description; item.iconPath = node.icon; item.command = node.command; - return item; + break; } case "message": default: { - const item = new vscode.TreeItem( + item = new vscode.TreeItem( node.label, vscode.TreeItemCollapsibleState.None ); item.iconPath = node.icon || new vscode.ThemeIcon("info"); item.description = node.description; - return item; + break; } } + item.accessibilityInformation = treeAccessibilityInformation(node); + return item; } refreshAllViews() { this.overviewProvider.refresh(); this.hotspotsProvider.refresh(); this.sessionProvider.refresh(); + this.reviewCodeLensProvider.refresh(); this.updateViewChrome(); } @@ -1963,39 +3286,77 @@ class CodeCloneController { const state = this.getPrimaryState(); if (this.overviewView) { this.overviewView.badge = undefined; + this.overviewView.description = state?.stale ? "Stale" : undefined; } if (this.hotspotsView) { + this.hotspotsView.description = focusModeSpec(this.hotspotFocusMode).label; + this.hotspotsView.message = + state && state.stale ? staleMessage(state.staleReason) : undefined; const newCount = Number( - safeObject(state?.latestSummary).findings?.new || 0 + this.reviewArtifactCount(state, "newRegressions") ); const productionCount = Number( - safeObject(state?.latestSummary).findings?.production || 0 + this.reviewArtifactCount(state, "productionHotspots") + ); + const changedCount = Number(this.reviewArtifactCount(state, "changedFiles")); + const actionableCount = Math.max( + newCount + productionCount, + changedCount ); - const changedCount = Number(state?.changedSummary?.new_findings || 0); - const actionableCount = Math.max(newCount + productionCount, changedCount); const godModuleCount = Number( - safeObject(state?.metricsSummary).god_modules?.candidates || 0 + this.reviewArtifactCount(state, "godModules") ); + let badgeValue = 0; + let badgeTooltip = ""; + switch (this.hotspotFocusMode) { + case "new": + badgeValue = newCount; + badgeTooltip = `${newCount} new regressions are visible in Hotspots`; + break; + case "production": + badgeValue = productionCount; + badgeTooltip = `${productionCount} production hotspots are visible in Hotspots`; + break; + case "changed": + badgeValue = changedCount; + badgeTooltip = `${changedCount} changed-files review items are visible in Hotspots`; + break; + case "reportOnly": + badgeValue = godModuleCount; + badgeTooltip = `${godModuleCount} report-only God Module candidates are visible in Hotspots`; + break; + default: + badgeValue = actionableCount > 0 ? actionableCount : godModuleCount; + badgeTooltip = + actionableCount > 0 + ? `${actionableCount} review items need attention` + : `${godModuleCount} report-only God Module candidates are visible in Hotspots`; + break; + } this.hotspotsView.badge = - actionableCount > 0 + badgeValue > 0 ? { - value: actionableCount, - tooltip: `${actionableCount} review items need attention`, + value: badgeValue, + tooltip: badgeTooltip, } - : godModuleCount > 0 - ? { - value: godModuleCount, - tooltip: `${godModuleCount} report-only God Module candidates are visible in Hotspots`, - } - : undefined; + : undefined; } if (this.sessionView) { this.sessionView.badge = undefined; + this.sessionView.description = + state && state.reviewed.length > 0 ? `${state.reviewed.length} reviewed` : undefined; } } updateContextKeys() { const state = this.getPrimaryState(); + const activeTarget = this.activeReviewTarget; + const targetVisibleInEditor = this.isTargetVisibleInEditor(activeTarget); + void vscode.commands.executeCommand( + "setContext", + "codeclone.workspaceTrusted", + vscode.workspace.isTrusted + ); void vscode.commands.executeCommand( "setContext", "codeclone.connected", @@ -2006,6 +3367,41 @@ class CodeCloneController { "codeclone.hasRun", Boolean(state && state.latestSummary) ); + void vscode.commands.executeCommand( + "setContext", + "codeclone.runStale", + Boolean(state && state.stale) + ); + void vscode.commands.executeCommand( + "setContext", + "codeclone.hasActiveReviewTarget", + Boolean(activeTarget) + ); + void vscode.commands.executeCommand( + "setContext", + "codeclone.activeReviewTargetVisibleInEditor", + Boolean(targetVisibleInEditor) + ); + void vscode.commands.executeCommand( + "setContext", + "codeclone.activeReviewTargetIsFinding", + Boolean(activeTarget && activeTarget.nodeType !== "godModule") + ); + void vscode.commands.executeCommand( + "setContext", + "codeclone.activeReviewTargetIsReviewed", + Boolean(activeTarget && activeTarget.reviewed) + ); + void vscode.commands.executeCommand( + "setContext", + "codeclone.activeReviewTargetIsGodModule", + Boolean(activeTarget && activeTarget.nodeType === "godModule") + ); + void vscode.commands.executeCommand( + "setContext", + "codeclone.hotspotFocusMode", + this.hotspotFocusMode + ); } updateStatusBar() { @@ -2016,11 +3412,26 @@ class CodeCloneController { this.statusBar.hide(); return; } + if (!vscode.workspace.isTrusted) { + this.statusBar.text = "CodeClone restricted"; + this.statusBar.tooltip = + "Restricted Mode is active. Grant workspace trust to enable local CodeClone analysis and the local MCP server."; + this.statusBar.accessibilityInformation = { + label: + "CodeClone restricted. Grant workspace trust to enable local analysis.", + }; + this.statusBar.command = "codeclone.manageWorkspaceTrust"; + this.statusBar.show(); + return; + } const state = this.getPrimaryState(); if (!this.connectionInfo.connected) { this.statusBar.text = "CodeClone setup"; this.statusBar.tooltip = - "Run Analyze Workspace to start CodeClone and create the first run. Use Verify Local Server only if you want to check the launcher manually."; + "CodeClone needs a local MCP launcher. Analyze Workspace usually connects automatically. Use Verify Local Server only when you want to check the launcher manually."; + this.statusBar.accessibilityInformation = { + label: "CodeClone setup. Local launcher verification is required.", + }; this.statusBar.command = "codeclone.analyzeWorkspace"; this.statusBar.show(); return; @@ -2028,15 +3439,32 @@ class CodeCloneController { if (!state || !state.latestSummary) { this.statusBar.text = "CodeClone ready"; this.statusBar.tooltip = - "The local CodeClone server is ready. Run Analyze Workspace or Review Changes."; + "The local CodeClone server is ready. Start with Analyze Workspace or Review Changes."; + this.statusBar.accessibilityInformation = { + label: "CodeClone ready. Start with Analyze Workspace or Review Changes.", + }; this.statusBar.command = "codeclone.analyzeWorkspace"; this.statusBar.show(); return; } - this.statusBar.text = `CodeClone ${state.latestSummary.health.score}/${state.latestSummary.health.grade}`; + this.statusBar.text = state.stale + ? `CodeClone ${state.latestSummary.health.score}/${state.latestSummary.health.grade} · stale` + : `CodeClone ${state.latestSummary.health.score}/${state.latestSummary.health.grade}`; this.statusBar.command = "codeclone.openOverview"; + const drift = this.baselineDrift(state); + const driftLine = + drift.newFindings !== null || drift.healthDelta !== null || drift.newClones !== null + ? `\nBaseline drift: ${this.baselineDriftSummary(state)}` + : ""; this.statusBar.tooltip = - `${state.folder.name}\nRun ${state.currentRunId}\n${state.latestSummary.findings.total} findings`; + `${state.folder.name}\nRun ${state.currentRunId}\n${state.latestSummary.findings.total} findings` + + driftLine + + (state.stale ? `\nFreshness: stale · ${state.staleReason}` : ""); + this.statusBar.accessibilityInformation = { + label: state.stale + ? `CodeClone ${state.latestSummary.health.score} slash ${state.latestSummary.health.grade}, stale.` + : `CodeClone ${state.latestSummary.health.score} slash ${state.latestSummary.health.grade}.`, + }; this.statusBar.show(); } @@ -2078,7 +3506,7 @@ class CodeCloneController { if (choice === "Copy install command") { await vscode.env.clipboard.writeText('pip install --pre "codeclone[mcp]"'); await vscode.window.showInformationMessage( - 'Copied: pip install --pre "codeclone[mcp]"' + "Copied the recommended install command." ); return; } diff --git a/extensions/vscode-codeclone/src/mcpClient.js b/extensions/vscode-codeclone/src/mcpClient.js index d793274..d68dc97 100644 --- a/extensions/vscode-codeclone/src/mcpClient.js +++ b/extensions/vscode-codeclone/src/mcpClient.js @@ -3,7 +3,12 @@ const { spawn } = require("node:child_process"); const { EventEmitter } = require("node:events"); +const { trimTail } = require("./support"); + const REQUEST_TIMEOUT_MS = 5 * 60 * 1000; +const MAX_STDOUT_BUFFER_CHARS = 4 * 1024 * 1024; +const MAX_STDERR_BUFFER_CHARS = 256 * 1024; +const MAX_LOG_LINE_CHARS = 4096; class MCPClientError extends Error { constructor(message) { @@ -240,7 +245,12 @@ class CodeCloneMcpClient extends EventEmitter { } _handleStdout(chunk) { - this.stdoutBuffer += chunk; + this.stdoutBuffer = this._appendBoundedChunk( + this.stdoutBuffer, + chunk, + MAX_STDOUT_BUFFER_CHARS, + "stdout" + ); const lines = this.stdoutBuffer.split(/\r?\n/); this.stdoutBuffer = lines.pop() || ""; for (const rawLine of lines) { @@ -252,7 +262,9 @@ class CodeCloneMcpClient extends EventEmitter { try { message = JSON.parse(line); } catch { - this.outputChannel.appendLine(`[codeclone] stdout: ${line}`); + this.outputChannel.appendLine( + `[codeclone] stdout: ${trimTail(line, MAX_LOG_LINE_CHARS)}` + ); continue; } if ( @@ -290,14 +302,21 @@ class CodeCloneMcpClient extends EventEmitter { } _handleStderr(chunk) { - this.stderrBuffer += chunk; + this.stderrBuffer = this._appendBoundedChunk( + this.stderrBuffer, + chunk, + MAX_STDERR_BUFFER_CHARS, + "stderr" + ); const lines = this.stderrBuffer.split(/\r?\n/); this.stderrBuffer = lines.pop() || ""; for (const rawLine of lines) { const line = rawLine.trim(); if (line) { this._rememberDiagnostic(line); - this.outputChannel.appendLine(`[codeclone] stderr: ${line}`); + this.outputChannel.appendLine( + `[codeclone] stderr: ${trimTail(line, MAX_LOG_LINE_CHARS)}` + ); } } } @@ -324,12 +343,27 @@ class CodeCloneMcpClient extends EventEmitter { } _rememberDiagnostic(line) { - this.diagnostics.push(line); + this.diagnostics.push(trimTail(line, MAX_LOG_LINE_CHARS)); if (this.diagnostics.length > 10) { this.diagnostics.shift(); } } + _appendBoundedChunk(current, chunk, maxChars, streamName) { + const combined = `${current}${chunk}`; + if (combined.length <= maxChars) { + return combined; + } + const truncated = trimTail(combined, maxChars); + this._rememberDiagnostic( + `CodeClone MCP ${streamName} buffer exceeded ${maxChars} characters and was truncated.` + ); + this.outputChannel.appendLine( + `[codeclone] ${streamName} buffer exceeded ${maxChars} characters; keeping the most recent output.` + ); + return truncated; + } + _sameLaunchSpec(left, right) { if (!left || !right) { return false; diff --git a/extensions/vscode-codeclone/src/support.js b/extensions/vscode-codeclone/src/support.js new file mode 100644 index 0000000..b5842df --- /dev/null +++ b/extensions/vscode-codeclone/src/support.js @@ -0,0 +1,82 @@ +"use strict"; + +const path = require("node:path"); + +const STALE_REASON_EDITOR = "unsaved editor changes"; +const STALE_REASON_WORKSPACE = "workspace changed after this run"; + +function signedInteger(value) { + if (typeof value !== "number" || Number.isNaN(value)) { + return "0"; + } + return value > 0 ? `+${value}` : String(value); +} + +function parseUtcTimestamp(value) { + if (!value) { + return null; + } + const parsed = Date.parse(String(value)); + return Number.isNaN(parsed) ? null : parsed; +} + +function staleMessage(reason) { + if (reason === STALE_REASON_EDITOR) { + return "Review data may be stale because there are unsaved editor changes."; + } + return "Review data may be stale because the workspace changed after this run."; +} + +function normalizedLaunchSpec(spec) { + const command = String(spec?.command || "").trim(); + if (!command) { + throw new Error("CodeClone MCP launcher command must not be empty."); + } + const args = Array.isArray(spec?.args) + ? spec.args + .filter((value) => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean) + : []; + const cwd = String(spec?.cwd || "").trim(); + if (!cwd) { + throw new Error("CodeClone MCP launcher cwd must not be empty."); + } + return { command, args, cwd }; +} + +function trimTail(value, maxChars) { + const text = String(value || ""); + if (!Number.isFinite(maxChars) || maxChars < 1) { + return ""; + } + return text.length <= maxChars ? text : text.slice(-maxChars); +} + +function resolveWorkspacePath(rootPath, relativePath) { + const root = String(rootPath || "").trim(); + const candidate = String(relativePath || "").trim(); + if (!root || !candidate) { + return null; + } + const resolved = path.resolve(root, candidate); + const relativeToRoot = path.relative(root, resolved); + if ( + relativeToRoot === "" || + (!relativeToRoot.startsWith("..") && !path.isAbsolute(relativeToRoot)) + ) { + return resolved; + } + return null; +} + +module.exports = { + STALE_REASON_EDITOR, + STALE_REASON_WORKSPACE, + normalizedLaunchSpec, + parseUtcTimestamp, + resolveWorkspacePath, + signedInteger, + staleMessage, + trimTail, +}; diff --git a/extensions/vscode-codeclone/test/extensionHost/index.js b/extensions/vscode-codeclone/test/extensionHost/index.js new file mode 100644 index 0000000..e3cf7b0 --- /dev/null +++ b/extensions/vscode-codeclone/test/extensionHost/index.js @@ -0,0 +1,49 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const vscode = require("vscode"); + +async function run() { + const extension = vscode.extensions.getExtension("orenlab.codeclone"); + assert.ok(extension, "Expected orenlab.codeclone extension to be registered."); + + await extension.activate(); + + const packageJson = extension.packageJSON; + assert.equal(packageJson.name, "codeclone"); + assert.equal( + packageJson.capabilities.untrustedWorkspaces.supported, + "limited", + "Expected Restricted Mode support to be limited." + ); + assert.deepEqual( + [...packageJson.capabilities.untrustedWorkspaces.restrictedConfigurations].sort(), + ["codeclone.mcp.args", "codeclone.mcp.command"] + ); + + const commandList = await vscode.commands.getCommands(true); + for (const command of [ + "codeclone.manageWorkspaceTrust", + "codeclone.analyzeWorkspace", + "codeclone.analyzeChangedFiles", + "codeclone.reviewPriorityQueue", + "codeclone.openSetupHelp", + "codeclone.showHelpTopic", + ]) { + assert.ok( + commandList.includes(command), + `Expected command ${command} to be registered.` + ); + } + + const viewIds = packageJson.contributes.views.codeclone.map((view) => view.id); + assert.deepEqual(viewIds, [ + "codeclone.overview", + "codeclone.hotspots", + "codeclone.session", + ]); + + await vscode.commands.executeCommand("codeclone.openOverview"); +} + +module.exports = { run }; diff --git a/extensions/vscode-codeclone/test/mcpClient.test.js b/extensions/vscode-codeclone/test/mcpClient.test.js new file mode 100644 index 0000000..1cc0291 --- /dev/null +++ b/extensions/vscode-codeclone/test/mcpClient.test.js @@ -0,0 +1,51 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { CodeCloneMcpClient } = require("../src/mcpClient"); + +function outputChannelStub() { + const lines = []; + return { + lines, + appendLine(line) { + lines.push(String(line)); + }, + }; +} + +test("bounded stream append truncates oversized buffers and records a diagnostic", () => { + const outputChannel = outputChannelStub(); + const client = new CodeCloneMcpClient(outputChannel); + + const result = client._appendBoundedChunk("abc", "defgh", 5, "stdout"); + + assert.equal(result, "defgh"); + assert.equal(client.diagnostics.length, 1); + assert.match(client.diagnostics[0], /stdout buffer exceeded 5 characters/); + assert.equal(outputChannel.lines.length, 1); + assert.match(outputChannel.lines[0], /stdout buffer exceeded 5 characters/); +}); + +test("diagnostic history stays bounded", () => { + const client = new CodeCloneMcpClient(outputChannelStub()); + + for (let index = 0; index < 12; index += 1) { + client._rememberDiagnostic(`diagnostic-${index}`); + } + + assert.equal(client.diagnostics.length, 10); + assert.equal(client.diagnostics[0], "diagnostic-2"); + assert.equal(client.diagnostics[9], "diagnostic-11"); +}); + +test("diagnostics trim very long lines to the supported maximum", () => { + const client = new CodeCloneMcpClient(outputChannelStub()); + const veryLongLine = "x".repeat(5000); + + client._rememberDiagnostic(`prefix:${veryLongLine}`); + + assert.equal(client.diagnostics.length, 1); + assert.equal(client.diagnostics[0].length, 4096); +}); diff --git a/extensions/vscode-codeclone/test/runExtensionHost.js b/extensions/vscode-codeclone/test/runExtensionHost.js new file mode 100644 index 0000000..ad9c66e --- /dev/null +++ b/extensions/vscode-codeclone/test/runExtensionHost.js @@ -0,0 +1,66 @@ +"use strict"; + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); + +function resolveVsCodeCli() { + const candidates = [ + process.env.VSCODE_CLI, + "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", + "/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code", + ].filter(Boolean); + return candidates.find((candidate) => fs.existsSync(candidate)) || null; +} + +async function main() { + const cliPath = resolveVsCodeCli(); + if (!cliPath) { + throw new Error( + "Could not find a local VS Code CLI. Set VSCODE_CLI or install Visual Studio Code." + ); + } + + const extensionDevelopmentPath = path.resolve(__dirname, ".."); + const extensionTestsPath = path.resolve(__dirname, "extensionHost", "index.js"); + const workspaceFolder = extensionDevelopmentPath; + const userDataDir = fs.mkdtempSync( + path.join(os.tmpdir(), "codeclone-vscode-test-user-") + ); + const extensionsDir = fs.mkdtempSync( + path.join(os.tmpdir(), "codeclone-vscode-test-ext-") + ); + + const args = [ + workspaceFolder, + "--disable-extensions", + "--disable-workspace-trust", + "--skip-welcome", + "--skip-release-notes", + `--user-data-dir=${userDataDir}`, + `--extensions-dir=${extensionsDir}`, + `--extensionDevelopmentPath=${extensionDevelopmentPath}`, + `--extensionTestsPath=${extensionTestsPath}`, + ]; + + await new Promise((resolve, reject) => { + const child = spawn(cliPath, args, { + stdio: "inherit", + shell: false, + }); + child.once("error", reject); + child.once("exit", (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(`VS Code extension host tests exited with code ${code}.`)); + }); + }); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/extensions/vscode-codeclone/test/support.test.js b/extensions/vscode-codeclone/test/support.test.js new file mode 100644 index 0000000..ecaa6e5 --- /dev/null +++ b/extensions/vscode-codeclone/test/support.test.js @@ -0,0 +1,85 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + STALE_REASON_EDITOR, + STALE_REASON_WORKSPACE, + normalizedLaunchSpec, + parseUtcTimestamp, + resolveWorkspacePath, + signedInteger, + staleMessage, + trimTail, +} = require("../src/support"); + +test("signedInteger formats positive, zero, and negative values", () => { + assert.equal(signedInteger(3), "+3"); + assert.equal(signedInteger(0), "0"); + assert.equal(signedInteger(-2), "-2"); + assert.equal(signedInteger(Number.NaN), "0"); +}); + +test("parseUtcTimestamp returns milliseconds for valid UTC strings", () => { + assert.equal( + parseUtcTimestamp("2026-04-03T17:00:00Z"), + Date.parse("2026-04-03T17:00:00Z") + ); + assert.equal(parseUtcTimestamp("not-a-date"), null); + assert.equal(parseUtcTimestamp(""), null); +}); + +test("staleMessage stays explicit for editor and workspace drift", () => { + assert.equal( + staleMessage(STALE_REASON_EDITOR), + "Review data may be stale because there are unsaved editor changes." + ); + assert.equal( + staleMessage(STALE_REASON_WORKSPACE), + "Review data may be stale because the workspace changed after this run." + ); +}); + +test("normalizedLaunchSpec trims arguments and rejects empty command or cwd", () => { + assert.deepEqual( + normalizedLaunchSpec({ + command: " codeclone-mcp ", + args: [" --stdio ", "", " "], + cwd: " /tmp/workspace ", + }), + { + command: "codeclone-mcp", + args: ["--stdio"], + cwd: "/tmp/workspace", + } + ); + assert.throws( + () => normalizedLaunchSpec({ command: "", args: [], cwd: "/tmp" }), + /must not be empty/ + ); + assert.throws( + () => normalizedLaunchSpec({ command: "codeclone-mcp", args: [], cwd: "" }), + /must not be empty/ + ); +}); + +test("resolveWorkspacePath keeps paths inside the workspace root only", () => { + const root = "/workspace/repo"; + assert.equal( + resolveWorkspacePath(root, "src/module.py"), + "/workspace/repo/src/module.py" + ); + assert.equal( + resolveWorkspacePath(root, "./src/../src/module.py"), + "/workspace/repo/src/module.py" + ); + assert.equal(resolveWorkspacePath(root, "../outside.py"), null); + assert.equal(resolveWorkspacePath(root, ""), null); +}); + +test("trimTail keeps the newest part of long strings", () => { + assert.equal(trimTail("abcdef", 4), "cdef"); + assert.equal(trimTail("abc", 10), "abc"); + assert.equal(trimTail("abc", 0), ""); +}); From 7c92fbd45f81a4f08a1ee6e169cde4aa3f918545 Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Sat, 4 Apr 2026 01:09:56 +0500 Subject: [PATCH 05/15] feat(vscode): harden the VS Code client with trust-aware UX, safer launcher/runtime handling, and tested review workflows --- extensions/vscode-codeclone/package.json | 10 +++++++ extensions/vscode-codeclone/src/extension.js | 11 +++++++ extensions/vscode-codeclone/src/support.js | 24 +++++++++++++++ .../vscode-codeclone/test/manifest.test.js | 29 +++++++++++++++++++ .../vscode-codeclone/test/support.test.js | 14 +++++++++ 5 files changed, 88 insertions(+) create mode 100644 extensions/vscode-codeclone/test/manifest.test.js diff --git a/extensions/vscode-codeclone/package.json b/extensions/vscode-codeclone/package.json index 5480488..5e029da 100644 --- a/extensions/vscode-codeclone/package.json +++ b/extensions/vscode-codeclone/package.json @@ -209,6 +209,11 @@ "category": "CodeClone", "icon": "$(filter)" }, + { + "command": "codeclone.reviewFinding", + "title": "Review Finding", + "category": "CodeClone" + }, { "command": "codeclone.openFinding", "title": "Open Finding", @@ -287,6 +292,11 @@ "category": "CodeClone", "icon": "$(symbol-module)" }, + { + "command": "codeclone.reviewGodModule", + "title": "Review Candidate", + "category": "CodeClone" + }, { "command": "codeclone.copyGodModuleBrief", "title": "Copy Report-only Brief", diff --git a/extensions/vscode-codeclone/src/extension.js b/extensions/vscode-codeclone/src/extension.js index 44b202a..cc0676d 100644 --- a/extensions/vscode-codeclone/src/extension.js +++ b/extensions/vscode-codeclone/src/extension.js @@ -15,6 +15,7 @@ const { resolveWorkspacePath, signedInteger, staleMessage, + workspaceLocalLauncherCandidates, } = require("./support"); const execFileAsync = promisify(execFile); @@ -1134,6 +1135,16 @@ class CodeCloneController { cwd: folder.uri.fsPath, }); } + const localLauncher = workspaceLocalLauncherCandidates(folder.uri.fsPath).find( + (candidate) => fs.existsSync(candidate) + ); + if (localLauncher) { + return normalizedLaunchSpec({ + command: localLauncher, + args: Array.isArray(configuredArgs) ? configuredArgs : [], + cwd: folder.uri.fsPath, + }); + } const primary = normalizedLaunchSpec({ command: "codeclone-mcp", args: Array.isArray(configuredArgs) ? configuredArgs : [], diff --git a/extensions/vscode-codeclone/src/support.js b/extensions/vscode-codeclone/src/support.js index b5842df..d34b6d2 100644 --- a/extensions/vscode-codeclone/src/support.js +++ b/extensions/vscode-codeclone/src/support.js @@ -70,6 +70,29 @@ function resolveWorkspacePath(rootPath, relativePath) { return null; } +function workspaceLocalLauncherCandidates( + rootPath, + platform = process.platform +) { + const root = String(rootPath || "").trim(); + if (!root) { + return []; + } + const platformPath = platform === "win32" ? path.win32 : path.posix; + if (platform === "win32") { + return [ + platformPath.join(root, ".venv", "Scripts", "codeclone-mcp.exe"), + platformPath.join(root, ".venv", "Scripts", "codeclone-mcp.cmd"), + platformPath.join(root, "venv", "Scripts", "codeclone-mcp.exe"), + platformPath.join(root, "venv", "Scripts", "codeclone-mcp.cmd"), + ]; + } + return [ + platformPath.join(root, ".venv", "bin", "codeclone-mcp"), + platformPath.join(root, "venv", "bin", "codeclone-mcp"), + ]; +} + module.exports = { STALE_REASON_EDITOR, STALE_REASON_WORKSPACE, @@ -79,4 +102,5 @@ module.exports = { signedInteger, staleMessage, trimTail, + workspaceLocalLauncherCandidates, }; diff --git a/extensions/vscode-codeclone/test/manifest.test.js b/extensions/vscode-codeclone/test/manifest.test.js new file mode 100644 index 0000000..5ba57e5 --- /dev/null +++ b/extensions/vscode-codeclone/test/manifest.test.js @@ -0,0 +1,29 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); + +function loadPackageJson() { + const filePath = path.resolve(__dirname, "..", "package.json"); + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +test("every menu command is declared in contributes.commands", () => { + const pkg = loadPackageJson(); + const declaredCommands = new Set( + pkg.contributes.commands.map((entry) => entry.command) + ); + const missing = []; + + for (const items of Object.values(pkg.contributes.menus)) { + for (const entry of items) { + if (!declaredCommands.has(entry.command)) { + missing.push(entry.command); + } + } + } + + assert.deepEqual(missing, []); +}); diff --git a/extensions/vscode-codeclone/test/support.test.js b/extensions/vscode-codeclone/test/support.test.js index ecaa6e5..f3759f1 100644 --- a/extensions/vscode-codeclone/test/support.test.js +++ b/extensions/vscode-codeclone/test/support.test.js @@ -12,6 +12,7 @@ const { signedInteger, staleMessage, trimTail, + workspaceLocalLauncherCandidates, } = require("../src/support"); test("signedInteger formats positive, zero, and negative values", () => { @@ -83,3 +84,16 @@ test("trimTail keeps the newest part of long strings", () => { assert.equal(trimTail("abc", 10), "abc"); assert.equal(trimTail("abc", 0), ""); }); + +test("workspaceLocalLauncherCandidates prefer workspace virtual environments", () => { + assert.deepEqual(workspaceLocalLauncherCandidates("/workspace/repo", "linux"), [ + "/workspace/repo/.venv/bin/codeclone-mcp", + "/workspace/repo/venv/bin/codeclone-mcp", + ]); + assert.deepEqual(workspaceLocalLauncherCandidates("C:\\repo", "win32"), [ + "C:\\repo\\.venv\\Scripts\\codeclone-mcp.exe", + "C:\\repo\\.venv\\Scripts\\codeclone-mcp.cmd", + "C:\\repo\\venv\\Scripts\\codeclone-mcp.exe", + "C:\\repo\\venv\\Scripts\\codeclone-mcp.cmd", + ]); +}); From e0280b74fc14ca596fc0e68639479ba2d289bf82 Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Sat, 4 Apr 2026 16:16:58 +0500 Subject: [PATCH 06/15] feat(vscode,report): rename overloaded modules canonically and harden the VS Code surface --- .dockerignore | 1 + .github/actions/codeclone/README.md | 2 +- .gitignore | 1 + AGENTS.md | 2 +- CHANGELOG.md | 43 +- README.md | 43 +- codeclone/_cli_summary.py | 20 +- codeclone/_html_css.py | 26 +- codeclone/_html_report/_assemble.py | 4 +- codeclone/_html_report/_components.py | 3 +- codeclone/_html_report/_context.py | 8 +- codeclone/_html_report/_sections/_coupling.py | 21 +- codeclone/_html_report/_sections/_overview.py | 166 +- codeclone/cli.py | 22 +- codeclone/mcp_service.py | 25 +- codeclone/metrics/__init__.py | 4 +- .../{god_modules.py => overloaded_modules.py} | 2 +- codeclone/pipeline.py | 4 +- codeclone/report/json_contract.py | 58 +- codeclone/report/markdown.py | 12 +- codeclone/report/serialize.py | 16 +- codeclone/ui_messages.py | 10 +- docs/book/02-terminology.md | 2 +- docs/book/08-report.md | 8 +- docs/book/14-compatibility-and-versioning.md | 2 +- docs/book/15-health-score.md | 24 +- docs/book/20-mcp-interface.md | 88 +- docs/book/21-vscode-extension.md | 49 +- docs/book/appendix/b-schema-layouts.md | 4 +- docs/mcp.md | 91 +- docs/vscode-extension.md | 4 +- extensions/vscode-codeclone/.vscodeignore | 4 + extensions/vscode-codeclone/CHANGELOG.md | 2 +- extensions/vscode-codeclone/README.md | 4 +- .../vscode-codeclone/esbuild.config.mjs | 25 + extensions/vscode-codeclone/jsconfig.json | 30 + extensions/vscode-codeclone/package-lock.json | 4504 +++++++++++++++++ extensions/vscode-codeclone/package.json | 46 +- extensions/vscode-codeclone/src/constants.js | 90 + extensions/vscode-codeclone/src/extension.js | 1037 +--- extensions/vscode-codeclone/src/formatters.js | 316 ++ extensions/vscode-codeclone/src/mcpClient.js | 8 +- extensions/vscode-codeclone/src/providers.js | 133 + extensions/vscode-codeclone/src/renderers.js | 266 + extensions/vscode-codeclone/src/runtime.js | 84 + .../vscode-codeclone/test/runExtensionHost.js | 8 +- tests/test_cli_inprocess.py | 2 +- tests/test_cli_unit.py | 26 +- tests/test_html_report.py | 70 +- tests/test_mcp_server.py | 30 +- tests/test_mcp_service.py | 46 +- tests/test_pipeline_metrics.py | 18 +- tests/test_report_contract_coverage.py | 24 +- uv.lock | 12 +- 54 files changed, 6161 insertions(+), 1389 deletions(-) rename codeclone/metrics/{god_modules.py => overloaded_modules.py} (99%) create mode 100644 extensions/vscode-codeclone/esbuild.config.mjs create mode 100644 extensions/vscode-codeclone/jsconfig.json create mode 100644 extensions/vscode-codeclone/package-lock.json create mode 100644 extensions/vscode-codeclone/src/constants.js create mode 100644 extensions/vscode-codeclone/src/formatters.js create mode 100644 extensions/vscode-codeclone/src/providers.js create mode 100644 extensions/vscode-codeclone/src/renderers.js create mode 100644 extensions/vscode-codeclone/src/runtime.js diff --git a/.dockerignore b/.dockerignore index c49a425..9b5f4ee 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,3 +18,4 @@ docs codeclone.egg-info .pre-commit-config.yaml uv.lock +extensions diff --git a/.github/actions/codeclone/README.md b/.github/actions/codeclone/README.md index bc51388..88dbc69 100644 --- a/.github/actions/codeclone/README.md +++ b/.github/actions/codeclone/README.md @@ -149,7 +149,7 @@ Explicit prerelease: ```yaml with: - package-version: "2.0.0b3" + package-version: "2.0.0b4" ``` Local/self-repo validation: diff --git a/.gitignore b/.gitignore index 24a517f..44369ac 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ site/ /docs/SPEC-2.0.0.md /.uv-cache/ /package-lock.json +extensions/vscode-codeclone/node_modules diff --git a/AGENTS.md b/AGENTS.md index 776a578..554688f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -574,7 +574,7 @@ Use modern syntax when it stays compatible with 3.10+: Prefer these rules: - **Domain / contracts / enums** live near the domain owner (baseline statuses in baseline domain). -- If a module becomes a “god module”, split by: +- If a module becomes an “overloaded module”, split by: - model (types) - io/serialization - rules/validation diff --git a/CHANGELOG.md b/CHANGELOG.md index 7494c28..d6be8ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,46 +2,35 @@ ## [2.0.0b4] -2.0.0b4 deepens the platform model introduced in b3: MCP becomes more self-guiding, report-only analysis expands with -module-level hotspot ranking, findings and suggestions are separated more cleanly by role, Health Score documentation -now formalizes phased score-model evolution, and CodeClone gains its first native IDE surface in preview. - ### MCP server -- Add bounded MCP `help(topic=...)` as a compact uncertainty-recovery and semantic-routing tool for workflow, baseline, - suppressions, latest-run semantics, review state, and changed-scope routing. +- Add `help(topic=...)` tool for workflow guidance, baseline semantics, and review-state routing. +- Optimize MCP payloads: short finding IDs (sha256-based for block clones), compact `derived` section projection, + bounded `metrics_detail` with pagination. ### Report contract -- Bump canonical report schema to `2.3` and add `metrics.families.god_modules` as a project-relative, report-only - module-hotspot layer. -- Surface `God Modules` consistently across canonical JSON, text/markdown, HTML Overview + Quality projections, and - bounded MCP `metrics_detail` access without changing findings, health, gates, baseline semantics, or SARIF. -- Tighten the findings/suggestions role split: low-signal local structural `info` hints remain canonical findings, - while separate suggestion cards are reserved for action-surplus cases and structural findings can render compact - inline suggested action in HTML. +- Bump canonical report schema to `2.3`. +- Add `metrics.overloaded_modules` — report-only module-hotspot ranking by size, complexity, and coupling pressure. +- Surface Overloaded Modules across JSON, text/markdown, HTML, and MCP without affecting findings, health, or gates. +- Normalize the canonical family name and MCP/report output to `overloaded_modules`; `god_modules` remains accepted as a read-only MCP input alias during transition. ### CLI and HTML -- Align CLI and HTML scope summaries with canonical report-wide inventory totals. -- Polish `God Modules` presentation so report-only module-hotspot summaries read consistently across surfaces. +- Align CLI and HTML scope summaries with canonical inventory totals. +- Redesign Overview tab: Executive Summary becomes 2-column (Issue Breakdown + Source Breakdown) with scan scope in + the section subtitle; Overloaded Modules section replaces the earlier stretched module-hotspot layout. ### Documentation -- Add a dedicated Health Score chapter documenting current scoring inputs, report-only / non-scoring layers, and the - phased policy for future health-model expansion. -- Explicitly document that future releases may lower a repository score because the scoring model becomes broader or - stricter, not only because the code became worse. +- Add Health Score chapter: scoring inputs, report-only layers, phased expansion policy. +- Document that future releases may lower scores due to broader scoring model, not only worse code. -### IDE integration +### IDE integration (preview) -- Add a preview VS Code extension as the first native IDE surface for `codeclone-mcp`, bringing baseline-aware, - triage-first structural review and guided source-first drill-down into the editor. -- Establish the initial extension interaction model, including explicit setup/session semantics, review-loop navigation, - hotspot focus persistence, lightweight Explorer decorations, safe HTML-report bridging, and accessibility/status - polish. -- Add extension-side regression coverage with Node unit tests, local extension-host smoke, and validated preview `.vsix` - packaging. +- Add VS Code extension (`codeclone-mcp` client) with baseline-aware triage, source drill-down, Explorer decorations, + and HTML-report bridging. +- Add Node unit tests, extension-host smoke tests, and `.vsix` packaging. ## [2.0.0b3] - 20260401 diff --git a/README.md b/README.md index 35e552f..6d3dd5c 100644 --- a/README.md +++ b/README.md @@ -39,15 +39,13 @@ Live sample report: - **Clone detection** — function (CFG fingerprint), block (statement windows), and segment (report-only) clones - **Structural findings** — duplicated branch families, clone guard/exit divergence and clone-cohort drift (report-only) - **Quality metrics** — cyclomatic complexity, coupling (`CBO`), cohesion (`LCOM4`), dependency cycles, dead code, - health - score, and report-only `God Modules` profiling + health score, and report-only `Overloaded Modules` profiling - **Baseline governance** — separates accepted **legacy** debt from **new regressions** and lets CI fail **only** on what changed - **Reports** — interactive HTML, deterministic JSON/TXT plus Markdown and SARIF projections from one canonical report -- **MCP server** — optional read-only MCP surface for AI agents and IDEs, designed as a budget-aware guided control +- **MCP server** — optional read-only surface for AI agents and IDEs, designed as a budget-aware guided control surface for agentic development -- **VS Code extension** — preview native client for CodeClone MCP with baseline-aware, triage-first structural - review inside the editor +- **VS Code extension** — preview native client for CodeClone MCP with triage-first structural review - **CI-first** — deterministic output, stable ordering, exit code contract, pre-commit support - **Fast** — incremental caching, parallel processing, warm-run optimization, and reproducible benchmark coverage @@ -170,18 +168,8 @@ codeclone-mcp --transport stdio codeclone-mcp --transport streamable-http --port 8000 ``` -21 tools + 10 resources — deterministic, baseline-aware, and read-only. -Never mutates source files, baselines, or repo state. - -Payloads are optimized for LLM context: compact summaries by default, full detail on demand. -The cheapest useful path is also the most obvious path: first-pass triage stays compact, and deeper detail is explicit. - -Recommended agent flow: -`analyze_repository` or `analyze_changed_paths` → `get_run_summary` or `get_production_triage` → -`list_hotspots` or `check_*` → `get_finding` → `get_remediation` - -If workflow or contract meaning is unclear, `help(topic=...)` returns a compact -semantic guide with the safest next step and canonical doc links. +21 tools + 10 resources. Read-only — never mutates source, baselines, or repo state. +Payloads are optimized for LLM context: compact summaries by default, full detail on drill-down. Docs: [MCP usage guide](https://orenlab.github.io/codeclone/mcp/) @@ -190,21 +178,10 @@ Docs: ### VS Code Extension -The repository also ships a preview VS Code extension in +A preview VS Code extension ships in [`extensions/vscode-codeclone/`](https://github.com/orenlab/codeclone/tree/main/extensions/vscode-codeclone). - -It is: - -- native VS Code first -- baseline-aware -- triage-first -- read-only with respect to repository state -- powered by the same `codeclone-mcp` contract surface -- limited in Restricted Mode until workspace trust is granted - -It focuses on source-first structural review inside the editor: overview, -hotspots, review loop, changed-files pass, and explicit drill-down into finding, -remediation, or local HTML report when needed. +It connects to `codeclone-mcp` and provides triage-first structural review inside the editor: +overview, hotspots, review loop, and drill-down into findings or the HTML report. Docs: [VS Code extension guide](https://orenlab.github.io/codeclone/vscode-extension/) @@ -300,11 +277,11 @@ class Middleware: # codeclone: ignore[dead-code] Dynamic/runtime false positives are resolved via explicit inline suppressions, not via broad heuristics.
-Canonical JSON report shape (v2.2) +Canonical JSON report shape (v2.3) ```json { - "report_schema_version": "2.2", + "report_schema_version": "2.3", "meta": { "codeclone_version": "2.0.0b4", "project_name": "...", diff --git a/codeclone/_cli_summary.py b/codeclone/_cli_summary.py index 8bf0e85..14de73c 100644 --- a/codeclone/_cli_summary.py +++ b/codeclone/_cli_summary.py @@ -26,10 +26,10 @@ class MetricsSnapshot: health_total: int health_grade: str suppressed_dead_code_count: int = 0 - god_modules_candidates: int = 0 - god_modules_total: int = 0 - god_modules_population_status: str = "" - god_modules_top_score: float = 0.0 + overloaded_modules_candidates: int = 0 + overloaded_modules_total: int = 0 + overloaded_modules_population_status: str = "" + overloaded_modules_top_score: float = 0.0 @dataclass(frozen=True, slots=True) @@ -136,7 +136,7 @@ def _print_metrics( dead=metrics.dead_code_count, health=metrics.health_total, grade=metrics.health_grade, - god_modules=metrics.god_modules_candidates, + overloaded_modules=metrics.overloaded_modules_candidates, ) ) else: @@ -166,11 +166,11 @@ def _print_metrics( ) ) console.print( - ui.fmt_metrics_god_modules( - candidates=metrics.god_modules_candidates, - total=metrics.god_modules_total, - population_status=metrics.god_modules_population_status, - top_score=metrics.god_modules_top_score, + ui.fmt_metrics_overloaded_modules( + candidates=metrics.overloaded_modules_candidates, + total=metrics.overloaded_modules_total, + population_status=metrics.overloaded_modules_population_status, + top_score=metrics.overloaded_modules_top_score, ) ) diff --git a/codeclone/_html_css.py b/codeclone/_html_css.py index b596364..d13c70f 100644 --- a/codeclone/_html_css.py +++ b/codeclone/_html_css.py @@ -683,23 +683,23 @@ .dir-hotspot-meta{display:flex;flex-wrap:wrap;gap:6px;font-size:.68rem;color:var(--text-muted)} .dir-hotspot-meta span{font-variant-numeric:tabular-nums} .dir-hotspot-meta-sep{opacity:.3} -.god-module-list{display:flex;flex-direction:column;gap:0} -.god-module-entry{padding:var(--sp-2) 0;border-bottom:1px solid color-mix(in srgb,var(--border) 50%,transparent)} -.god-module-entry:last-child{border-bottom:none;padding-bottom:0} -.god-module-entry:first-child{padding-top:0} -.god-module-head{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--sp-2);margin-bottom:4px} -.god-module-title{display:flex;align-items:center;flex-wrap:wrap;gap:var(--sp-2);min-width:0} -.god-module-title code{font-size:.78rem;font-weight:600;color:var(--text-primary);line-height:1.35} -.god-module-score{flex-shrink:0;font-size:.72rem;font-weight:700;font-variant-numeric:tabular-nums; +.overloaded-module-list{display:flex;flex-direction:column;gap:0} +.overloaded-module-entry{padding:var(--sp-2) 0;border-bottom:1px solid color-mix(in srgb,var(--border) 50%,transparent)} +.overloaded-module-entry:last-child{border-bottom:none;padding-bottom:0} +.overloaded-module-entry:first-child{padding-top:0} +.overloaded-module-head{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--sp-2);margin-bottom:4px} +.overloaded-module-title{display:flex;align-items:center;flex-wrap:wrap;gap:var(--sp-2);min-width:0} +.overloaded-module-title code{font-size:.78rem;font-weight:600;color:var(--text-primary);line-height:1.35} +.overloaded-module-score{flex-shrink:0;font-size:.72rem;font-weight:700;font-variant-numeric:tabular-nums; color:var(--accent-primary);background:var(--accent-muted);border-radius:999px;padding:2px 8px} -.god-module-metrics{display:flex;flex-wrap:wrap;gap:6px;font-size:.68rem;color:var(--text-muted)} -.god-module-metrics span{font-variant-numeric:tabular-nums} -.god-module-reasons,.god-module-signal-list{display:flex;flex-wrap:wrap;gap:var(--sp-1);margin-top:var(--sp-2)} -.god-module-reason-chip,.god-module-signal-pill{display:inline-flex;align-items:center;gap:5px; +.overloaded-module-metrics{display:flex;flex-wrap:wrap;gap:6px;font-size:.68rem;color:var(--text-muted)} +.overloaded-module-metrics span{font-variant-numeric:tabular-nums} +.overloaded-module-reasons,.overloaded-module-signal-list{display:flex;flex-wrap:wrap;gap:var(--sp-1);margin-top:var(--sp-2)} +.overloaded-module-reason-chip,.overloaded-module-signal-pill{display:inline-flex;align-items:center;gap:5px; font-size:.68rem;font-weight:500;color:var(--text-secondary);background:var(--bg-raised); border:1px solid color-mix(in srgb,var(--border) 60%,transparent);border-radius:999px; padding:2px 8px} -.god-module-signal-count{font-variant-numeric:tabular-nums;color:var(--text-muted)} +.overloaded-module-signal-count{font-variant-numeric:tabular-nums;color:var(--text-muted)} /* Health radar chart */ .health-radar{display:flex;justify-content:center;padding:var(--sp-3) 0} .health-radar svg{width:100%;max-width:520px;height:auto;overflow:visible} diff --git a/codeclone/_html_report/_assemble.py b/codeclone/_html_report/_assemble.py index 5de6ad8..0449800 100644 --- a/codeclone/_html_report/_assemble.py +++ b/codeclone/_html_report/_assemble.py @@ -111,7 +111,9 @@ def build_html_report( _as_int(_as_mapping(ctx.complexity_map.get("summary")).get("high_risk")) + _as_int(_as_mapping(ctx.coupling_map.get("summary")).get("high_risk")) + _as_int(_as_mapping(ctx.cohesion_map.get("summary")).get("low_cohesion")) - + _as_int(_as_mapping(ctx.god_modules_map.get("summary")).get("candidates")) + + _as_int( + _as_mapping(ctx.overloaded_modules_map.get("summary")).get("candidates") + ) ) def _tab_badge(count: int) -> str: diff --git a/codeclone/_html_report/_components.py b/codeclone/_html_report/_components.py index 2b2a0af..9b05788 100644 --- a/codeclone/_html_report/_components.py +++ b/codeclone/_html_report/_components.py @@ -51,7 +51,6 @@ def overview_cluster_header(title: str, subtitle: str | None = None) -> str: _SUMMARY_ICON_KEYS: dict[str, tuple[str, str]] = { - "scan scope": ("overview", "summary-icon summary-icon--info"), "top risks": ("top-risks", "summary-icon summary-icon--risk"), "issue breakdown": ("issue-breakdown", "summary-icon summary-icon--info"), "source breakdown": ("source-breakdown", "summary-icon summary-icon--info"), @@ -59,7 +58,7 @@ def overview_cluster_header(title: str, subtitle: str | None = None) -> str: "clone groups": ("clone-groups", "summary-icon summary-icon--info"), "low cohesion": ("low-cohesion", "summary-icon summary-icon--info"), "top candidates": ("quality", "summary-icon summary-icon--info"), - "candidate profile": ("quality", "summary-icon summary-icon--info"), + "more candidates": ("quality", "summary-icon summary-icon--info"), "health profile": ("health-profile", "summary-icon summary-icon--info"), } diff --git a/codeclone/_html_report/_context.py b/codeclone/_html_report/_context.py index c49a4a7..22057b5 100644 --- a/codeclone/_html_report/_context.py +++ b/codeclone/_html_report/_context.py @@ -62,7 +62,7 @@ class ReportContext: cohesion_map: Mapping[str, object] dependencies_map: Mapping[str, object] dead_code_map: Mapping[str, object] - god_modules_map: Mapping[str, object] + overloaded_modules_map: Mapping[str, object] health_map: Mapping[str, object] # -- suggestions + structural -- @@ -237,7 +237,9 @@ def build_context( cohesion_map = _as_mapping(metrics_map.get("cohesion")) dependencies_map = _as_mapping(metrics_map.get("dependencies")) dead_code_map = _as_mapping(metrics_map.get("dead_code")) - god_modules_map = _as_mapping(metrics_map.get("god_modules")) + overloaded_modules_map = _as_mapping(metrics_map.get("overloaded_modules")) + if not overloaded_modules_map: + overloaded_modules_map = _as_mapping(metrics_map.get("god_modules")) health_map = _as_mapping(metrics_map.get("health")) suggestions_tuple = tuple(suggestions or ()) @@ -282,7 +284,7 @@ def build_context( cohesion_map=cohesion_map, dependencies_map=dependencies_map, dead_code_map=dead_code_map, - god_modules_map=god_modules_map, + overloaded_modules_map=overloaded_modules_map, health_map=health_map, suggestions=suggestions_tuple, structural_findings=tuple(structural_findings or ()), diff --git a/codeclone/_html_report/_sections/_coupling.py b/codeclone/_html_report/_sections/_coupling.py index d6a9751..8b43683 100644 --- a/codeclone/_html_report/_sections/_coupling.py +++ b/codeclone/_html_report/_sections/_coupling.py @@ -53,12 +53,12 @@ def render_quality_panel(ctx: ReportContext) -> str: coupling_summary = _as_mapping(ctx.coupling_map.get("summary")) cohesion_summary = _as_mapping(ctx.cohesion_map.get("summary")) complexity_summary = _as_mapping(ctx.complexity_map.get("summary")) - god_modules_summary = _as_mapping(ctx.god_modules_map.get("summary")) + overloaded_modules_summary = _as_mapping(ctx.overloaded_modules_map.get("summary")) coupling_high_risk = _as_int(coupling_summary.get("high_risk")) cohesion_low = _as_int(cohesion_summary.get("low_cohesion")) complexity_high_risk = _as_int(complexity_summary.get("high_risk")) - god_module_candidates = _as_int(god_modules_summary.get("candidates")) + overloaded_module_candidates = _as_int(overloaded_modules_summary.get("candidates")) cc_max = _as_int(complexity_summary.get("max")) # Insight @@ -72,12 +72,14 @@ def render_quality_panel(ctx: ReportContext) -> str: f"High-complexity: {complexity_high_risk}; " f"high-coupling: {coupling_high_risk}; " f"low-cohesion: {cohesion_low}; " - f"god modules: {god_module_candidates}; " + f"overloaded modules: {overloaded_module_candidates}; " f"max CC {cc_max}; " f"max CBO {coupling_summary.get('max', 'n/a')}; " f"max LCOM4 {cohesion_summary.get('max', 'n/a')}." ) - if god_module_candidates > 0 or (coupling_high_risk > 0 and cohesion_low > 0): + if overloaded_module_candidates > 0 or ( + coupling_high_risk > 0 and cohesion_low > 0 + ): tone = "risk" elif coupling_high_risk > 0 or cohesion_low > 0 or complexity_high_risk > 0: tone = "warn" @@ -152,7 +154,7 @@ def render_quality_panel(ctx: ReportContext) -> str: ctx=ctx, ) - gm_rows_data = _as_sequence(ctx.god_modules_map.get("items")) + gm_rows_data = _as_sequence(ctx.overloaded_modules_map.get("items")) gm_rows = [ ( str(_as_mapping(r).get("module", "")), @@ -180,7 +182,7 @@ def render_quality_panel(ctx: ReportContext) -> str: "Complexity total", ), rows=gm_rows, - empty_message="God-module profiling is not available.", + empty_message="Overloaded-module profiling is not available.", ctx=ctx, ) @@ -188,7 +190,12 @@ def render_quality_panel(ctx: ReportContext) -> str: ("complexity", "Complexity", complexity_high_risk, cx_panel), ("coupling", "Coupling (CBO)", coupling_high_risk, cp_panel), ("cohesion", "Cohesion (LCOM4)", cohesion_low, ch_panel), - ("god-modules", "God Modules", god_module_candidates, gm_panel), + ( + "overloaded-modules", + "Overloaded Modules", + overloaded_module_candidates, + gm_panel, + ), ] return insight_block( diff --git a/codeclone/_html_report/_sections/_overview.py b/codeclone/_html_report/_sections/_overview.py index 4c52616..385b918 100644 --- a/codeclone/_html_report/_sections/_overview.py +++ b/codeclone/_html_report/_sections/_overview.py @@ -9,7 +9,6 @@ from __future__ import annotations import math -from collections import Counter from collections.abc import Mapping from typing import TYPE_CHECKING @@ -62,12 +61,6 @@ "coupling": "coupling", "dependency": "dependency", } -_GOD_MODULE_REASON_LABELS: dict[str, str] = { - "size_pressure": "size pressure", - "dependency_pressure": "dependency pressure", - "hub_like_shape": "hub-like shape", - "repeated_import_pressure": "repeated import pressure", -} def _health_gauge_html( @@ -398,22 +391,6 @@ def _format_count(value: int | float) -> str: return f"{int(value):,}" -def _overview_fact_rows_html(facts: list[tuple[str, str]]) -> str: - if not facts: - return "" - return ( - '
' - + "".join( - '
' - f'{_escape_html(label)}' - f'{_escape_html(value)}' - "
" - for label, value in facts - ) - + "
" - ) - - def _mb(*pairs: tuple[str, object]) -> str: """Render compact micro-badges for stat-card detail rows.""" return "".join( @@ -425,41 +402,26 @@ def _mb(*pairs: tuple[str, object]) -> str: ) -def _run_snapshot_section(ctx: ReportContext) -> str: +def _scan_scope_subtitle(ctx: ReportContext) -> str: + """Build a subtitle string with scan-scope essentials for the Executive Summary header.""" inventory = _as_mapping(getattr(ctx, "inventory_map", {})) if not inventory: - return "" + return "Project-wide context derived from the full scanned root." files = _as_mapping(inventory.get("files")) code = _as_mapping(inventory.get("code")) total_found = _as_int(files.get("total_found")) - analyzed = _as_int(files.get("analyzed")) - cached = _as_int(files.get("cached")) - skipped = _as_int(files.get("skipped")) - source_io_skipped = _as_int(files.get("source_io_skipped")) parsed_lines = _as_int(code.get("parsed_lines")) functions = _as_int(code.get("functions")) methods = _as_int(code.get("methods")) classes = _as_int(code.get("classes")) callable_total = functions + methods - summary_parts = [ - f"{_format_count(total_found)} found", - f"{_format_count(analyzed)} analyzed", - f"{_format_count(cached)} cached", - f"{_format_count(skipped + source_io_skipped)} skipped", - ] - facts = [ - ("Cached files", _format_count(cached)), - ("Skipped files", _format_count(skipped + source_io_skipped)), - ("Parsed lines", _format_count(parsed_lines)), - ("Callables", _format_count(callable_total)), - ("Classes", _format_count(classes)), - ] return ( - '
' - f"{' · '.join(summary_parts)}" - "
" + _overview_fact_rows_html(facts) + f"{_format_count(total_found)} files \u00b7 " + f"{_format_count(parsed_lines)} lines \u00b7 " + f"{_format_count(callable_total)} callables \u00b7 " + f"{_format_count(classes)} classes" ) @@ -580,116 +542,74 @@ def _directory_hotspots_section(ctx: ReportContext) -> str: ) -def _god_modules_section(ctx: ReportContext) -> str: - god_modules = _as_mapping(getattr(ctx, "god_modules_map", {})) - if not god_modules: +def _overloaded_modules_section(ctx: ReportContext) -> str: + overloaded_modules = _as_mapping(getattr(ctx, "overloaded_modules_map", {})) + if not overloaded_modules: return "" - summary = _as_mapping(god_modules.get("summary")) + summary = _as_mapping(overloaded_modules.get("summary")) candidates = _as_int(summary.get("candidates")) if candidates <= 0: return "" candidate_rows = [ _as_mapping(item) - for item in _as_sequence(god_modules.get("items")) + for item in _as_sequence(overloaded_modules.get("items")) if str(_as_mapping(item).get("candidate_status", "")).strip() == "candidate" ][:5] if not candidate_rows: return "" - top_rows = candidate_rows[:4] rows_html: list[str] = [] - reason_counts: Counter[str] = Counter() for row in candidate_rows: - for reason in _as_sequence(row.get("candidate_reasons")): - if str(reason).strip(): - reason_counts[str(reason)] += 1 - - signal_pills = "".join( - '' - f"{_escape_html(_GOD_MODULE_REASON_LABELS.get(reason, reason.replace('_', ' ')))}" - f'{count}' - "" - for reason, count in sorted( - reason_counts.items(), - key=lambda item: (-item[1], item[0]), - )[:4] - ) - - for row in top_rows: score = _as_float(row.get("score")) - reason_labels = [ - _GOD_MODULE_REASON_LABELS.get(str(reason), str(reason).replace("_", " ")) - for reason in _as_sequence(row.get("candidate_reasons")) - if str(reason).strip() - ] relative_path = str(row.get("relative_path", "")).strip() if not relative_path: relative_path = str(row.get("module", "")).replace(".", "/") + ".py" fan_summary = f"{_as_int(row.get('fan_in'))}/{_as_int(row.get('fan_out'))}" - reason_html = ( - '
' - + "".join( - f'{_escape_html(label)}' - for label in reason_labels[:3] - ) - + "
" - if reason_labels - else "" - ) rows_html.append( - '
' - '
' - '
' + '
' + '
' + '
' f"{_escape_html(relative_path)}" f"{_source_kind_badge_html(str(row.get('source_kind', 'other')))}" "
" - f'{score:.2f}' + f'{score:.2f}' "
" - '
' + '
' f"{_escape_html(_format_count(_as_int(row.get('loc'))))} LOC" f"{_DIR_META_SEP}" f"fan-in/out {_escape_html(fan_summary)}" f"{_DIR_META_SEP}" f"complexity {_escape_html(str(_as_int(row.get('complexity_total'))))}" "
" - f"{reason_html}" "
" ) - profile_facts = [("Top score", f"{_as_float(summary.get('top_score')):.2f}")] - average_score = _as_float(summary.get("average_score")) - if average_score > 0: - profile_facts.append(("Average score", f"{average_score:.2f}")) - population_status = str(summary.get("population_status", "")).strip() - if population_status: - profile_facts.append(("Population", population_status.replace("_", " "))) - - profile_html = ( - '
' + total_ranked = _as_int(summary.get("total")) + subtitle = ( f"{candidates} candidate{'s' if candidates != 1 else ''} " - f"across {_as_int(summary.get('total'))} ranked module{'s' if _as_int(summary.get('total')) != 1 else ''}." - "
" - + _overview_fact_rows_html(profile_facts) - + ( - f'
{signal_pills}
' - if signal_pills - else "" - ) + f"across {total_ranked} ranked module{'s' if total_ranked != 1 else ''}" + " \u2014 disproportionate size, complexity, or coupling." ) + mid = (len(rows_html) + 1) // 2 + left_html = "".join(rows_html[:mid]) + right_html = "".join(rows_html[mid:]) return ( '
' - + overview_cluster_header( - "God Modules", - "Report-only module hotspots derived from project-relative implementation burden and dependency pressure.", - ) + + overview_cluster_header("Overloaded Modules", subtitle) + '
' + overview_summary_item_html( label="Top candidates", - body_html='
' + "".join(rows_html) + "
", + body_html='
' + left_html + "
", ) - + overview_summary_item_html( - label="Candidate profile", - body_html=profile_html, + + ( + overview_summary_item_html( + label="More candidates", + body_html='
' + + right_html + + "
", + ) + if right_html + else "" ) + "
" ) @@ -922,14 +842,12 @@ def _baselined_detail( "cohesion": None, } - # Executive summary: issue breakdown (sorted) + source breakdown + # Executive summary: issue breakdown + source breakdown (2-col) + scan_scope_subtitle = _scan_scope_subtitle(ctx) executive = ( '
' - + overview_cluster_header( - "Executive Summary", - "Project-wide context derived from the full scanned root.", - ) - + '
' + + overview_cluster_header("Executive Summary", scan_scope_subtitle) + + '
' + overview_summary_item_html( label="Issue breakdown", body_html=_issue_breakdown_html(ctx, deltas=_issue_deltas), @@ -940,10 +858,6 @@ def _baselined_detail( _as_mapping(ctx.overview_data.get("source_breakdown")) ), ) - + overview_summary_item_html( - label="Scan scope", - body_html=_run_snapshot_section(ctx), - ) + "
" ) @@ -965,7 +879,7 @@ def _baselined_detail( + "
" + executive + _directory_hotspots_section(ctx) - + _god_modules_section(ctx) + + _overloaded_modules_section(ctx) + _analytics_section(ctx) ) diff --git a/codeclone/cli.py b/codeclone/cli.py index d6ea960..1ed5e51 100644 --- a/codeclone/cli.py +++ b/codeclone/cli.py @@ -1464,10 +1464,10 @@ def _prepare_run_inputs() -> tuple[ if analysis_result.project_metrics is not None: pm = analysis_result.project_metrics - god_modules_summary = _as_mapping( - _as_mapping(analysis_result.metrics_payload).get("god_modules") + overloaded_modules_summary = _as_mapping( + _as_mapping(analysis_result.metrics_payload).get("overloaded_modules") ).get("summary") - god_modules_summary_map = _as_mapping(god_modules_summary) + overloaded_modules_summary_map = _as_mapping(overloaded_modules_summary) _print_metrics( console=cast("_PrinterLike", console), quiet=args.quiet, @@ -1484,15 +1484,17 @@ def _prepare_run_inputs() -> tuple[ health_total=pm.health.total, health_grade=pm.health.grade, suppressed_dead_code_count=analysis_result.suppressed_dead_code_items, - god_modules_candidates=_as_int( - god_modules_summary_map.get("candidates") + overloaded_modules_candidates=_as_int( + overloaded_modules_summary_map.get("candidates") ), - god_modules_total=_as_int(god_modules_summary_map.get("total")), - god_modules_population_status=str( - god_modules_summary_map.get("population_status", "") + overloaded_modules_total=_as_int( + overloaded_modules_summary_map.get("total") ), - god_modules_top_score=_coerce.as_float( - god_modules_summary_map.get("top_score") + overloaded_modules_population_status=str( + overloaded_modules_summary_map.get("population_status", "") + ), + overloaded_modules_top_score=_coerce.as_float( + overloaded_modules_summary_map.get("top_score") ), ), ) diff --git a/codeclone/mcp_service.py b/codeclone/mcp_service.py index 5990cc5..5248653 100644 --- a/codeclone/mcp_service.py +++ b/codeclone/mcp_service.py @@ -140,6 +140,7 @@ "dependencies", "dead_code", "god_modules", + "overloaded_modules", "health", ] ReportSection = Literal[ @@ -278,9 +279,13 @@ "dependencies", "dead_code", "god_modules", + "overloaded_modules", "health", } ) +_METRICS_DETAIL_FAMILY_ALIASES: Final[dict[str, str]] = { + "god_modules": "overloaded_modules", +} _SHORT_RUN_ID_LENGTH = 8 _SHORT_HASH_ID_LENGTH = 6 @@ -1322,14 +1327,20 @@ def get_report_section( raise MCPServiceContractError( "Report section 'metrics_detail' is not available in this run." ) - validated_family = cast( - "MetricsDetailFamily | None", - self._validate_optional_choice( - "family", - family, - _VALID_METRICS_DETAIL_FAMILIES, - ), + validated_family_input = self._validate_optional_choice( + "family", + family, + _VALID_METRICS_DETAIL_FAMILIES, + ) + normalized_family = ( + _METRICS_DETAIL_FAMILY_ALIASES.get( + str(validated_family_input), + str(validated_family_input), + ) + if validated_family_input is not None + else None ) + validated_family = cast("MetricsDetailFamily | None", normalized_family) return self._metrics_detail_payload( metrics=metrics, family=validated_family, diff --git a/codeclone/metrics/__init__.py b/codeclone/metrics/__init__.py index 885524a..14ea398 100644 --- a/codeclone/metrics/__init__.py +++ b/codeclone/metrics/__init__.py @@ -17,14 +17,14 @@ longest_chains, max_depth, ) -from .god_modules import build_god_modules_payload from .health import HealthInputs, compute_health +from .overloaded_modules import build_overloaded_modules_payload __all__ = [ "HealthInputs", "build_dep_graph", - "build_god_modules_payload", "build_import_graph", + "build_overloaded_modules_payload", "cohesion_risk", "compute_cbo", "compute_health", diff --git a/codeclone/metrics/god_modules.py b/codeclone/metrics/overloaded_modules.py similarity index 99% rename from codeclone/metrics/god_modules.py rename to codeclone/metrics/overloaded_modules.py index 903afcd..46b414b 100644 --- a/codeclone/metrics/god_modules.py +++ b/codeclone/metrics/overloaded_modules.py @@ -92,7 +92,7 @@ def _round_score(value: float) -> float: return round(float(value), 4) -def build_god_modules_payload( +def build_overloaded_modules_payload( *, scan_root: str, source_stats_by_file: Sequence[tuple[str, int, int, int, int]], diff --git a/codeclone/pipeline.py b/codeclone/pipeline.py index ad901f3..832141a 100644 --- a/codeclone/pipeline.py +++ b/codeclone/pipeline.py @@ -34,7 +34,7 @@ from .metrics import ( HealthInputs, build_dep_graph, - build_god_modules_payload, + build_overloaded_modules_payload, compute_health, find_suppressed_unused, find_unused, @@ -1349,7 +1349,7 @@ def _serialize_dead_item( "grade": project_metrics.health.grade, "dimensions": dict(project_metrics.health.dimensions), }, - "god_modules": build_god_modules_payload( + "overloaded_modules": build_overloaded_modules_payload( scan_root=scan_root, source_stats_by_file=source_stats_by_file, units=units, diff --git a/codeclone/report/json_contract.py b/codeclone/report/json_contract.py index aeed324..e3b8600 100644 --- a/codeclone/report/json_contract.py +++ b/codeclone/report/json_contract.py @@ -97,7 +97,7 @@ "structural_group_id", ] -_GOD_MODULES_FAMILY = "god_modules" +_OVERLOADED_MODULES_FAMILY = "overloaded_modules" def _optional_str(value: object) -> str | None: @@ -354,8 +354,8 @@ def _collect_paths_from_metrics(metrics: Mapping[str, object]) -> set[str]: filepath = _optional_str(item_map.get("filepath")) if filepath is not None: paths.add(filepath) - god_modules = _as_mapping(metrics.get(_GOD_MODULES_FAMILY)) - for item in _as_sequence(god_modules.get("items")): + overloaded_modules = _as_mapping(metrics.get(_OVERLOADED_MODULES_FAMILY)) + for item in _as_sequence(overloaded_modules.get("items")): item_map = _as_mapping(item) filepath = _optional_str(item_map.get("filepath")) if filepath is not None: @@ -640,9 +640,9 @@ def _normalize_suppressed_by( str(key): _as_int(value) for key, value in sorted(_as_mapping(health.get("dimensions")).items()) } - god_modules = _as_mapping(metrics_map.get(_GOD_MODULES_FAMILY)) - god_detection = _as_mapping(god_modules.get("detection")) - god_items = sorted( + overloaded_modules = _as_mapping(metrics_map.get(_OVERLOADED_MODULES_FAMILY)) + overloaded_modules_detection = _as_mapping(overloaded_modules.get("detection")) + overloaded_module_items = sorted( ( { "module": str(item_map.get("module", "")).strip(), @@ -686,7 +686,7 @@ def _normalize_suppressed_by( if str(reason).strip() ], } - for item in _as_sequence(god_modules.get("items")) + for item in _as_sequence(overloaded_modules.get("items")) for item_map in (_as_mapping(item),) ), key=lambda item: ( @@ -706,7 +706,7 @@ def _normalize_suppressed_by( coupling_summary = _as_mapping(coupling.get("summary")) cohesion_summary = _as_mapping(cohesion.get("summary")) dead_code_summary = _as_mapping(dead_code.get("summary")) - god_summary = _as_mapping(god_modules.get("summary")) + overloaded_modules_summary = _as_mapping(overloaded_modules.get("summary")) dead_high_confidence = sum( 1 for item in dead_items @@ -782,49 +782,61 @@ def _normalize_suppressed_by( "items": [], "items_truncated": False, }, - _GOD_MODULES_FAMILY: { + _OVERLOADED_MODULES_FAMILY: { "summary": { - "total": len(god_items), - "candidates": _as_int(god_summary.get("candidates")), + "total": len(overloaded_module_items), + "candidates": _as_int(overloaded_modules_summary.get("candidates")), "population_status": str( - god_summary.get("population_status", "limited") + overloaded_modules_summary.get("population_status", "limited") + ), + "top_score": round( + _as_float(overloaded_modules_summary.get("top_score")), + 4, ), - "top_score": round(_as_float(god_summary.get("top_score")), 4), "average_score": round( - _as_float(god_summary.get("average_score")), + _as_float(overloaded_modules_summary.get("average_score")), 4, ), "candidate_score_cutoff": round( - _as_float(god_summary.get("candidate_score_cutoff")), + _as_float(overloaded_modules_summary.get("candidate_score_cutoff")), 4, ), }, "detection": { - "version": str(god_detection.get("version", "1")), - "scope": str(god_detection.get("scope", "report_only")), + "version": str(overloaded_modules_detection.get("version", "1")), + "scope": str(overloaded_modules_detection.get("scope", "report_only")), "strategy": str( - god_detection.get("strategy", "project_relative_composite") + overloaded_modules_detection.get( + "strategy", + "project_relative_composite", + ) ), "minimum_population": _as_int( - god_detection.get("minimum_population"), + overloaded_modules_detection.get("minimum_population"), ), "size_signals": [ str(signal) - for signal in _as_sequence(god_detection.get("size_signals")) + for signal in _as_sequence( + overloaded_modules_detection.get("size_signals") + ) if str(signal).strip() ], "dependency_signals": [ str(signal) - for signal in _as_sequence(god_detection.get("dependency_signals")) + for signal in _as_sequence( + overloaded_modules_detection.get("dependency_signals") + ) if str(signal).strip() ], "shape_signals": [ str(signal) - for signal in _as_sequence(god_detection.get("shape_signals")) + for signal in _as_sequence( + overloaded_modules_detection.get("shape_signals") + ) if str(signal).strip() ], }, - "items": god_items, + "items": overloaded_module_items, "items_truncated": False, }, } diff --git a/codeclone/report/markdown.py b/codeclone/report/markdown.py index a987490..9c5fc0a 100644 --- a/codeclone/report/markdown.py +++ b/codeclone/report/markdown.py @@ -43,7 +43,7 @@ ("complexity", "Complexity", 3), ("coupling", "Coupling", 3), ("cohesion", "Cohesion", 3), - ("god-modules", "God Modules", 3), + ("overloaded-modules", "Overloaded Modules", 3), ("dependencies", "Dependencies", 3), ("dead-code-metrics", "Dead Code", 3), ("dead-code-suppressed", "Suppressed Dead Code", 3), @@ -435,8 +435,8 @@ def render_markdown_report_document(payload: Mapping[str, object]) -> str: ("lcom4", "method_count", "instance_var_count", "risk"), ), ( - "god-modules", - "God Modules", + "overloaded-modules", + "Overloaded Modules", ( "total", "candidates", @@ -470,9 +470,13 @@ def render_markdown_report_document(payload: Mapping[str, object]) -> str: family_key = ( "dead_code" if anchor_id == "dead-code-metrics" - else ("god_modules" if anchor_id == "god-modules" else anchor_id) + else ( + "overloaded_modules" if anchor_id == "overloaded-modules" else anchor_id + ) ) family_payload = _as_mapping(metrics_families.get(family_key)) + if not family_payload and family_key == "overloaded_modules": + family_payload = _as_mapping(metrics_families.get("god_modules")) family_summary_map = _as_mapping(family_payload.get("summary")) _append_anchor(lines, anchor_id, title, 3) _append_kv_bullets( diff --git a/codeclone/report/serialize.py b/codeclone/report/serialize.py index c8a5579..191115f 100644 --- a/codeclone/report/serialize.py +++ b/codeclone/report/serialize.py @@ -600,7 +600,7 @@ def render_text_report_document(payload: Mapping[str, object]) -> str: "complexity", "coupling", "cohesion", - "god_modules", + "overloaded_modules", "dependencies", "dead_code", "health", @@ -614,7 +614,7 @@ def render_text_report_document(payload: Mapping[str, object]) -> str: keys = ("total", "average", "max", "low_cohesion") case "dependencies": keys = ("modules", "edges", "cycles", "max_depth") - case "god_modules": + case "overloaded_modules": keys = ( "total", "candidates", @@ -628,15 +628,17 @@ def render_text_report_document(payload: Mapping[str, object]) -> str: keys = ("score", "grade") lines.append(f"{family_name}: {_format_key_values(family_summary, keys)}") - god_modules_family = _as_mapping(metrics_families.get("god_modules")) - god_module_items = _as_sequence(god_modules_family.get("items")) + overloaded_modules_family = _as_mapping(metrics_families.get("overloaded_modules")) + if not overloaded_modules_family: + overloaded_modules_family = _as_mapping(metrics_families.get("god_modules")) + overloaded_module_items = _as_sequence(overloaded_modules_family.get("items")) lines.extend( [ "", - "GOD MODULES (top 10)", + "OVERLOADED MODULES (top 10)", ] ) - if not god_module_items: + if not overloaded_module_items: lines.append("(none)") else: lines.extend( @@ -655,7 +657,7 @@ def render_text_report_document(payload: Mapping[str, object]) -> str: "complexity_total", ), ) - for item in map(_as_mapping, god_module_items[:10]) + for item in map(_as_mapping, overloaded_module_items[:10]) ) lines.append("") diff --git a/codeclone/ui_messages.py b/codeclone/ui_messages.py index 24eb442..f19ed48 100644 --- a/codeclone/ui_messages.py +++ b/codeclone/ui_messages.py @@ -178,7 +178,7 @@ SUMMARY_COMPACT_METRICS = ( "Metrics cc={cc_avg}/{cc_max} cbo={cbo_avg}/{cbo_max}" " lcom4={lcom_avg}/{lcom_max} cycles={cycles} dead_code={dead}" - " health={health}({grade}) god_modules={god_modules}" + " health={health}({grade}) overloaded_modules={overloaded_modules}" ) SUMMARY_COMPACT_CHANGED_SCOPE = ( "Changed paths={paths} findings={findings} new={new} known={known}" @@ -397,7 +397,7 @@ def fmt_summary_compact_metrics( dead: int, health: int, grade: str, - god_modules: int, + overloaded_modules: int, ) -> str: return SUMMARY_COMPACT_METRICS.format( cc_avg=f"{cc_avg:.1f}", @@ -410,7 +410,7 @@ def fmt_summary_compact_metrics( dead=dead, health=health, grade=grade, - god_modules=god_modules, + overloaded_modules=overloaded_modules, ) @@ -536,7 +536,7 @@ def fmt_metrics_dead_code(count: int, *, suppressed: int = 0) -> str: ) -def fmt_metrics_god_modules( +def fmt_metrics_overloaded_modules( *, candidates: int, total: int, @@ -551,7 +551,7 @@ def fmt_metrics_god_modules( note = "report-only" if population_status and population_status != "ok": note = f"{note}; {population_status.replace('_', ' ')} population" - return f" {'God Modules':<{_L}}{summary} [dim]({note})[/dim]" + return f" {'Overloaded':<{_L}}{summary} [dim]({note})[/dim]" def fmt_changed_scope_paths(*, count: int) -> str: diff --git a/docs/book/02-terminology.md b/docs/book/02-terminology.md index a10dd04..316f483 100644 --- a/docs/book/02-terminology.md +++ b/docs/book/02-terminology.md @@ -35,7 +35,7 @@ Define terms exactly as used by code and tests. - **health score**: weighted blend of seven dimension scores (0–100). Dimensions: clones 25%, complexity 20%, cohesion 15%, coupling 10%, dead code 10%, dependencies 10%, coverage 10%. - Report-only layers such as `God Modules` do not currently affect the score. + Report-only layers such as `Overloaded Modules` do not currently affect the score. Grade bands: A ≥90, B ≥75, C ≥60, D ≥40, F <40. - **design finding**: metric-driven finding (complexity/coupling/cohesion) emitted by the canonical report builder when a class or function exceeds diff --git a/docs/book/08-report.md b/docs/book/08-report.md index 2d3eb9c..9be84ba 100644 --- a/docs/book/08-report.md +++ b/docs/book/08-report.md @@ -34,15 +34,15 @@ Canonical provenance additions: Canonical report-only metrics additions: -- `metrics.families.god_modules` records project-relative module hotspot - profiles and candidate classification for `God Modules` +- `metrics.families.overloaded_modules` records project-relative module hotspot + profiles and candidate classification for `Overloaded Modules` - the family is canonical report truth, but it does **not** participate in findings totals, health, gates, baseline NEW/KNOWN semantics, or SARIF in `b4` -- `God Modules` is a report-only experimental layer rather than a second +- `Overloaded Modules` is a report-only experimental layer rather than a second complexity metric: - complexity reports local control-flow hotspots in functions and methods - - `God Modules` reports module-level responsibility overload and dependency + - `Overloaded Modules` reports module-level responsibility overload and dependency pressure - the layer may later become scoring only after validation and explicit health-model documentation updates diff --git a/docs/book/14-compatibility-and-versioning.md b/docs/book/14-compatibility-and-versioning.md index f85383e..3cf3f6c 100644 --- a/docs/book/14-compatibility-and-versioning.md +++ b/docs/book/14-compatibility-and-versioning.md @@ -68,7 +68,7 @@ Version bump rules: `report_schema_version` because they alter canonical report semantics and integrity payload. - The same is true for additive canonical metrics families such as - `metrics.families.god_modules`: even though the layer is report-only and does + `metrics.families.overloaded_modules`: even though the layer is report-only and does not affect health/gates/findings, it still changes canonical report schema and integrity payload, so it requires a report-schema bump. - CodeClone does not currently define a separate health-model version constant. diff --git a/docs/book/15-health-score.md b/docs/book/15-health-score.md index 8778f81..be7b71e 100644 --- a/docs/book/15-health-score.md +++ b/docs/book/15-health-score.md @@ -74,26 +74,18 @@ The current health model is deterministic and explainable by design: The following layers are visible today but do **not** currently affect Health Score: -### God Modules +### Overloaded Modules -`God Modules` is currently a report-only experimental layer. +`Overloaded Modules` is currently a report-only experimental layer. - It surfaces module-level hotspots derived from implementation burden and dependency pressure. -- It is visible in `metrics.families.god_modules`, HTML, Markdown/TXT, and MCP - `metrics_detail(family="god_modules")`. +- It is visible in `metrics.families.overloaded_modules`, HTML, Markdown/TXT, and MCP + `metrics_detail(family="overloaded_modules")`. - It does not currently affect Health Score, gates, baseline novelty, or SARIF. -- It is **not** a restatement of cyclomatic complexity: - complexity highlights local control-flow hotspots in functions and methods, - while `God Modules` highlights module-level responsibility overload and - dependency pressure. - -Suggested interpretation: - -> Complexity highlights local control-flow hotspots in functions and methods. -> God Modules highlights module-level responsibility overload and dependency -> pressure. Complexity may contribute to the signal, but God Modules is not a -> restatement of cyclomatic complexity. +- It is **not** a restatement of cyclomatic complexity: complexity highlights + local control-flow hotspots, while Overloaded Modules highlights module-level + responsibility overload and dependency pressure. ### Other visible non-scoring layers @@ -140,7 +132,7 @@ Current versioning note: - `tests/test_metrics_modules.py::test_health_helpers_and_compute_health_boundaries` - `tests/test_pipeline_metrics.py::test_compute_project_metrics_respects_skip_flags` -- `tests/test_report_contract_coverage.py::test_report_contract_includes_canonical_god_modules_family` +- `tests/test_report_contract_coverage.py::test_report_contract_includes_canonical_overloaded_modules_family` - `tests/test_report_contract_coverage.py::test_overview_health_snapshot_handles_non_mapping_dimensions` ## See also diff --git a/docs/book/20-mcp-interface.md b/docs/book/20-mcp-interface.md index 28885e6..146501b 100644 --- a/docs/book/20-mcp-interface.md +++ b/docs/book/20-mcp-interface.md @@ -4,12 +4,10 @@ Define the current public MCP surface in the `2.0` beta line. -This interface is **optional** and is installed via the `mcp` extra. It does -not replace the CLI or the canonical JSON report contract. Instead, it exposes -the existing deterministic analysis pipeline as a **read-only MCP server** for -AI agents and MCP-capable clients. -It is intentionally budget-aware and triage-first: the MCP surface is shaped as -guided control flow for agentic development, not as a flat dump of report data. +This interface is **optional** (installed via the `mcp` extra). It exposes +the deterministic analysis pipeline as a **read-only MCP server** for AI agents +and MCP-capable clients. It does not replace the CLI or the canonical report +contract. ## Public surface @@ -91,47 +89,41 @@ produced by the report contract. Current tool set (`21` tools): -| Tool | Key parameters | Purpose / notes | -|--------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `analyze_repository` | absolute `root`, `analysis_mode`, `changed_paths`, `git_diff_ref`, inline thresholds, cache/baseline paths | Run deterministic CodeClone analysis, register the latest run, and return a compact MCP summary. The intended next step is `get_run_summary` or `get_production_triage`, not broad listing by default | -| `analyze_changed_paths` | absolute `root`, `changed_paths` or `git_diff_ref`, `analysis_mode`, inline thresholds | Diff-aware fast path: analyze a repo, attach a changed-files projection, and return a compact changed-files snapshot. The intended next step is `get_report_section(section="changed")` or `get_production_triage` | -| `get_run_summary` | `run_id` | Return the stored summary for the latest or specified run, with slim inventory counts instead of the full file registry; this is the cheapest run-level snapshot and `health` becomes explicit `available=false` when metrics were skipped | -| `get_production_triage` | `run_id`, `max_hotspots`, `max_suggestions` | Return a compact production-first MCP projection: health, cache `freshness`, production hotspots, production suggestions, and global source-kind counters. This is the default first-pass view for large or noisy repositories | -| `help` | `topic`, `detail` | Return a bounded semantic guide for a small set of MCP topics (`workflow`, `suppressions`, `baseline`, `latest_runs`, `review_state`, `changed_scope`) with next-step routing and canonical doc links. This is for uncertainty recovery, not full manual access | -| `compare_runs` | `run_id_before`, `run_id_after`, `focus` | Compare two registered runs by finding ids and run-to-run health delta; MCP returns short run ids, compact regression/improvement cards, `mixed` for conflicting signals, and `incomparable` with top-level `reason`, empty comparison cards, and `health_delta=null` when roots/settings differ | -| `evaluate_gates` | `run_id`, gate thresholds/booleans | Evaluate CI/gating conditions against an existing run without exiting the process | -| `get_report_section` | `run_id`, `section`, `family`, `path`, `offset`, `limit` | Return a canonical report section. Prefer targeted sections instead of `section="all"` unless the client truly needs the full canonical report. `metrics` is summary-only; `metrics_detail` is paginated/bounded, falls back to summary+hint when unfiltered, and can expose report-only families such as `god_modules` | -| `list_findings` | `family`, `category`, `severity`, `source_kind`, `novelty`, `sort_by`, `detail_level`, `changed_paths`, `git_diff_ref`, `exclude_reviewed`, pagination | Return deterministically ordered finding groups with filtering and pagination; compact summary detail is the default. Intended for broader filtered review after hotspots or `check_*`, not as the cheapest first-pass call | -| `get_finding` | `finding_id`, `run_id`, `detail_level` | Return one finding by id; defaults to `normal` detail and accepts MCP short ids. Use this after `list_hotspots`, `list_findings`, or `check_*` instead of raising detail on larger lists | -| `get_remediation` | `finding_id`, `run_id`, `detail_level` | Return just the remediation/explainability packet for one finding. Use this when the client needs the fix packet without pulling broader detail payloads | -| `list_hotspots` | `kind`, `run_id`, `detail_level`, `changed_paths`, `git_diff_ref`, `exclude_reviewed`, `limit`, `max_results` | Return one derived hotlist (`most_actionable`, `highest_spread`, `highest_priority`, `production_hotspots`, `test_fixture_hotspots`) with compact summary cards. This is the preferred first-pass triage surface before broader `list_findings` calls | -| `check_clones` | `run_id`, `root`, `path`, `clone_type`, `source_kind`, `max_results`, `detail_level` | Return clone findings from a compatible stored run; `health.dimensions` includes only `clones`. Prefer this narrower tool over `list_findings` when only clone debt is needed | -| `check_complexity` | `run_id`, `root`, `path`, `min_complexity`, `max_results`, `detail_level` | Return complexity hotspots from a compatible stored run; `health.dimensions` includes only `complexity`. Prefer this narrower tool over `list_findings` when only complexity is needed | -| `check_coupling` | `run_id`, `root`, `path`, `max_results`, `detail_level` | Return coupling hotspots from a compatible stored run; `health.dimensions` includes only `coupling`. Prefer this narrower tool over `list_findings` when only coupling is needed | -| `check_cohesion` | `run_id`, `root`, `path`, `max_results`, `detail_level` | Return cohesion hotspots from a compatible stored run; `health.dimensions` includes only `cohesion`. Prefer this narrower tool over `list_findings` when only cohesion is needed | -| `check_dead_code` | `run_id`, `root`, `path`, `min_severity`, `max_results`, `detail_level` | Return dead-code findings from a compatible stored run; `health.dimensions` includes only `dead_code`. Prefer this narrower tool over `list_findings` when only dead code is needed | -| `generate_pr_summary` | `run_id`, `changed_paths`, `git_diff_ref`, `format` | Build a PR-friendly changed-files summary in markdown or JSON. Prefer `markdown` for compact LLM-facing output and reserve `json` for machine post-processing | -| `mark_finding_reviewed` | `finding_id`, `run_id`, `note` | Mark a finding as reviewed in the in-memory MCP session | -| `list_reviewed_findings` | `run_id` | Return the current reviewed findings for the selected run | -| `clear_session_runs` | none | Clear all stored in-memory runs plus ephemeral review/gate/session caches for the current server process | - -All analysis/report tools are read-only with respect to repo state. The only -mutable MCP tools are `mark_finding_reviewed` and `clear_session_runs`, and -their effects are session-local and in-memory only. `analyze_repository`, -`analyze_changed_paths`, and `evaluate_gates` are -sessionful and may populate or reuse in-memory run state. The granular -`check_*` tools are read-only over stored runs: use `analyze_repository` or -`analyze_changed_paths` first, then query the latest run or pass a specific -`run_id`. - -Budget-aware workflow is intentional: - -- first pass: `get_run_summary` or `get_production_triage` -- semantic clarification: `help(topic=...)` when contract or workflow meaning is unclear -- targeted triage: `list_hotspots` or the relevant `check_*` -- single-finding drill-down: `get_finding`, then `get_remediation` -- bounded metrics drill-down: `get_report_section(section="metrics_detail", family=..., limit=...)` -- PR output: `generate_pr_summary(format="markdown")` unless machine JSON is explicitly needed +| Tool | Key parameters | Purpose | +|--------------------------|-----------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------| +| `analyze_repository` | absolute `root`, `analysis_mode`, thresholds, cache/baseline paths | Full analysis → compact summary; then `get_run_summary` or `get_production_triage` | +| `analyze_changed_paths` | absolute `root`, `changed_paths` or `git_diff_ref`, `analysis_mode` | Diff-aware analysis → compact changed-files snapshot | +| `get_run_summary` | `run_id` | Cheapest run snapshot: health, findings, baseline, inventory | +| `get_production_triage` | `run_id`, `max_hotspots`, `max_suggestions` | Production-first view: health, hotspots, suggestions | +| `help` | `topic`, `detail` | Semantic guide for workflow, baseline, suppressions, review state, changed-scope | +| `compare_runs` | `run_id_before`, `run_id_after`, `focus` | Run-to-run delta: regressions, improvements, health change | +| `evaluate_gates` | `run_id`, gate thresholds | Preview CI gating decisions | +| `get_report_section` | `run_id`, `section`, `family`, `path`, `offset`, `limit` | Read report sections; `metrics_detail` is paginated with family/path filters | +| `list_findings` | `family`, `severity`, `novelty`, `sort_by`, `detail_level`, `changed_paths`, pagination | Filtered, paginated findings; use after hotspots or `check_*` | +| `get_finding` | `finding_id`, `run_id`, `detail_level` | Single finding detail by id; defaults to `normal` | +| `get_remediation` | `finding_id`, `run_id`, `detail_level` | Remediation payload for one finding | +| `list_hotspots` | `kind`, `run_id`, `detail_level`, `changed_paths`, `limit` | Priority-ranked hotspot views; preferred before broad listing | +| `check_clones` | `run_id`, `root`, `path`, `clone_type`, `source_kind`, `detail_level` | Clone findings only; `health.dimensions` includes only `clones` | +| `check_complexity` | `run_id`, `root`, `path`, `min_complexity`, `detail_level` | Complexity hotspots only | +| `check_coupling` | `run_id`, `root`, `path`, `detail_level` | Coupling hotspots only | +| `check_cohesion` | `run_id`, `root`, `path`, `detail_level` | Cohesion hotspots only | +| `check_dead_code` | `run_id`, `root`, `path`, `min_severity`, `detail_level` | Dead-code findings only | +| `generate_pr_summary` | `run_id`, `changed_paths`, `git_diff_ref`, `format` | PR-friendly markdown or JSON summary | +| `mark_finding_reviewed` | `finding_id`, `run_id`, `note` | Session-local review marker (in-memory) | +| `list_reviewed_findings` | `run_id` | List reviewed findings for a run | +| `clear_session_runs` | none | Reset in-memory runs and session state | + +All tools are read-only except `mark_finding_reviewed` and `clear_session_runs` +(session-local, in-memory). `check_*` tools query stored runs — call +`analyze_repository` or `analyze_changed_paths` first. + +Recommended workflow: + +1. `get_run_summary` or `get_production_triage` +2. `help(topic=...)` if contract meaning is unclear +3. `list_hotspots` or `check_*` +4. `get_finding` → `get_remediation` +5. `generate_pr_summary(format="markdown")` ## Resources @@ -193,7 +185,7 @@ state behind `codeclone://latest/...`. - Canonical JSON remains the source of truth for report semantics. - `list_findings` and `list_hotspots` are deterministic projections over the canonical report, not a separate analysis branch. -- `metrics_detail(family="god_modules")` exposes the canonical report-only +- `metrics_detail(family="overloaded_modules")` exposes the canonical report-only module-hotspot layer, but does not promote it into findings, hotlists, or gate semantics. - `get_remediation` is a deterministic MCP projection over existing diff --git a/docs/book/21-vscode-extension.md b/docs/book/21-vscode-extension.md index 4cd6b45..301ab41 100644 --- a/docs/book/21-vscode-extension.md +++ b/docs/book/21-vscode-extension.md @@ -50,7 +50,7 @@ It also provides: - command palette entry points for analysis and review - one onboarding walkthrough - markdown detail panels for findings, remediation, help topics, setup help, - restricted-mode guidance, and report-only God Module detail + restricted-mode guidance, and report-only Overloaded Module detail - lightweight Explorer file decorations for review-relevant files - editor-local CodeLens and title actions for the active review target @@ -75,7 +75,7 @@ The extension currently supports: - changed-files analysis against a configured git diff reference - compact overview of structural health, current run state, and baseline drift - review queues for new regressions, production hotspots, changed-scope - findings, and report-only `God Modules` + findings, and report-only `Overloaded Modules` - source reveal, peek, canonical finding detail, remediation detail, and session-local reviewed markers - bounded MCP help topics inside the IDE @@ -123,39 +123,22 @@ For this reason: - local analysis and local MCP startup remain disabled until trust is granted - virtual workspaces are unsupported -## Design decisions - -The extension follows these implementation rules: +## Design rules - **Native VS Code first**: tree views, status bar, Quick Pick, CodeLens, and - file decorations come before any richer custom UI. -- **Source-first review**: findings prefer `Reveal Source` over immediate - detail panels. -- **Explicit deepening**: canonical finding detail, remediation, and HTML - report bridges remain opt-in actions. -- **Report-only separation**: `God Modules` stay clearly outside findings, - gates, and health dimensions. -- **Safe local HTML bridge**: `Open in HTML Report` must verify that a local - `report.html` exists and is not obviously older than the current run. -- **Session-local workflow state**: reviewed markers may shape the review UX - but must not leak into repository truth. - -## UX rules - -The extension should preserve these product rules: - -- The cheapest useful path should be the most obvious path. -- First-run UX should lead to `Analyze Workspace`, not transport setup detail. -- Review actions should prefer opening source before opening deeper structured - detail. -- Report-only layers such as `God Modules` must remain visually distinct from - findings, gates, and health dimensions. -- The extension should minimize noise and avoid duplicating the HTML report in - the sidebar. -- Restricted Mode should still explain what the extension needs, without - pretending local analysis is available before trust is granted. -- Accessibility labels should stay meaningful on tree items and status - surfaces. + file decorations before any custom UI. +- **Source-first**: findings prefer `Reveal Source` over detail panels; + canonical detail and HTML report bridge are opt-in. +- **Report-only separation**: Overloaded Modules stay visually distinct from + findings, gates, and health. +- **Safe HTML bridge**: `Open in HTML Report` verifies the local file exists + and is not older than the current run. +- **Session-local state**: reviewed markers shape review UX but never leak + into repository truth. +- **First-run clarity**: onboarding leads to `Analyze Workspace`, not + transport setup. +- **Restricted Mode honesty**: explain requirements without pretending + analysis is available before trust is granted. ## Relationship to other interfaces diff --git a/docs/book/appendix/b-schema-layouts.md b/docs/book/appendix/b-schema-layouts.md index df80c40..5224e9f 100644 --- a/docs/book/appendix/b-schema-layouts.md +++ b/docs/book/appendix/b-schema-layouts.md @@ -166,7 +166,7 @@ Notes: "high_confidence": 0, "suppressed": 1 }, - "god_modules": { + "overloaded_modules": { "total": 0, "candidates": 0, "population_status": "limited", @@ -192,7 +192,7 @@ Notes: } ] }, - "god_modules": { + "overloaded_modules": { "summary": { "total": 0, "candidates": 0, diff --git a/docs/mcp.md b/docs/mcp.md index aa12f47..2d17752 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,16 +1,11 @@ # MCP Usage Guide CodeClone MCP is a **read-only, baseline-aware** analysis server for AI agents -and MCP-capable clients. It exposes the existing deterministic pipeline without -mutating source files, baselines, cache, or on-disk report artifacts. Only -session-local review/run state is mutable in memory. -It is not only bounded in payload shape — it actively guides agents toward -low-cost, high-signal workflows. +and MCP-capable clients. It exposes the deterministic pipeline without mutating +source files, baselines, cache, or report artifacts. Session-local review/run +state is mutable in memory only. -MCP is a **client integration surface**, not a model-specific feature. It works -with any MCP-capable client regardless of the backend model. -In practice, the cheapest useful path is also the most obvious one: summary or -triage first, then hotspots or focused checks, then single-finding drill-down. +Works with any MCP-capable client regardless of backend model. ## Install @@ -49,55 +44,43 @@ run-scoped URI templates. ## Tool surface -| Tool | Purpose | -|--------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `analyze_repository` | Full analysis → register as latest run and return a compact MCP summary; then prefer `get_run_summary` or `get_production_triage` for the first pass | -| `analyze_changed_paths` | Diff-aware analysis with `changed_paths` or `git_diff_ref`; returns a compact changed-files snapshot; then prefer `get_report_section(section="changed")` or `get_production_triage` before broader list calls | -| `get_run_summary` | Cheapest run-level snapshot: compact health/findings/baseline summary with slim inventory counts; `health` is explicit `available=false` when metrics were skipped | -| `get_production_triage` | Compact production-first view: health, cache freshness, production hotspots, production suggestions; best default first pass on noisy repos | -| `help` | Compact semantic/orientation tool for supported topics like workflow, baseline, suppressions, latest-runs semantics, review state, and changed-scope routing; use when MCP meaning or the safest next step is unclear | -| `compare_runs` | Regressions, improvements, and run-to-run health delta between comparable runs; returns `mixed` for conflicting signals and `incomparable` when roots/settings differ, with empty comparison cards and `health_delta=null` in that case | -| `list_findings` | Filtered, paginated finding groups with compact summary payloads by default; use after hotspots or `check_*` when you need a broader filtered list | -| `get_finding` | Deep inspection of one finding by id; defaults to normal detail and accepts `detail_level`; use after `list_hotspots`, `list_findings`, or `check_*` | -| `get_remediation` | Structured remediation payload for one finding; defaults to normal detail; use when you only need the fix packet for a single finding | -| `list_hotspots` | Derived views: highest priority, production hotspots, spread, etc., with compact summary cards; preferred first-pass triage before broader listing | -| `get_report_section` | Read canonical report sections; prefer specific sections over `section="all"`; `metrics` is summary-only, `metrics_detail` is paginated/bounded and can expose report-only families such as `god_modules` | -| `evaluate_gates` | Preview CI/gating decisions without exiting | -| `check_clones` | Clone findings from a stored run; cheaper and narrower than `list_findings` when you only need clone debt | -| `check_complexity` | Complexity hotspots from a stored run; cheaper and narrower than `list_findings` when you only need complexity | -| `check_coupling` | Coupling hotspots from a stored run; cheaper and narrower than `list_findings` when you only need coupling | -| `check_cohesion` | Cohesion hotspots from a stored run; cheaper and narrower than `list_findings` when you only need cohesion | -| `check_dead_code` | Dead-code findings from a stored run; cheaper and narrower than `list_findings` when you only need dead code | -| `generate_pr_summary` | PR-friendly markdown or JSON summary; prefer `markdown` for compact LLM-facing output and `json` for machine post-processing | -| `mark_finding_reviewed` | Session-local review marker (in-memory only) | -| `list_reviewed_findings` | List reviewed findings for a run | -| `clear_session_runs` | Reset all in-memory runs and session caches | +| Tool | Purpose | +|--------------------------|-----------------------------------------------------------------------------------------------------| +| `analyze_repository` | Full analysis → compact summary; use `get_run_summary` or `get_production_triage` as the first pass | +| `analyze_changed_paths` | Diff-aware analysis via `changed_paths` or `git_diff_ref`; compact changed-files snapshot | +| `get_run_summary` | Cheapest run snapshot: health, findings, baseline, inventory | +| `get_production_triage` | Production-first view: health, hotspots, suggestions; best first pass for noisy repos | +| `help` | Semantic guide for workflow, baseline, suppressions, review state, changed-scope routing | +| `compare_runs` | Run-to-run delta: regressions, improvements, health change | +| `list_findings` | Filtered, paginated findings; use after hotspots or `check_*` | +| `get_finding` | Single finding detail by id; defaults to `normal` detail level | +| `get_remediation` | Remediation payload for one finding | +| `list_hotspots` | Priority-ranked hotspot views; preferred before broad listing | +| `get_report_section` | Read report sections; `metrics_detail` is paginated with family/path filters | +| `evaluate_gates` | Preview CI gating decisions | +| `check_clones` | Clone findings only; narrower than `list_findings` | +| `check_complexity` | Complexity hotspots only | +| `check_coupling` | Coupling hotspots only | +| `check_cohesion` | Cohesion hotspots only | +| `check_dead_code` | Dead-code findings only | +| `generate_pr_summary` | PR-friendly markdown or JSON summary | +| `mark_finding_reviewed` | Session-local review marker (in-memory) | +| `list_reviewed_findings` | List reviewed findings for a run | +| `clear_session_runs` | Reset in-memory runs and session state | > `check_*` tools query stored runs only. Call `analyze_repository` or > `analyze_changed_paths` first. -`check_*` responses keep `health.score` and `health.grade`, but slim -`health.dimensions` down to the one dimension relevant to that tool. -`metrics_detail(family="god_modules")` exposes the canonical report-only -module-hotspot layer without turning it into findings, hotlists, or gate data. -List-style finding responses now use short MCP finding ids and compact relative -locations by default; `normal` keeps structured `{path, line, end_line, symbol}` -locations, while `full` keeps the richer compatibility payload including `uri`. -Summary-style MCP cache payloads expose `freshness` (`fresh`, `mixed`, `reused`). -Inline design-threshold parameters on `analyze_repository` / -`analyze_changed_paths` become part of the canonical run: they are recorded in -`meta.analysis_thresholds.design_findings` and define that run's canonical -design findings. -`help(topic=...)` is intentionally static and bounded: it explains meaning, -flags common anti-patterns, suggests a safe next step, and points to canonical -docs without turning MCP into a documentation proxy. - -Run ids in MCP payloads are short session handles (first 8 hex chars of the -canonical digest). MCP tools and run-scoped resources accept both short and full -run ids. Finding ids follow the same rule: MCP responses use compact ids, while -the canonical `report.json` keeps full finding ids unchanged. When a short -finding id would collide within a run, MCP lengthens it just enough to keep it -unique. +**Payload conventions:** + +- `check_*` responses include only the relevant health dimension. +- Finding responses use short MCP IDs and relative paths by default; + `detail_level=full` restores the compatibility payload with URIs. +- Run IDs are 8-char hex handles; finding IDs are short prefixed forms. + Both accept the full canonical form as input. +- `metrics_detail(family="overloaded_modules")` exposes the report-only + module-hotspot layer without turning it into findings or gate data. +- `help(topic=...)` is static: meaning, anti-patterns, next step, doc links. ## Resource surface diff --git a/docs/vscode-extension.md b/docs/vscode-extension.md index f3076ac..f0cf3b5 100644 --- a/docs/vscode-extension.md +++ b/docs/vscode-extension.md @@ -15,7 +15,7 @@ The extension helps you: - focus on new regressions and production hotspots first - jump directly to source locations - open canonical finding or remediation detail only when needed -- inspect report-only God Module candidates without treating them like findings +- inspect report-only Overloaded Module candidates without treating them like findings It does not create a second truth model and it does not mutate the repository. @@ -54,7 +54,7 @@ Primary operational view for: - new regressions - production hotspots - changed-files findings -- report-only God Module candidates +- report-only Overloaded Module candidates ### Runs & Session diff --git a/extensions/vscode-codeclone/.vscodeignore b/extensions/vscode-codeclone/.vscodeignore index 1aeb973..2d4c73f 100644 --- a/extensions/vscode-codeclone/.vscodeignore +++ b/extensions/vscode-codeclone/.vscodeignore @@ -1,7 +1,11 @@ .vscode/** .DS_Store DESIGN.md +esbuild.config.mjs +jsconfig.json media/.thumb/** media/icon-source.svg *.vsix +src/** test/** +node_modules diff --git a/extensions/vscode-codeclone/CHANGELOG.md b/extensions/vscode-codeclone/CHANGELOG.md index 52ee2de..e276414 100644 --- a/extensions/vscode-codeclone/CHANGELOG.md +++ b/extensions/vscode-codeclone/CHANGELOG.md @@ -8,5 +8,5 @@ - add setup guidance for local `codeclone-mcp` installation and launcher issues - add guided review actions that prefer revealing source before opening deeper detail -- surface report-only `God Modules` as a distinct IDE layer without promoting +- surface report-only `Overloaded Modules` as a distinct IDE layer without promoting them to health or gating truth diff --git a/extensions/vscode-codeclone/README.md b/extensions/vscode-codeclone/README.md index 6ea7ccc..72acc6d 100644 --- a/extensions/vscode-codeclone/README.md +++ b/extensions/vscode-codeclone/README.md @@ -81,7 +81,7 @@ The main operational view. It focuses on: - new regressions - production hotspots - changed-files findings -- report-only God Module candidates +- report-only Overloaded Module candidates Focus mode is explicit and persisted per workspace. The extension favors `Recommended` by default and keeps report-only candidates visually separate from @@ -131,7 +131,7 @@ opening raw JSON-like details by default. MCP and canonical report semantics only. - **Source-first**: review should move you to code before it opens deeper detail. -- **Report-only separation**: `God Modules` are visible but intentionally kept +- **Report-only separation**: `Overloaded Modules` are visible but intentionally kept outside findings, gates, and health. - **Limited Restricted Mode**: the extension keeps setup/onboarding available in untrusted workspaces, but local analysis and MCP stay disabled until trust is diff --git a/extensions/vscode-codeclone/esbuild.config.mjs b/extensions/vscode-codeclone/esbuild.config.mjs new file mode 100644 index 0000000..7d2b79b --- /dev/null +++ b/extensions/vscode-codeclone/esbuild.config.mjs @@ -0,0 +1,25 @@ +import { build, context } from "esbuild"; + +const watch = process.argv.includes("--watch"); + +const options = { + entryPoints: ["src/extension.js"], + outfile: "dist/extension.js", + bundle: true, + platform: "node", + format: "cjs", + target: "node20", + external: ["vscode"], + sourcemap: true, + legalComments: "none", + logLevel: "info", + treeShaking: true, + packages: "external", +}; + +if (watch) { + const ctx = await context(options); + await ctx.watch(); +} else { + await build(options); +} diff --git a/extensions/vscode-codeclone/jsconfig.json b/extensions/vscode-codeclone/jsconfig.json new file mode 100644 index 0000000..0f99446 --- /dev/null +++ b/extensions/vscode-codeclone/jsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": [ + "ES2022" + ], + "maxNodeModuleJsDepth": 0, + "noImplicitAny": false, + "resolveJsonModule": true, + "skipLibCheck": true, + "useUnknownInCatchVariables": false, + "types": [ + "node", + "vscode" + ] + }, + "include": [ + "src/**/*.js", + "test/**/*.js" + ], + "exclude": [ + "node_modules", + "media", + ".vscode" + ] +} diff --git a/extensions/vscode-codeclone/package-lock.json b/extensions/vscode-codeclone/package-lock.json new file mode 100644 index 0000000..873f397 --- /dev/null +++ b/extensions/vscode-codeclone/package-lock.json @@ -0,0 +1,4504 @@ +{ + "name": "codeclone", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codeclone", + "version": "0.2.0", + "license": "MPL-2.0", + "devDependencies": { + "@types/node": "^25.5.2", + "@types/vscode": "1.100.0", + "@vscode/vsce": "^3.7.1", + "esbuild": "^0.28.0", + "typescript": "^6.0.2" + }, + "engines": { + "vscode": "^1.100.0" + } + }, + "node_modules/@azu/format-text": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", + "integrity": "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@azu/style-format": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@azu/style-format/-/style-format-1.0.1.tgz", + "integrity": "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "@azu/format-text": "^1.0.1" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", + "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.23.0.tgz", + "integrity": "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.6.3.tgz", + "integrity": "sha512-sTjMtUm+bJpENU/1WlRzHEsgEHppZDZ1EtNyaOODg/sQBtMxxJzGB+MOCM+T2Q5Qe1fKBrdxUmjyRxm0r7Ez9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.4.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.4.1.tgz", + "integrity": "sha512-Bl8f+w37xkXsYh7QRkAKCFGYtWMYuOVO7Lv+BxILrvGz3HbIEF22Pt0ugyj0QPOl6NLrHcnNUQ9yeew98P/5iw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.1.2.tgz", + "integrity": "sha512-DoeSJ9U5KPAIZoHsPywvfEj2MhBniQe0+FSpjLUTdWoIkI999GB5USkW6nNEHnIaLVxROHXvprWA1KzdS1VQ4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.4.1", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@secretlint/config-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", + "integrity": "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/config-loader": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-10.2.2.tgz", + "integrity": "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "ajv": "^8.17.1", + "debug": "^4.4.1", + "rc-config-loader": "^4.1.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/core": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", + "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "structured-source": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/formatter": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", + "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "@textlint/linter-formatter": "^15.2.0", + "@textlint/module-interop": "^15.2.0", + "@textlint/types": "^15.2.0", + "chalk": "^5.4.1", + "debug": "^4.4.1", + "pluralize": "^8.0.0", + "strip-ansi": "^7.1.0", + "table": "^6.9.0", + "terminal-link": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/formatter/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@secretlint/node": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.2.2.tgz", + "integrity": "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-loader": "^10.2.2", + "@secretlint/core": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "@secretlint/source-creator": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "p-map": "^7.0.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/profiler": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.2.tgz", + "integrity": "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/resolver": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", + "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/secretlint-formatter-sarif": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.2.tgz", + "integrity": "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-sarif-builder": "^3.2.0" + } + }, + "node_modules/@secretlint/secretlint-rule-no-dotenv": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz", + "integrity": "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/secretlint-rule-preset-recommend": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-10.2.2.tgz", + "integrity": "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/source-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-10.2.2.tgz", + "integrity": "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2", + "istextorbinary": "^9.5.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/types": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.2.2.tgz", + "integrity": "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@textlint/ast-node-types": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.5.2.tgz", + "integrity": "sha512-fCaOxoup5LIyBEo7R1oYWE7V4bSX0KQeHh66twon9e9usaLE3ijgF8QjYsR6joCssdeCHVd0wHm7ppsEyTr6vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.5.2.tgz", + "integrity": "sha512-jAw7jWM8+wU9cG6Uu31jGyD1B+PAVePCvnPKC/oov+2iBPKk3ao30zc/Itmi7FvXo4oPaL9PmzPPQhyniPVgVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azu/format-text": "^1.0.2", + "@azu/style-format": "^1.0.1", + "@textlint/module-interop": "15.5.2", + "@textlint/resolver": "15.5.2", + "@textlint/types": "15.5.2", + "chalk": "^4.1.2", + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "lodash": "^4.17.23", + "pluralize": "^2.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "table": "^6.9.0", + "text-table": "^0.2.0" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/pluralize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", + "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@textlint/module-interop": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.5.2.tgz", + "integrity": "sha512-mg6rMQ3+YjwiXCYoQXbyVfDucpTa1q5mhspd/9qHBxUq4uY6W8GU42rmT3GW0V1yOfQ9z/iRrgPtkp71s8JzXg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/resolver": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.5.2.tgz", + "integrity": "sha512-YEITdjRiJaQrGLUWxWXl4TEg+d2C7+TNNjbGPHPH7V7CCnXm+S9GTjGAL7Q2WSGJyFEKt88Jvx6XdJffRv4HEA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/types": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.5.2.tgz", + "integrity": "sha512-sJOrlVLLXp4/EZtiWKWq9y2fWyZlI8GP+24rnU5avtPWBIMm/1w97yzKrAqYF8czx2MqR391z5akhnfhj2f/AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "15.5.2" + } + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/vscode": { + "version": "1.100.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.100.0.tgz", + "integrity": "sha512-4uNyvzHoraXEeCamR3+fzcBlh7Afs4Ifjs4epINyUX/jvdk0uzLnwiDY35UKDKnkCHP5Nu3dljl2H8lR6s+rQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.4.tgz", + "integrity": "sha512-CI0NhTrz4EBaa0U+HaaUZrJhPoso8sG7ZFya8uQoBA57fjzrjRSv87ekCjLZOFExN+gXE/z0xuN2QfH4H2HrLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@vscode/vsce": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.7.1.tgz", + "integrity": "sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/identity": "^4.1.0", + "@secretlint/node": "^10.1.2", + "@secretlint/secretlint-formatter-sarif": "^10.1.2", + "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", + "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", + "@vscode/vsce-sign": "^2.0.0", + "azure-devops-node-api": "^12.5.0", + "chalk": "^4.1.2", + "cheerio": "^1.0.0-rc.9", + "cockatiel": "^3.1.2", + "commander": "^12.1.0", + "form-data": "^4.0.0", + "glob": "^11.0.0", + "hosted-git-info": "^4.0.2", + "jsonc-parser": "^3.2.0", + "leven": "^3.1.0", + "markdown-it": "^14.1.0", + "mime": "^1.3.4", + "minimatch": "^3.0.3", + "parse-semver": "^1.1.1", + "read": "^1.0.7", + "secretlint": "^10.1.2", + "semver": "^7.5.2", + "tmp": "^0.2.3", + "typed-rest-client": "^1.8.4", + "url-join": "^4.0.1", + "xml2js": "^0.5.0", + "yauzl": "^2.3.1", + "yazl": "^2.2.2" + }, + "bin": { + "vsce": "vsce" + }, + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "keytar": "^7.7.0" + } + }, + "node_modules/@vscode/vsce-sign": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.9.tgz", + "integrity": "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g==", + "dev": true, + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optionalDependencies": { + "@vscode/vsce-sign-alpine-arm64": "2.0.6", + "@vscode/vsce-sign-alpine-x64": "2.0.6", + "@vscode/vsce-sign-darwin-arm64": "2.0.6", + "@vscode/vsce-sign-darwin-x64": "2.0.6", + "@vscode/vsce-sign-linux-arm": "2.0.6", + "@vscode/vsce-sign-linux-arm64": "2.0.6", + "@vscode/vsce-sign-linux-x64": "2.0.6", + "@vscode/vsce-sign-win32-arm64": "2.0.6", + "@vscode/vsce-sign-win32-x64": "2.0.6" + } + }, + "node_modules/@vscode/vsce-sign-alpine-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz", + "integrity": "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-alpine-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz", + "integrity": "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz", + "integrity": "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz", + "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz", + "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz", + "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz", + "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-win32-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz", + "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce-sign-win32-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz", + "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/azure-devops-node-api": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", + "integrity": "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "^1.8.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/binaryextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", + "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/boundary": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", + "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/cockatiel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", + "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/editions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz", + "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "version-range": "^4.15.0" + }, + "engines": { + "ecmascript": ">= es5", + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istextorbinary": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", + "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "binaryextensions": "^6.11.0", + "editions": "^6.21.0", + "textextensions": "^6.11.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-sarif-builder": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.4.0.tgz", + "integrity": "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sarif": "^2.1.7", + "fs-extra": "^11.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-semver": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", + "integrity": "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.1.0" + } + }, + "node_modules/parse-semver/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc-config-loader": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", + "integrity": "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "json5": "^2.2.3", + "require-from-string": "^2.0.2" + } + }, + "node_modules/read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "mute-stream": "~0.0.4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/secretlint": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", + "integrity": "sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-creator": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/node": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "debug": "^4.4.1", + "globby": "^14.1.0", + "read-pkg": "^9.0.1" + }, + "bin": { + "secretlint": "bin/secretlint.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/structured-source": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", + "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boundary": "^2.0.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/terminal-link": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", + "integrity": "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "supports-hyperlinks": "^3.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/textextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", + "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-rest-client": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", + "integrity": "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "qs": "^6.9.1", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + } + }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/version-range": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", + "integrity": "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==", + "dev": true, + "license": "Artistic-2.0", + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yazl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", + "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3" + } + } + } +} diff --git a/extensions/vscode-codeclone/package.json b/extensions/vscode-codeclone/package.json index 5e029da..3b503be 100644 --- a/extensions/vscode-codeclone/package.json +++ b/extensions/vscode-codeclone/package.json @@ -46,9 +46,13 @@ "vscode": "^1.100.0" }, "scripts": { - "check": "node --check src/support.js && node --check src/mcpClient.js && node --check src/extension.js", + "build": "node esbuild.config.mjs", + "watch": "node esbuild.config.mjs --watch", + "typecheck": "tsc -p jsconfig.json --noEmit", + "check": "npm run build && npm run typecheck && node --check src/constants.js && node --check src/formatters.js && node --check src/runtime.js && node --check src/renderers.js && node --check src/providers.js && node --check src/support.js && node --check src/mcpClient.js && node --check src/extension.js", "test": "node --test test/*.test.js", - "test:host": "node test/runExtensionHost.js" + "test:host": "npm run build && node test/runExtensionHost.js", + "vscode:prepublish": "npm run build" }, "categories": [ "Linters", @@ -72,12 +76,11 @@ "onCommand:codeclone.markFindingReviewed", "onCommand:codeclone.showHelpTopic", "onCommand:codeclone.openSetupHelp", - "onCommand:codeclone.openGodModule", - "onCommand:codeclone.copyGodModuleBrief" - , + "onCommand:codeclone.openOverloadedModule", + "onCommand:codeclone.copyOverloadedModuleBrief", "onCommand:codeclone.manageWorkspaceTrust" ], - "main": "./src/extension.js", + "main": "./dist/extension.js", "contributes": { "viewsContainers": { "activitybar": [ @@ -287,18 +290,18 @@ "icon": "$(shield)" }, { - "command": "codeclone.openGodModule", + "command": "codeclone.openOverloadedModule", "title": "Open Candidate Detail", "category": "CodeClone", "icon": "$(symbol-module)" }, { - "command": "codeclone.reviewGodModule", + "command": "codeclone.reviewOverloadedModule", "title": "Review Candidate", "category": "CodeClone" }, { - "command": "codeclone.copyGodModuleBrief", + "command": "codeclone.copyOverloadedModuleBrief", "title": "Copy Report-only Brief", "category": "CodeClone", "icon": "$(copy)" @@ -362,15 +365,15 @@ "when": "false" }, { - "command": "codeclone.openGodModule", + "command": "codeclone.openOverloadedModule", "when": "false" }, { - "command": "codeclone.copyGodModuleBrief", + "command": "codeclone.copyOverloadedModuleBrief", "when": "false" }, { - "command": "codeclone.reviewGodModule", + "command": "codeclone.reviewOverloadedModule", "when": "false" } ], @@ -523,13 +526,13 @@ "group": "inline@1" }, { - "command": "codeclone.openGodModule", - "when": "viewItem == codeclone.godModule", + "command": "codeclone.openOverloadedModule", + "when": "viewItem == codeclone.overloadedModule", "group": "inline@1" }, { - "command": "codeclone.copyGodModuleBrief", - "when": "viewItem == codeclone.godModule", + "command": "codeclone.copyOverloadedModuleBrief", + "when": "viewItem == codeclone.overloadedModule", "group": "navigation@1" } ], @@ -565,8 +568,8 @@ "group": "secondary@2" }, { - "command": "codeclone.copyGodModuleBrief", - "when": "editorTextFocus && codeclone.activeReviewTargetVisibleInEditor && codeclone.activeReviewTargetIsGodModule", + "command": "codeclone.copyOverloadedModuleBrief", + "when": "editorTextFocus && codeclone.activeReviewTargetVisibleInEditor && codeclone.activeReviewTargetIsOverloadedModule", "group": "secondary@1" } ] @@ -645,5 +648,12 @@ } } } + }, + "devDependencies": { + "@types/node": "^25.5.2", + "@types/vscode": "1.100.0", + "@vscode/vsce": "^3.7.1", + "esbuild": "^0.28.0", + "typescript": "^6.0.2" } } diff --git a/extensions/vscode-codeclone/src/constants.js b/extensions/vscode-codeclone/src/constants.js new file mode 100644 index 0000000..ef5def3 --- /dev/null +++ b/extensions/vscode-codeclone/src/constants.js @@ -0,0 +1,90 @@ +"use strict"; + +const HELP_TOPICS = [ + "workflow", + "suppressions", + "baseline", + "latest_runs", + "review_state", + "changed_scope", +]; + +const HOTSPOT_GROUPS = [ + { id: "newRegressions", label: "New Regressions", icon: "diff-added" }, + { id: "productionHotspots", label: "Production Hotspots", icon: "target" }, + { id: "changedFiles", label: "Changed Files", icon: "git-commit" }, + { id: "overloadedModules", label: "Overloaded Modules", icon: "symbol-module" }, +]; + +const HOTSPOT_FOCUS_MODES = [ + { + id: "recommended", + label: "Recommended", + description: "Show the highest-signal review surfaces for the current run.", + }, + { + id: "new", + label: "New Regressions", + description: "Focus only on baseline-new findings.", + }, + { + id: "production", + label: "Production", + description: "Focus only on production hotspots.", + }, + { + id: "changed", + label: "Changed Files", + description: "Focus only on findings touching the selected diff.", + }, + { + id: "reportOnly", + label: "Report-only", + description: "Focus only on report-only Overloaded Module candidates.", + }, + { + id: "all", + label: "All Groups", + description: "Show every hotspot group, including empty ones.", + }, +]; + +const HOTSPOT_GROUPS_BY_MODE = { + recommended: HOTSPOT_GROUPS.map((group) => group.id), + new: ["newRegressions"], + production: ["productionHotspots"], + changed: ["changedFiles"], + reportOnly: ["overloadedModules"], + all: HOTSPOT_GROUPS.map((group) => group.id), +}; + +const REVIEW_DECORATION_THEMES = { + new: { + badge: "N", + color: "problemsErrorIcon.foreground", + tooltip: "CodeClone new regression", + }, + production: { + badge: "P", + color: "problemsWarningIcon.foreground", + tooltip: "CodeClone production hotspot", + }, + changed: { + badge: "C", + color: "charts.blue", + tooltip: "CodeClone changed-files review item", + }, +}; + +const WORKSPACE_STATE_HOTSPOT_FOCUS_MODE = "codeclone.hotspotFocusMode"; +const WORKSPACE_STATE_LAST_HELP_TOPIC = "codeclone.lastHelpTopic"; + +module.exports = { + HELP_TOPICS, + HOTSPOT_GROUPS, + HOTSPOT_FOCUS_MODES, + HOTSPOT_GROUPS_BY_MODE, + REVIEW_DECORATION_THEMES, + WORKSPACE_STATE_HOTSPOT_FOCUS_MODE, + WORKSPACE_STATE_LAST_HELP_TOPIC, +}; diff --git a/extensions/vscode-codeclone/src/extension.js b/extensions/vscode-codeclone/src/extension.js index cc0676d..0b6a538 100644 --- a/extensions/vscode-codeclone/src/extension.js +++ b/extensions/vscode-codeclone/src/extension.js @@ -1,12 +1,72 @@ "use strict"; -const { execFile } = require("node:child_process"); -const fs = require("node:fs"); +const fs = require("node:fs/promises"); const path = require("node:path"); -const { promisify } = require("node:util"); +/** @type {any} */ const vscode = require("vscode"); +const { + HELP_TOPICS, + HOTSPOT_GROUPS, + HOTSPOT_FOCUS_MODES, + HOTSPOT_GROUPS_BY_MODE, + REVIEW_DECORATION_THEMES, + WORKSPACE_STATE_HOTSPOT_FOCUS_MODE, + WORKSPACE_STATE_LAST_HELP_TOPIC, +} = require("./constants"); +const { + capitalize, + compactDecimal, + decimal, + emptyReviewArtifacts, + findingIcon, + firstNormalizedLocation, + focusModeSpec, + formatBaselineState, + formatBooleanWord, + formatCacheSummary, + formatKind, + formatNovelty, + formatRunScope, + formatSeverity, + formatSourceKindSummary, + isSpecificFocusMode, + normalizeFindingLocations, + normalizeRelativePath, + number, + reviewTargetKey, + safeArray, + safeObject, + sameLaunchSpec, + treeAccessibilityInformation, + workspaceRelativePath, +} = require("./formatters"); const { CodeCloneMcpClient, MCPClientError } = require("./mcpClient"); +const { + markdownBulletList, + renderFindingMarkdown, + renderOverloadedModuleMarkdown, + renderHelpMarkdown, + renderRemediationMarkdown, + renderRestrictedModeMarkdown, + renderSetupMarkdown, + renderTriageMarkdown, +} = require("./renderers"); +const { + HotspotsTreeProvider, + OverviewTreeProvider, + ReviewCodeLensProvider, + ReviewFileDecorationProvider, + SessionTreeProvider, + WorkspaceState, +} = require("./providers"); +const { + captureWorkspaceGitSnapshot, + looksLikeCodeCloneRepo, + pathExists, + readFileHead, + sameGitSnapshot, +} = require("./runtime"); const { STALE_REASON_EDITOR, STALE_REASON_WORKSPACE, @@ -18,730 +78,6 @@ const { workspaceLocalLauncherCandidates, } = require("./support"); -const execFileAsync = promisify(execFile); - -const HELP_TOPICS = [ - "workflow", - "suppressions", - "baseline", - "latest_runs", - "review_state", - "changed_scope", -]; - -const HOTSPOT_GROUPS = [ - { id: "newRegressions", label: "New Regressions", icon: "diff-added" }, - { id: "productionHotspots", label: "Production Hotspots", icon: "target" }, - { id: "changedFiles", label: "Changed Files", icon: "git-commit" }, - { id: "godModules", label: "God Modules", icon: "symbol-module" }, -]; - -const HOTSPOT_FOCUS_MODES = [ - { - id: "recommended", - label: "Recommended", - description: "Show the highest-signal review surfaces for the current run.", - }, - { - id: "new", - label: "New Regressions", - description: "Focus only on baseline-new findings.", - }, - { - id: "production", - label: "Production", - description: "Focus only on production hotspots.", - }, - { - id: "changed", - label: "Changed Files", - description: "Focus only on findings touching the selected diff.", - }, - { - id: "reportOnly", - label: "Report-only", - description: "Focus only on report-only God Module candidates.", - }, - { - id: "all", - label: "All Groups", - description: "Show every hotspot group, including empty ones.", - }, -]; - -const HOTSPOT_GROUPS_BY_MODE = { - recommended: HOTSPOT_GROUPS.map((group) => group.id), - new: ["newRegressions"], - production: ["productionHotspots"], - changed: ["changedFiles"], - reportOnly: ["godModules"], - all: HOTSPOT_GROUPS.map((group) => group.id), -}; - -const REVIEW_DECORATION_THEMES = { - new: { - badge: "N", - color: "problemsErrorIcon.foreground", - tooltip: "CodeClone new regression", - }, - production: { - badge: "P", - color: "problemsWarningIcon.foreground", - tooltip: "CodeClone production hotspot", - }, - changed: { - badge: "C", - color: "charts.blue", - tooltip: "CodeClone changed-files review item", - }, -}; - -const WORKSPACE_STATE_HOTSPOT_FOCUS_MODE = "codeclone.hotspotFocusMode"; -const WORKSPACE_STATE_LAST_HELP_TOPIC = "codeclone.lastHelpTopic"; - -function number(value) { - if (typeof value !== "number" || Number.isNaN(value)) { - return "0"; - } - return value.toLocaleString("en-US"); -} - -function decimal(value, digits = 2) { - if (typeof value !== "number" || Number.isNaN(value)) { - return "0.00"; - } - return value.toFixed(digits); -} - -function compactDecimal(value) { - if (typeof value !== "number" || Number.isNaN(value)) { - return "0"; - } - return value.toFixed(2).replace(/\.?0+$/, ""); -} - -function capitalize(value) { - if (!value) { - return ""; - } - return value.charAt(0).toUpperCase() + value.slice(1); -} - -function formatBooleanWord(value) { - return value ? "yes" : "no"; -} - -function formatBaselineState(payload) { - const entry = safeObject(payload); - const status = String(entry.status || "unknown"); - return entry.trusted ? `${status} · trusted` : `${status} · untrusted`; -} - -function formatCacheSummary(payload) { - const entry = safeObject(payload); - const usage = entry.used ? "used" : "fresh"; - const freshness = entry.freshness ? String(entry.freshness) : "unknown"; - return `${usage} · ${freshness}`; -} - -function formatRunScope(value) { - return value === "changed" ? "changed files" : "workspace"; -} - -function formatSourceKindSummary(value) { - const entries = Object.entries(safeObject(value)) - .filter(([, count]) => typeof count === "number" && count > 0) - .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)); - if (entries.length === 0) { - return "No production findings by source kind."; - } - return entries - .map(([key, count]) => `${capitalize(key)} ${count}`) - .join(" · "); -} - -function sameLaunchSpec(left, right) { - if (!left || !right) { - return false; - } - const leftArgs = Array.isArray(left.args) ? left.args : []; - const rightArgs = Array.isArray(right.args) ? right.args : []; - return ( - left.command === right.command && - left.cwd === right.cwd && - JSON.stringify(leftArgs) === JSON.stringify(rightArgs) - ); -} - -function normalizeRelativePath(value) { - return String(value || "").replace(/\\/g, "/"); -} - -function workspaceRelativePath(folder, fsPath) { - return normalizeRelativePath(path.relative(folder.uri.fsPath, fsPath)); -} - -function uniqueStrings(values) { - return [...new Set(values.filter(Boolean))]; -} - -function formatSeverity(value) { - return capitalize(String(value || "info")); -} - -function formatNovelty(value) { - const novelty = String(value || "").trim(); - if (!novelty) { - return ""; - } - return capitalize(novelty); -} - -function formatKind(value) { - const kind = String(value || ""); - switch (kind) { - case "function_clone": - return "Function clone"; - case "block_clone": - return "Block clone"; - case "segment_clone": - return "Segment clone"; - case "class_hotspot": - return "Class hotspot"; - case "module_hotspot": - return "Module hotspot"; - case "duplicated_branches": - return "Duplicated branches"; - default: - return capitalize(kind.replace(/_/g, " ")); - } -} - -function focusModeSpec(modeId) { - return ( - HOTSPOT_FOCUS_MODES.find((entry) => entry.id === modeId) || - HOTSPOT_FOCUS_MODES[0] - ); -} - -function isSpecificFocusMode(modeId) { - return modeId !== "recommended" && modeId !== "all"; -} - -function reviewTargetKey(target) { - if (!target || typeof target !== "object") { - return ""; - } - if (target.nodeType === "godModule" && safeObject(target.item).path) { - return `god:${String(target.item.path)}`; - } - if (target.findingId) { - return `finding:${String(target.findingId)}`; - } - return ""; -} - -function findingIcon(severity) { - switch (String(severity || "").toLowerCase()) { - case "critical": - return new vscode.ThemeIcon( - "error", - new vscode.ThemeColor("problemsErrorIcon.foreground") - ); - case "warning": - return new vscode.ThemeIcon( - "warning", - new vscode.ThemeColor("problemsWarningIcon.foreground") - ); - default: - return new vscode.ThemeIcon( - "info", - new vscode.ThemeColor("problemsInfoIcon.foreground") - ); - } -} - -function safeArray(value) { - return Array.isArray(value) ? value : []; -} - -function safeObject(value) { - return value && typeof value === "object" ? value : {}; -} - -function emptyReviewArtifacts() { - return { - newRegressions: [], - productionHotspots: [], - changedFiles: [], - godModules: [], - }; -} - -function normalizeLocations(value) { - if (!Array.isArray(value)) { - return []; - } - return value - .map((entry) => { - if (typeof entry === "string") { - const match = entry.match(/^(.+):(\d+)$/); - return { - path: match ? match[1] : entry, - line: match ? Number(match[2]) : null, - end_line: null, - symbol: null, - }; - } - if (entry && typeof entry === "object") { - return { - path: entry.path ? String(entry.path) : "", - line: - typeof entry.line === "number" ? entry.line : null, - end_line: - typeof entry.end_line === "number" ? entry.end_line : null, - symbol: entry.symbol ? String(entry.symbol) : null, - }; - } - return null; - }) - .filter(Boolean); -} - -function firstLocation(value) { - const locations = normalizeLocations(value); - return locations.length > 0 ? locations[0] : null; -} - -function normalizeFindingLocations(folder, value) { - return normalizeLocations(value) - .filter((location) => location.path) - .map((location) => { - const relativePath = normalizeRelativePath(location.path); - const absolutePath = resolveWorkspacePath(folder.uri.fsPath, relativePath); - if (!absolutePath) { - return null; - } - return { - ...location, - path: relativePath, - absolutePath, - }; - }) - .filter(Boolean); -} - -function firstNormalizedLocation(folder, value) { - const locations = normalizeFindingLocations(folder, value); - return locations.length > 0 ? locations[0] : null; -} - -async function gitStdout(cwd, args) { - try { - const result = await execFileAsync("git", args, { - cwd, - maxBuffer: 1024 * 1024, - }); - return String(result.stdout || "").trim(); - } catch { - return null; - } -} - -async function captureWorkspaceGitSnapshot(folder) { - const cwd = folder.uri.fsPath; - const [head, status] = await Promise.all([ - gitStdout(cwd, ["rev-parse", "HEAD"]), - gitStdout(cwd, ["status", "--porcelain=v1", "--untracked-files=normal"]), - ]); - return { - head, - dirtySignature: status || "", - }; -} - -function sameGitSnapshot(left, right) { - return ( - safeObject(left).head === safeObject(right).head && - safeObject(left).dirtySignature === safeObject(right).dirtySignature - ); -} - -function looksLikeCodeCloneRepo(folderPath) { - return ( - fs.existsSync(path.join(folderPath, "pyproject.toml")) && - fs.existsSync(path.join(folderPath, "codeclone", "mcp_server.py")) - ); -} - -function readFileHead(filePath, maxBytes = 16384) { - const fd = fs.openSync(filePath, "r"); - try { - const buffer = Buffer.allocUnsafe(maxBytes); - const bytesRead = fs.readSync(fd, buffer, 0, maxBytes, 0); - return buffer.toString("utf8", 0, bytesRead); - } finally { - fs.closeSync(fd); - } -} - -function markdownBulletList(values) { - return values.map((value) => `- ${value}`).join("\n"); -} - -function renderHelpMarkdown(topic, payload) { - const lines = [ - `# CodeClone MCP Help: ${topic}`, - "", - payload.summary || "", - "", - "## Key points", - markdownBulletList(safeArray(payload.key_points)), - "", - "## Recommended tools", - markdownBulletList(safeArray(payload.recommended_tools).map((tool) => `\`${tool}\``)), - ]; - const warnings = safeArray(payload.warnings); - if (warnings.length > 0) { - lines.push("", "## Warnings", markdownBulletList(warnings)); - } - const antiPatterns = safeArray(payload.anti_patterns); - if (antiPatterns.length > 0) { - lines.push("", "## Anti-patterns", markdownBulletList(antiPatterns)); - } - const docLinks = safeArray(payload.doc_links); - if (docLinks.length > 0) { - lines.push( - "", - "## Docs", - markdownBulletList( - docLinks.map((entry) => `[${entry.title}](${entry.url})`) - ) - ); - } - return lines.join("\n"); -} - -function renderSetupMarkdown() { - return [ - "# Set Up CodeClone MCP", - "", - "The VS Code extension needs a local `codeclone-mcp` launcher.", - "", - "## Recommended install for the preview extension", - "", - "```bash", - "pip install --pre \"codeclone[mcp]\"", - "```", - "", - "## Verify the launcher", - "", - "```bash", - "codeclone-mcp --help", - "```", - "", - "## If CodeClone lives in a custom environment", - "", - "- Set `codeclone.mcp.command` to the launcher you want VS Code to use.", - "- Set `codeclone.mcp.args` if that launcher needs extra arguments.", - "- In the CodeClone repository itself, the extension can also fall back to `uv run codeclone-mcp`.", - "", - "## What the extension expects", - "", - "- A local `codeclone-mcp` command, or an explicit custom launcher in settings.", - "- MCP support installed, not only the base `codeclone` package.", - "", - "Once that is ready, run `Analyze Workspace` again.", - ].join("\n"); -} - -function renderRestrictedModeMarkdown(topic) { - return [ - `# CodeClone: Restricted Mode`, - "", - `The workspace is not trusted, so CodeClone keeps local analysis and the local MCP server offline.`, - "", - topic - ? `Live MCP help for \`${topic}\` becomes available after workspace trust is granted.` - : "Live MCP help topics become available after workspace trust is granted.", - "", - "## What you can do safely right now", - "", - "- Review installation and setup guidance.", - "- Inspect the extension surface and onboarding text.", - "- Grant workspace trust when you are ready to enable local analysis.", - "", - "## Next step", - "", - "Run `Manage Workspace Trust`, then open the help topic again.", - ].join("\n"); -} - -function renderFindingMarkdown(payload) { - const remediation = safeObject(payload.remediation); - const locations = normalizeLocations(payload.locations); - const spread = safeObject(payload.spread); - const lines = [ - `# ${formatKind(payload.kind)}`, - "", - `- Finding id: \`${payload.id}\``, - `- Severity: ${formatSeverity(payload.severity)}`, - `- Scope: ${payload.scope || "unknown"}`, - `- Priority: ${compactDecimal(payload.priority)}`, - `- Count: ${payload.count || 0}`, - `- Spread: ${spread.files || 0} files / ${spread.functions || 0} functions`, - ]; - if (locations.length > 0) { - lines.push( - "", - "## Locations", - markdownBulletList( - locations.map((location) => { - const range = - location.line !== null && location.end_line !== null - ? `${location.line}-${location.end_line}` - : location.line !== null - ? `${location.line}` - : "?"; - const symbol = location.symbol ? ` — \`${location.symbol}\`` : ""; - return `\`${location.path}:${range}\`${symbol}`; - }) - ) - ); - } - if (Object.keys(remediation).length > 0) { - lines.push("", "## Remediation"); - if (remediation.shape) { - lines.push("", remediation.shape); - } - if (remediation.why_now) { - lines.push("", `Why now: ${remediation.why_now}`); - } - if (remediation.effort || remediation.risk) { - lines.push( - "", - `Effort: ${remediation.effort || "unknown"} · Risk: ${remediation.risk || "unknown"}` - ); - } - const steps = safeArray(remediation.steps); - if (steps.length > 0) { - lines.push("", "### Steps", markdownBulletList(steps)); - } - } - return lines.join("\n"); -} - -function renderRemediationMarkdown(payload) { - const remediation = safeObject(payload.remediation); - const lines = [ - `# Remediation: \`${payload.finding_id}\``, - "", - ]; - if (remediation.shape) { - lines.push(remediation.shape, ""); - } - lines.push( - `- Effort: ${remediation.effort || "unknown"}`, - `- Risk: ${remediation.risk || "unknown"}` - ); - if (remediation.why_now) { - lines.push("", `Why now: ${remediation.why_now}`); - } - const steps = safeArray(remediation.steps); - if (steps.length > 0) { - lines.push("", "## Steps", markdownBulletList(steps)); - } - return lines.join("\n"); -} - -function renderTriageMarkdown(state) { - const summary = safeObject(state.latestSummary); - const triage = safeObject(state.latestTriage); - const health = safeObject(summary.health); - const findings = safeObject(summary.findings); - const triageFindings = safeObject(triage.findings); - const topHotspots = safeObject(triage.top_hotspots); - const topSuggestions = safeObject(triage.top_suggestions); - const items = safeArray(topHotspots.items); - const suggestions = safeArray(topSuggestions.items); - const lines = [ - `# CodeClone Production Triage`, - "", - `- Run: \`${state.currentRunId || "n/a"}\``, - `- Workspace: \`${state.folder.name}\``, - `- Health: ${health.score || 0}/${health.grade || "?"}`, - `- Findings: ${findings.total || 0} total · ${findings.production || 0} production`, - `- Source kinds: ${formatSourceKindSummary(triageFindings.by_source_kind)}`, - ]; - if (items.length > 0) { - lines.push( - "", - "## Top production hotspots", - markdownBulletList( - items.map( - (item) => - `\`${item.id}\` — ${formatKind(item.kind)} · ${formatSeverity( - item.severity - )} · ${item.scope || "unknown"} · priority ${compactDecimal(item.priority)}` - ) - ) - ); - } else { - lines.push("", "## Top production hotspots", "", "None."); - } - if (suggestions.length > 0) { - lines.push( - "", - "## Top suggestions", - markdownBulletList( - suggestions.map((item) => `\`${item.id}\` — ${item.summary || "Suggestion"}`) - ) - ); - } - return lines.join("\n"); -} - -function renderGodModuleMarkdown(item) { - const reasons = safeArray(item.candidate_reasons); - const lines = [ - `# God Module Candidate`, - "", - `- Path: \`${item.path}\``, - `- Module: \`${item.module}\``, - `- Source kind: ${item.source_kind || "unknown"}`, - `- Score: ${decimal(item.score)}`, - `- LOC: ${number(item.loc)}`, - `- Callables: ${item.callable_count || 0}`, - `- Complexity total / max: ${item.complexity_total || 0} / ${item.complexity_max || 0}`, - `- Fan-in / fan-out: ${item.fan_in || 0} / ${item.fan_out || 0}`, - `- Total dependencies: ${item.total_deps || 0}`, - `- Import edges / reimport edges: ${item.import_edges || 0} / ${item.reimport_edges || 0}`, - `- Reimport ratio: ${decimal(item.reimport_ratio)}`, - `- Instability: ${decimal(item.instability)}`, - `- Hub balance: ${decimal(item.hub_balance)}`, - ]; - if (reasons.length > 0) { - lines.push("", "## Candidate reasons", markdownBulletList(reasons)); - } - return lines.join("\n"); -} - -function treeAccessibilityInformation(node) { - const label = String(node?.label || "").trim(); - const description = String(node?.description || "").trim(); - if (!label && !description) { - return undefined; - } - const spoken = description ? `${label}, ${description}` : label; - return { label: spoken }; -} - -class WorkspaceState { - constructor(folder) { - this.folder = folder; - this.currentRunId = null; - this.latestSummary = null; - this.metricsSummary = null; - this.latestTriage = null; - this.changedSummary = null; - this.reviewed = []; - this.lastScope = "workspace"; - this.lastUpdatedAt = null; - this.groupCache = new Map(); - this.reviewArtifacts = emptyReviewArtifacts(); - this.gitSnapshot = null; - this.stale = false; - this.staleReason = null; - this.lastStaleCheckAt = 0; - } -} - -class BaseTreeProvider { - constructor(controller) { - this.controller = controller; - this.emitter = new vscode.EventEmitter(); - this.onDidChangeTreeData = this.emitter.event; - } - - refresh() { - this.emitter.fire(undefined); - } - - dispose() { - this.emitter.dispose(); - } -} - -class OverviewTreeProvider extends BaseTreeProvider { - async getTreeItem(node) { - return this.controller.createTreeItem(node); - } - - async getChildren(node) { - return this.controller.getOverviewChildren(node); - } -} - -class HotspotsTreeProvider extends BaseTreeProvider { - async getTreeItem(node) { - return this.controller.createTreeItem(node); - } - - async getChildren(node) { - return this.controller.getHotspotsChildren(node); - } -} - -class SessionTreeProvider extends BaseTreeProvider { - async getTreeItem(node) { - return this.controller.createTreeItem(node); - } - - async getChildren(node) { - return this.controller.getSessionChildren(node); - } -} - -class ReviewCodeLensProvider { - constructor(controller) { - this.controller = controller; - this.emitter = new vscode.EventEmitter(); - this.onDidChangeCodeLenses = this.emitter.event; - } - - refresh() { - this.emitter.fire(undefined); - } - - provideCodeLenses(document) { - return this.controller.provideReviewCodeLenses(document); - } - - dispose() { - this.emitter.dispose(); - } -} - -class ReviewFileDecorationProvider { - constructor(controller) { - this.controller = controller; - this.emitter = new vscode.EventEmitter(); - this.onDidChangeFileDecorations = this.emitter.event; - } - - refresh(uri) { - this.emitter.fire(uri); - } - - provideFileDecoration(uri) { - return this.controller.provideFileDecoration(uri); - } - - dispose() { - this.emitter.dispose(); - } -} - class CodeCloneController { constructor(context) { this.context = context; @@ -768,12 +104,12 @@ class CodeCloneController { ), }); this.revealDecorationTimeout = null; - this.connectionInfo = { + this.connectionInfo = /** @type {any} */ ({ connected: false, serverInfo: null, toolCount: 0, launchSpec: null, - }; + }); this.statusBar = vscode.window.createStatusBarItem( "codeclone.status", vscode.StatusBarAlignment.Left, @@ -941,14 +277,14 @@ class CodeCloneController { vscode.commands.registerCommand("codeclone.clearSessionState", () => this.clearSessionState() ), - vscode.commands.registerCommand("codeclone.openGodModule", (node) => - this.openGodModule(node) + vscode.commands.registerCommand("codeclone.openOverloadedModule", (node) => + this.openOverloadedModule(node) ), - vscode.commands.registerCommand("codeclone.copyGodModuleBrief", (node) => - this.copyGodModuleBrief(node) + vscode.commands.registerCommand("codeclone.copyOverloadedModuleBrief", (node) => + this.copyOverloadedModuleBrief(node) ), - vscode.commands.registerCommand("codeclone.reviewGodModule", (node) => - this.reviewGodModule(node) + vscode.commands.registerCommand("codeclone.reviewOverloadedModule", (node) => + this.reviewOverloadedModule(node) ), ]; this.context.subscriptions.push(...subscriptions); @@ -1124,7 +460,7 @@ class CodeCloneController { await this.refreshStaleState(state); } - resolveLaunchSpec(folder) { + async resolveLaunchSpec(folder) { const config = vscode.workspace.getConfiguration("codeclone", folder.uri); const configuredCommand = config.get("mcp.command", "auto"); const configuredArgs = config.get("mcp.args", []); @@ -1135,9 +471,14 @@ class CodeCloneController { cwd: folder.uri.fsPath, }); } - const localLauncher = workspaceLocalLauncherCandidates(folder.uri.fsPath).find( - (candidate) => fs.existsSync(candidate) + const candidates = workspaceLocalLauncherCandidates(folder.uri.fsPath); + const candidateChecks = await Promise.all( + candidates.map(async (candidate) => ({ + candidate, + exists: await pathExists(candidate), + })) ); + const localLauncher = candidateChecks.find((entry) => entry.exists)?.candidate; if (localLauncher) { return normalizedLaunchSpec({ command: localLauncher, @@ -1145,12 +486,12 @@ class CodeCloneController { cwd: folder.uri.fsPath, }); } - const primary = normalizedLaunchSpec({ + const primary = /** @type {any} */ (normalizedLaunchSpec({ command: "codeclone-mcp", args: Array.isArray(configuredArgs) ? configuredArgs : [], cwd: folder.uri.fsPath, - }); - primary.fallback = looksLikeCodeCloneRepo(folder.uri.fsPath) + })); + primary.fallback = (await looksLikeCodeCloneRepo(folder.uri.fsPath)) ? normalizedLaunchSpec({ command: "uv", args: ["run", "codeclone-mcp"], @@ -1161,7 +502,7 @@ class CodeCloneController { } async ensureConnected(folder) { - const launchSpec = this.resolveLaunchSpec(folder); + const launchSpec = await this.resolveLaunchSpec(folder); if (this.client.isConnected() && this.connectionInfo.launchSpec) { const activeLaunchSpec = this.connectionInfo.launchSpec; if ( @@ -1278,7 +619,7 @@ class CodeCloneController { newRegressionsResponse, productionHotspotsResponse, changedFilesResponse, - godModulesResponse, + overloadedModulesResponse, ] = await Promise.all([ this.client.callTool("list_findings", { run_id: runId, @@ -1309,7 +650,7 @@ class CodeCloneController { this.client.callTool("get_report_section", { run_id: runId, section: "metrics_detail", - family: "god_modules", + family: "overloaded_modules", limit: 25, }), ]); @@ -1317,7 +658,7 @@ class CodeCloneController { newRegressions: safeArray(newRegressionsResponse.items), productionHotspots: safeArray(productionHotspotsResponse.items), changedFiles: safeArray(changedFilesResponse.items), - godModules: safeArray(godModulesResponse.items), + overloadedModules: safeArray(overloadedModulesResponse.items), }; state.groupCache.clear(); this.rebuildFileDecorations(); @@ -1538,15 +879,23 @@ class CodeCloneController { activeFindingTarget(node) { const candidate = node || this.activeReviewTarget; - if (!candidate || candidate.nodeType === "godModule" || !candidate.findingId) { + if ( + !candidate || + candidate.nodeType === "overloadedModule" || + !candidate.findingId + ) { return null; } return candidate; } - activeGodModuleTarget(node) { + activeOverloadedModuleTarget(node) { const candidate = node || this.activeReviewTarget; - if (!candidate || candidate.nodeType !== "godModule" || !safeObject(candidate.item).path) { + if ( + !candidate || + candidate.nodeType !== "overloadedModule" || + !safeObject(candidate.item).path + ) { return null; } return candidate; @@ -1557,7 +906,7 @@ class CodeCloneController { return false; } const fsPath = editor.document.uri.fsPath; - if (target.nodeType === "godModule") { + if (target.nodeType === "overloadedModule") { const state = this.states.get(target.workspaceKey); if (!state) { return false; @@ -1615,8 +964,8 @@ class CodeCloneController { return safeArray(artifacts.productionHotspots); case "changedFiles": return safeArray(artifacts.changedFiles); - case "godModules": - return safeArray(artifacts.godModules); + case "overloadedModules": + return safeArray(artifacts.overloadedModules); default: return []; } @@ -1628,7 +977,7 @@ class CodeCloneController { activeHotspotGroupIds(state) { const requested = - HOTSPOT_GROUPS_BY_MODE[this.hotspotFocusMode] || + /** @type {any} */ (HOTSPOT_GROUPS_BY_MODE)[this.hotspotFocusMode] || HOTSPOT_GROUPS_BY_MODE.recommended; if (this.hotspotFocusMode === "all") { return requested; @@ -1689,7 +1038,7 @@ class CodeCloneController { "codeclone", "report.html" ); - if (!fs.existsSync(htmlPath)) { + if (!(await pathExists(htmlPath))) { return { htmlPath, exists: false, @@ -1698,10 +1047,10 @@ class CodeCloneController { generatedAtUtc: null, }; } - const stat = fs.statSync(htmlPath); + const stat = await fs.stat(htmlPath); let generatedAtUtc = null; try { - const html = readFileHead(htmlPath); + const html = await readFileHead(htmlPath); const match = html.match(/data-report-generated-at-utc="([^"]+)"/); generatedAtUtc = match ? match[1] : null; } catch { @@ -1729,13 +1078,13 @@ class CodeCloneController { }; } - toGodModuleNodes(state, items) { - return items.map((item) => this.buildGodModuleNode(state, item)); + toOverloadedModuleNodes(state, items) { + return items.map((item) => this.buildOverloadedModuleNode(state, item)); } - buildGodModuleNode(state, item) { + buildOverloadedModuleNode(state, item) { return { - nodeType: "godModule", + nodeType: "overloadedModule", workspaceKey: state.folder.uri.toString(), runId: state.currentRunId, item, @@ -1743,16 +1092,16 @@ class CodeCloneController { description: `${decimal(item.score)} · ${item.source_kind} · report-only`, tooltip: `${item.module} · ${number(item.loc)} LOC · ${item.total_deps} deps`, icon: new vscode.ThemeIcon("symbol-module"), - contextValue: "codeclone.godModule", + contextValue: "codeclone.overloadedModule", command: { - command: "codeclone.reviewGodModule", - title: "Review God Module", + command: "codeclone.reviewOverloadedModule", + title: "Review Overloaded Module", arguments: [ { workspaceKey: state.folder.uri.toString(), runId: state.currentRunId, item, - nodeType: "godModule", + nodeType: "overloadedModule", }, ], }, @@ -1765,15 +1114,15 @@ class CodeCloneController { this.hotspotFocusMode === "recommended" ? ["changedFiles", "newRegressions", "productionHotspots"] : this.hotspotFocusMode === "all" - ? ["changedFiles", "newRegressions", "productionHotspots", "godModules"] + ? ["changedFiles", "newRegressions", "productionHotspots", "overloadedModules"] : this.activeHotspotGroupIds(state); const queue = []; const seen = new Set(); for (const groupId of groupIds) { - if (groupId === "godModules") { - for (const node of this.toGodModuleNodes( + if (groupId === "overloadedModules") { + for (const node of this.toOverloadedModuleNodes( state, - safeArray(artifacts.godModules) + safeArray(artifacts.overloadedModules) )) { const key = reviewTargetKey(node); if (!key || seen.has(key)) { @@ -1799,9 +1148,9 @@ class CodeCloneController { if ( this.hotspotFocusMode === "recommended" && queue.length === 0 && - safeArray(artifacts.godModules).length > 0 + safeArray(artifacts.overloadedModules).length > 0 ) { - return this.toGodModuleNodes(state, safeArray(artifacts.godModules)); + return this.toOverloadedModuleNodes(state, safeArray(artifacts.overloadedModules)); } return queue; } @@ -1842,8 +1191,8 @@ class CodeCloneController { return; } const nextNode = queue[nextIndex]; - if (nextNode.nodeType === "godModule") { - await this.revealGodModuleSource(nextNode); + if (nextNode.nodeType === "overloadedModule") { + await this.revealOverloadedModuleSource(nextNode); return; } await this.revealFindingSource(nextNode); @@ -1903,8 +1252,8 @@ class CodeCloneController { } ); if (picked) { - if (picked.node.nodeType === "godModule") { - await this.reviewGodModule(picked.node); + if (picked.node.nodeType === "overloadedModule") { + await this.reviewOverloadedModule(picked.node); } else { await this.reviewFinding(picked.node); } @@ -2022,9 +1371,7 @@ class CodeCloneController { if (!resolved) { return; } - const state = this.states.get(resolved.workspaceKey); - const locations = safeArray(resolved.locations) - .map((location) => { + const locationCandidates = safeArray(resolved.locations).map((location) => { const uri = vscode.Uri.file(location.absolutePath); const startLine = Math.max(Number(location.line || 1) - 1, 0); const endLine = Math.max( @@ -2034,8 +1381,17 @@ class CodeCloneController { const start = new vscode.Position(startLine, 0); const end = new vscode.Position(endLine, 0); return new vscode.Location(uri, new vscode.Range(start, end)); - }) - .filter((entry) => fs.existsSync(entry.uri.fsPath)); + }); + const locations = ( + await Promise.all( + locationCandidates.map(async (entry) => ({ + entry, + exists: await pathExists(entry.uri.fsPath), + })) + ) + ) + .filter((entry) => entry.exists) + .map((entry) => entry.entry); if (locations.length === 0) { await vscode.window.showInformationMessage( "This finding does not expose source locations for Peek." @@ -2309,13 +1665,13 @@ class CodeCloneController { await this.revealWorkspacePath( state.folder, location.path, - location.line, - location.end_line + location.line ?? undefined, + location.end_line ?? undefined ); } - async revealGodModuleSource(node) { - const activeNode = this.activeGodModuleTarget(node); + async revealOverloadedModuleSource(node) { + const activeNode = this.activeOverloadedModuleTarget(node); if (!activeNode) { return; } @@ -2325,13 +1681,19 @@ class CodeCloneController { } const resolved = { ...activeNode, - nodeType: "godModule", + nodeType: "overloadedModule", }; this.setActiveReviewTarget(resolved); await this.revealWorkspacePath(state.folder, activeNode.item.path); } - async revealWorkspacePath(folder, relativePath, line = null, endLine = null) { + /** + * @param {any} folder + * @param {string} relativePath + * @param {number | undefined} [line] + * @param {number | undefined} [endLine] + */ + async revealWorkspacePath(folder, relativePath, line = undefined, endLine = undefined) { const absolutePath = resolveWorkspacePath(folder.uri.fsPath, relativePath); if (!absolutePath) { await vscode.window.showWarningMessage( @@ -2417,17 +1779,17 @@ class CodeCloneController { await this.showMarkdownDocument(renderSetupMarkdown()); } - async openGodModule(node) { - const activeNode = this.activeGodModuleTarget(node); + async openOverloadedModule(node) { + const activeNode = this.activeOverloadedModuleTarget(node); if (!activeNode) { return; } this.setActiveReviewTarget(activeNode); - await this.showMarkdownDocument(renderGodModuleMarkdown(activeNode.item)); + await this.showMarkdownDocument(renderOverloadedModuleMarkdown(activeNode.item)); } - async reviewGodModule(node) { - const activeNode = this.activeGodModuleTarget(node); + async reviewOverloadedModule(node) { + const activeNode = this.activeOverloadedModuleTarget(node); if (!activeNode) { return; } @@ -2441,7 +1803,7 @@ class CodeCloneController { }, { label: "Show report-only detail", - description: "Open God Module summary", + description: "Open Overloaded Module summary", action: "detail", }, { @@ -2458,18 +1820,18 @@ class CodeCloneController { return; } if (picked.action === "reveal") { - await this.revealGodModuleSource(activeNode); + await this.revealOverloadedModuleSource(activeNode); return; } if (picked.action === "brief") { - await this.copyGodModuleBrief(activeNode); + await this.copyOverloadedModuleBrief(activeNode); return; } - await this.openGodModule(activeNode); + await this.openOverloadedModule(activeNode); } - async copyGodModuleBrief(node) { - const activeNode = this.activeGodModuleTarget(node); + async copyOverloadedModuleBrief(node) { + const activeNode = this.activeOverloadedModuleTarget(node); if (!activeNode) { return; } @@ -2575,7 +1937,7 @@ class CodeCloneController { if (!target) { return []; } - if (target.nodeType === "godModule") { + if (target.nodeType === "overloadedModule") { const state = this.states.get(target.workspaceKey); if (!state) { return []; @@ -2595,12 +1957,12 @@ class CodeCloneController { title: "$(arrow-down) Next hotspot", }), new vscode.CodeLens(range, { - command: "codeclone.openGodModule", + command: "codeclone.openOverloadedModule", title: "$(symbol-module) Report-only detail", arguments: [target], }), new vscode.CodeLens(range, { - command: "codeclone.copyGodModuleBrief", + command: "codeclone.copyOverloadedModuleBrief", title: "$(copy) Copy report-only brief", arguments: [target], }), @@ -2659,7 +2021,7 @@ class CodeCloneController { changed: this.reviewArtifactCount(state, "changedFiles"), new: this.reviewArtifactCount(state, "newRegressions"), production: this.reviewArtifactCount(state, "productionHotspots"), - godModules: this.reviewArtifactCount(state, "godModules"), + overloadedModules: this.reviewArtifactCount(state, "overloadedModules"), }; const baselineDrift = this.baselineDrift(state); if (!node) { @@ -2706,13 +2068,13 @@ class CodeCloneController { icon: new vscode.ThemeIcon("git-commit"), }); } - if (safeObject(state.metricsSummary).god_modules) { - const godModules = safeObject(state.metricsSummary).god_modules; + if (safeObject(state.metricsSummary).overloaded_modules) { + const overloadedModules = safeObject(state.metricsSummary).overloaded_modules; sections.push({ nodeType: "section", id: "overview.god", - label: "God Modules", - description: `${godModules.candidates} candidates · top ${decimal(godModules.top_score)} (report-only)`, + label: "Overloaded Modules", + description: `${overloadedModules.candidates} candidates · top ${decimal(overloadedModules.top_score)} (report-only)`, icon: new vscode.ThemeIcon("symbol-module"), }); } @@ -2800,16 +2162,16 @@ class CodeCloneController { ]; } if (node.id === "overview.god") { - const godModules = safeObject(state.metricsSummary).god_modules; + const overloadedModules = safeObject(state.metricsSummary).overloaded_modules; return [ - this.detailNode("Candidates", number(godModules.candidates)), - this.detailNode("Ranked modules", number(godModules.total)), - this.detailNode("Top score", decimal(godModules.top_score)), - this.detailNode("Average score", decimal(godModules.average_score)), - this.detailNode("Population", String(godModules.population_status)), + this.detailNode("Candidates", number(overloadedModules.candidates)), + this.detailNode("Ranked modules", number(overloadedModules.total)), + this.detailNode("Top score", decimal(overloadedModules.top_score)), + this.detailNode("Average score", decimal(overloadedModules.average_score)), + this.detailNode("Population", String(overloadedModules.population_status)), this.detailNode( "Review surface", - `${number(reviewCounts.godModules)} visible in Hotspots` + `${number(reviewCounts.overloadedModules)} visible in Hotspots` ), ]; } @@ -3000,10 +2362,10 @@ class CodeCloneController { this.reviewArtifactItems(state, "changedFiles") ); break; - case "godModules": - nodes = this.toGodModuleNodes( + case "overloadedModules": + nodes = this.toOverloadedModuleNodes( state, - this.reviewArtifactItems(state, "godModules") + this.reviewArtifactItems(state, "overloadedModules") ); break; default: @@ -3088,8 +2450,8 @@ class CodeCloneController { return state.changedSummary ? `${this.reviewArtifactCount(state, "changedFiles")} visible · ${state.changedSummary.verdict}` : "not analyzed"; - case "godModules": - return `${this.reviewArtifactCount(state, "godModules")} report-only`; + case "overloadedModules": + return `${this.reviewArtifactCount(state, "overloadedModules")} report-only`; default: return ""; } @@ -3103,8 +2465,8 @@ class CodeCloneController { return "No production hotspots are visible."; case "changedFiles": return "No findings touching changed files are visible."; - case "godModules": - return "No report-only God Module candidates are visible."; + case "overloadedModules": + return "No report-only Overloaded Module candidates are visible."; default: return "Nothing is visible in this category."; } @@ -3114,7 +2476,8 @@ class CodeCloneController { const specificMode = isSpecificFocusMode(this.hotspotFocusMode); if (specificMode) { const allowed = - HOTSPOT_GROUPS_BY_MODE[this.hotspotFocusMode] || HOTSPOT_GROUPS_BY_MODE.recommended; + /** @type {any} */ (HOTSPOT_GROUPS_BY_MODE)[this.hotspotFocusMode] || + HOTSPOT_GROUPS_BY_MODE.recommended; if (!allowed.includes(groupId)) { return false; } @@ -3131,8 +2494,8 @@ class CodeCloneController { return this.hotspotFocusMode === "changed"; } return specificMode || this.reviewArtifactCount(state, "changedFiles") > 0; - case "godModules": - return specificMode || this.reviewArtifactCount(state, "godModules") > 0; + case "overloadedModules": + return specificMode || this.reviewArtifactCount(state, "overloadedModules") > 0; default: return false; } @@ -3173,11 +2536,11 @@ class CodeCloneController { title: "Review production hotspots", }; } - if (this.reviewArtifactCount(state, "godModules") > 0) { + if (this.reviewArtifactCount(state, "overloadedModules") > 0) { return { - label: "Inspect report-only God Modules", + label: "Inspect report-only Overloaded Modules", command: "codeclone.focusHotspots", - title: "Inspect report-only God Modules", + title: "Inspect report-only Overloaded Modules", }; } return { @@ -3233,7 +2596,7 @@ class CodeCloneController { item.command = node.command; break; } - case "godModule": { + case "overloadedModule": { item = new vscode.TreeItem( node.label, vscode.TreeItemCollapsibleState.None @@ -3241,7 +2604,7 @@ class CodeCloneController { item.description = node.description; item.tooltip = node.tooltip; item.iconPath = node.icon; - item.contextValue = "codeclone.godModule"; + item.contextValue = "codeclone.overloadedModule"; item.command = node.command; break; } @@ -3314,8 +2677,8 @@ class CodeCloneController { newCount + productionCount, changedCount ); - const godModuleCount = Number( - this.reviewArtifactCount(state, "godModules") + const overloadedModuleCount = Number( + this.reviewArtifactCount(state, "overloadedModules") ); let badgeValue = 0; let badgeTooltip = ""; @@ -3333,15 +2696,15 @@ class CodeCloneController { badgeTooltip = `${changedCount} changed-files review items are visible in Hotspots`; break; case "reportOnly": - badgeValue = godModuleCount; - badgeTooltip = `${godModuleCount} report-only God Module candidates are visible in Hotspots`; + badgeValue = overloadedModuleCount; + badgeTooltip = `${overloadedModuleCount} report-only Overloaded Module candidates are visible in Hotspots`; break; default: - badgeValue = actionableCount > 0 ? actionableCount : godModuleCount; + badgeValue = actionableCount > 0 ? actionableCount : overloadedModuleCount; badgeTooltip = actionableCount > 0 ? `${actionableCount} review items need attention` - : `${godModuleCount} report-only God Module candidates are visible in Hotspots`; + : `${overloadedModuleCount} report-only Overloaded Module candidates are visible in Hotspots`; break; } this.hotspotsView.badge = @@ -3396,7 +2759,7 @@ class CodeCloneController { void vscode.commands.executeCommand( "setContext", "codeclone.activeReviewTargetIsFinding", - Boolean(activeTarget && activeTarget.nodeType !== "godModule") + Boolean(activeTarget && activeTarget.nodeType !== "overloadedModule") ); void vscode.commands.executeCommand( "setContext", @@ -3405,8 +2768,8 @@ class CodeCloneController { ); void vscode.commands.executeCommand( "setContext", - "codeclone.activeReviewTargetIsGodModule", - Boolean(activeTarget && activeTarget.nodeType === "godModule") + "codeclone.activeReviewTargetIsOverloadedModule", + Boolean(activeTarget && activeTarget.nodeType === "overloadedModule") ); void vscode.commands.executeCommand( "setContext", @@ -3536,10 +2899,8 @@ function activate(context) { controller = new CodeCloneController(context); } -async function deactivate() { - if (controller) { - await controller.client.dispose(); - } +function deactivate() { + controller = null; } module.exports = { diff --git a/extensions/vscode-codeclone/src/formatters.js b/extensions/vscode-codeclone/src/formatters.js new file mode 100644 index 0000000..bb90929 --- /dev/null +++ b/extensions/vscode-codeclone/src/formatters.js @@ -0,0 +1,316 @@ +"use strict"; + +const path = require("node:path"); +/** @type {any} */ +const vscode = require("vscode"); + +const { + HOTSPOT_FOCUS_MODES, +} = require("./constants"); +const { resolveWorkspacePath } = require("./support"); + +/** + * @typedef {Object.} LooseObject + */ + +/** + * @typedef {{ + * path: string, + * line: number | null, + * end_line: number | null, + * symbol: string | null + * }} FindingLocation + */ + +/** + * @typedef {FindingLocation & { absolutePath: string }} NormalizedFindingLocation + */ + +function number(value) { + if (typeof value !== "number" || Number.isNaN(value)) { + return "0"; + } + return value.toLocaleString("en-US"); +} + +function decimal(value, digits = 2) { + if (typeof value !== "number" || Number.isNaN(value)) { + return "0.00"; + } + return value.toFixed(digits); +} + +function compactDecimal(value) { + if (typeof value !== "number" || Number.isNaN(value)) { + return "0"; + } + return value.toFixed(2).replace(/\.?0+$/, ""); +} + +function capitalize(value) { + if (!value) { + return ""; + } + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function formatBooleanWord(value) { + return value ? "yes" : "no"; +} + +function formatBaselineState(payload) { + const entry = safeObject(payload); + const status = String(entry.status || "unknown"); + return entry.trusted ? `${status} · trusted` : `${status} · untrusted`; +} + +function formatCacheSummary(payload) { + const entry = safeObject(payload); + const usage = entry.used ? "used" : "fresh"; + const freshness = entry.freshness ? String(entry.freshness) : "unknown"; + return `${usage} · ${freshness}`; +} + +function formatRunScope(value) { + return value === "changed" ? "changed files" : "workspace"; +} + +function formatSourceKindSummary(value) { + const entries = Object.entries(safeObject(value)) + .filter(([, count]) => typeof count === "number" && count > 0) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)); + if (entries.length === 0) { + return "No production findings by source kind."; + } + return entries + .map(([key, count]) => `${capitalize(key)} ${count}`) + .join(" · "); +} + +function sameLaunchSpec(left, right) { + if (!left || !right) { + return false; + } + const leftArgs = Array.isArray(left.args) ? left.args : []; + const rightArgs = Array.isArray(right.args) ? right.args : []; + return ( + left.command === right.command && + left.cwd === right.cwd && + JSON.stringify(leftArgs) === JSON.stringify(rightArgs) + ); +} + +function normalizeRelativePath(value) { + return String(value || "").replace(/\\/g, "/"); +} + +function workspaceRelativePath(folder, fsPath) { + return normalizeRelativePath(path.relative(folder.uri.fsPath, fsPath)); +} + +function formatSeverity(value) { + return capitalize(String(value || "info")); +} + +function formatNovelty(value) { + const novelty = String(value || "").trim(); + if (!novelty) { + return ""; + } + return capitalize(novelty); +} + +function formatKind(value) { + const kind = String(value || ""); + switch (kind) { + case "function_clone": + return "Function clone"; + case "block_clone": + return "Block clone"; + case "segment_clone": + return "Segment clone"; + case "class_hotspot": + return "Class hotspot"; + case "module_hotspot": + return "Module hotspot"; + case "duplicated_branches": + return "Duplicated branches"; + default: + return capitalize(kind.replace(/_/g, " ")); + } +} + +function focusModeSpec(modeId) { + return ( + HOTSPOT_FOCUS_MODES.find((entry) => entry.id === modeId) || + HOTSPOT_FOCUS_MODES[0] + ); +} + +function isSpecificFocusMode(modeId) { + return modeId !== "recommended" && modeId !== "all"; +} + +function reviewTargetKey(target) { + if (!target || typeof target !== "object") { + return ""; + } + if (target.nodeType === "overloadedModule" && safeObject(target.item).path) { + return `overloaded:${String(target.item.path)}`; + } + if (target.findingId) { + return `finding:${String(target.findingId)}`; + } + return ""; +} + +function findingIcon(severity) { + switch (String(severity || "").toLowerCase()) { + case "critical": + return new vscode.ThemeIcon( + "error", + new vscode.ThemeColor("problemsErrorIcon.foreground") + ); + case "warning": + return new vscode.ThemeIcon( + "warning", + new vscode.ThemeColor("problemsWarningIcon.foreground") + ); + default: + return new vscode.ThemeIcon( + "info", + new vscode.ThemeColor("problemsInfoIcon.foreground") + ); + } +} + +/** + * @param {unknown} value + * @returns {any[]} + */ +function safeArray(value) { + return Array.isArray(value) ? value : []; +} + +/** + * @param {unknown} value + * @returns {LooseObject} + */ +function safeObject(value) { + return value && typeof value === "object" ? value : {}; +} + +function emptyReviewArtifacts() { + return { + newRegressions: [], + productionHotspots: [], + changedFiles: [], + overloadedModules: [], + }; +} + +/** + * @param {unknown} value + * @returns {FindingLocation[]} + */ +function normalizeLocations(value) { + if (!Array.isArray(value)) { + return []; + } + const locations = value + .map((entry) => { + if (typeof entry === "string") { + const match = entry.match(/^(.+):(\d+)$/); + return { + path: match ? match[1] : entry, + line: match ? Number(match[2]) : null, + end_line: null, + symbol: null, + }; + } + if (entry && typeof entry === "object") { + return { + path: entry.path ? String(entry.path) : "", + line: typeof entry.line === "number" ? entry.line : null, + end_line: typeof entry.end_line === "number" ? entry.end_line : null, + symbol: entry.symbol ? String(entry.symbol) : null, + }; + } + return null; + }) + .filter(Boolean); + return /** @type {FindingLocation[]} */ (locations); +} + +/** + * @param {any} folder + * @param {unknown} value + * @returns {NormalizedFindingLocation[]} + */ +function normalizeFindingLocations(folder, value) { + const locations = normalizeLocations(value) + .filter((location) => location.path) + .map((location) => { + const relativePath = normalizeRelativePath(location.path); + const absolutePath = resolveWorkspacePath(folder.uri.fsPath, relativePath); + if (!absolutePath) { + return null; + } + return { + ...location, + path: relativePath, + absolutePath, + }; + }) + .filter(Boolean); + return /** @type {NormalizedFindingLocation[]} */ (locations); +} + +/** + * @param {any} folder + * @param {unknown} value + * @returns {NormalizedFindingLocation | null} + */ +function firstNormalizedLocation(folder, value) { + const locations = normalizeFindingLocations(folder, value); + return locations.length > 0 ? locations[0] : null; +} + +function treeAccessibilityInformation(node) { + const label = String(node?.label || "").trim(); + const description = String(node?.description || "").trim(); + if (!label && !description) { + return undefined; + } + const spoken = description ? `${label}, ${description}` : label; + return { label: spoken }; +} + +module.exports = { + capitalize, + compactDecimal, + decimal, + emptyReviewArtifacts, + findingIcon, + firstNormalizedLocation, + focusModeSpec, + formatBaselineState, + formatBooleanWord, + formatCacheSummary, + formatKind, + formatNovelty, + formatRunScope, + formatSeverity, + formatSourceKindSummary, + isSpecificFocusMode, + normalizeFindingLocations, + normalizeLocations, + normalizeRelativePath, + number, + reviewTargetKey, + safeArray, + safeObject, + sameLaunchSpec, + treeAccessibilityInformation, + workspaceRelativePath, +}; diff --git a/extensions/vscode-codeclone/src/mcpClient.js b/extensions/vscode-codeclone/src/mcpClient.js index d68dc97..a1f90b6 100644 --- a/extensions/vscode-codeclone/src/mcpClient.js +++ b/extensions/vscode-codeclone/src/mcpClient.js @@ -3,8 +3,10 @@ const { spawn } = require("node:child_process"); const { EventEmitter } = require("node:events"); +const { version: EXTENSION_VERSION } = require("../package.json"); const { trimTail } = require("./support"); +const MCP_PROTOCOL_VERSION = "2025-03-26"; const REQUEST_TIMEOUT_MS = 5 * 60 * 1000; const MAX_STDOUT_BUFFER_CHARS = 4 * 1024 * 1024; const MAX_STDERR_BUFFER_CHARS = 256 * 1024; @@ -70,11 +72,11 @@ class CodeCloneMcpClient extends EventEmitter { await this._spawn(launchSpec); try { const initializeResult = await this.request("initialize", { - protocolVersion: "2025-03-26", + protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: "CodeClone VS Code", - version: "0.0.1", + version: EXTENSION_VERSION, }, }); this._write({ @@ -214,7 +216,7 @@ class CodeCloneMcpClient extends EventEmitter { " " )}`.trim() ); - resolve(); + resolve(undefined); }); child.on("exit", (code, signal) => { this.outputChannel.appendLine( diff --git a/extensions/vscode-codeclone/src/providers.js b/extensions/vscode-codeclone/src/providers.js new file mode 100644 index 0000000..e35870d --- /dev/null +++ b/extensions/vscode-codeclone/src/providers.js @@ -0,0 +1,133 @@ +"use strict"; + +/** @type {any} */ +const vscode = require("vscode"); + +const { emptyReviewArtifacts, treeAccessibilityInformation } = require("./formatters"); + +/** + * @typedef {import("vscode").TreeDataProvider} VSCodeTreeDataProvider + * @typedef {import("vscode").CodeLensProvider} VSCodeCodeLensProvider + * @typedef {import("vscode").FileDecorationProvider} VSCodeFileDecorationProvider + */ + +class WorkspaceState { + constructor(folder) { + this.folder = folder; + this.currentRunId = null; + this.latestSummary = null; + this.metricsSummary = null; + this.latestTriage = null; + this.changedSummary = null; + this.reviewed = []; + this.lastScope = "workspace"; + this.lastUpdatedAt = null; + this.groupCache = new Map(); + this.reviewArtifacts = emptyReviewArtifacts(); + this.gitSnapshot = null; + this.stale = false; + this.staleReason = null; + this.lastStaleCheckAt = 0; + } +} + +class BaseTreeProvider { + constructor(controller) { + this.controller = controller; + this.emitter = new vscode.EventEmitter(); + this.onDidChangeTreeData = this.emitter.event; + } + + refresh() { + this.emitter.fire(undefined); + } + + dispose() { + this.emitter.dispose(); + } +} + +/** @implements {VSCodeTreeDataProvider} */ +class OverviewTreeProvider extends BaseTreeProvider { + async getTreeItem(node) { + return this.controller.createTreeItem(node); + } + + async getChildren(node) { + return this.controller.getOverviewChildren(node); + } +} + +/** @implements {VSCodeTreeDataProvider} */ +class HotspotsTreeProvider extends BaseTreeProvider { + async getTreeItem(node) { + return this.controller.createTreeItem(node); + } + + async getChildren(node) { + return this.controller.getHotspotsChildren(node); + } +} + +/** @implements {VSCodeTreeDataProvider} */ +class SessionTreeProvider extends BaseTreeProvider { + async getTreeItem(node) { + return this.controller.createTreeItem(node); + } + + async getChildren(node) { + return this.controller.getSessionChildren(node); + } +} + +/** @implements {VSCodeCodeLensProvider} */ +class ReviewCodeLensProvider { + constructor(controller) { + this.controller = controller; + this.emitter = new vscode.EventEmitter(); + this.onDidChangeCodeLenses = this.emitter.event; + } + + refresh() { + this.emitter.fire(undefined); + } + + provideCodeLenses(document) { + return this.controller.provideReviewCodeLenses(document); + } + + dispose() { + this.emitter.dispose(); + } +} + +/** @implements {VSCodeFileDecorationProvider} */ +class ReviewFileDecorationProvider { + constructor(controller) { + this.controller = controller; + this.emitter = new vscode.EventEmitter(); + this.onDidChangeFileDecorations = this.emitter.event; + } + + refresh(uri) { + this.emitter.fire(uri); + } + + provideFileDecoration(uri) { + return this.controller.provideFileDecoration(uri); + } + + dispose() { + this.emitter.dispose(); + } +} + +module.exports = { + HotspotsTreeProvider, + OverviewTreeProvider, + ReviewCodeLensProvider, + ReviewFileDecorationProvider, + SessionTreeProvider, + WorkspaceState, + treeAccessibilityInformation, +}; diff --git a/extensions/vscode-codeclone/src/renderers.js b/extensions/vscode-codeclone/src/renderers.js new file mode 100644 index 0000000..db8b5a8 --- /dev/null +++ b/extensions/vscode-codeclone/src/renderers.js @@ -0,0 +1,266 @@ +"use strict"; + +const { + capitalize, + compactDecimal, + decimal, + formatKind, + formatSeverity, + formatSourceKindSummary, + normalizeLocations, + number, + safeArray, + safeObject, +} = require("./formatters"); + +function markdownBulletList(values) { + return values.map((value) => `- ${value}`).join("\n"); +} + +function renderHelpMarkdown(topic, payload) { + const lines = [ + `# CodeClone MCP Help: ${topic}`, + "", + payload.summary || "", + "", + "## Key points", + markdownBulletList(safeArray(payload.key_points)), + "", + "## Recommended tools", + markdownBulletList(safeArray(payload.recommended_tools).map((tool) => `\`${tool}\``)), + ]; + const warnings = safeArray(payload.warnings); + if (warnings.length > 0) { + lines.push("", "## Warnings", markdownBulletList(warnings)); + } + const antiPatterns = safeArray(payload.anti_patterns); + if (antiPatterns.length > 0) { + lines.push("", "## Anti-patterns", markdownBulletList(antiPatterns)); + } + const docLinks = safeArray(payload.doc_links); + if (docLinks.length > 0) { + lines.push( + "", + "## Docs", + markdownBulletList( + docLinks.map((entry) => `[${entry.title}](${entry.url})`) + ) + ); + } + return lines.join("\n"); +} + +function renderSetupMarkdown() { + return [ + "# Set Up CodeClone MCP", + "", + "The VS Code extension needs a local `codeclone-mcp` launcher.", + "", + "## Recommended install for the preview extension", + "", + "```bash", + "pip install --pre \"codeclone[mcp]\"", + "```", + "", + "## Verify the launcher", + "", + "```bash", + "codeclone-mcp --help", + "```", + "", + "## If CodeClone lives in a custom environment", + "", + "- Set `codeclone.mcp.command` to the launcher you want VS Code to use.", + "- Set `codeclone.mcp.args` if that launcher needs extra arguments.", + "- In the CodeClone repository itself, the extension can also fall back to `uv run codeclone-mcp`.", + "", + "## What the extension expects", + "", + "- A local `codeclone-mcp` command, or an explicit custom launcher in settings.", + "- MCP support installed, not only the base `codeclone` package.", + "", + "Once that is ready, run `Analyze Workspace` again.", + ].join("\n"); +} + +function renderRestrictedModeMarkdown(topic) { + return [ + "# CodeClone: Restricted Mode", + "", + "The workspace is not trusted, so CodeClone keeps local analysis and the local MCP server offline.", + "", + topic + ? `Live MCP help for \`${topic}\` becomes available after workspace trust is granted.` + : "Live MCP help topics become available after workspace trust is granted.", + "", + "## What you can do safely right now", + "", + "- Review installation and setup guidance.", + "- Inspect the extension surface and onboarding text.", + "- Grant workspace trust when you are ready to enable local analysis.", + "", + "## Next step", + "", + "Run `Manage Workspace Trust`, then open the help topic again.", + ].join("\n"); +} + +function renderFindingMarkdown(payload) { + const remediation = safeObject(payload.remediation); + const locations = normalizeLocations(payload.locations); + const spread = safeObject(payload.spread); + const lines = [ + `# ${formatKind(payload.kind)}`, + "", + `- Finding id: \`${payload.id}\``, + `- Severity: ${formatSeverity(payload.severity)}`, + `- Scope: ${payload.scope || "unknown"}`, + `- Priority: ${compactDecimal(payload.priority)}`, + `- Count: ${payload.count || 0}`, + `- Spread: ${spread.files || 0} files / ${spread.functions || 0} functions`, + ]; + if (locations.length > 0) { + lines.push( + "", + "## Locations", + markdownBulletList( + locations.map((location) => { + const range = + location.line !== null && location.end_line !== null + ? `${location.line}-${location.end_line}` + : location.line !== null + ? `${location.line}` + : "?"; + const symbol = location.symbol ? ` — \`${location.symbol}\`` : ""; + return `\`${location.path}:${range}\`${symbol}`; + }) + ) + ); + } + if (Object.keys(remediation).length > 0) { + lines.push("", "## Remediation"); + if (remediation.shape) { + lines.push("", remediation.shape); + } + if (remediation.why_now) { + lines.push("", `Why now: ${remediation.why_now}`); + } + if (remediation.effort || remediation.risk) { + lines.push( + "", + `Effort: ${remediation.effort || "unknown"} · Risk: ${remediation.risk || "unknown"}` + ); + } + const steps = safeArray(remediation.steps); + if (steps.length > 0) { + lines.push("", "### Steps", markdownBulletList(steps)); + } + } + return lines.join("\n"); +} + +function renderRemediationMarkdown(payload) { + const remediation = safeObject(payload.remediation); + const lines = [ + `# Remediation: \`${payload.finding_id}\``, + "", + ]; + if (remediation.shape) { + lines.push(remediation.shape, ""); + } + lines.push( + `- Effort: ${remediation.effort || "unknown"}`, + `- Risk: ${remediation.risk || "unknown"}` + ); + if (remediation.why_now) { + lines.push("", `Why now: ${remediation.why_now}`); + } + const steps = safeArray(remediation.steps); + if (steps.length > 0) { + lines.push("", "## Steps", markdownBulletList(steps)); + } + return lines.join("\n"); +} + +function renderTriageMarkdown(state) { + const summary = safeObject(state.latestSummary); + const triage = safeObject(state.latestTriage); + const health = safeObject(summary.health); + const findings = safeObject(summary.findings); + const triageFindings = safeObject(triage.findings); + const topHotspots = safeObject(triage.top_hotspots); + const topSuggestions = safeObject(triage.top_suggestions); + const items = safeArray(topHotspots.items); + const suggestions = safeArray(topSuggestions.items); + const lines = [ + "# CodeClone Production Triage", + "", + `- Run: \`${state.currentRunId || "n/a"}\``, + `- Workspace: \`${state.folder.name}\``, + `- Health: ${health.score || 0}/${health.grade || "?"}`, + `- Findings: ${findings.total || 0} total · ${findings.production || 0} production`, + `- Source kinds: ${formatSourceKindSummary(triageFindings.by_source_kind)}`, + ]; + if (items.length > 0) { + lines.push( + "", + "## Top production hotspots", + markdownBulletList( + items.map( + (item) => + `\`${item.id}\` — ${formatKind(item.kind)} · ${formatSeverity( + item.severity + )} · ${item.scope || "unknown"} · priority ${compactDecimal(item.priority)}` + ) + ) + ); + } else { + lines.push("", "## Top production hotspots", "", "None."); + } + if (suggestions.length > 0) { + lines.push( + "", + "## Top suggestions", + markdownBulletList( + suggestions.map((item) => `\`${item.id}\` — ${item.summary || "Suggestion"}`) + ) + ); + } + return lines.join("\n"); +} + +function renderOverloadedModuleMarkdown(item) { + const reasons = safeArray(item.candidate_reasons); + const lines = [ + "# Overloaded Module Candidate", + "", + `- Path: \`${item.path}\``, + `- Module: \`${item.module}\``, + `- Source kind: ${item.source_kind || "unknown"}`, + `- Score: ${decimal(item.score)}`, + `- LOC: ${number(item.loc)}`, + `- Callables: ${item.callable_count || 0}`, + `- Complexity total / max: ${item.complexity_total || 0} / ${item.complexity_max || 0}`, + `- Fan-in / fan-out: ${item.fan_in || 0} / ${item.fan_out || 0}`, + `- Total dependencies: ${item.total_deps || 0}`, + `- Import edges / reimport edges: ${item.import_edges || 0} / ${item.reimport_edges || 0}`, + `- Reimport ratio: ${decimal(item.reimport_ratio)}`, + `- Instability: ${decimal(item.instability)}`, + `- Hub balance: ${decimal(item.hub_balance)}`, + ]; + if (reasons.length > 0) { + lines.push("", "## Candidate reasons", markdownBulletList(reasons)); + } + return lines.join("\n"); +} + +module.exports = { + markdownBulletList, + renderFindingMarkdown, + renderOverloadedModuleMarkdown, + renderHelpMarkdown, + renderRemediationMarkdown, + renderRestrictedModeMarkdown, + renderSetupMarkdown, + renderTriageMarkdown, +}; diff --git a/extensions/vscode-codeclone/src/runtime.js b/extensions/vscode-codeclone/src/runtime.js new file mode 100644 index 0000000..af0726f --- /dev/null +++ b/extensions/vscode-codeclone/src/runtime.js @@ -0,0 +1,84 @@ +"use strict"; + +const { execFile } = require("node:child_process"); +const fs = require("node:fs/promises"); +const path = require("node:path"); + +function execFilePromise(command, args, options) { + return new Promise((resolve, reject) => { + execFile(command, args, options, (error, stdout, stderr) => { + if (error) { + reject(error); + return; + } + resolve({ stdout, stderr }); + }); + }); +} + +async function gitStdout(cwd, args) { + try { + const result = await execFilePromise("git", args, { + cwd, + maxBuffer: 1024 * 1024, + }); + return String(result.stdout || "").trim(); + } catch { + return null; + } +} + +async function captureWorkspaceGitSnapshot(folder) { + const cwd = folder.uri.fsPath; + const [head, status] = await Promise.all([ + gitStdout(cwd, ["rev-parse", "HEAD"]), + gitStdout(cwd, ["status", "--porcelain=v1", "--untracked-files=normal"]), + ]); + return { + head, + dirtySignature: status || "", + }; +} + +function sameGitSnapshot(left, right) { + return ( + (left?.head || null) === (right?.head || null) && + (left?.dirtySignature || "") === (right?.dirtySignature || "") + ); +} + +async function pathExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function looksLikeCodeCloneRepo(folderPath) { + const [hasPyproject, hasServer] = await Promise.all([ + pathExists(path.join(folderPath, "pyproject.toml")), + pathExists(path.join(folderPath, "codeclone", "mcp_server.py")), + ]); + return hasPyproject && hasServer; +} + +async function readFileHead(filePath, maxBytes = 16384) { + const handle = await fs.open(filePath, "r"); + try { + const buffer = Buffer.allocUnsafe(maxBytes); + const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0); + return buffer.toString("utf8", 0, bytesRead); + } finally { + await handle.close(); + } +} + +module.exports = { + captureWorkspaceGitSnapshot, + looksLikeCodeCloneRepo, + pathExists, + readFileHead, + sameGitSnapshot, +}; diff --git a/extensions/vscode-codeclone/test/runExtensionHost.js b/extensions/vscode-codeclone/test/runExtensionHost.js index ad9c66e..8987b4e 100644 --- a/extensions/vscode-codeclone/test/runExtensionHost.js +++ b/extensions/vscode-codeclone/test/runExtensionHost.js @@ -11,7 +11,11 @@ function resolveVsCodeCli() { "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", "/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code", ].filter(Boolean); - return candidates.find((candidate) => fs.existsSync(candidate)) || null; + return ( + candidates.find( + (candidate) => typeof candidate === "string" && fs.existsSync(candidate) + ) || null + ); } async function main() { @@ -52,7 +56,7 @@ async function main() { child.once("error", reject); child.once("exit", (code) => { if (code === 0) { - resolve(); + resolve(undefined); return; } reject(new Error(`VS Code extension host tests exited with code ${code}.`)); diff --git a/tests/test_cli_inprocess.py b/tests/test_cli_inprocess.py index 13f61a4..677038f 100644 --- a/tests/test_cli_inprocess.py +++ b/tests/test_cli_inprocess.py @@ -3308,7 +3308,7 @@ def test_cli_summary_format_stable( assert "Summary" in out assert out.count("Summary") == 1 assert "Metrics" in out - assert "God Modules" in out + assert "Overloaded" in out assert "callables" in out assert "Files parsed" not in out assert "Input" not in out diff --git a/tests/test_cli_unit.py b/tests/test_cli_unit.py index fe80c1d..8ea712b 100644 --- a/tests/test_cli_unit.py +++ b/tests/test_cli_unit.py @@ -910,10 +910,10 @@ def test_compact_summary_labels_use_machine_scannable_keys() -> None: dead=1, health=85, grade="B", - god_modules=3, + overloaded_modules=3, ) == "Metrics cc=2.8/21 cbo=0.6/8 lcom4=1.2/4" - " cycles=0 dead_code=1 health=85(B) god_modules=3" + " cycles=0 dead_code=1 health=85(B) overloaded_modules=3" ) @@ -944,24 +944,24 @@ def test_ui_summary_formatters_cover_optional_branches() -> None: clean_with_suppressed = ui.fmt_metrics_dead_code(0, suppressed=9) assert "✔ clean" in clean_with_suppressed assert "(9 suppressed)" in clean_with_suppressed - god_modules = ui.fmt_metrics_god_modules( + overloaded_modules = ui.fmt_metrics_overloaded_modules( candidates=4, total=158, population_status="ok", top_score=0.98, ) assert all( - fragment in god_modules + fragment in overloaded_modules for fragment in ("4", "max score 0.98", "158 ranked", "(report-only)") ) - limited_god_modules = ui.fmt_metrics_god_modules( + limited_overloaded_modules = ui.fmt_metrics_overloaded_modules( candidates=0, total=12, population_status="limited", top_score=0.0, ) - assert "12 ranked" in limited_god_modules - assert "report-only; limited population" in limited_god_modules + assert "12 ranked" in limited_overloaded_modules + assert "report-only; limited population" in limited_overloaded_modules changed_paths = ui.fmt_changed_scope_paths(count=45) assert "45" in changed_paths assert "from git diff" in changed_paths @@ -1023,7 +1023,7 @@ def test_print_changed_scope_uses_compact_line_in_quiet_mode( assert "known=5" in out -def test_print_metrics_in_quiet_mode_includes_god_modules( +def test_print_metrics_in_quiet_mode_includes_overloaded_modules( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.setattr(cli, "console", cli._make_console(no_color=True)) @@ -1042,14 +1042,14 @@ def test_print_metrics_in_quiet_mode_includes_god_modules( dead_code_count=0, health_total=85, health_grade="B", - god_modules_candidates=3, - god_modules_total=158, - god_modules_population_status="ok", - god_modules_top_score=0.98, + overloaded_modules_candidates=3, + overloaded_modules_total=158, + overloaded_modules_population_status="ok", + overloaded_modules_top_score=0.98, ), ) out = capsys.readouterr().out - assert "god_modules=3" in out + assert "overloaded_modules=3" in out def test_configure_metrics_mode_rejects_skip_metrics_with_metrics_flags( diff --git a/tests/test_html_report.py b/tests/test_html_report.py index e08a0e7..afee155 100644 --- a/tests/test_html_report.py +++ b/tests/test_html_report.py @@ -1568,7 +1568,7 @@ def _metrics_payload( "grade": health_grade, "dimensions": {"coverage": 99}, }, - "god_modules": { + "overloaded_modules": { "summary": { "total": 1, "candidates": 0, @@ -1653,7 +1653,7 @@ def test_html_report_metrics_risk_branches() -> None: ) -def test_html_report_renders_god_modules_in_quality_and_overview() -> None: +def test_html_report_renders_overloaded_modules_in_quality_and_overview() -> None: payload = _metrics_payload( health_score=72, health_grade="B", @@ -1666,9 +1666,9 @@ def test_html_report_renders_god_modules_in_quality_and_overview() -> None: dead_total=1, dead_critical=1, ) - god_modules = payload["god_modules"] - assert isinstance(god_modules, dict) - god_modules["summary"] = { + overloaded_modules = payload["overloaded_modules"] + assert isinstance(overloaded_modules, dict) + overloaded_modules["summary"] = { "total": 3, "candidates": 1, "population_status": "ok", @@ -1676,7 +1676,7 @@ def test_html_report_renders_god_modules_in_quality_and_overview() -> None: "average_score": 0.42, "candidate_score_cutoff": 0.88, } - god_modules["items"] = [ + overloaded_modules["items"] = [ { "module": "pkg.hub", "relative_path": "pkg/hub.py", @@ -1719,17 +1719,44 @@ def test_html_report_renders_god_modules_in_quality_and_overview() -> None: _assert_html_contains( html, - "God Modules", + "Overloaded Modules", "pkg.hub", - "Candidate profile", + "Top candidates", "0.93", - "hub-like shape", - "god-modules", + "overloaded-modules", ) + assert "hub-like shape" not in html assert "Candidate cutoff" not in html assert "Ranked modules" not in html +def test_html_report_renders_overloaded_modules_from_legacy_god_modules_key() -> None: + payload = _metrics_payload( + health_score=72, + health_grade="B", + complexity_max=25, + complexity_high_risk=1, + coupling_high_risk=1, + cohesion_low=1, + dep_cycles=[], + dep_max_depth=4, + dead_total=1, + dead_critical=1, + ) + legacy_overloaded_modules = payload.pop("overloaded_modules") + payload["god_modules"] = legacy_overloaded_modules + + html = build_html_report( + func_groups={}, + block_groups={}, + segment_groups={}, + report_meta={"scan_root": "/outside/project"}, + metrics=payload, + ) + + _assert_html_contains(html, "Overloaded Modules") + + def test_html_report_renders_run_snapshot_from_canonical_inventory() -> None: metrics = _metrics_payload( health_score=82, @@ -1779,15 +1806,26 @@ def test_html_report_renders_run_snapshot_from_canonical_inventory() -> None: report_document=report_document, ) + inventory = cast(dict[str, object], report_document["inventory"]) + files_inventory = cast(dict[str, object], inventory["files"]) + code_inventory = cast(dict[str, object], inventory["code"]) + total_found = cast(int, files_inventory["total_found"]) + parsed_lines = cast(int, code_inventory["parsed_lines"]) + functions = cast(int, code_inventory["functions"]) + methods = cast(int, code_inventory["methods"]) + classes = cast(int, code_inventory["classes"]) + expected_summary = ( + f"{total_found} files \u00b7 " + f"{parsed_lines:,} lines \u00b7 " + f"{functions + methods} callables \u00b7 " + f"{classes} classes" + ) _assert_html_contains( html, - "Scan scope", - "Parsed lines", - "Callables", - "Cached files", - "22,320", - "158 found · 120 analyzed · 38 cached · 3 skipped", + "Executive Summary", + expected_summary, ) + assert "Scan scope" not in html def test_html_report_metrics_without_health_score_uses_info_overview() -> None: diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index b69c51d..648d72f 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -369,7 +369,19 @@ def test_mcp_server_tool_roundtrip_and_resources(tmp_path: Path) -> None: ) ) ) - god_modules_page = _structured_tool_result( + overloaded_modules_page = _structured_tool_result( + asyncio.run( + server.call_tool( + "get_report_section", + { + "section": "metrics_detail", + "family": "overloaded_modules", + "limit": 5, + }, + ) + ) + ) + overloaded_modules_alias_page = _structured_tool_result( asyncio.run( server.call_tool( "get_report_section", @@ -378,17 +390,21 @@ def test_mcp_server_tool_roundtrip_and_resources(tmp_path: Path) -> None: ) ) assert cast("list[dict[str, object]]", metrics_detail_page["items"]) - assert god_modules_page["family"] == "god_modules" + assert overloaded_modules_page["family"] == "overloaded_modules" + assert overloaded_modules_alias_page["family"] == "overloaded_modules" + assert overloaded_modules_alias_page["items"] == overloaded_modules_page["items"] report_metrics = cast("dict[str, object]", report_payload["metrics"]) report_families = cast("dict[str, object]", report_metrics["families"]) - report_god_modules = cast("dict[str, object]", report_families["god_modules"]) - report_god_module_items = cast( + report_overloaded_modules = cast( + "dict[str, object]", report_families["overloaded_modules"] + ) + report_overloaded_module_items = cast( "list[dict[str, object]]", - report_god_modules["items"], + report_overloaded_modules["items"], ) assert ( - cast("list[dict[str, object]]", god_modules_page["items"])[0]["path"] - == report_god_module_items[0]["relative_path"] + cast("list[dict[str, object]]", overloaded_modules_page["items"])[0]["path"] + == report_overloaded_module_items[0]["relative_path"] ) changed_section = _structured_tool_result( asyncio.run(server.call_tool("get_report_section", {"section": "changed"})) diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py index 28df851..37fb78d 100644 --- a/tests/test_mcp_service.py +++ b/tests/test_mcp_service.py @@ -867,7 +867,7 @@ def test_mcp_service_metrics_sections_split_summary_and_detail( "cohesion", "dependencies", "dead_code", - "god_modules", + "overloaded_modules", "health", } assert "families" not in metrics_summary @@ -875,26 +875,44 @@ def test_mcp_service_metrics_sections_split_summary_and_detail( assert set(metrics_detail) == {"summary", "_hint"} assert "family" in metrics_detail_page assert cast("list[dict[str, object]]", metrics_detail_page["items"]) - god_modules_page = service.get_report_section( + overloaded_modules_page = service.get_report_section( + run_id=run_id, + section="metrics_detail", + family="overloaded_modules", + limit=5, + ) + assert overloaded_modules_page["family"] == "overloaded_modules" + overloaded_modules_items = cast( + "list[dict[str, object]]", overloaded_modules_page["items"] + ) + assert overloaded_modules_items + overloaded_modules_alias_page = service.get_report_section( run_id=run_id, section="metrics_detail", family="god_modules", limit=5, ) - assert god_modules_page["family"] == "god_modules" - god_modules_items = cast("list[dict[str, object]]", god_modules_page["items"]) - assert god_modules_items + assert overloaded_modules_alias_page["family"] == "overloaded_modules" + assert ( + cast("list[dict[str, object]]", overloaded_modules_alias_page["items"]) + == overloaded_modules_items + ) report_record = service._runs.get(run_id) assert report_record is not None report_document = report_record.report_document metrics_map = cast("dict[str, object]", report_document["metrics"]) families_map = cast("dict[str, object]", metrics_map["families"]) - god_modules_family = cast("dict[str, object]", families_map["god_modules"]) - god_modules_report_items = cast( + overloaded_modules_family = cast( + "dict[str, object]", families_map["overloaded_modules"] + ) + overloaded_modules_report_items = cast( "list[dict[str, object]]", - god_modules_family["items"], + overloaded_modules_family["items"], + ) + assert ( + overloaded_modules_items[0]["path"] + == overloaded_modules_report_items[0]["relative_path"] ) - assert god_modules_items[0]["path"] == god_modules_report_items[0]["relative_path"] def test_mcp_service_evaluate_gates_on_existing_run(tmp_path: Path) -> None: @@ -3234,11 +3252,11 @@ def test_mcp_service_summary_and_metrics_detail_helper_fallbacks( } ], } - god_modules_payload = service._metrics_detail_payload( + overloaded_modules_payload = service._metrics_detail_payload( metrics={ "summary": {}, "families": { - "god_modules": { + "overloaded_modules": { "items": [ { "relative_path": "zeta.py", @@ -3256,13 +3274,13 @@ def test_mcp_service_summary_and_metrics_detail_helper_fallbacks( } }, }, - family="god_modules", + family="overloaded_modules", path=None, offset=0, limit=5, ) - assert god_modules_payload == { - "family": "god_modules", + assert overloaded_modules_payload == { + "family": "overloaded_modules", "path": None, "offset": 0, "limit": 5, diff --git a/tests/test_pipeline_metrics.py b/tests/test_pipeline_metrics.py index 8d51320..324b3ad 100644 --- a/tests/test_pipeline_metrics.py +++ b/tests/test_pipeline_metrics.py @@ -7,7 +7,7 @@ from __future__ import annotations from codeclone.cache import CacheEntry -from codeclone.metrics import build_god_modules_payload +from codeclone.metrics import build_overloaded_modules_payload from codeclone.models import ( ClassMetrics, DeadCandidate, @@ -179,9 +179,7 @@ def test_build_metrics_report_payload_includes_suppressed_dead_code_items() -> N ] -def test_build_metrics_report_payload_includes_god_modules_for_small_population() -> ( - None -): +def test_metrics_payload_includes_overloaded_modules_for_small_population() -> None: payload = build_metrics_report_payload( scan_root="/repo", project_metrics=_project_metrics(dead_confidence="high"), @@ -213,16 +211,16 @@ def test_build_metrics_report_payload_includes_god_modules_for_small_population( suppressed_dead_code=(), ) - god_modules = payload["god_modules"] - assert isinstance(god_modules, dict) - summary = god_modules["summary"] + overloaded_modules = payload["overloaded_modules"] + assert isinstance(overloaded_modules, dict) + summary = overloaded_modules["summary"] assert summary["total"] == 2 assert summary["candidates"] == 0 assert summary["population_status"] == "limited" assert summary["top_score"] >= summary["average_score"] >= 0.0 assert summary["candidate_score_cutoff"] <= 1.0 assert summary["candidate_score_cutoff"] >= summary["top_score"] - items = god_modules["items"] + items = overloaded_modules["items"] assert [item["module"] for item in items] == ["pkg.alpha", "tests.test_beta"] assert items[0]["candidate_status"] == "ranked_only" assert items[0]["candidate_reasons"] == ["size_pressure", "dependency_pressure"] @@ -232,7 +230,7 @@ def test_build_metrics_report_payload_includes_god_modules_for_small_population( assert items[1]["source_kind"] == "tests" -def test_build_god_modules_payload_flags_project_relative_candidates() -> None: +def test_build_overloaded_modules_payload_flags_project_relative_candidates() -> None: scan_root = "/repo" source_stats = [ (f"{scan_root}/pkg/core.py", 2000, 24, 4, 2), @@ -277,7 +275,7 @@ def test_build_god_modules_payload_flags_project_relative_candidates() -> None: ), ] - payload = build_god_modules_payload( + payload = build_overloaded_modules_payload( scan_root=scan_root, source_stats_by_file=source_stats, units=units, diff --git a/tests/test_report_contract_coverage.py b/tests/test_report_contract_coverage.py index 0c09896..24f9215 100644 --- a/tests/test_report_contract_coverage.py +++ b/tests/test_report_contract_coverage.py @@ -354,7 +354,7 @@ def _rich_report_document() -> dict[str, object]: }, } }, - "god_modules": { + "overloaded_modules": { "summary": { "total": 2, "candidates": 1, @@ -1202,30 +1202,30 @@ def test_markdown_render_long_list_branches() -> None: assert "... and 2 more item(s)" in markdown -def test_report_contract_renderers_include_god_modules_section() -> None: +def test_report_contract_renderers_include_overloaded_modules_section() -> None: payload = _rich_report_document() text = render_text_report_document(payload) markdown = render_markdown_report_document(payload) - assert "GOD MODULES (top 10)" in text + assert "OVERLOADED MODULES (top 10)" in text assert "module=codeclone.alpha" in text - assert '' in markdown - assert "### God Modules" in markdown + assert '' in markdown + assert "### Overloaded Modules" in markdown assert "candidate_status=candidate" in markdown -def test_report_contract_includes_canonical_god_modules_family() -> None: +def test_report_contract_includes_canonical_overloaded_modules_family() -> None: payload = _rich_report_document() metrics = cast(dict[str, object], payload["metrics"]) summary = cast(dict[str, object], metrics["summary"]) families = cast(dict[str, object], metrics["families"]) - god_modules = cast(dict[str, object], families["god_modules"]) - god_summary = cast(dict[str, object], god_modules["summary"]) + overloaded_modules = cast(dict[str, object], families["overloaded_modules"]) + overloaded_summary = cast(dict[str, object], overloaded_modules["summary"]) - assert summary["god_modules"] == god_summary - assert god_summary == { + assert summary["overloaded_modules"] == overloaded_summary + assert overloaded_summary == { "total": 2, "candidates": 1, "population_status": "ok", @@ -1233,7 +1233,7 @@ def test_report_contract_includes_canonical_god_modules_family() -> None: "average_score": 0.58, "candidate_score_cutoff": 0.91, } - first = cast(list[dict[str, object]], god_modules["items"])[0] + first = cast(list[dict[str, object]], overloaded_modules["items"])[0] assert first["module"] == "codeclone.alpha" assert first["relative_path"] == "codeclone/alpha.py" assert first["candidate_status"] == "candidate" @@ -1889,7 +1889,7 @@ def test_collect_paths_from_metrics_covers_all_metric_families_and_skips_missing {"filepath": None}, ], }, - "god_modules": { + "overloaded_modules": { "items": [ {"filepath": "/repo/god.py"}, {"filepath": ""}, diff --git a/uv.lock b/uv.lock index d345f73..4443912 100644 --- a/uv.lock +++ b/uv.lock @@ -266,14 +266,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.1" +version = "8.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, ] [[package]] @@ -1749,16 +1749,16 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.42.0" +version = "0.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/f2/368268300fb8af33743508d738ef7bb4d56afdb46c6d9c0fa3dd515df171/uvicorn-0.43.0.tar.gz", hash = "sha256:ab1652d2fb23abf124f36ccc399828558880def222c3cb3d98d24021520dc6e8", size = 85686, upload-time = "2026-04-03T18:37:48.984Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, + { url = "https://files.pythonhosted.org/packages/55/df/0cf5b0c451602748fdc7a702d4667f6e209bf96aa6e3160d754234445f2a/uvicorn-0.43.0-py3-none-any.whl", hash = "sha256:46fac64f487fd968cd999e5e49efbbe64bd231b5bd8b4a0b482a23ebce499620", size = 68591, upload-time = "2026-04-03T18:37:47.64Z" }, ] [[package]] From 1b5558e52dc70e5f73056047304325c2312b299e Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Sat, 4 Apr 2026 22:32:35 +0500 Subject: [PATCH 07/15] feat(vscode): add profile-aware review workflows and harden the VS Code extension lifecycle - add conservative, deeper-review, and custom analysis profiles to the VS Code extension and pass them through to CodeClone MCP - improve review UX with clearer analysis-depth affordances, conservative-first guidance, and profile-aware overview/session state - harden extension lifecycle and resource handling with safer shutdown, single-flight MCP connection reuse, and cleanup of workspace/session state - expand extension-side regression coverage and refresh VS Code extension docs to match the current behavior --- README.md | 2 + docs/book/21-vscode-extension.md | 10 +- docs/vscode-extension.md | 20 +- extensions/vscode-codeclone/README.md | 13 + extensions/vscode-codeclone/package.json | 75 +++- extensions/vscode-codeclone/src/constants.js | 24 ++ extensions/vscode-codeclone/src/extension.js | 341 +++++++++++++++++- extensions/vscode-codeclone/src/mcpClient.js | 54 ++- extensions/vscode-codeclone/src/providers.js | 1 + extensions/vscode-codeclone/src/renderers.js | 3 +- extensions/vscode-codeclone/src/support.js | 139 +++++++ .../test/extensionHost/index.js | 4 + .../vscode-codeclone/test/mcpClient.test.js | 35 ++ .../vscode-codeclone/test/support.test.js | 89 +++++ 14 files changed, 773 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 6d3dd5c..dc508f9 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,8 @@ A preview VS Code extension ships in [`extensions/vscode-codeclone/`](https://github.com/orenlab/codeclone/tree/main/extensions/vscode-codeclone). It connects to `codeclone-mcp` and provides triage-first structural review inside the editor: overview, hotspots, review loop, and drill-down into findings or the HTML report. +It starts with a conservative first pass and can switch to an explicit +higher-sensitivity analysis profile for deeper local review. Docs: [VS Code extension guide](https://orenlab.github.io/codeclone/vscode-extension/) diff --git a/docs/book/21-vscode-extension.md b/docs/book/21-vscode-extension.md index 301ab41..36cb032 100644 --- a/docs/book/21-vscode-extension.md +++ b/docs/book/21-vscode-extension.md @@ -61,8 +61,9 @@ The intended IDE path mirrors CodeClone MCP: 1. `Analyze Workspace` or `Review Changes` 2. compact overview and priority review 3. review new regressions or production hotspots -4. reveal source -5. open canonical finding or remediation only when needed +4. use `Analysis Depth` only when you need a higher-sensitivity follow-up +5. reveal source +6. open canonical finding or remediation only when needed This is deliberately different from a lint-list model. The extension should prefer guided review over broad enumeration. @@ -73,6 +74,8 @@ The extension currently supports: - full-workspace analysis - changed-files analysis against a configured git diff reference +- conservative default analysis with an explicit deeper-review or custom-threshold + follow-up profile - compact overview of structural health, current run state, and baseline drift - review queues for new regressions, production hotspots, changed-scope findings, and report-only `Overloaded Modules` @@ -127,6 +130,9 @@ For this reason: - **Native VS Code first**: tree views, status bar, Quick Pick, CodeLens, and file decorations before any custom UI. +- **Conservative by default**: the extension starts with repo defaults or + `pyproject`-resolved thresholds and treats lower-threshold analysis as an + explicit exploratory follow-up. - **Source-first**: findings prefer `Reveal Source` over detail panels; canonical detail and HTML report bridge are opt-in. - **Report-only separation**: Overloaded Modules stay visually distinct from diff --git a/docs/vscode-extension.md b/docs/vscode-extension.md index f0cf3b5..77b488d 100644 --- a/docs/vscode-extension.md +++ b/docs/vscode-extension.md @@ -12,6 +12,8 @@ The extension helps you: - analyze the current workspace - review changed files against a git diff +- start with a conservative first pass and lower thresholds only when you need + a more sensitive follow-up - focus on new regressions and production hotspots first - jump directly to source locations - open canonical finding or remediation detail only when needed @@ -85,7 +87,9 @@ not as the primary IDE workflow. 1. Open the `CodeClone` view container. 2. Run `Analyze Workspace`. 3. Use `Review Priorities` or `Review Changes`. -4. Reveal source before opening deeper detail. +4. If the first pass looks clean but you want smaller repeated units, open + `Analysis Depth`. +5. Reveal source before opening deeper detail. If the launcher is missing, use `Setup Help` from the extension. @@ -106,6 +110,7 @@ the local MCP launcher. - native VS Code views first, not a custom report dashboard - baseline-aware review instead of broad lint-style listing +- conservative first pass by default; deeper sensitivity must stay explicit - report-only layers stay visually separate from findings and health - repository truth stays in CodeClone MCP and canonical report semantics @@ -117,6 +122,19 @@ the local MCP launcher. - `Open in HTML Report` opens a local HTML report only when it exists and looks fresh enough for the current run +## Settings that shape analysis depth + +- `codeclone.analysis.profile` keeps the default conservative first pass + explicit and exposes `Deeper review` and `Custom` as deliberate follow-ups +- `codeclone.analysis.minLoc` +- `codeclone.analysis.minStmt` +- `codeclone.analysis.blockMinLoc` +- `codeclone.analysis.blockMinStmt` +- `codeclone.analysis.segmentMinLoc` +- `codeclone.analysis.segmentMinStmt` + +Custom thresholds apply only when the profile is set to `custom`. + ## Source of truth The extension reads the same canonical analysis semantics already exposed by: diff --git a/extensions/vscode-codeclone/README.md b/extensions/vscode-codeclone/README.md index 72acc6d..bdb8730 100644 --- a/extensions/vscode-codeclone/README.md +++ b/extensions/vscode-codeclone/README.md @@ -16,6 +16,7 @@ CodeClone inside VS Code is designed for: - triage-first structural review - changed-files review against the current diff +- conservative first-pass analysis with an explicit deeper-review follow-up - baseline-aware distinction between known debt and new regressions - guided drill-down from hotspot to source, finding detail, and remediation - lightweight code navigation without turning the sidebar into a second report app @@ -63,6 +64,8 @@ codeclone-mcp --help 2. Open the `CodeClone` view container. 3. Run `Analyze Workspace`. 4. Use `Review Priorities` or `Review Changes` as the first pass. +5. If the first pass looks clean but you want smaller repeated units, open + `Analysis Depth`. If the local launcher is missing, use `Setup Help` from the view or command palette. @@ -165,6 +168,16 @@ Default cache policy for analysis requests. Git revision used by `Review Changes`. +### `codeclone.analysis.profile` + +Keeps the default conservative pass explicit and exposes `Deeper review` or +`Custom` only as deliberate higher-sensitivity follow-ups. + +### `codeclone.analysis.minLoc` and related threshold settings + +Function, block, and segment thresholds used only when +`codeclone.analysis.profile` is set to `custom`. + ### `codeclone.ui.showStatusBar` Show or hide the workspace-level status bar item. diff --git a/extensions/vscode-codeclone/package.json b/extensions/vscode-codeclone/package.json index 3b503be..8394960 100644 --- a/extensions/vscode-codeclone/package.json +++ b/extensions/vscode-codeclone/package.json @@ -66,6 +66,7 @@ "onCommand:codeclone.connectMcp", "onCommand:codeclone.analyzeWorkspace", "onCommand:codeclone.analyzeChangedFiles", + "onCommand:codeclone.setAnalysisProfile", "onCommand:codeclone.refreshCurrentRun", "onCommand:codeclone.openProductionTriage", "onCommand:codeclone.reviewPriorityQueue", @@ -119,12 +120,12 @@ { "view": "codeclone.overview", "when": "isWorkspaceTrusted && !codeclone.connected && !codeclone.hasRun", - "contents": "Start with [Analyze Workspace](command:codeclone.analyzeWorkspace) to create the first structural run for this workspace.\n\nUse [Review Changes](command:codeclone.analyzeChangedFiles) when you want a diff-focused pass.\n\nNeed local setup? Open [Setup Help](command:codeclone.openSetupHelp).\n\nUse [Verify Local Server](command:codeclone.connectMcp) only when you want to check the launcher manually." + "contents": "Start with [Analyze Workspace](command:codeclone.analyzeWorkspace) for a conservative first structural pass.\n\nUse [Review Changes](command:codeclone.analyzeChangedFiles) when you want a diff-focused pass, and [Analysis Depth](command:codeclone.setAnalysisProfile) only when you need a higher-sensitivity follow-up.\n\nNeed local setup? Open [Setup Help](command:codeclone.openSetupHelp).\n\nUse [Verify Local Server](command:codeclone.connectMcp) only when you want to check the launcher manually." }, { "view": "codeclone.overview", "when": "isWorkspaceTrusted && codeclone.connected && !codeclone.hasRun", - "contents": "CodeClone is ready for this workspace. Run [Analyze Workspace](command:codeclone.analyzeWorkspace) for a full structural pass, or [Review Changes](command:codeclone.analyzeChangedFiles) for a diff-focused review.\n\nNeed a quick mental model? Open [workflow help](command:codeclone.showHelpTopic)." + "contents": "CodeClone is ready for this workspace. Run [Analyze Workspace](command:codeclone.analyzeWorkspace) for a conservative full pass, or [Review Changes](command:codeclone.analyzeChangedFiles) for a diff-focused review.\n\nIf the first pass looks clean but you want smaller units, open [Analysis Depth](command:codeclone.setAnalysisProfile).\n\nNeed a quick mental model? Open [workflow help](command:codeclone.showHelpTopic)." }, { "view": "codeclone.hotspots", @@ -171,6 +172,12 @@ "category": "CodeClone", "icon": "$(git-commit)" }, + { + "command": "codeclone.setAnalysisProfile", + "title": "Analysis Depth", + "category": "CodeClone", + "icon": "$(settings-gear)" + }, { "command": "codeclone.refreshCurrentRun", "title": "Refresh", @@ -393,15 +400,20 @@ "when": "view == codeclone.overview && isWorkspaceTrusted && codeclone.hasRun", "group": "navigation@3" }, + { + "command": "codeclone.setAnalysisProfile", + "when": "view == codeclone.overview && isWorkspaceTrusted", + "group": "secondary@1" + }, { "command": "codeclone.reviewPriorityQueue", "when": "view == codeclone.overview && isWorkspaceTrusted && codeclone.hasRun", - "group": "secondary@1" + "group": "secondary@2" }, { "command": "codeclone.connectMcp", "when": "view == codeclone.overview && isWorkspaceTrusted && !codeclone.connected", - "group": "secondary@2" + "group": "secondary@3" }, { "command": "codeclone.manageWorkspaceTrust", @@ -428,15 +440,20 @@ "when": "view == codeclone.hotspots && isWorkspaceTrusted && codeclone.hasRun", "group": "navigation@3" }, + { + "command": "codeclone.setAnalysisProfile", + "when": "view == codeclone.hotspots && isWorkspaceTrusted", + "group": "secondary@1" + }, { "command": "codeclone.analyzeChangedFiles", "when": "view == codeclone.hotspots && isWorkspaceTrusted && codeclone.hasRun", - "group": "secondary@1" + "group": "secondary@2" }, { "command": "codeclone.openProductionTriage", "when": "view == codeclone.hotspots && isWorkspaceTrusted && codeclone.hasRun", - "group": "secondary@2" + "group": "secondary@3" }, { "command": "codeclone.analyzeWorkspace", @@ -641,6 +658,52 @@ "default": "HEAD", "description": "Git revision used for changed-files analysis." }, + "codeclone.analysis.profile": { + "type": "string", + "enum": [ + "defaults", + "deeperReview", + "custom" + ], + "default": "defaults", + "description": "Analysis sensitivity profile. Start with conservative defaults, then lower thresholds only for an explicit deeper pass." + }, + "codeclone.analysis.minLoc": { + "type": "integer", + "default": 10, + "minimum": 0, + "description": "Custom function minimum LOC. Used only when analysis.profile is set to custom." + }, + "codeclone.analysis.minStmt": { + "type": "integer", + "default": 6, + "minimum": 0, + "description": "Custom function minimum statement count. Used only when analysis.profile is set to custom." + }, + "codeclone.analysis.blockMinLoc": { + "type": "integer", + "default": 20, + "minimum": 0, + "description": "Custom block minimum LOC. Used only when analysis.profile is set to custom." + }, + "codeclone.analysis.blockMinStmt": { + "type": "integer", + "default": 8, + "minimum": 0, + "description": "Custom block minimum statement count. Used only when analysis.profile is set to custom." + }, + "codeclone.analysis.segmentMinLoc": { + "type": "integer", + "default": 20, + "minimum": 0, + "description": "Custom segment minimum LOC. Used only when analysis.profile is set to custom." + }, + "codeclone.analysis.segmentMinStmt": { + "type": "integer", + "default": 10, + "minimum": 0, + "description": "Custom segment minimum statement count. Used only when analysis.profile is set to custom." + }, "codeclone.ui.showStatusBar": { "type": "boolean", "default": true, diff --git a/extensions/vscode-codeclone/src/constants.js b/extensions/vscode-codeclone/src/constants.js index ef5def3..c30c714 100644 --- a/extensions/vscode-codeclone/src/constants.js +++ b/extensions/vscode-codeclone/src/constants.js @@ -2,6 +2,7 @@ const HELP_TOPICS = [ "workflow", + "analysis_profile", "suppressions", "baseline", "latest_runs", @@ -58,6 +59,28 @@ const HOTSPOT_GROUPS_BY_MODE = { all: HOTSPOT_GROUPS.map((group) => group.id), }; +const ANALYSIS_PROFILE_OPTIONS = [ + { + id: "defaults", + label: "Conservative", + description: "Recommended", + detail: "Use repo defaults or pyproject for the first pass.", + }, + { + id: "deeperReview", + label: "Deeper review", + description: "Higher sensitivity", + detail: + "Lower thresholds for a deliberate second pass on smaller repeated units.", + }, + { + id: "custom", + label: "Custom", + description: "Workspace settings", + detail: "Use the explicit function, block, and segment thresholds in settings.", + }, +]; + const REVIEW_DECORATION_THEMES = { new: { badge: "N", @@ -81,6 +104,7 @@ const WORKSPACE_STATE_LAST_HELP_TOPIC = "codeclone.lastHelpTopic"; module.exports = { HELP_TOPICS, + ANALYSIS_PROFILE_OPTIONS, HOTSPOT_GROUPS, HOTSPOT_FOCUS_MODES, HOTSPOT_GROUPS_BY_MODE, diff --git a/extensions/vscode-codeclone/src/extension.js b/extensions/vscode-codeclone/src/extension.js index 0b6a538..7ea68c8 100644 --- a/extensions/vscode-codeclone/src/extension.js +++ b/extensions/vscode-codeclone/src/extension.js @@ -6,6 +6,7 @@ const path = require("node:path"); const vscode = require("vscode"); const { + ANALYSIS_PROFILE_OPTIONS, HELP_TOPICS, HOTSPOT_GROUPS, HOTSPOT_FOCUS_MODES, @@ -68,8 +69,12 @@ const { sameGitSnapshot, } = require("./runtime"); const { + ANALYSIS_PROFILE_CUSTOM, + ANALYSIS_PROFILE_DEFAULTS, STALE_REASON_EDITOR, STALE_REASON_WORKSPACE, + resolveAnalysisSettings, + sameAnalysisSettings, normalizedLaunchSpec, parseUtcTimestamp, resolveWorkspacePath, @@ -81,6 +86,7 @@ const { class CodeCloneController { constructor(context) { this.context = context; + this.disposed = false; this.outputChannel = vscode.window.createOutputChannel("CodeClone"); this.client = new CodeCloneMcpClient(this.outputChannel); this.states = new Map(); @@ -134,7 +140,10 @@ class CodeCloneController { treeDataProvider: this.sessionProvider, showCollapseAll: false, }); - this.client.on("state", (state) => { + this.onClientState = (state) => { + if (this.disposed) { + return; + } this.connectionInfo.connected = Boolean(state.connected); this.connectionInfo.serverInfo = state.connected ? state.serverInfo || null @@ -148,12 +157,17 @@ class CodeCloneController { this.updateContextKeys(); this.updateStatusBar(); this.refreshAllViews(); - }); - this.client.on("exit", async () => { + }; + this.onClientExit = async () => { + if (this.disposed) { + return; + } await vscode.window.showWarningMessage( "The local CodeClone server disconnected. Run Analyze Workspace or Review Changes to reconnect." ); - }); + }; + this.client.on("state", this.onClientState); + this.client.on("exit", this.onClientExit); context.subscriptions.push( this.outputChannel, this.statusBar, @@ -185,12 +199,15 @@ class CodeCloneController { vscode.window.onDidChangeWindowState((state) => this.handleWindowStateChanged(state) ), + vscode.workspace.onDidChangeWorkspaceFolders((event) => + this.handleWorkspaceFoldersChanged(event) + ), vscode.workspace.onDidGrantWorkspaceTrust(() => this.handleWorkspaceTrustGranted() ), { dispose: () => { - void this.client.dispose(); + void this.dispose(); }, } ); @@ -200,6 +217,23 @@ class CodeCloneController { this.updateViewChrome(); } + async dispose() { + if (this.disposed) { + return; + } + this.disposed = true; + if (this.revealDecorationTimeout) { + clearTimeout(this.revealDecorationTimeout); + this.revealDecorationTimeout = null; + } + this.client.off("state", this.onClientState); + this.client.off("exit", this.onClientExit); + this.activeReviewTarget = null; + this.fileDecorations.clear(); + this.states.clear(); + await this.client.dispose({ emitState: false }); + } + registerCommands() { const subscriptions = [ vscode.commands.registerCommand("codeclone.manageWorkspaceTrust", () => @@ -214,6 +248,9 @@ class CodeCloneController { vscode.commands.registerCommand("codeclone.analyzeChangedFiles", (arg) => this.analyzeChangedFiles(arg) ), + vscode.commands.registerCommand("codeclone.setAnalysisProfile", (arg) => + this.setAnalysisProfile(arg) + ), vscode.commands.registerCommand("codeclone.refreshCurrentRun", () => this.refreshCurrentRun() ), @@ -408,6 +445,40 @@ class CodeCloneController { return this.pickWorkspaceFolder(prompt); } + async resolvePreferredFolderFromArg(arg, prompt) { + if (arg && arg.workspaceKey && this.states.has(arg.workspaceKey)) { + return this.states.get(arg.workspaceKey).folder; + } + const preferred = this.getPreferredFolder(); + if (preferred) { + return preferred; + } + const primaryState = this.getPrimaryState(); + if (primaryState) { + return primaryState.folder; + } + return this.pickWorkspaceFolder(prompt); + } + + configurationTarget() { + return (vscode.workspace.workspaceFolders || []).length > 1 + ? vscode.ConfigurationTarget.WorkspaceFolder + : vscode.ConfigurationTarget.Workspace; + } + + configuredAnalysisSettings(folder) { + const config = vscode.workspace.getConfiguration("codeclone", folder.uri); + return resolveAnalysisSettings({ + profile: config.get("analysis.profile", "defaults"), + minLoc: config.get("analysis.minLoc", 10), + minStmt: config.get("analysis.minStmt", 6), + blockMinLoc: config.get("analysis.blockMinLoc", 20), + blockMinStmt: config.get("analysis.blockMinStmt", 8), + segmentMinLoc: config.get("analysis.segmentMinLoc", 20), + segmentMinStmt: config.get("analysis.segmentMinStmt", 10), + }); + } + stateForDocument(document) { if (!document || !document.uri) { return null; @@ -460,6 +531,32 @@ class CodeCloneController { await this.refreshStaleState(state); } + handleWorkspaceFoldersChanged(event) { + if (this.disposed || !event.removed.length) { + return; + } + const removedKeys = new Set( + event.removed.map((folder) => folder.uri.toString()) + ); + let changed = false; + for (const key of removedKeys) { + changed = this.states.delete(key) || changed; + } + if (!changed) { + return; + } + if ( + this.activeReviewTarget && + removedKeys.has(this.activeReviewTarget.workspaceKey) + ) { + this.activeReviewTarget = null; + } + this.rebuildFileDecorations(); + this.updateContextKeys(); + this.updateStatusBar(); + this.refreshAllViews(); + } + async resolveLaunchSpec(folder) { const config = vscode.workspace.getConfiguration("codeclone", folder.uri); const configuredCommand = config.get("mcp.command", "auto"); @@ -566,7 +663,7 @@ class CodeCloneController { } async refreshStaleState(state) { - if (!state || !state.latestSummary) { + if (this.disposed || !state || !state.latestSummary) { return; } const now = Date.now(); @@ -590,6 +687,9 @@ class CodeCloneController { return; } const snapshot = await captureWorkspaceGitSnapshot(state.folder); + if (this.disposed) { + return; + } if (!sameGitSnapshot(snapshot, state.gitSnapshot)) { state.stale = true; state.staleReason = STALE_REASON_WORKSPACE; @@ -603,7 +703,7 @@ class CodeCloneController { } async refreshReviewArtifacts(state) { - if (!state || !state.currentRunId) { + if (this.disposed || !state || !state.currentRunId) { if (state) { state.reviewArtifacts = emptyReviewArtifacts(); state.groupCache.clear(); @@ -654,6 +754,9 @@ class CodeCloneController { limit: 25, }), ]); + if (this.disposed) { + return; + } state.reviewArtifacts = { newRegressions: safeArray(newRegressionsResponse.items), productionHotspots: safeArray(productionHotspotsResponse.items), @@ -665,6 +768,9 @@ class CodeCloneController { } rebuildFileDecorations() { + if (this.disposed) { + return; + } this.fileDecorations.clear(); for (const state of this.states.values()) { if (!state.latestSummary) { @@ -759,6 +865,84 @@ class CodeCloneController { await this.runAnalysis(folder, true); } + async setAnalysisProfile(arg) { + const folder = await this.resolvePreferredFolderFromArg( + arg, + "Select a workspace for CodeClone analysis settings" + ); + if (!folder) { + return; + } + const currentSettings = this.configuredAnalysisSettings(folder); + const state = this.getWorkspaceState(folder); + const picked = await vscode.window.showQuickPick( + ANALYSIS_PROFILE_OPTIONS.map((entry) => ({ + label: entry.label, + description: + entry.id === currentSettings.profileId + ? "Selected for next run" + : entry.description, + detail: entry.detail, + profileId: entry.id, + })), + { + placeHolder: + "Select how sensitive CodeClone should be on the next analysis run", + matchOnDetail: true, + } + ); + if (!picked) { + return; + } + + const config = vscode.workspace.getConfiguration("codeclone", folder.uri); + await config.update( + "analysis.profile", + picked.profileId, + this.configurationTarget() + ); + + const nextSettings = this.configuredAnalysisSettings(folder); + this.refreshAllViews(); + this.updateStatusBar(); + this.updateViewChrome(); + + const rerunActions = + picked.profileId === ANALYSIS_PROFILE_CUSTOM + ? state && state.latestSummary + ? state.lastScope === "changed" + ? ["Open Settings", "Review Changes", "Analyze Workspace", "Later"] + : ["Open Settings", "Analyze Workspace", "Review Changes", "Later"] + : ["Open Settings", "Later"] + : state && state.latestSummary + ? state.lastScope === "changed" + ? ["Review Changes", "Analyze Workspace", "Later"] + : ["Analyze Workspace", "Review Changes", "Later"] + : ["Analyze Workspace", "Later"]; + const message = `CodeClone analysis depth set to ${nextSettings.label}.`; + const choice = await vscode.window.showInformationMessage( + picked.profileId === ANALYSIS_PROFILE_CUSTOM + ? `${message} Update the workspace thresholds if you want custom values before the next run.` + : `${message} Re-run analysis when you want the new profile to take effect.`, + ...rerunActions + ); + + if (choice === "Analyze Workspace") { + await this.runAnalysis(folder, false); + return; + } + if (choice === "Review Changes") { + await this.runAnalysis(folder, true); + return; + } + if (choice === "Open Settings") { + await vscode.commands.executeCommand( + "workbench.action.openSettings", + "@ext:orenlab.codeclone codeclone.analysis" + ); + } + } + async refreshCurrentRun() { const state = this.getPrimaryState(); if (!state) { @@ -769,13 +953,21 @@ class CodeCloneController { } async runAnalysis(folder, changedMode) { + if (this.disposed) { + return; + } const state = this.getWorkspaceState(folder); const config = vscode.workspace.getConfiguration("codeclone", folder.uri); const cachePolicy = config.get("analysis.cachePolicy", "reuse"); const diffRef = config.get("analysis.changedDiffRef", "HEAD"); + const analysisSettings = this.configuredAnalysisSettings(folder); const title = changedMode ? `CodeClone: Analyzing changed files in ${folder.name}` : `CodeClone: Analyzing ${folder.name}`; + const profileTitleSuffix = + analysisSettings.profileId === ANALYSIS_PROFILE_DEFAULTS + ? "" + : ` (${analysisSettings.label})`; const previousText = this.statusBar.text; this.statusBar.text = "$(loading~spin) CodeClone analyzing"; this.statusBar.show(); @@ -783,7 +975,7 @@ class CodeCloneController { await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, - title, + title: `${title}${profileTitleSuffix}`, }, async () => { await this.ensureConnected(folder); @@ -792,10 +984,12 @@ class CodeCloneController { root: folder.uri.fsPath, git_diff_ref: diffRef, cache_policy: cachePolicy, + ...analysisSettings.overrides, }) : await this.client.callTool("analyze_repository", { root: folder.uri.fsPath, cache_policy: cachePolicy, + ...analysisSettings.overrides, }); const runId = String(analysisPayload.run_id); const summary = await this.client.callTool("get_run_summary", { @@ -819,6 +1013,7 @@ class CodeCloneController { state.latestTriage = triage; state.metricsSummary = metrics.summary || metrics; state.changedSummary = changedMode ? analysisPayload : null; + state.analysisSettings = analysisSettings; state.reviewed = safeArray(reviewed.items); state.lastScope = changedMode ? "changed" : "workspace"; state.lastUpdatedAt = new Date(); @@ -831,13 +1026,21 @@ class CodeCloneController { } ); this.clearActiveReviewTarget(); + if (this.disposed) { + return; + } this.updateContextKeys(); this.updateStatusBar(); this.refreshAllViews(); await this.openOverview(); } catch (error) { - this.handleError(error, "CodeClone analysis failed."); + if (!this.disposed) { + this.handleError(error, "CodeClone analysis failed."); + } } finally { + if (this.disposed) { + return; + } if (!this.connectionInfo.connected) { this.statusBar.text = "CodeClone disconnected"; } else if (previousText) { @@ -1888,6 +2091,7 @@ class CodeCloneController { state.metricsSummary = null; state.latestTriage = null; state.changedSummary = null; + state.analysisSettings = null; state.reviewed = []; state.reviewArtifacts = emptyReviewArtifacts(); state.gitSnapshot = null; @@ -1911,15 +2115,16 @@ class CodeCloneController { async pickHelpTopic() { const picked = await vscode.window.showQuickPick( HELP_TOPICS.map((topic) => ({ - label: topic, + label: topic.replace(/_/g, " "), description: topic === this.lastHelpTopic ? "Last opened" : "CodeClone MCP help topic", + topic, })), { placeHolder: "Select a CodeClone MCP help topic", } ); - return picked ? picked.label : null; + return picked ? picked.topic : null; } async showMarkdownDocument(markdown) { @@ -2012,11 +2217,36 @@ class CodeCloneController { ]; } + currentAnalysisSettings(state) { + if (!state) { + return null; + } + return state.analysisSettings || this.configuredAnalysisSettings(state.folder); + } + + pendingAnalysisSettings(state) { + if (!state) { + return null; + } + const currentSettings = this.currentAnalysisSettings(state); + const configuredSettings = this.configuredAnalysisSettings(state.folder); + return sameAnalysisSettings(currentSettings, configuredSettings) + ? null + : configuredSettings; + } + async getOverviewChildren(node) { const state = this.getPrimaryState(); if (!state || !state.latestSummary) { return []; } + const currentAnalysisSettings = this.currentAnalysisSettings(state); + const pendingAnalysisSettings = this.pendingAnalysisSettings(state); + const analysisCommand = { + command: "codeclone.setAnalysisProfile", + title: "Set analysis depth", + arguments: [{ workspaceKey: state.folder.uri.toString() }], + }; const reviewCounts = { changed: this.reviewArtifactCount(state, "changedFiles"), new: this.reviewArtifactCount(state, "newRegressions"), @@ -2044,7 +2274,9 @@ class CodeCloneController { label: "Current Run", description: state.stale ? `${state.currentRunId} · stale` - : `${state.currentRunId} · ${state.latestSummary.cache.freshness}`, + : currentAnalysisSettings + ? `${state.currentRunId} · ${currentAnalysisSettings.label.toLowerCase()}` + : `${state.currentRunId} · ${state.latestSummary.cache.freshness}`, icon: new vscode.ThemeIcon("pulse"), }, { @@ -2104,6 +2336,27 @@ class CodeCloneController { return [ this.detailNode("Workspace", state.folder.name), this.detailNode("Run ID", state.currentRunId), + this.detailNode( + "Analysis depth", + currentAnalysisSettings ? currentAnalysisSettings.label : "unknown", + analysisCommand + ), + this.detailNode( + "Threshold profile", + currentAnalysisSettings + ? currentAnalysisSettings.thresholdSummary + : "unknown", + analysisCommand + ), + ...(pendingAnalysisSettings + ? [ + this.detailNode( + "Next run", + `${pendingAnalysisSettings.label} · pending`, + analysisCommand + ), + ] + : []), this.detailNode( "Freshness", state.stale ? `stale · ${state.staleReason}` : "current" @@ -2122,7 +2375,6 @@ class CodeCloneController { ]; } if (node.id === "overview.triage") { - const triage = safeObject(state.latestTriage); const nextAction = this.describeNextBestAction(state); return [ this.detailNode("Next best action", nextAction.label, { @@ -2130,6 +2382,11 @@ class CodeCloneController { title: nextAction.title, }), this.detailNode("Focus mode", focusModeSpec(this.hotspotFocusMode).label), + this.detailNode( + "Analysis depth", + currentAnalysisSettings ? currentAnalysisSettings.label : "unknown", + analysisCommand + ), this.detailNode("New regressions", number(reviewCounts.new)), this.detailNode("Production hotspots", number(reviewCounts.production)), this.detailNode( @@ -2277,11 +2534,32 @@ class CodeCloneController { ), ]; } + const currentAnalysisSettings = this.currentAnalysisSettings(state); + const pendingAnalysisSettings = this.pendingAnalysisSettings(state); + const analysisCommand = { + command: "codeclone.setAnalysisProfile", + title: "Set analysis depth", + arguments: [{ workspaceKey: state.folder.uri.toString() }], + }; return [ this.detailNode("Workspace", state.folder.name), this.detailNode("Run ID", state.currentRunId), this.detailNode("Scope", formatRunScope(state.lastScope)), this.detailNode("Mode", state.latestSummary.mode), + this.detailNode( + "Analysis depth", + currentAnalysisSettings ? currentAnalysisSettings.label : "unknown", + analysisCommand + ), + ...(pendingAnalysisSettings + ? [ + this.detailNode( + "Next run", + `${pendingAnalysisSettings.label} · pending`, + analysisCommand + ), + ] + : []), this.detailNode( "Freshness", state.stale ? `stale · ${state.staleReason}` : "current" @@ -2502,6 +2780,7 @@ class CodeCloneController { } describeNextBestAction(state) { + const analysisSettings = this.currentAnalysisSettings(state); if (state.stale) { return { label: state.lastScope === "changed" ? "Review changes again" : "Refresh stale run", @@ -2543,6 +2822,16 @@ class CodeCloneController { title: "Inspect report-only Overloaded Modules", }; } + if ( + analysisSettings && + analysisSettings.profileId === ANALYSIS_PROFILE_DEFAULTS + ) { + return { + label: "Adjust analysis depth", + command: "codeclone.setAnalysisProfile", + title: "Adjust analysis depth", + }; + } return { label: "Repository looks structurally quiet", command: "codeclone.focusHotspots", @@ -2649,6 +2938,9 @@ class CodeCloneController { } refreshAllViews() { + if (this.disposed) { + return; + } this.overviewProvider.refresh(); this.hotspotsProvider.refresh(); this.sessionProvider.refresh(); @@ -2657,6 +2949,9 @@ class CodeCloneController { } updateViewChrome() { + if (this.disposed) { + return; + } const state = this.getPrimaryState(); if (this.overviewView) { this.overviewView.badge = undefined; @@ -2723,6 +3018,9 @@ class CodeCloneController { } updateContextKeys() { + if (this.disposed) { + return; + } const state = this.getPrimaryState(); const activeTarget = this.activeReviewTarget; const targetVisibleInEditor = this.isTargetVisibleInEditor(activeTarget); @@ -2779,6 +3077,9 @@ class CodeCloneController { } updateStatusBar() { + if (this.disposed) { + return; + } const showStatusBar = vscode.workspace .getConfiguration("codeclone") .get("ui.showStatusBar", true); @@ -2826,12 +3127,18 @@ class CodeCloneController { : `CodeClone ${state.latestSummary.health.score}/${state.latestSummary.health.grade}`; this.statusBar.command = "codeclone.openOverview"; const drift = this.baselineDrift(state); + const analysisSettings = this.currentAnalysisSettings(state); + const pendingAnalysisSettings = this.pendingAnalysisSettings(state); const driftLine = drift.newFindings !== null || drift.healthDelta !== null || drift.newClones !== null ? `\nBaseline drift: ${this.baselineDriftSummary(state)}` : ""; this.statusBar.tooltip = `${state.folder.name}\nRun ${state.currentRunId}\n${state.latestSummary.findings.total} findings` + + (analysisSettings ? `\nAnalysis depth: ${analysisSettings.label}` : "") + + (pendingAnalysisSettings + ? `\nNext run: ${pendingAnalysisSettings.label} · pending` + : "") + driftLine + (state.stale ? `\nFreshness: stale · ${state.staleReason}` : ""); this.statusBar.accessibilityInformation = { @@ -2899,7 +3206,13 @@ function activate(context) { controller = new CodeCloneController(context); } -function deactivate() { +async function deactivate() { + if (controller) { + const activeController = controller; + controller = null; + await activeController.dispose(); + return; + } controller = null; } diff --git a/extensions/vscode-codeclone/src/mcpClient.js b/extensions/vscode-codeclone/src/mcpClient.js index a1f90b6..933df5d 100644 --- a/extensions/vscode-codeclone/src/mcpClient.js +++ b/extensions/vscode-codeclone/src/mcpClient.js @@ -34,6 +34,8 @@ class CodeCloneMcpClient extends EventEmitter { this.launchSpec = null; this.serverInfo = null; this.toolNames = []; + this.connectPromise = null; + this.connectLaunchSpec = null; } isConnected() { @@ -56,16 +58,36 @@ class CodeCloneMcpClient extends EventEmitter { } async connect(launchSpec) { - if ( - this.process !== null && - this.connected && - this._sameLaunchSpec(launchSpec, this.launchSpec) - ) { - return { - serverInfo: this.serverInfo, - toolNames: [...this.toolNames], - }; + if (this._sameLaunchSpec(launchSpec, this.launchSpec) && this.connected) { + return this._connectionResult(); + } + if (this.connectPromise) { + if (this._sameLaunchSpec(launchSpec, this.connectLaunchSpec)) { + return this.connectPromise; + } + try { + await this.connectPromise; + } catch { + // Ignore the previous attempt here; the new launch spec gets its own try. + } + if (this._sameLaunchSpec(launchSpec, this.launchSpec) && this.connected) { + return this._connectionResult(); + } + } + const attempt = this._connectInternal(launchSpec); + this.connectPromise = attempt; + this.connectLaunchSpec = { ...launchSpec }; + try { + return await attempt; + } finally { + if (this.connectPromise === attempt) { + this.connectPromise = null; + this.connectLaunchSpec = null; + } } + } + + async _connectInternal(launchSpec) { if (this.process !== null || this.connected || this.initialized) { await this.dispose({ emitState: false }); } @@ -97,10 +119,7 @@ class CodeCloneMcpClient extends EventEmitter { toolNames: [...this.toolNames], launchSpec: this.getConnectionSnapshot().launchSpec, }); - return { - serverInfo: this.serverInfo, - toolNames: [...this.toolNames], - }; + return this._connectionResult(); } catch (error) { await this.dispose({ emitState: false }); throw error; @@ -140,6 +159,8 @@ class CodeCloneMcpClient extends EventEmitter { async dispose(options = {}) { const emitState = options.emitState !== false; + this.connectPromise = null; + this.connectLaunchSpec = null; for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(new MCPClientError("CodeClone MCP connection closed.")); @@ -376,6 +397,13 @@ class CodeCloneMcpClient extends EventEmitter { JSON.stringify(left.args) === JSON.stringify(right.args) ); } + + _connectionResult() { + return { + serverInfo: this.serverInfo, + toolNames: [...this.toolNames], + }; + } } module.exports = { diff --git a/extensions/vscode-codeclone/src/providers.js b/extensions/vscode-codeclone/src/providers.js index e35870d..8e7fe28 100644 --- a/extensions/vscode-codeclone/src/providers.js +++ b/extensions/vscode-codeclone/src/providers.js @@ -19,6 +19,7 @@ class WorkspaceState { this.metricsSummary = null; this.latestTriage = null; this.changedSummary = null; + this.analysisSettings = null; this.reviewed = []; this.lastScope = "workspace"; this.lastUpdatedAt = null; diff --git a/extensions/vscode-codeclone/src/renderers.js b/extensions/vscode-codeclone/src/renderers.js index db8b5a8..79e851f 100644 --- a/extensions/vscode-codeclone/src/renderers.js +++ b/extensions/vscode-codeclone/src/renderers.js @@ -18,8 +18,9 @@ function markdownBulletList(values) { } function renderHelpMarkdown(topic, payload) { + const titleTopic = String(topic || "").replace(/_/g, " "); const lines = [ - `# CodeClone MCP Help: ${topic}`, + `# CodeClone MCP Help: ${titleTopic}`, "", payload.summary || "", "", diff --git a/extensions/vscode-codeclone/src/support.js b/extensions/vscode-codeclone/src/support.js index d34b6d2..ad187ac 100644 --- a/extensions/vscode-codeclone/src/support.js +++ b/extensions/vscode-codeclone/src/support.js @@ -4,6 +4,30 @@ const path = require("node:path"); const STALE_REASON_EDITOR = "unsaved editor changes"; const STALE_REASON_WORKSPACE = "workspace changed after this run"; +const ANALYSIS_PROFILE_DEFAULTS = "defaults"; +const ANALYSIS_PROFILE_DEEPER_REVIEW = "deeperReview"; +const ANALYSIS_PROFILE_CUSTOM = "custom"; +const ANALYSIS_PROFILE_IDS = new Set([ + ANALYSIS_PROFILE_DEFAULTS, + ANALYSIS_PROFILE_DEEPER_REVIEW, + ANALYSIS_PROFILE_CUSTOM, +]); +const DEFAULT_ANALYSIS_THRESHOLDS = Object.freeze({ + minLoc: 10, + minStmt: 6, + blockMinLoc: 20, + blockMinStmt: 8, + segmentMinLoc: 20, + segmentMinStmt: 10, +}); +const DEEP_REVIEW_ANALYSIS_THRESHOLDS = Object.freeze({ + minLoc: 5, + minStmt: 2, + blockMinLoc: 5, + blockMinStmt: 2, + segmentMinLoc: 5, + segmentMinStmt: 2, +}); function signedInteger(value) { if (typeof value !== "number" || Number.isNaN(value)) { @@ -93,12 +117,127 @@ function workspaceLocalLauncherCandidates( ]; } +function normalizeAnalysisProfile(value) { + const profileId = String(value || "").trim(); + return ANALYSIS_PROFILE_IDS.has(profileId) + ? profileId + : ANALYSIS_PROFILE_DEFAULTS; +} + +function nonNegativeInteger(value, fallback) { + const parsed = + typeof value === "number" && Number.isFinite(value) + ? Math.trunc(value) + : Number.parseInt(String(value ?? ""), 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + +function customAnalysisThresholds(value = {}) { + return { + minLoc: nonNegativeInteger(value.minLoc, DEFAULT_ANALYSIS_THRESHOLDS.minLoc), + minStmt: nonNegativeInteger( + value.minStmt, + DEFAULT_ANALYSIS_THRESHOLDS.minStmt + ), + blockMinLoc: nonNegativeInteger( + value.blockMinLoc, + DEFAULT_ANALYSIS_THRESHOLDS.blockMinLoc + ), + blockMinStmt: nonNegativeInteger( + value.blockMinStmt, + DEFAULT_ANALYSIS_THRESHOLDS.blockMinStmt + ), + segmentMinLoc: nonNegativeInteger( + value.segmentMinLoc, + DEFAULT_ANALYSIS_THRESHOLDS.segmentMinLoc + ), + segmentMinStmt: nonNegativeInteger( + value.segmentMinStmt, + DEFAULT_ANALYSIS_THRESHOLDS.segmentMinStmt + ), + }; +} + +function analysisThresholdOverrides(thresholds) { + return { + min_loc: thresholds.minLoc, + min_stmt: thresholds.minStmt, + block_min_loc: thresholds.blockMinLoc, + block_min_stmt: thresholds.blockMinStmt, + segment_min_loc: thresholds.segmentMinLoc, + segment_min_stmt: thresholds.segmentMinStmt, + }; +} + +function formatAnalysisThresholdSummary(profileId, thresholds) { + switch (profileId) { + case ANALYSIS_PROFILE_DEFAULTS: + return "Repo defaults / pyproject"; + case ANALYSIS_PROFILE_DEEPER_REVIEW: + return "5/2 across functions, blocks, and segments"; + default: + return ( + `func ${thresholds.minLoc}/${thresholds.minStmt} · ` + + `block ${thresholds.blockMinLoc}/${thresholds.blockMinStmt} · ` + + `seg ${thresholds.segmentMinLoc}/${thresholds.segmentMinStmt}` + ); + } +} + +function resolveAnalysisSettings(value = {}) { + const profileId = normalizeAnalysisProfile(value.profile); + const thresholds = + profileId === ANALYSIS_PROFILE_DEEPER_REVIEW + ? { ...DEEP_REVIEW_ANALYSIS_THRESHOLDS } + : customAnalysisThresholds(value); + const label = + profileId === ANALYSIS_PROFILE_DEFAULTS + ? "Conservative" + : profileId === ANALYSIS_PROFILE_DEEPER_REVIEW + ? "Deeper review" + : "Custom"; + const detail = + profileId === ANALYSIS_PROFILE_DEFAULTS + ? "Use repo defaults or pyproject for the first pass." + : profileId === ANALYSIS_PROFILE_DEEPER_REVIEW + ? "Lower thresholds for a deliberate second pass on smaller units." + : "Use the explicit threshold settings from this workspace."; + return { + profileId, + label, + detail, + thresholds, + thresholdSummary: formatAnalysisThresholdSummary(profileId, thresholds), + overrides: + profileId === ANALYSIS_PROFILE_DEFAULTS + ? {} + : analysisThresholdOverrides(thresholds), + }; +} + +function sameAnalysisSettings(left, right) { + if (!left || !right) { + return false; + } + return JSON.stringify(left) === JSON.stringify(right); +} + module.exports = { + ANALYSIS_PROFILE_CUSTOM, + ANALYSIS_PROFILE_DEEPER_REVIEW, + ANALYSIS_PROFILE_DEFAULTS, + DEFAULT_ANALYSIS_THRESHOLDS, + DEEP_REVIEW_ANALYSIS_THRESHOLDS, STALE_REASON_EDITOR, STALE_REASON_WORKSPACE, + analysisThresholdOverrides, + customAnalysisThresholds, normalizedLaunchSpec, + normalizeAnalysisProfile, parseUtcTimestamp, resolveWorkspacePath, + resolveAnalysisSettings, + sameAnalysisSettings, signedInteger, staleMessage, trimTail, diff --git a/extensions/vscode-codeclone/test/extensionHost/index.js b/extensions/vscode-codeclone/test/extensionHost/index.js index e3cf7b0..6c263fc 100644 --- a/extensions/vscode-codeclone/test/extensionHost/index.js +++ b/extensions/vscode-codeclone/test/extensionHost/index.js @@ -44,6 +44,10 @@ async function run() { ]); await vscode.commands.executeCommand("codeclone.openOverview"); + + if (typeof extension.exports?.deactivate === "function") { + await extension.exports.deactivate(); + } } module.exports = { run }; diff --git a/extensions/vscode-codeclone/test/mcpClient.test.js b/extensions/vscode-codeclone/test/mcpClient.test.js index 1cc0291..34acab2 100644 --- a/extensions/vscode-codeclone/test/mcpClient.test.js +++ b/extensions/vscode-codeclone/test/mcpClient.test.js @@ -49,3 +49,38 @@ test("diagnostics trim very long lines to the supported maximum", () => { assert.equal(client.diagnostics.length, 1); assert.equal(client.diagnostics[0].length, 4096); }); + +test("concurrent connect calls with the same launch spec share one in-flight connection", async () => { + const client = new CodeCloneMcpClient(outputChannelStub()); + const launchSpec = { command: "codeclone-mcp", args: [], cwd: "/tmp/workspace" }; + let spawnCalls = 0; + const requestMethods = []; + + client._spawn = async (spec) => { + spawnCalls += 1; + client.process = /** @type {any} */ ({}); + client.launchSpec = { ...spec }; + }; + client.request = async (method) => { + requestMethods.push(method); + if (method === "initialize") { + await new Promise((resolve) => setTimeout(resolve, 5)); + return { serverInfo: { name: "CodeClone MCP" } }; + } + if (method === "tools/list") { + return { tools: [{ name: "analyze_repository" }] }; + } + throw new Error(`Unexpected method ${method}`); + }; + client._write = () => {}; + + const [first, second] = await Promise.all([ + client.connect(launchSpec), + client.connect(launchSpec), + ]); + + assert.equal(spawnCalls, 1); + assert.deepEqual(requestMethods, ["initialize", "tools/list"]); + assert.deepEqual(first, second); + assert.equal(client.isConnected(), true); +}); diff --git a/extensions/vscode-codeclone/test/support.test.js b/extensions/vscode-codeclone/test/support.test.js index f3759f1..bba97c8 100644 --- a/extensions/vscode-codeclone/test/support.test.js +++ b/extensions/vscode-codeclone/test/support.test.js @@ -4,11 +4,21 @@ const test = require("node:test"); const assert = require("node:assert/strict"); const { + ANALYSIS_PROFILE_CUSTOM, + ANALYSIS_PROFILE_DEEPER_REVIEW, + ANALYSIS_PROFILE_DEFAULTS, + DEFAULT_ANALYSIS_THRESHOLDS, + DEEP_REVIEW_ANALYSIS_THRESHOLDS, STALE_REASON_EDITOR, STALE_REASON_WORKSPACE, + analysisThresholdOverrides, + customAnalysisThresholds, normalizedLaunchSpec, + normalizeAnalysisProfile, parseUtcTimestamp, resolveWorkspacePath, + resolveAnalysisSettings, + sameAnalysisSettings, signedInteger, staleMessage, trimTail, @@ -97,3 +107,82 @@ test("workspaceLocalLauncherCandidates prefer workspace virtual environments", ( "C:\\repo\\venv\\Scripts\\codeclone-mcp.cmd", ]); }); + +test("normalizeAnalysisProfile falls back to conservative defaults", () => { + assert.equal(normalizeAnalysisProfile("defaults"), ANALYSIS_PROFILE_DEFAULTS); + assert.equal( + normalizeAnalysisProfile("deeperReview"), + ANALYSIS_PROFILE_DEEPER_REVIEW + ); + assert.equal(normalizeAnalysisProfile("custom"), ANALYSIS_PROFILE_CUSTOM); + assert.equal(normalizeAnalysisProfile("unknown"), ANALYSIS_PROFILE_DEFAULTS); +}); + +test("customAnalysisThresholds normalizes values to non-negative integers", () => { + assert.deepEqual( + customAnalysisThresholds({ + minLoc: "5", + minStmt: 2.7, + blockMinLoc: -4, + blockMinStmt: "bad", + segmentMinLoc: 0, + segmentMinStmt: 3, + }), + { + minLoc: 5, + minStmt: 2, + blockMinLoc: DEFAULT_ANALYSIS_THRESHOLDS.blockMinLoc, + blockMinStmt: DEFAULT_ANALYSIS_THRESHOLDS.blockMinStmt, + segmentMinLoc: 0, + segmentMinStmt: 3, + } + ); +}); + +test("resolveAnalysisSettings keeps defaults conservative and deeper review explicit", () => { + assert.deepEqual(resolveAnalysisSettings({}), { + profileId: ANALYSIS_PROFILE_DEFAULTS, + label: "Conservative", + detail: "Use repo defaults or pyproject for the first pass.", + thresholds: DEFAULT_ANALYSIS_THRESHOLDS, + thresholdSummary: "Repo defaults / pyproject", + overrides: {}, + }); + assert.deepEqual(resolveAnalysisSettings({ profile: "deeperReview" }), { + profileId: ANALYSIS_PROFILE_DEEPER_REVIEW, + label: "Deeper review", + detail: "Lower thresholds for a deliberate second pass on smaller units.", + thresholds: DEEP_REVIEW_ANALYSIS_THRESHOLDS, + thresholdSummary: "5/2 across functions, blocks, and segments", + overrides: analysisThresholdOverrides(DEEP_REVIEW_ANALYSIS_THRESHOLDS), + }); +}); + +test("resolveAnalysisSettings uses workspace thresholds in custom mode", () => { + const custom = resolveAnalysisSettings({ + profile: "custom", + minLoc: 7, + minStmt: 3, + blockMinLoc: 11, + blockMinStmt: 4, + segmentMinLoc: 13, + segmentMinStmt: 5, + }); + assert.deepEqual(custom.thresholds, { + minLoc: 7, + minStmt: 3, + blockMinLoc: 11, + blockMinStmt: 4, + segmentMinLoc: 13, + segmentMinStmt: 5, + }); + assert.equal(custom.thresholdSummary, "func 7/3 · block 11/4 · seg 13/5"); +}); + +test("sameAnalysisSettings compares profile payloads structurally", () => { + const left = resolveAnalysisSettings({ profile: "custom", minLoc: 8 }); + const right = resolveAnalysisSettings({ profile: "custom", minLoc: 8 }); + const other = resolveAnalysisSettings({ profile: "deeperReview" }); + assert.equal(sameAnalysisSettings(left, right), true); + assert.equal(sameAnalysisSettings(left, other), false); +}); From be3ab263269715481aca994491ed89d0a4082464 Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Sat, 4 Apr 2026 22:33:20 +0500 Subject: [PATCH 08/15] feat(mcp,report): harden core contracts, cleanup structural noise, and tighten MCP guidance - implement the audit-driven cleanup across baseline/cache/report/html internals with shared JSON IO, safer normalization and path handling, and cleaner structural rendering boundaries - remove safe non-golden structural and clone noise surfaced by stricter analysis passes without touching golden fixture debt - strengthen MCP semantics with conservative-first threshold guidance, the new analysis_profile help topic, and tighter workflow/help wording - refresh core docs and contract tests for baseline, report, MCP, and stricter analysis behavior --- codeclone/_cli_baselines.py | 8 +- codeclone/_cli_gating.py | 50 +- codeclone/_html_badges.py | 16 +- codeclone/_html_css.py | 71 +-- codeclone/_html_data_attrs.py | 4 +- codeclone/_html_escape.py | 7 - codeclone/_html_filters.py | 6 +- codeclone/_html_report/_assemble.py | 4 +- codeclone/_html_report/_components.py | 4 +- codeclone/_html_report/_context.py | 7 +- codeclone/_html_report/_glossary.py | 4 +- codeclone/_html_report/_sections/_clones.py | 28 +- .../_html_report/_sections/_dependencies.py | 10 +- codeclone/_html_report/_sections/_overview.py | 52 +- .../_html_report/_sections/_structural.py | 543 +++++++++++++++- .../_html_report/_sections/_suggestions.py | 4 +- codeclone/_html_report/_tables.py | 6 +- codeclone/_html_report/_tabs.py | 8 +- codeclone/_json_io.py | 84 +++ codeclone/baseline.py | 47 +- codeclone/cache.py | 13 +- codeclone/cache_io.py | 31 +- codeclone/cfg.py | 23 +- codeclone/extractor.py | 89 ++- codeclone/grouping.py | 46 +- codeclone/mcp_server.py | 35 +- codeclone/mcp_service.py | 236 ++++--- codeclone/metrics/_risk.py | 21 + codeclone/metrics/cohesion.py | 10 +- codeclone/metrics/complexity.py | 15 +- codeclone/metrics/coupling.py | 14 +- codeclone/metrics/dead_code.py | 45 +- codeclone/metrics_baseline.py | 45 +- codeclone/normalize.py | 22 +- codeclone/report/__init__.py | 32 - codeclone/report/derived.py | 1 + codeclone/report/findings.py | 578 +----------------- codeclone/report/json_contract.py | 25 +- codeclone/report/sarif.py | 33 +- codeclone/report/serialize.py | 14 +- codeclone/structural_findings.py | 145 ++--- codeclone/suppressions.py | 66 +- docs/book/20-mcp-interface.md | 59 +- docs/mcp.md | 47 +- pyproject.toml | 1 + tests/_mcp_fixtures.py | 16 + tests/test_architecture.py | 53 +- tests/test_baseline.py | 2 +- tests/test_cfg.py | 230 ++++--- tests/test_cli_inprocess.py | 96 ++- tests/test_cli_unit.py | 62 +- tests/test_extractor.py | 475 ++++++-------- tests/test_html_report.py | 6 +- tests/test_mcp_server.py | 19 +- tests/test_mcp_service.py | 77 ++- tests/test_metrics_baseline.py | 2 +- tests/test_metrics_modules.py | 139 +++-- tests/test_normalize.py | 11 + tests/test_report.py | 42 +- tests/test_report_branch_invariants.py | 8 +- tests/test_report_contract_coverage.py | 100 +-- tests/test_structural_findings.py | 350 ++++++----- tests/test_suppressions.py | 373 ++++++----- uv.lock | 83 +++ 64 files changed, 2505 insertions(+), 2248 deletions(-) create mode 100644 codeclone/_json_io.py create mode 100644 codeclone/metrics/_risk.py create mode 100644 tests/_mcp_fixtures.py diff --git a/codeclone/_cli_baselines.py b/codeclone/_cli_baselines.py index ed415f7..f06839a 100644 --- a/codeclone/_cli_baselines.py +++ b/codeclone/_cli_baselines.py @@ -6,12 +6,14 @@ from __future__ import annotations -import json import sys from dataclasses import dataclass +from json import JSONDecodeError from pathlib import Path from typing import TYPE_CHECKING, Protocol +import orjson + from . import ui_messages as ui from .baseline import ( BASELINE_UNTRUSTED_STATUSES, @@ -101,8 +103,8 @@ def probe_metrics_baseline_section(path: Path) -> MetricsBaselineSectionProbe: payload=None, ) try: - raw_payload = json.loads(path.read_text("utf-8")) - except (OSError, json.JSONDecodeError): + raw_payload = orjson.loads(path.read_text("utf-8")) + except (OSError, JSONDecodeError): return MetricsBaselineSectionProbe( has_metrics_section=True, payload=None, diff --git a/codeclone/_cli_gating.py b/codeclone/_cli_gating.py index 5a5ae7d..96b96e2 100644 --- a/codeclone/_cli_gating.py +++ b/codeclone/_cli_gating.py @@ -90,37 +90,37 @@ def policy_context(*, args: _GatingArgs, gate_kind: str) -> str: if args.ci: return "ci" - parts: list[str] = [] - + parts: tuple[str | None, ...] match gate_kind: case "metrics": - if args.fail_on_new_metrics: - parts.append("fail-on-new-metrics") - if args.fail_complexity >= 0: - parts.append(f"fail-complexity={args.fail_complexity}") - if args.fail_coupling >= 0: - parts.append(f"fail-coupling={args.fail_coupling}") - if args.fail_cohesion >= 0: - parts.append(f"fail-cohesion={args.fail_cohesion}") - if args.fail_cycles: - parts.append("fail-cycles") - if args.fail_dead_code: - parts.append("fail-dead-code") - if args.fail_health >= 0: - parts.append(f"fail-health={args.fail_health}") - + parts = ( + "fail-on-new-metrics" if args.fail_on_new_metrics else None, + f"fail-complexity={args.fail_complexity}" + if args.fail_complexity >= 0 + else None, + f"fail-coupling={args.fail_coupling}" + if args.fail_coupling >= 0 + else None, + f"fail-cohesion={args.fail_cohesion}" + if args.fail_cohesion >= 0 + else None, + "fail-cycles" if args.fail_cycles else None, + "fail-dead-code" if args.fail_dead_code else None, + f"fail-health={args.fail_health}" if args.fail_health >= 0 else None, + ) case "new-clones": - if args.fail_on_new: - parts.append("fail-on-new") - + parts = ("fail-on-new" if args.fail_on_new else None,) case "threshold": - if args.fail_threshold >= 0: - parts.append(f"fail-threshold={args.fail_threshold}") - + parts = ( + f"fail-threshold={args.fail_threshold}" + if args.fail_threshold >= 0 + else None, + ) case _: - pass + parts = () - return ", ".join(parts) if parts else "custom" + enabled_parts = tuple(part for part in parts if part is not None) + return ", ".join(enabled_parts) if enabled_parts else "custom" def print_gating_failure_block( diff --git a/codeclone/_html_badges.py b/codeclone/_html_badges.py index dc06b15..2ea9ee4 100644 --- a/codeclone/_html_badges.py +++ b/codeclone/_html_badges.py @@ -18,7 +18,7 @@ from collections.abc import Callable, Sequence -from ._html_escape import _escape_attr, _escape_html +from ._html_escape import _escape_html from .domain.quality import ( EFFORT_EASY, EFFORT_HARD, @@ -63,16 +63,16 @@ def _quality_badge_html(text: str) -> str: r = text.strip().lower() if r in (RISK_LOW, RISK_HIGH, RISK_MEDIUM): return ( - f'{_escape_html(r)}' + f'{_escape_html(r)}' ) if r in (SEVERITY_CRITICAL, SEVERITY_WARNING, SEVERITY_INFO): return ( - f'' + f'' f"{_escape_html(r)}" ) if r in _EFFORT_CSS: return ( - f'{_escape_html(r)}' + f'{_escape_html(r)}' ) return _escape_html(text) @@ -80,7 +80,7 @@ def _quality_badge_html(text: str) -> str: def _source_kind_badge_html(source_kind: str) -> str: normalized = normalize_source_kind(source_kind) return ( - f'' + f'' f"{_escape_html(source_kind_label(normalized))}" ) @@ -117,7 +117,7 @@ def _render_chain_flow( for i, mod in enumerate(parts): short = _short_label(str(mod)) nodes.append( - f'' + f'' f"{_escape_html(short)}" ) if arrows and i < len(parts) - 1: @@ -154,7 +154,7 @@ def _stat_card( if glossary_tip_fn is not None: tip_html = glossary_tip_fn(label) elif tip: - tip_html = f'?' + tip_html = f'?' detail_html = "" if detail: @@ -167,7 +167,7 @@ def _stat_card( value_cls = f" meta-value--{value_tone}" if value_tone else "" return ( - f'
' + f'
' f'
{_escape_html(label)}{tip_html}{delta_html}
' f'
{_escape_html(str(value))}
' f"{detail_html}" diff --git a/codeclone/_html_css.py b/codeclone/_html_css.py index d13c70f..64c7325 100644 --- a/codeclone/_html_css.py +++ b/codeclone/_html_css.py @@ -179,7 +179,7 @@ .main-tab-icon{flex-shrink:0;opacity:.72} .main-tab-label{display:inline-flex;align-items:center} .tab-count{display:inline-flex;align-items:center;justify-content:center;min-width:18px; - height:18px;padding:0 5px;font-size:.7rem;font-weight:700;border-radius:9px; + height:18px;padding:0 5px;font-size:.68rem;font-weight:700;border-radius:var(--radius-sm); background:var(--bg-overlay);color:var(--text-muted);margin-left:var(--sp-1)} .main-tab[aria-selected="true"] .tab-count{background:var(--accent-primary); color:#fff} @@ -370,9 +370,9 @@ .group-summary{font-size:.8rem;color:var(--text-muted)} /* Badges */ -.clone-type-badge{font-size:.75rem;font-weight:500;padding:1px var(--sp-2); +.clone-type-badge{font-size:.68rem;font-weight:500;padding:2px var(--sp-2); border-radius:var(--radius-sm);background:var(--accent-muted);color:var(--accent-primary)} -.clone-count-badge{font-size:.75rem;font-weight:600;padding:1px var(--sp-2); +.clone-count-badge{font-size:.68rem;font-weight:600;padding:2px var(--sp-2); border-radius:var(--radius-sm);background:var(--bg-overlay);color:var(--text-secondary)} /* Group body */ @@ -385,7 +385,7 @@ /* Group explain */ .group-explain{padding:var(--sp-2) var(--sp-4);display:flex;flex-wrap:wrap;gap:var(--sp-1); background:var(--bg-raised);border-bottom:1px solid var(--border)} -.group-explain-item{font-size:.75rem;padding:1px var(--sp-2);border-radius:var(--radius-sm); +.group-explain-item{font-size:.68rem;padding:2px var(--sp-2);border-radius:var(--radius-sm); background:var(--bg-overlay);color:var(--text-muted);font-family:var(--font-mono);white-space:nowrap} .group-explain-warn{color:var(--warning);background:var(--warning-muted)} .group-explain-muted{opacity:.7} @@ -433,8 +433,8 @@ # --------------------------------------------------------------------------- _BADGES = """\ -.risk-badge,.severity-badge{display:inline-flex;align-items:center;font-size:.72rem;font-weight:600; - padding:1px var(--sp-2);border-radius:var(--radius-sm);text-transform:uppercase;letter-spacing:.02em} +.risk-badge,.severity-badge{display:inline-flex;align-items:center;font-size:.68rem;font-weight:600; + padding:2px var(--sp-2);border-radius:var(--radius-sm);text-transform:uppercase;letter-spacing:.02em} .risk-critical,.severity-critical{background:var(--error-muted);color:var(--error)} .risk-high,.severity-high{background:var(--error-muted);color:var(--error)} .risk-warning,.severity-warning{background:var(--warning-muted);color:var(--warning)} @@ -442,19 +442,19 @@ .risk-low,.severity-low{background:var(--success-muted);color:var(--success)} .risk-info,.severity-info{background:var(--info-muted);color:var(--info)} -.source-kind-badge{display:inline-flex;align-items:center;font-size:.72rem;font-weight:500; - padding:1px var(--sp-2);border-radius:var(--radius-sm);background:var(--bg-overlay);color:var(--text-muted)} +.source-kind-badge{display:inline-flex;align-items:center;font-size:.68rem;font-weight:500; + padding:2px var(--sp-2);border-radius:var(--radius-sm);background:var(--bg-overlay);color:var(--text-muted)} .source-kind-production{background:var(--error-muted);color:var(--error)} .source-kind-test,.source-kind-test_util{background:var(--info-muted);color:var(--info)} .source-kind-fixture,.source-kind-conftest{background:var(--warning-muted);color:var(--warning)} .source-kind-import,.source-kind-cross_kind{background:var(--accent-muted);color:var(--accent-primary)} -.category-badge{display:inline-flex;align-items:center;gap:3px;font-size:.7rem; - font-family:var(--font-mono);padding:1px var(--sp-2);border-radius:var(--radius-sm); +.category-badge{display:inline-flex;align-items:center;gap:3px;font-size:.68rem; + font-family:var(--font-mono);padding:2px var(--sp-2);border-radius:var(--radius-sm); background:var(--bg-overlay);color:var(--text-muted);white-space:nowrap} .category-badge-key{font-weight:400;color:var(--text-muted)} .category-badge-val{font-weight:600;color:var(--text-secondary)} .finding-why-chips{display:flex;flex-wrap:wrap;gap:var(--sp-1);margin:var(--sp-1) 0} -.finding-why-chips .category-badge{font-size:.72rem} +.finding-why-chips .category-badge{font-size:.68rem} """ # --------------------------------------------------------------------------- @@ -587,8 +587,8 @@ .kpi-micro-val{font-weight:500;font-variant-numeric:tabular-nums;color:var(--text-muted)} .kpi-micro-lbl{font-weight:400;color:var(--text-muted);text-transform:lowercase} .kpi-micro--baselined{color:var(--success);font-weight:500;font-size:.6rem} -.kpi-delta{font-size:.58rem;font-weight:700;margin-left:auto; - padding:1px 5px;border-radius:8px;white-space:nowrap} +.kpi-delta{font-size:.62rem;font-weight:700;margin-left:auto; + padding:1px 5px;border-radius:var(--radius-sm);white-space:nowrap} .kpi-delta--good{color:var(--success);background:var(--success-muted)} .kpi-delta--bad{color:var(--error);background:var(--error-muted)} .kpi-delta--neutral{color:var(--text-muted);background:var(--bg-raised)} @@ -671,16 +671,19 @@ .dir-hotspot-entry{padding:var(--sp-2) 0;border-bottom:1px solid color-mix(in srgb,var(--border) 50%,transparent)} .dir-hotspot-entry:last-child{border-bottom:none;padding-bottom:0} .dir-hotspot-entry:first-child{padding-top:0} -.dir-hotspot-path{display:flex;align-items:center;gap:var(--sp-2);margin-bottom:4px;min-width:0} -.dir-hotspot-path code{font-size:.78rem;font-weight:600;color:var(--text-primary);line-height:1.3} -.dir-hotspot-bar-row{display:flex;align-items:center;gap:var(--sp-2);margin-bottom:3px} -.dir-hotspot-bar-track{flex:1;height:4px;border-radius:2px;background:var(--bg-raised); - overflow:hidden;display:flex} +/* Row 1: path + badge */ +.dir-hotspot-head{display:flex;align-items:center;gap:var(--sp-2);min-width:0} +.dir-hotspot-path{font-size:.78rem;font-weight:600;color:var(--text-primary);line-height:1.3; + overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1} +/* Row 2: bar + pct + meta */ +.dir-hotspot-detail{display:flex;align-items:center;gap:var(--sp-2);margin-top:3px} +.dir-hotspot-bar-track{width:30%;flex-shrink:0;height:4px;border-radius:2px; + background:var(--bg-raised);overflow:hidden;display:flex} .dir-hotspot-bar-prev{height:100%;background:var(--text-muted);opacity:.18} .dir-hotspot-bar-cur{height:100%;background:var(--accent-primary);opacity:.7} -.dir-hotspot-pct{font-size:.7rem;font-weight:600;font-variant-numeric:tabular-nums; - color:var(--text-muted);min-width:3.2em;text-align:right} -.dir-hotspot-meta{display:flex;flex-wrap:wrap;gap:6px;font-size:.68rem;color:var(--text-muted)} +.dir-hotspot-pct{font-size:.72rem;font-weight:600;font-variant-numeric:tabular-nums; + color:var(--text-secondary);white-space:nowrap;flex-shrink:0} +.dir-hotspot-meta{display:flex;flex-wrap:wrap;gap:4px 6px;font-size:.68rem;color:var(--text-muted)} .dir-hotspot-meta span{font-variant-numeric:tabular-nums} .dir-hotspot-meta-sep{opacity:.3} .overloaded-module-list{display:flex;flex-direction:column;gap:0} @@ -690,15 +693,15 @@ .overloaded-module-head{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--sp-2);margin-bottom:4px} .overloaded-module-title{display:flex;align-items:center;flex-wrap:wrap;gap:var(--sp-2);min-width:0} .overloaded-module-title code{font-size:.78rem;font-weight:600;color:var(--text-primary);line-height:1.35} -.overloaded-module-score{flex-shrink:0;font-size:.72rem;font-weight:700;font-variant-numeric:tabular-nums; - color:var(--accent-primary);background:var(--accent-muted);border-radius:999px;padding:2px 8px} +.overloaded-module-score{flex-shrink:0;font-size:.68rem;font-weight:700;font-variant-numeric:tabular-nums; + color:var(--accent-primary);background:var(--accent-muted);border-radius:var(--radius-sm);padding:2px var(--sp-2)} .overloaded-module-metrics{display:flex;flex-wrap:wrap;gap:6px;font-size:.68rem;color:var(--text-muted)} .overloaded-module-metrics span{font-variant-numeric:tabular-nums} .overloaded-module-reasons,.overloaded-module-signal-list{display:flex;flex-wrap:wrap;gap:var(--sp-1);margin-top:var(--sp-2)} .overloaded-module-reason-chip,.overloaded-module-signal-pill{display:inline-flex;align-items:center;gap:5px; font-size:.68rem;font-weight:500;color:var(--text-secondary);background:var(--bg-raised); - border:1px solid color-mix(in srgb,var(--border) 60%,transparent);border-radius:999px; - padding:2px 8px} + border:1px solid color-mix(in srgb,var(--border) 60%,transparent);border-radius:var(--radius-sm); + padding:2px var(--sp-2)} .overloaded-module-signal-count{font-variant-numeric:tabular-nums;color:var(--text-muted)} /* Health radar chart */ .health-radar{display:flex;justify-content:center;padding:var(--sp-3) 0} @@ -750,8 +753,8 @@ .dep-hub-pill{display:inline-flex;align-items:center;gap:var(--sp-1);padding:var(--sp-1) var(--sp-2); border-radius:var(--radius-sm);background:var(--bg-overlay);font-size:.8rem} .dep-hub-name{color:var(--text-primary);font-family:var(--font-mono);font-size:.8rem} -.dep-hub-deg{font-size:.72rem;font-weight:600;color:var(--accent-primary); - background:var(--accent-muted);padding:0 var(--sp-1);border-radius:var(--radius-sm)} +.dep-hub-deg{font-size:.68rem;font-weight:600;color:var(--accent-primary); + background:var(--accent-muted);padding:2px var(--sp-2);border-radius:var(--radius-sm)} /* Legend */ .dep-legend{display:flex;gap:var(--sp-4);align-items:center;margin-bottom:var(--sp-4); @@ -822,12 +825,12 @@ .suggestion-sev--critical{background:var(--error-muted);color:var(--error)} .suggestion-sev--warning{background:var(--warning-muted);color:var(--warning)} .suggestion-sev--info{background:var(--info-muted);color:var(--info)} -.suggestion-sev-inline{font-size:.72rem;font-weight:600;padding:1px var(--sp-1); +.suggestion-sev-inline{font-size:.68rem;font-weight:600;padding:2px var(--sp-2); border-radius:var(--radius-sm)} .suggestion-title{font-weight:600;font-size:.85rem;color:var(--text-primary);flex:1;min-width:0} .suggestion-meta{display:flex;align-items:center;gap:var(--sp-2);flex-shrink:0;flex-wrap:wrap} .suggestion-meta-badge{font-size:.68rem;font-weight:600;padding:2px var(--sp-2); - border-radius:999px;background:var(--bg-overlay);color:var(--text-muted); + border-radius:var(--radius-sm);background:var(--bg-overlay);color:var(--text-muted); white-space:nowrap;line-height:1.2;font-variant-numeric:tabular-nums} .suggestion-effort--easy{color:var(--success);background:var(--success-muted, rgba(34,197,94,.1))} .suggestion-effort--moderate{color:var(--warning);background:var(--warning-muted)} @@ -836,7 +839,7 @@ /* Body — context + summary */ .suggestion-body{padding:0 var(--sp-4) var(--sp-3);display:flex;flex-direction:column;gap:var(--sp-1)} .suggestion-context{display:flex;gap:var(--sp-1);flex-wrap:wrap} -.suggestion-chip{font-size:.65rem;font-weight:500;padding:1px 6px;border-radius:var(--radius-sm); +.suggestion-chip{font-size:.68rem;font-weight:500;padding:2px var(--sp-2);border-radius:var(--radius-sm); background:var(--bg-overlay);color:var(--text-muted);white-space:nowrap} .suggestion-summary{font-size:.8rem;font-family:var(--font-mono);color:var(--text-secondary);line-height:1.5} .suggestion-action{display:flex;align-items:center;gap:var(--sp-1); @@ -896,7 +899,7 @@ /* Header row */ .sf-head{padding:var(--sp-3) var(--sp-4);display:flex;align-items:center;gap:var(--sp-2);flex-wrap:wrap} -.sf-kind-badge{font-size:.68rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em; +.sf-kind-badge{font-size:.68rem;font-weight:600;text-transform:uppercase;letter-spacing:.03em; padding:2px var(--sp-2);border-radius:var(--radius-sm);white-space:nowrap; background:var(--info-muted);color:var(--info)} .sf-title{font-weight:600;font-size:.85rem;color:var(--text-primary);flex:1;min-width:0} @@ -976,7 +979,7 @@ font-family:var(--font-mono);font-size:.72rem} /* Boolean check/cross badges */ -.meta-bool{font-size:.7rem;font-weight:600;padding:1px 8px;border-radius:10px; +.meta-bool{font-size:.68rem;font-weight:600;padding:2px var(--sp-2);border-radius:var(--radius-sm); display:inline-flex;align-items:center;gap:3px} .meta-bool-true{background:var(--success-muted);color:var(--success)} .meta-bool-false{background:var(--error-muted);color:var(--error)} @@ -984,8 +987,8 @@ /* Provenance summary badges */ .prov-summary{display:flex;flex-wrap:wrap;align-items:center;gap:6px; padding:var(--sp-2) var(--sp-4);border-top:1px solid var(--border)} -.prov-badge{display:inline-flex;align-items:center;gap:4px;font-size:.66rem; - padding:2px 8px;border-radius:var(--radius-sm);background:var(--bg-raised); +.prov-badge{display:inline-flex;align-items:center;gap:4px;font-size:.68rem; + padding:2px var(--sp-2);border-radius:var(--radius-sm);background:var(--bg-raised); white-space:nowrap;line-height:1.3;border:1px solid color-mix(in srgb,var(--border) 55%,transparent)} .prov-badge-val{font-weight:600;font-variant-numeric:tabular-nums} .prov-badge-lbl{font-weight:400;color:var(--text-muted);text-transform:lowercase} diff --git a/codeclone/_html_data_attrs.py b/codeclone/_html_data_attrs.py index 74b2f8b..d4e94f3 100644 --- a/codeclone/_html_data_attrs.py +++ b/codeclone/_html_data_attrs.py @@ -8,7 +8,7 @@ from __future__ import annotations -from ._html_escape import _escape_attr +from ._html_escape import _escape_html __all__ = ["_build_data_attrs"] @@ -26,5 +26,5 @@ def _build_data_attrs(attrs: dict[str, object | None]) -> str: if val is None: continue s = str(val) - parts.append(f'{key}="{_escape_attr(s)}"') + parts.append(f'{key}="{_escape_html(s)}"') return f" {' '.join(parts)}" if parts else "" diff --git a/codeclone/_html_escape.py b/codeclone/_html_escape.py index 63b1a7e..381b033 100644 --- a/codeclone/_html_escape.py +++ b/codeclone/_html_escape.py @@ -16,13 +16,6 @@ def _escape_html(v: object) -> str: return text -def _escape_attr(v: object) -> str: - text = html.escape("" if v is None else str(v), quote=True) - text = text.replace("`", "`") - text = text.replace("\u2028", "
").replace("\u2029", "
") - return text - - def _meta_display(v: object) -> str: if isinstance(v, bool): return "true" if v else "false" diff --git a/codeclone/_html_filters.py b/codeclone/_html_filters.py index dd9bbf3..e700fad 100644 --- a/codeclone/_html_filters.py +++ b/codeclone/_html_filters.py @@ -10,7 +10,7 @@ from collections.abc import Sequence -from ._html_escape import _escape_attr, _escape_html +from ._html_escape import _escape_html __all__ = [ "CLONE_TYPE_OPTIONS", @@ -45,14 +45,14 @@ def _render_select( directly on the element (e.g. ``data-source-kind-filter="functions"``). """ parts = [ - f'") diff --git a/codeclone/_html_report/_assemble.py b/codeclone/_html_report/_assemble.py index 0449800..8884d7f 100644 --- a/codeclone/_html_report/_assemble.py +++ b/codeclone/_html_report/_assemble.py @@ -13,7 +13,7 @@ from .. import __version__, _coerce from .._html_css import build_css -from .._html_escape import _escape_attr, _escape_html +from .._html_escape import _escape_html from .._html_js import build_js from .._html_snippets import _FileCache, _pygments_css from ..contracts import DOCS_URL, ISSUES_URL, REPOSITORY_URL @@ -401,5 +401,5 @@ def _scope(rules: str, prefix: str) -> str: css=css_html, js=js_html, body=body_html, - scan_root=_escape_attr(ctx.scan_root), + scan_root=_escape_html(ctx.scan_root), ) diff --git a/codeclone/_html_report/_components.py b/codeclone/_html_report/_components.py index 9b05788..3a87292 100644 --- a/codeclone/_html_report/_components.py +++ b/codeclone/_html_report/_components.py @@ -13,7 +13,7 @@ from .._coerce import as_int as _as_int from .._html_badges import _source_kind_badge_html -from .._html_escape import _escape_attr, _escape_html +from .._html_escape import _escape_html from ._icons import section_icon_html Tone = Literal["ok", "warn", "risk", "info"] @@ -29,7 +29,7 @@ def insight_block(*, question: str, answer: str, tone: Tone = "info") -> str: return ( - f'
' + f'
' f'
{_escape_html(question)}
' f'
{_escape_html(answer)}
' "
" diff --git a/codeclone/_html_report/_context.py b/codeclone/_html_report/_context.py index 22057b5..efac981 100644 --- a/codeclone/_html_report/_context.py +++ b/codeclone/_html_report/_context.py @@ -142,11 +142,8 @@ def _group_sort_key(items: Collection[object]) -> tuple[int]: def _meta_pick(*values: object) -> object | None: for value in values: - if value is None: - continue - if isinstance(value, str) and not value.strip(): - continue - return value + if value is not None and (not isinstance(value, str) or value.strip()): + return value return None diff --git a/codeclone/_html_report/_glossary.py b/codeclone/_html_report/_glossary.py index ba05a00..70b7428 100644 --- a/codeclone/_html_report/_glossary.py +++ b/codeclone/_html_report/_glossary.py @@ -8,7 +8,7 @@ from __future__ import annotations -from .._html_escape import _escape_attr +from .._html_escape import _escape_html GLOSSARY: dict[str, str] = { # Complexity @@ -56,4 +56,4 @@ def glossary_tip(label: str) -> str: tip = GLOSSARY.get(label.lower(), "") if not tip: return "" - return f' ?' + return f' ?' diff --git a/codeclone/_html_report/_sections/_clones.py b/codeclone/_html_report/_sections/_clones.py index f071e3c..f60e363 100644 --- a/codeclone/_html_report/_sections/_clones.py +++ b/codeclone/_html_report/_sections/_clones.py @@ -14,7 +14,7 @@ from ... import _coerce from ..._html_badges import _source_kind_badge_html from ..._html_data_attrs import _build_data_attrs -from ..._html_escape import _escape_attr, _escape_html +from ..._html_escape import _escape_html from ..._html_filters import CLONE_TYPE_OPTIONS, SPREAD_OPTIONS, _render_select from ..._html_snippets import _render_code_block from ...report._source_kinds import SOURCE_KIND_FILTER_VALUES @@ -159,7 +159,7 @@ def _render_group_explanation(meta: Mapping[str, object]) -> str: "data-assert-ratio": str(meta.get("assert_ratio", "")), "data-consecutive-asserts": str(meta.get("consecutive_asserts", "")), } - attr_html = " ".join(f'{k}="{_escape_attr(v)}"' for k, v in attrs.items() if v) + attr_html = " ".join(f'{k}="{_escape_html(v)}"' for k, v in attrs.items() if v) parts = [f'{_escape_html(text)}' for text, css in items] note = "" if isinstance(meta.get("hint_note"), str): @@ -175,7 +175,7 @@ def _render_section_toolbar( group_count: int, ) -> str: return ( - f'