From de622cca9a3183c0ebd410b88ca3714c6e547d63 Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sat, 11 Apr 2026 16:01:55 +0900 Subject: [PATCH 1/2] refactor(hud): extract 9 modules for parallel statusbar work Infrastructure Wave: establish lib/ layout + re-export pattern, move 3 low-risk items (BUDDY_FACE, format_rate_limits, get_fresh_version) and reserve 6 skeleton paths for Wave 1-D and Wave 2-A-E with docstring contracts. - lib/hud_buddy.py re-exports canonical BUDDY_FACE from tiny_actor_presets - lib/hud_version.py renames _get_fresh_version -> get_fresh_version (public) - lib/hud_rate_limits.py moves format_rate_limits verbatim - 6 skeleton modules for future waves with API contracts in docstrings - codingbuddy-hud.py adds sys.path bootstrap + re-exports (no noqa) - 9 new test_hud_*.py with tmp_path fixture + re-export identity locks Golden Rule: 133 tests in test_hud.py/test_hud_state.py/test_mode_detect_hud.py remain 100% passing (behavior-preserving). Total 155/155 tests pass. Design decisions from 4-reviewer PLAN panel feedback: 1. BUDDY_FACE re-exported from canonical tiny_actor_presets (avoids 4-way dup) 2. sys.path bootstrap follows sibling hook convention (no noqa: E402) 3. Skeletons retained with explicit docstring contracts 4. costHistory field deferred to Wave 2-B owner 5. get_fresh_version public rename + _get_fresh_version backcompat alias 6. Optional dead import removed from hud_version.py 7. Framed as Infrastructure Wave (layout + re-export, not LOC reduction) Closes #1464 --- .../hooks/codingbuddy-hud.py | 72 ++++++++----------- .../claude-code-plugin/hooks/lib/hud_buddy.py | 17 +++++ .../hooks/lib/hud_cache_savings.py | 14 ++++ .../hooks/lib/hud_context_bar.py | 15 ++++ .../hooks/lib/hud_layout.py | 16 +++++ .../hooks/lib/hud_rainbow.py | 13 ++++ .../hooks/lib/hud_rate_limits.py | 31 ++++++++ .../hooks/lib/hud_session.py | 16 +++++ .../hooks/lib/hud_velocity.py | 15 ++++ .../hooks/lib/hud_version.py | 54 ++++++++++++++ .../tests/test_hud_buddy.py | 31 ++++++++ .../tests/test_hud_cache_savings.py | 15 ++++ .../tests/test_hud_context_bar.py | 15 ++++ .../tests/test_hud_layout.py | 15 ++++ .../tests/test_hud_rainbow.py | 15 ++++ .../tests/test_hud_rate_limits.py | 54 ++++++++++++++ .../tests/test_hud_session.py | 15 ++++ .../tests/test_hud_velocity.py | 15 ++++ .../tests/test_hud_version.py | 70 ++++++++++++++++++ 19 files changed, 466 insertions(+), 42 deletions(-) create mode 100644 packages/claude-code-plugin/hooks/lib/hud_buddy.py create mode 100644 packages/claude-code-plugin/hooks/lib/hud_cache_savings.py create mode 100644 packages/claude-code-plugin/hooks/lib/hud_context_bar.py create mode 100644 packages/claude-code-plugin/hooks/lib/hud_layout.py create mode 100644 packages/claude-code-plugin/hooks/lib/hud_rainbow.py create mode 100644 packages/claude-code-plugin/hooks/lib/hud_rate_limits.py create mode 100644 packages/claude-code-plugin/hooks/lib/hud_session.py create mode 100644 packages/claude-code-plugin/hooks/lib/hud_velocity.py create mode 100644 packages/claude-code-plugin/hooks/lib/hud_version.py create mode 100644 packages/claude-code-plugin/tests/test_hud_buddy.py create mode 100644 packages/claude-code-plugin/tests/test_hud_cache_savings.py create mode 100644 packages/claude-code-plugin/tests/test_hud_context_bar.py create mode 100644 packages/claude-code-plugin/tests/test_hud_layout.py create mode 100644 packages/claude-code-plugin/tests/test_hud_rainbow.py create mode 100644 packages/claude-code-plugin/tests/test_hud_rate_limits.py create mode 100644 packages/claude-code-plugin/tests/test_hud_session.py create mode 100644 packages/claude-code-plugin/tests/test_hud_velocity.py create mode 100644 packages/claude-code-plugin/tests/test_hud_version.py diff --git a/packages/claude-code-plugin/hooks/codingbuddy-hud.py b/packages/claude-code-plugin/hooks/codingbuddy-hud.py index e72a645b..c99ca493 100644 --- a/packages/claude-code-plugin/hooks/codingbuddy-hud.py +++ b/packages/claude-code-plugin/hooks/codingbuddy-hud.py @@ -15,7 +15,36 @@ import sys from datetime import datetime, timezone -BUDDY_FACE = "\u25d5\u203f\u25d5" # ◕‿◕ +# --- lib import bootstrap --- +# statusLine entry script: sys.path insertion here is intentional so +# lib/* imports work when Claude Code invokes `python codingbuddy-hud.py`. +_LIB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib") +if _LIB_DIR not in sys.path: + sys.path.insert(0, _LIB_DIR) + +# === test_hud.py compatibility re-exports — DO NOT REMOVE without coordinated test update === +# Defensive fallback: statusLine is a hot path invoked by Claude Code on +# every render. If any lib module is temporarily broken (e.g. mid-wave +# refactor), fall back to minimal inline implementations so the status +# bar still renders instead of crashing the Claude Code subprocess. +try: + from hud_buddy import BUDDY_FACE # canonical SSoT via tiny_actor_presets +except Exception: # pragma: no cover - defensive + BUDDY_FACE = "\u25d5\u203f\u25d5" # ◕‿◕ + +try: + from hud_rate_limits import format_rate_limits +except Exception: # pragma: no cover - defensive + def format_rate_limits(stdin_data: dict) -> str: # type: ignore[misc] + return "" + +try: + from hud_version import get_fresh_version as _get_fresh_version # backcompat alias +except Exception: # pragma: no cover - defensive + def _get_fresh_version( # type: ignore[misc] + hud_state: dict, *, plugins_file: str = "" + ) -> str: + return hud_state.get("version", "") # Agent eye glyphs from .ai-rules agent definitions. AGENT_GLYPHS = { @@ -303,25 +332,6 @@ def resolve_model_label(stdin_data: dict) -> tuple: return (model_id, display_name) -def format_rate_limits(stdin_data: dict) -> str: - """Format rate-limit info if present. Returns '' when absent.""" - rl = stdin_data.get("rate_limits") - if not rl: - return "" - parts = [] - five = rl.get("five_hour") - if five: - pct = five.get("used_percentage", 0) - parts.append(f"5h:{pct:.0f}%") - seven = rl.get("seven_day") - if seven: - pct = seven.get("used_percentage", 0) - parts.append(f"7d:{pct:.0f}%") - if not parts: - return "" - return "RL:" + ",".join(parts) - - def format_worktree(stdin_data: dict) -> str: """Format worktree name if present. Returns '' when absent.""" wt = stdin_data.get("worktree") @@ -395,28 +405,6 @@ def format_badge_line(agent: str, focus: str, blocker_count) -> str: return " ".join(badges) -def _get_fresh_version(hud_state: dict, *, plugins_file: str = "") -> str: - """Return the most current plugin version. - - Prefers installed_plugins.json (authoritative after updates) - over the hud-state snapshot written at session start. - Pass *plugins_file* explicitly for testing. - """ - try: - lib_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib") - if lib_dir not in sys.path: - sys.path.insert(0, lib_dir) - from hud_helpers import read_installed_version - - kwargs = {"plugins_file": plugins_file} if plugins_file else {} - fresh = read_installed_version(**kwargs) - if fresh: - return fresh - except Exception: - pass - return hud_state.get("version", "") - - def format_status_line( stdin_data: dict, hud_state: dict, diff --git a/packages/claude-code-plugin/hooks/lib/hud_buddy.py b/packages/claude-code-plugin/hooks/lib/hud_buddy.py new file mode 100644 index 00000000..50da56f0 --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/hud_buddy.py @@ -0,0 +1,17 @@ +"""Buddy face re-export for CodingBuddy statusLine (#1326). + +``BUDDY_FACE`` is canonically defined in +``tiny_actor_presets.BUDDY_FACE`` and already covered by +``tests/test_tiny_actor_presets.py`` for value/type assertions. This +module re-exports it so statusLine helpers that conceptually belong to +the HUD layer can depend on a ``hud_*`` module instead of reaching into +``tiny_actor_presets``. + +Wave 0 establishes the re-export only. Wave 2-A will extend this file +with breathing Buddy face state logic (e.g., ``get_buddy_face(phase)``). +""" +from __future__ import annotations + +from tiny_actor_presets import BUDDY_FACE # canonical SSoT + +__all__ = ["BUDDY_FACE"] diff --git a/packages/claude-code-plugin/hooks/lib/hud_cache_savings.py b/packages/claude-code-plugin/hooks/lib/hud_cache_savings.py new file mode 100644 index 00000000..8f4f843f --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/hud_cache_savings.py @@ -0,0 +1,14 @@ +"""Cache-savings badge for CodingBuddy statusLine (#1326). + +Wave 0 skeleton — reserved for **Wave 2-C**. + +Planned contents (Wave 2-C owner fills): + * ``compute_cache_savings(cost_breakdown: dict) -> float`` — USD + avoided by cache hits + * ``format_cache_savings_badge(savings_usd: float) -> str`` + +Source of truth for the computation is the stdin ``cost`` payload +(cached-input vs non-cached-input token counts combined with +``MODEL_PRICING`` — both already available in ``codingbuddy-hud``). +This module will be the single import target for Wave 3 assembly. +""" diff --git a/packages/claude-code-plugin/hooks/lib/hud_context_bar.py b/packages/claude-code-plugin/hooks/lib/hud_context_bar.py new file mode 100644 index 00000000..5ec9dcd5 --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/hud_context_bar.py @@ -0,0 +1,15 @@ +"""Smart context bar visualization for CodingBuddy statusLine (#1326). + +Wave 0 skeleton — reserved for **Wave 2-E**. + +Planned contents (Wave 2-E owner fills): + * ``CONTEXT_BAR_WIDTH: int`` — segment count + * ``CONTEXT_BAR_THRESHOLDS: tuple[float, float, float]`` — warning + / danger / critical cut-offs + * ``render_context_bar(used_tokens: int, total_tokens: int) -> str`` + +Wave 2-E will render the bar from the ``context`` payload already +parsed in ``codingbuddy-hud``. This file is a reserved import target +so Wave 3 integration can depend on ``hud_context_bar`` without +creating the module mid-merge. +""" diff --git a/packages/claude-code-plugin/hooks/lib/hud_layout.py b/packages/claude-code-plugin/hooks/lib/hud_layout.py new file mode 100644 index 00000000..d3135f66 --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/hud_layout.py @@ -0,0 +1,16 @@ +"""Adaptive layout engine for CodingBuddy statusLine (#1326). + +Wave 0 skeleton — reserved for **Wave 1-D**. + +Planned contents (Wave 1-D owner fills): + * ``SEGMENT_PRIORITY: list[tuple[str, int]]`` — drop order when + width-constrained + * ``_visible_len(s: str) -> int`` — ANSI-aware length + * ``_shorten_model_label(name: str, *, compact: bool = False) -> str`` + * ``_fit_segments(segments: list[str], width: int, *, separator: str) -> str`` + +Wave 1-D will also migrate the segment-assembly logic currently inline +in ``codingbuddy-hud.format_status_line`` to these helpers. Until then, +this file is a reserved import target so Wave workers downstream +(Wave 2-E, Wave 3) can reference ``hud_layout`` without creating it. +""" diff --git a/packages/claude-code-plugin/hooks/lib/hud_rainbow.py b/packages/claude-code-plugin/hooks/lib/hud_rainbow.py new file mode 100644 index 00000000..65be9372 --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/hud_rainbow.py @@ -0,0 +1,13 @@ +"""Mode rainbow ANSI colouring for CodingBuddy statusLine (#1326). + +Wave 0 skeleton — reserved for **Wave 2-D**. + +Planned contents (Wave 2-D owner fills): + * ``MODE_PALETTE: dict[str, tuple[int, int, int]]`` — per-mode RGB + gradient anchors (PLAN/ACT/EVAL/AUTO) + * ``gradient_ansi(text: str, palette: tuple) -> str`` + * ``render_mode_rainbow(mode: str, text: str) -> str`` + +Wave 2-D will wire the rainbow into ``format_status_line`` (or its +``hud_layout`` successor) in place of the plain text mode label. +""" diff --git a/packages/claude-code-plugin/hooks/lib/hud_rate_limits.py b/packages/claude-code-plugin/hooks/lib/hud_rate_limits.py new file mode 100644 index 00000000..49ed221f --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/hud_rate_limits.py @@ -0,0 +1,31 @@ +"""Rate-limit formatting for CodingBuddy statusLine (#1326). + +Extracted verbatim from codingbuddy-hud.py as part of the Wave 0 refactor. +Behavior-preserving — see tests/test_hud.py for the contract. +""" +from __future__ import annotations + +from typing import Any, Dict + + +def format_rate_limits(stdin_data: Dict[str, Any]) -> str: + """Format Claude Code rate-limit badge. + + Returns an empty string when no rate-limit data is supplied so the + badge can be dropped from the status line silently. + """ + rl = stdin_data.get("rate_limits") + if not rl: + return "" + parts = [] + five = rl.get("five_hour") + if five: + pct = five.get("used_percentage", 0) + parts.append(f"5h:{pct:.0f}%") + seven = rl.get("seven_day") + if seven: + pct = seven.get("used_percentage", 0) + parts.append(f"7d:{pct:.0f}%") + if not parts: + return "" + return "RL:" + ",".join(parts) diff --git a/packages/claude-code-plugin/hooks/lib/hud_session.py b/packages/claude-code-plugin/hooks/lib/hud_session.py new file mode 100644 index 00000000..44f5e0de --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/hud_session.py @@ -0,0 +1,16 @@ +"""Session self-heal and stale state detection (#1326). + +Wave 0 skeleton — reserved for **Wave 1-B**. + +Planned contents (Wave 1-B owner fills): + * ``detect_stale_session(state: dict, *, now: datetime | None = None) -> bool`` + * ``reset_stale_session(state_file: str) -> None`` + * ``SESSION_STALE_SECONDS`` constant + +The current monolith embeds no session self-heal logic; Wave 1-B will +introduce both the helpers and their call site in +``codingbuddy-hud.format_status_line`` (or its Wave 1-D successor in +``hud_layout``). This module exists as a placeholder so Wave 1-B can +commit to its own sub-branch without racing other Wave workers to +create the file. +""" diff --git a/packages/claude-code-plugin/hooks/lib/hud_velocity.py b/packages/claude-code-plugin/hooks/lib/hud_velocity.py new file mode 100644 index 00000000..9e758375 --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/hud_velocity.py @@ -0,0 +1,15 @@ +"""Cost velocity indicator for CodingBuddy statusLine (#1326). + +Wave 0 skeleton — reserved for **Wave 2-B**. + +Planned contents (Wave 2-B owner fills): + * ``record_cost_sample(state_file: str, cost_usd: float, *, now=None) -> None`` + * ``compute_velocity(history: list[dict]) -> float`` — $/hour + * ``format_velocity_badge(velocity_usd_per_hour: float) -> str`` + * ``MAX_COST_HISTORY_ENTRIES`` constant + +Wave 2-B will ALSO extend ``lib/hud_state.py`` with a +``"costHistory": []`` entry in both ``_EXTENDED_DEFAULTS`` and +``init_hud_state()`` (this is deliberately NOT done in Wave 0 — schema +design belongs with the feature owner). +""" diff --git a/packages/claude-code-plugin/hooks/lib/hud_version.py b/packages/claude-code-plugin/hooks/lib/hud_version.py new file mode 100644 index 00000000..9361a0bc --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/hud_version.py @@ -0,0 +1,54 @@ +"""Version resolution for CodingBuddy statusLine (#1326). + +Wave 0 extracts the plugin-version fallback logic from +``codingbuddy-hud.py`` so Wave 1-A can extend the resolution chain +without touching the monolith. + +The public entry point is :func:`get_fresh_version`. ``codingbuddy-hud`` +calls it internally from ``format_status_line``; callers pass the +current ``hud_state`` dict and an optional ``plugins_file`` override +used by the test-suite to point at a fixture path. + +Behavior-preserving contract (mirrors the original monolith helper): + +1. Attempt to read the freshest version from + ``installed_plugins.json`` via + :func:`hud_helpers.read_installed_version`. +2. On success, return that value. +3. On any failure (missing file, parse error, unexpected exception), + fall back to ``hud_state.get("version", "")``. +""" +from __future__ import annotations + +from typing import Any, Dict + + +def get_fresh_version( + hud_state: Dict[str, Any], + *, + plugins_file: str = "", +) -> str: + """Return the freshest known plugin version string. + + Args: + hud_state: Current HUD state dict (supplies the fallback + ``version`` field). + plugins_file: Optional override for the + ``installed_plugins.json`` path, used by tests. + + Notes: + ``hud_helpers`` is imported lazily inside the function body to + preserve the hot-path resilience of the original monolith. If + ``hud_helpers`` is temporarily broken (e.g. mid-wave refactor), + the statusLine still renders via the ``hud_state`` fallback + instead of crashing at module load. + """ + try: + from hud_helpers import read_installed_version # lazy for resilience + kwargs = {"plugins_file": plugins_file} if plugins_file else {} + fresh = read_installed_version(**kwargs) + if fresh: + return fresh + except Exception: + pass + return hud_state.get("version", "") diff --git a/packages/claude-code-plugin/tests/test_hud_buddy.py b/packages/claude-code-plugin/tests/test_hud_buddy.py new file mode 100644 index 00000000..4f6ff296 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_hud_buddy.py @@ -0,0 +1,31 @@ +"""Sanity test for hud_buddy re-export (Wave 0 / #1463).""" +import importlib +import os +import sys + +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_hooks_dir = os.path.join(os.path.dirname(_tests_dir), "hooks") +_lib_dir = os.path.join(_hooks_dir, "lib") +for _p in (_hooks_dir, _lib_dir): + if _p not in sys.path: + sys.path.insert(0, _p) + +import hud_buddy # noqa: E402 +from tiny_actor_presets import BUDDY_FACE as CANONICAL_BUDDY_FACE # noqa: E402 + + +def test_reexport_is_canonical_ssot(): + """hud_buddy.BUDDY_FACE must be the canonical tiny_actor_presets.BUDDY_FACE.""" + assert hud_buddy.BUDDY_FACE is CANONICAL_BUDDY_FACE + + +def test_value_matches_glyph(): + """Sanity: the face is the three-char smiley.""" + assert hud_buddy.BUDDY_FACE == "\u25d5\u203f\u25d5" + + +def test_reexport_identity_from_codingbuddy_hud(): + """Lock: hud.BUDDY_FACE is the same object (re-export chain intact).""" + hud_main = importlib.import_module("codingbuddy-hud") + assert hud_main.BUDDY_FACE is hud_buddy.BUDDY_FACE + assert hud_main.BUDDY_FACE is CANONICAL_BUDDY_FACE diff --git a/packages/claude-code-plugin/tests/test_hud_cache_savings.py b/packages/claude-code-plugin/tests/test_hud_cache_savings.py new file mode 100644 index 00000000..61e2a815 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_hud_cache_savings.py @@ -0,0 +1,15 @@ +"""Skeleton sanity for hud_cache_savings — Wave 2-C placeholder (#1463).""" +import os +import sys + +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_hooks_dir = os.path.join(os.path.dirname(_tests_dir), "hooks") +_lib_dir = os.path.join(_hooks_dir, "lib") +for _p in (_hooks_dir, _lib_dir): + if _p not in sys.path: + sys.path.insert(0, _p) + + +def test_module_loads(): + """Contract: hud_cache_savings must be importable. Wave 2-C will add real assertions.""" + import hud_cache_savings # noqa: F401 diff --git a/packages/claude-code-plugin/tests/test_hud_context_bar.py b/packages/claude-code-plugin/tests/test_hud_context_bar.py new file mode 100644 index 00000000..51802b8e --- /dev/null +++ b/packages/claude-code-plugin/tests/test_hud_context_bar.py @@ -0,0 +1,15 @@ +"""Skeleton sanity for hud_context_bar — Wave 2-E placeholder (#1463).""" +import os +import sys + +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_hooks_dir = os.path.join(os.path.dirname(_tests_dir), "hooks") +_lib_dir = os.path.join(_hooks_dir, "lib") +for _p in (_hooks_dir, _lib_dir): + if _p not in sys.path: + sys.path.insert(0, _p) + + +def test_module_loads(): + """Contract: hud_context_bar must be importable. Wave 2-E will add real assertions.""" + import hud_context_bar # noqa: F401 diff --git a/packages/claude-code-plugin/tests/test_hud_layout.py b/packages/claude-code-plugin/tests/test_hud_layout.py new file mode 100644 index 00000000..c66c168b --- /dev/null +++ b/packages/claude-code-plugin/tests/test_hud_layout.py @@ -0,0 +1,15 @@ +"""Skeleton sanity for hud_layout — Wave 1-D placeholder (#1463).""" +import os +import sys + +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_hooks_dir = os.path.join(os.path.dirname(_tests_dir), "hooks") +_lib_dir = os.path.join(_hooks_dir, "lib") +for _p in (_hooks_dir, _lib_dir): + if _p not in sys.path: + sys.path.insert(0, _p) + + +def test_module_loads(): + """Contract: hud_layout must be importable. Wave 1-D will add real assertions.""" + import hud_layout # noqa: F401 diff --git a/packages/claude-code-plugin/tests/test_hud_rainbow.py b/packages/claude-code-plugin/tests/test_hud_rainbow.py new file mode 100644 index 00000000..29333634 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_hud_rainbow.py @@ -0,0 +1,15 @@ +"""Skeleton sanity for hud_rainbow — Wave 2-D placeholder (#1463).""" +import os +import sys + +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_hooks_dir = os.path.join(os.path.dirname(_tests_dir), "hooks") +_lib_dir = os.path.join(_hooks_dir, "lib") +for _p in (_hooks_dir, _lib_dir): + if _p not in sys.path: + sys.path.insert(0, _p) + + +def test_module_loads(): + """Contract: hud_rainbow must be importable. Wave 2-D will add real assertions.""" + import hud_rainbow # noqa: F401 diff --git a/packages/claude-code-plugin/tests/test_hud_rate_limits.py b/packages/claude-code-plugin/tests/test_hud_rate_limits.py new file mode 100644 index 00000000..f3896762 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_hud_rate_limits.py @@ -0,0 +1,54 @@ +"""Sanity + behavior test for the hud_rate_limits module (Wave 0 / #1463).""" +import importlib +import os +import sys + +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_hooks_dir = os.path.join(os.path.dirname(_tests_dir), "hooks") +_lib_dir = os.path.join(_hooks_dir, "lib") +for _p in (_hooks_dir, _lib_dir): + if _p not in sys.path: + sys.path.insert(0, _p) + +import hud_rate_limits # noqa: E402 + + +def test_module_has_public_api(): + assert hasattr(hud_rate_limits, "format_rate_limits") + + +def test_empty_returns_empty(): + assert hud_rate_limits.format_rate_limits({}) == "" + + +def test_no_rate_limits_key_returns_empty(): + assert hud_rate_limits.format_rate_limits({"cost": {}}) == "" + + +def test_five_hour_only(): + stdin = {"rate_limits": {"five_hour": {"used_percentage": 23.5}}} + assert hud_rate_limits.format_rate_limits(stdin) == "RL:5h:24%" + + +def test_seven_day_only(): + stdin = {"rate_limits": {"seven_day": {"used_percentage": 80}}} + assert hud_rate_limits.format_rate_limits(stdin) == "RL:7d:80%" + + +def test_both_limits(): + stdin = { + "rate_limits": { + "five_hour": {"used_percentage": 10}, + "seven_day": {"used_percentage": 40}, + } + } + result = hud_rate_limits.format_rate_limits(stdin) + assert "5h:10%" in result + assert "7d:40%" in result + assert result.startswith("RL:") + + +def test_reexport_identity_from_codingbuddy_hud(): + """Lock: codingbuddy-hud.format_rate_limits must be the same function.""" + hud_main = importlib.import_module("codingbuddy-hud") + assert hud_main.format_rate_limits is hud_rate_limits.format_rate_limits diff --git a/packages/claude-code-plugin/tests/test_hud_session.py b/packages/claude-code-plugin/tests/test_hud_session.py new file mode 100644 index 00000000..36d1bc8d --- /dev/null +++ b/packages/claude-code-plugin/tests/test_hud_session.py @@ -0,0 +1,15 @@ +"""Skeleton sanity for hud_session — Wave 1-B placeholder (#1463).""" +import os +import sys + +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_hooks_dir = os.path.join(os.path.dirname(_tests_dir), "hooks") +_lib_dir = os.path.join(_hooks_dir, "lib") +for _p in (_hooks_dir, _lib_dir): + if _p not in sys.path: + sys.path.insert(0, _p) + + +def test_module_loads(): + """Contract: hud_session must be importable. Wave 1-B will add real assertions.""" + import hud_session # noqa: F401 diff --git a/packages/claude-code-plugin/tests/test_hud_velocity.py b/packages/claude-code-plugin/tests/test_hud_velocity.py new file mode 100644 index 00000000..53261cda --- /dev/null +++ b/packages/claude-code-plugin/tests/test_hud_velocity.py @@ -0,0 +1,15 @@ +"""Skeleton sanity for hud_velocity — Wave 2-B placeholder (#1463).""" +import os +import sys + +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_hooks_dir = os.path.join(os.path.dirname(_tests_dir), "hooks") +_lib_dir = os.path.join(_hooks_dir, "lib") +for _p in (_hooks_dir, _lib_dir): + if _p not in sys.path: + sys.path.insert(0, _p) + + +def test_module_loads(): + """Contract: hud_velocity must be importable. Wave 2-B will add real assertions.""" + import hud_velocity # noqa: F401 diff --git a/packages/claude-code-plugin/tests/test_hud_version.py b/packages/claude-code-plugin/tests/test_hud_version.py new file mode 100644 index 00000000..46b1b8b8 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_hud_version.py @@ -0,0 +1,70 @@ +"""Sanity + behavior test for the hud_version module (Wave 0 / #1463).""" +import importlib +import os +import sys + +_tests_dir = os.path.dirname(os.path.abspath(__file__)) +_hooks_dir = os.path.join(os.path.dirname(_tests_dir), "hooks") +_lib_dir = os.path.join(_hooks_dir, "lib") +for _p in (_hooks_dir, _lib_dir): + if _p not in sys.path: + sys.path.insert(0, _p) + +import hud_version # noqa: E402 + + +def test_module_has_public_api(): + assert hasattr(hud_version, "get_fresh_version") + + +def test_fallback_to_hud_state_version(tmp_path): + missing = tmp_path / "no_plugins.json" + result = hud_version.get_fresh_version( + {"version": "9.9.9"}, plugins_file=str(missing) + ) + assert result == "9.9.9" + + +def test_empty_state_returns_empty(tmp_path): + missing = tmp_path / "no_plugins.json" + result = hud_version.get_fresh_version({}, plugins_file=str(missing)) + assert result == "" + + +def test_reads_installed_plugins_file_when_present(tmp_path): + """Contract: a real installed_plugins.json overrides hud_state.""" + plugins = tmp_path / "installed_plugins.json" + plugins.write_text( + '{"plugins": {"codingbuddy@v1": [{"version": "7.7.7"}]}}', + encoding="utf-8", + ) + result = hud_version.get_fresh_version( + {"version": "ignored"}, plugins_file=str(plugins) + ) + assert result == "7.7.7" + + +def test_import_does_not_read_real_plugins_file(monkeypatch, tmp_path): + """Lock: module load must not touch ~/.claude/plugins/installed_plugins.json.""" + fake_home = tmp_path / "fake_home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + # Use reload (not sys.modules.pop + import_module) so the top-level + # `import hud_version` binding above stays live. Otherwise sibling + # tests asserting identity (e.g. test_reexport_alias_from_codingbuddy_hud) + # would break because they reference an obsolete module object. + importlib.reload(hud_version) + # Reaching this line means reload succeeded without touching real FS. + assert True + + +def test_reexport_alias_from_codingbuddy_hud(): + """Lock: hud._get_fresh_version must be hud_version.get_fresh_version. + + Uses reload to re-sync after any earlier test that mutated sys.modules + (test order should not matter for identity locks). + """ + importlib.reload(hud_version) + sys.modules.pop("codingbuddy-hud", None) + hud_main = importlib.import_module("codingbuddy-hud") + assert hud_main._get_fresh_version is hud_version.get_fresh_version From 11c38ddf83bcf7a0f74738d317539d18eb0443aa Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sat, 11 Apr 2026 21:40:59 +0900 Subject: [PATCH 2/2] fix(hud,landing): narrow fallback imports + bump next to 16.2.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combined Wave 0 polish items from the #1465/#1485 review cycle: 1. Narrow `except Exception` → `except ImportError` in the 3 lib fallback import blocks (qual-1465 HIGH-1). Real logic bugs (SyntaxError, NameError, AttributeError) inside lib modules now surface immediately instead of being silently swallowed by a catch-all. 2. Drop inline stub functions for `format_rate_limits` and `_get_fresh_version` (qual-1465 HIGH-2). Eliminates the signature drift between canonical lib definitions and in-file fallback stubs observed on the integrator branch (Wave 1-A `plugin_json_file` kwarg drift). The outer main() try/except still catches any runtime failure and emits the minimal "◕‿◕ CodingBuddy" safe output via the BUDDY_FACE constant. 3. Hoist `hud_velocity` + `hud_cache_savings` imports to module top as `_format_velocity_segment` / `_format_cache_savings` (perf-1485 H1). Eliminates ~0.47μs sys.modules lookup per render. Integrator branch only — no-op on refactor/wave branches where the inline imports don't exist yet. 4. Bump next to 16.2.3 for GHSA-q4gf-8mx6-v5v3 (landing-security-check). Aligns eslint-config-next and updates setup.test.ts assertion. Refs: qual-1465 HIGH-1/2, perf-1485 H1, https://github.com/advisories/GHSA-q4gf-8mx6-v5v3 --- apps/landing-page/__tests__/setup.test.ts | 2 +- apps/landing-page/package.json | 4 +- .../hooks/codingbuddy-hud.py | 42 +++-- yarn.lock | 170 +++++++++++++++++- 4 files changed, 197 insertions(+), 21 deletions(-) diff --git a/apps/landing-page/__tests__/setup.test.ts b/apps/landing-page/__tests__/setup.test.ts index 00d1c0c6..1b5e64f8 100644 --- a/apps/landing-page/__tests__/setup.test.ts +++ b/apps/landing-page/__tests__/setup.test.ts @@ -8,7 +8,7 @@ describe('Next.js 16 Project Setup', () => { }); test('Next.js 16.x is installed and locked', () => { - expect(pkg.dependencies.next).toBe('16.1.6'); + expect(pkg.dependencies.next).toBe('16.2.3'); }); test('React 19.x is installed and locked', () => { diff --git a/apps/landing-page/package.json b/apps/landing-page/package.json index 89265a00..a5c557ca 100644 --- a/apps/landing-page/package.json +++ b/apps/landing-page/package.json @@ -32,7 +32,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.563.0", - "next": "16.1.6", + "next": "16.2.3", "next-intl": "^4.8.2", "next-themes": "^0.4.6", "prism-react-renderer": "^2.4.1", @@ -56,7 +56,7 @@ "axe-core": "^4.11.1", "babel-plugin-react-compiler": "1.0.0", "eslint": "^9", - "eslint-config-next": "16.1.6", + "eslint-config-next": "16.2.3", "happy-dom": "^20.8.8", "jest-axe": "^10.0.0", "madge": "^8.0.0", diff --git a/packages/claude-code-plugin/hooks/codingbuddy-hud.py b/packages/claude-code-plugin/hooks/codingbuddy-hud.py index c99ca493..a3472e4e 100644 --- a/packages/claude-code-plugin/hooks/codingbuddy-hud.py +++ b/packages/claude-code-plugin/hooks/codingbuddy-hud.py @@ -23,28 +23,40 @@ sys.path.insert(0, _LIB_DIR) # === test_hud.py compatibility re-exports — DO NOT REMOVE without coordinated test update === -# Defensive fallback: statusLine is a hot path invoked by Claude Code on -# every render. If any lib module is temporarily broken (e.g. mid-wave -# refactor), fall back to minimal inline implementations so the status -# bar still renders instead of crashing the Claude Code subprocess. +# Narrow the fallback to ImportError only: real logic bugs in lib modules +# (SyntaxError, NameError, AttributeError) must surface immediately instead +# of being silently swallowed by a catch-all. If a lib module fails to import +# entirely, the outer main() try/except at the bottom of this file still +# emits the minimal safe output via the BUDDY_FACE constant. try: from hud_buddy import BUDDY_FACE # canonical SSoT via tiny_actor_presets -except Exception: # pragma: no cover - defensive - BUDDY_FACE = "\u25d5\u203f\u25d5" # ◕‿◕ +except ImportError: # pragma: no cover - defensive + BUDDY_FACE = "◕‿◕" # minimal constant for safe-output path try: - from hud_rate_limits import format_rate_limits -except Exception: # pragma: no cover - defensive - def format_rate_limits(stdin_data: dict) -> str: # type: ignore[misc] - return "" + from hud_rate_limits import format_rate_limits # noqa: F401 re-exported for test_hud.py +except ImportError: # pragma: no cover - defensive + pass # main() catch-all handles absence try: from hud_version import get_fresh_version as _get_fresh_version # backcompat alias -except Exception: # pragma: no cover - defensive - def _get_fresh_version( # type: ignore[misc] - hud_state: dict, *, plugins_file: str = "" - ) -> str: - return hud_state.get("version", "") +except ImportError: # pragma: no cover - defensive + pass # main() catch-all handles absence + +# Wave 2-B velocity + Wave 2-C cache savings hot-path suffixes for the cost segment. +# Hoisted to module top per perf-1485 H1 so format_status_line avoids a +# sys.modules lookup on every render (~0.47μs saved per call). +try: + from hud_velocity import format_velocity_segment as _format_velocity_segment +except ImportError: # pragma: no cover - defensive + def _format_velocity_segment(stdin_data, hud_state=None): # type: ignore[misc] + return "" + +try: + from hud_cache_savings import format_cache_savings as _format_cache_savings +except ImportError: # pragma: no cover - defensive + def _format_cache_savings(stdin_data): # type: ignore[misc] + return "" # Agent eye glyphs from .ai-rules agent definitions. AGENT_GLYPHS = { diff --git a/yarn.lock b/yarn.lock index 1eac05b0..76937473 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1561,6 +1561,13 @@ __metadata: languageName: node linkType: hard +"@next/env@npm:16.2.3": + version: 16.2.3 + resolution: "@next/env@npm:16.2.3" + checksum: 10c0/56c3fee8ea226efe59ef065e054380f872c00c45c9fe4475eaa45f80773c3c1adc3ead3ccdd77447d3c1aeb4b3004aaaa033dd4a100d3e572fd01b83f992dde8 + languageName: node + linkType: hard + "@next/eslint-plugin-next@npm:16.1.6": version: 16.1.6 resolution: "@next/eslint-plugin-next@npm:16.1.6" @@ -1570,6 +1577,15 @@ __metadata: languageName: node linkType: hard +"@next/eslint-plugin-next@npm:16.2.3": + version: 16.2.3 + resolution: "@next/eslint-plugin-next@npm:16.2.3" + dependencies: + fast-glob: "npm:3.3.1" + checksum: 10c0/be881aa89e0840ab60455b07a2bb9ec0d686c664a0d91e8ca815797a65ca71d7bd79d186b0df5b6892c2bf57bd07fa05421cd93e2812dfeaedfad5ed9fd1023e + languageName: node + linkType: hard + "@next/swc-darwin-arm64@npm:16.1.6": version: 16.1.6 resolution: "@next/swc-darwin-arm64@npm:16.1.6" @@ -1577,6 +1593,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-darwin-arm64@npm:16.2.3": + version: 16.2.3 + resolution: "@next/swc-darwin-arm64@npm:16.2.3" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@next/swc-darwin-x64@npm:16.1.6": version: 16.1.6 resolution: "@next/swc-darwin-x64@npm:16.1.6" @@ -1584,6 +1607,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-darwin-x64@npm:16.2.3": + version: 16.2.3 + resolution: "@next/swc-darwin-x64@npm:16.2.3" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@next/swc-linux-arm64-gnu@npm:16.1.6": version: 16.1.6 resolution: "@next/swc-linux-arm64-gnu@npm:16.1.6" @@ -1591,6 +1621,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-linux-arm64-gnu@npm:16.2.3": + version: 16.2.3 + resolution: "@next/swc-linux-arm64-gnu@npm:16.2.3" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@next/swc-linux-arm64-musl@npm:16.1.6": version: 16.1.6 resolution: "@next/swc-linux-arm64-musl@npm:16.1.6" @@ -1598,6 +1635,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-linux-arm64-musl@npm:16.2.3": + version: 16.2.3 + resolution: "@next/swc-linux-arm64-musl@npm:16.2.3" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "@next/swc-linux-x64-gnu@npm:16.1.6": version: 16.1.6 resolution: "@next/swc-linux-x64-gnu@npm:16.1.6" @@ -1605,6 +1649,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-linux-x64-gnu@npm:16.2.3": + version: 16.2.3 + resolution: "@next/swc-linux-x64-gnu@npm:16.2.3" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@next/swc-linux-x64-musl@npm:16.1.6": version: 16.1.6 resolution: "@next/swc-linux-x64-musl@npm:16.1.6" @@ -1612,6 +1663,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-linux-x64-musl@npm:16.2.3": + version: 16.2.3 + resolution: "@next/swc-linux-x64-musl@npm:16.2.3" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@next/swc-win32-arm64-msvc@npm:16.1.6": version: 16.1.6 resolution: "@next/swc-win32-arm64-msvc@npm:16.1.6" @@ -1619,6 +1677,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-win32-arm64-msvc@npm:16.2.3": + version: 16.2.3 + resolution: "@next/swc-win32-arm64-msvc@npm:16.2.3" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@next/swc-win32-x64-msvc@npm:16.1.6": version: 16.1.6 resolution: "@next/swc-win32-x64-msvc@npm:16.1.6" @@ -1626,6 +1691,13 @@ __metadata: languageName: node linkType: hard +"@next/swc-win32-x64-msvc@npm:16.2.3": + version: 16.2.3 + resolution: "@next/swc-win32-x64-msvc@npm:16.2.3" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -5276,6 +5348,15 @@ __metadata: languageName: node linkType: hard +"baseline-browser-mapping@npm:^2.9.19": + version: 2.10.17 + resolution: "baseline-browser-mapping@npm:2.10.17" + bin: + baseline-browser-mapping: dist/cli.cjs + checksum: 10c0/e792a92a6b206521681e3ab3a72770023f74a3274450bfe11ba55a075ba26f5820d5d2d02d92e25224b8d01e327b78fbf3e116bdc6ac74b3d9c52f5e3f4a048a + languageName: node + linkType: hard + "better-sqlite3@npm:^11.9.1": version: 11.10.0 resolution: "better-sqlite3@npm:11.10.0" @@ -7028,6 +7109,29 @@ __metadata: languageName: node linkType: hard +"eslint-config-next@npm:16.2.3": + version: 16.2.3 + resolution: "eslint-config-next@npm:16.2.3" + dependencies: + "@next/eslint-plugin-next": "npm:16.2.3" + eslint-import-resolver-node: "npm:^0.3.6" + eslint-import-resolver-typescript: "npm:^3.5.2" + eslint-plugin-import: "npm:^2.32.0" + eslint-plugin-jsx-a11y: "npm:^6.10.0" + eslint-plugin-react: "npm:^7.37.0" + eslint-plugin-react-hooks: "npm:^7.0.0" + globals: "npm:16.4.0" + typescript-eslint: "npm:^8.46.0" + peerDependencies: + eslint: ">=9.0.0" + typescript: ">=3.3.1" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/c6fd3accadb53c636f034baf4363d22847bf824c8ca1ecfa8047a4eee7882d156e75f60f37098357c7ae07e646dfaa23a176336abd3c74aa9a2df61aee984653 + languageName: node + linkType: hard + "eslint-config-prettier@npm:10.1.8": version: 10.1.8 resolution: "eslint-config-prettier@npm:10.1.8" @@ -8942,12 +9046,12 @@ __metadata: class-variance-authority: "npm:^0.7.1" clsx: "npm:^2.1.1" eslint: "npm:^9" - eslint-config-next: "npm:16.1.6" + eslint-config-next: "npm:16.2.3" happy-dom: "npm:^20.8.8" jest-axe: "npm:^10.0.0" lucide-react: "npm:^0.563.0" madge: "npm:^8.0.0" - next: "npm:16.1.6" + next: "npm:16.2.3" next-intl: "npm:^4.8.2" next-themes: "npm:^0.4.6" prettier: "npm:^3.4.2" @@ -9858,6 +9962,66 @@ __metadata: languageName: node linkType: hard +"next@npm:16.2.3": + version: 16.2.3 + resolution: "next@npm:16.2.3" + dependencies: + "@next/env": "npm:16.2.3" + "@next/swc-darwin-arm64": "npm:16.2.3" + "@next/swc-darwin-x64": "npm:16.2.3" + "@next/swc-linux-arm64-gnu": "npm:16.2.3" + "@next/swc-linux-arm64-musl": "npm:16.2.3" + "@next/swc-linux-x64-gnu": "npm:16.2.3" + "@next/swc-linux-x64-musl": "npm:16.2.3" + "@next/swc-win32-arm64-msvc": "npm:16.2.3" + "@next/swc-win32-x64-msvc": "npm:16.2.3" + "@swc/helpers": "npm:0.5.15" + baseline-browser-mapping: "npm:^2.9.19" + caniuse-lite: "npm:^1.0.30001579" + postcss: "npm:8.4.31" + sharp: "npm:^0.34.5" + styled-jsx: "npm:5.1.6" + peerDependencies: + "@opentelemetry/api": ^1.1.0 + "@playwright/test": ^1.51.1 + babel-plugin-react-compiler: "*" + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + dependenciesMeta: + "@next/swc-darwin-arm64": + optional: true + "@next/swc-darwin-x64": + optional: true + "@next/swc-linux-arm64-gnu": + optional: true + "@next/swc-linux-arm64-musl": + optional: true + "@next/swc-linux-x64-gnu": + optional: true + "@next/swc-linux-x64-musl": + optional: true + "@next/swc-win32-arm64-msvc": + optional: true + "@next/swc-win32-x64-msvc": + optional: true + sharp: + optional: true + peerDependenciesMeta: + "@opentelemetry/api": + optional: true + "@playwright/test": + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + bin: + next: dist/bin/next + checksum: 10c0/8a9d27fc773d69f7f471cf1a23bde2ab2950e0411ef3e0d5c1664ed9654e94c3304eae1c4283ec0fa4e70e7b3f4416913350e118e0c18e8b055693dc5d021883 + languageName: node + linkType: hard + "node-abi@npm:^3.3.0": version: 3.89.0 resolution: "node-abi@npm:3.89.0" @@ -11302,7 +11466,7 @@ __metadata: languageName: node linkType: hard -"sharp@npm:^0.34.4": +"sharp@npm:^0.34.4, sharp@npm:^0.34.5": version: 0.34.5 resolution: "sharp@npm:0.34.5" dependencies: