File size: 12,156 Bytes
d999bba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8294cde
 
 
 
 
 
 
 
 
 
 
 
d999bba
 
8294cde
d999bba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8294cde
 
 
 
 
 
 
d999bba
 
 
 
8294cde
 
 
 
d999bba
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
"""Regression tests for API discoverability (the 80-endpoint API surface).

Per `docs/audits/audit_02_hf_gradio.md` finding 2.4 (R1):
    "Add a regression test that asserts all api_name values are
     unique, all api_description values are non-empty, and all
     api_description values are meaningful English sentences."

This test walks the source code statically (not the built Gradio
Blocks) to find all ``api_name=...`` and ``api_description=...``
parameters in event handler calls. It does not require building
the Gradio app, so it runs in < 1 second.

Why static analysis instead of building the Blocks:
- Building the Blocks requires the full app context (DB, providers,
  etc.) which is heavy and slow.
- Static analysis is sufficient to catch the most common regressions:
  duplicate api_name, empty api_description, missing api_name on
  public event handlers.

What static analysis does NOT catch:
- Event handlers registered programmatically (e.g., in a loop with
  computed api_name). These are rare in ShopStack.
- The runtime shape of the API surface (e.g., whether the endpoint
  actually responds). For runtime shape, see test_app.py which
  builds the full app.
"""
from __future__ import annotations

import ast
import re
from pathlib import Path

import pytest


# Files that contain event handler registrations with api_name.
# Keep this list in sync with the audit's per-file inventory.
UI_DIR = Path(__file__).resolve().parents[1] / "shopstack" / "ui"
APP_PY = Path(__file__).resolve().parents[1] / "app.py"


def _collect_api_endpoints() -> list[dict]:
    """Walk source files and extract (api_name, api_description) pairs.

    Returns a list of dicts with keys:
        - file: source file path
        - line: line number
        - api_name: the value passed to api_name=
        - api_description: the value passed to api_description=
                        (or None if missing)

    Implementation: For each api_name keyword argument, we look at
    the same Call node's full set of keyword arguments. If
    api_description is also a keyword in the same call, we capture
    it (handling string concatenation by joining parts).
    """
    endpoints: list[dict] = []
    sources: list[Path] = list(UI_DIR.rglob("*.py")) + [APP_PY]

    for src in sources:
        try:
            content = src.read_text()
        except FileNotFoundError:
            continue
        try:
            tree = ast.parse(content)
        except SyntaxError:
            continue

        for node in ast.walk(tree):
            if not isinstance(node, ast.Call):
                continue

            # Find api_name and api_description in this call
            api_name_kw = None
            api_desc_kw = None
            for kw in node.keywords:
                if kw.arg == "api_name":
                    api_name_kw = kw
                elif kw.arg == "api_description":
                    api_desc_kw = kw

            if api_name_kw is None:
                continue

            if not (
                isinstance(api_name_kw.value, ast.Constant)
                and isinstance(api_name_kw.value.value, str)
            ):
                continue

            api_name = api_name_kw.value.value
            line = api_name_kw.value.lineno
            # Normalize file path: "shopstack/ui/tabs/basket.py" or "app.py"
            try:
                file = str(src.relative_to(Path(__file__).resolve().parents[1]))
            except ValueError:
                file = str(src.name)

            # Extract api_description (handles string concatenation and BinOp)
            api_desc = _extract_string_value(api_desc_kw.value) if api_desc_kw else None

            endpoints.append({
                "file": file,
                "line": line,
                "api_name": api_name,
                "api_description": api_desc,
            })

    return endpoints


def _extract_string_value(node) -> str | None:
    """Recursively extract a string value from an AST node.

    Handles:
    - ast.Constant (literal string)
    - ast.BinOp with ast.Add operator (string concatenation)
    - Returns None if the node is not a string-producing expression.
    """
    if isinstance(node, ast.Constant) and isinstance(node.value, str):
        return node.value
    if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
        left = _extract_string_value(node.left)
        right = _extract_string_value(node.right)
        if left is not None and right is not None:
            return left + right
    return None


# Collect once at module load time (test results don't change between runs)
ALL_ENDPOINTS = _collect_api_endpoints()


def test_api_name_count_meets_threshold():
    """The app exposes a healthy number of named API endpoints.

    v3 baseline: 80. We assert >= 50 to allow for renames/refactors
    without flagging trivial changes. If this fails, the API
    surface has shrunk significantly — investigate.
    """
    assert len(ALL_ENDPOINTS) >= 50, (
        f"Only {len(ALL_ENDPOINTS)} api_name endpoints found. "
        f"Expected >= 50. See docs/audits/audit_02_hf_gradio.md "
        f"for the v3 inventory."
    )


def test_all_api_names_unique():
    """No two event handlers should share the same api_name.

    Duplicate api_name would cause Gradio to raise on app build
    (or silently override one handler). This test fails fast.
    """
    from collections import Counter

    names = [e["api_name"] for e in ALL_ENDPOINTS]
    counts = Counter(names)
    duplicates = {name: count for name, count in counts.items() if count > 1}

    assert not duplicates, (
        f"Duplicate api_name values found: {duplicates}. "
        f"Gradio will raise on app build (or override silently). "
        f"Rename one of the conflicting endpoints."
    )


def test_all_api_names_non_empty():
    """Every api_name should be a non-empty meaningful string.

    Empty api_name disables the public API endpoint — but if
    someone passes an empty string by accident, this test catches it.
    """
    for ep in ALL_ENDPOINTS:
        assert ep["api_name"], (
            f"Empty api_name at {ep['file']}:{ep['line']}"
        )
        assert len(ep["api_name"]) >= 3, (
            f"api_name '{ep['api_name']}' is too short (< 3 chars) at "
            f"{ep['file']}:{ep['line']}"
        )
        # Snake_case convention
        assert re.match(r"^[a-z][a-z0-9_]*$", ep["api_name"]), (
            f"api_name '{ep['api_name']}' is not snake_case at "
            f"{ep['file']}:{ep['line']}"
        )


def test_all_api_descriptions_non_empty():
    """Every event handler with api_name should have a meaningful description.

    The description is what `gradio info` shows to API consumers.
    An empty description is a poor user experience.
    """
    missing_desc = [
        ep for ep in ALL_ENDPOINTS
        if not ep["api_description"] or len(ep["api_description"]) < 10
    ]

    assert not missing_desc, (
        f"Missing or short api_description for these endpoints: "
        f"{[ep['api_name'] for ep in missing_desc[:5]]}... "
        f"({len(missing_desc)} total). Add a meaningful description."
    )


def test_all_api_descriptions_end_with_period():
    """Most api_descriptions should end with a period (style check).

    The shopstack convention is mixed:
    - Older endpoints use full English sentences with periods
    - Newer endpoints use phrase-style (no period)

    We assert that at least SOME descriptions end with periods
    (to ensure periods are used somewhere) and that the ratio
    doesn't drop below 5% (to ensure periods aren't completely
    abandoned).
    """
    not_ending_with_period = [
        ep for ep in ALL_ENDPOINTS
        if ep["api_description"] and not ep["api_description"].rstrip().endswith(".")
    ]

    ending_with_period = [
        ep for ep in ALL_ENDPOINTS
        if ep["api_description"] and ep["api_description"].rstrip().endswith(".")
    ]

    total = len(ALL_ENDPOINTS)
    pct_ending = len(ending_with_period) / total if total else 0

    # At least 5% should end with periods
    assert pct_ending >= 0.05, (
        f"Only {len(ending_with_period)}/{total} api_descriptions "
        f"({pct_ending:.1%}) end with a period. The period convention "
        f"may have drifted — consider standardizing."
    )


def test_known_endpoints_exist():
    """Spot-check that critical API endpoints are still present.

    This test guards against accidental removal of high-value
    endpoints. If an endpoint is renamed intentionally, update
    this test.
    """
    names = {ep["api_name"] for ep in ALL_ENDPOINTS}

    # Core endpoints that consumers depend on
    required = {
        "save_locale",
        "runtime_status",  # AI-9: external deployment verification
        "switch_household",
        "create_household",
        "ask",
        "ask_submit",
        "market_scan",
        "home_scan",
        "build_list",
        "mark_purchased",
        "complete_list",
        "unified_plan",
        "price_search",
        "add_purchase",
        "consume_item",
        "export_json",
        "export_csv",
        "import_data",
        "trace_search",
        "trace_export",
        "notes_save",
        "notes_reload",
    }

    missing = required - names
    assert not missing, (
        f"Required API endpoints missing: {missing}. "
        f"If an endpoint was renamed, update this test."
    )


def test_endpoints_per_sub_builder():
    """Each sub-builder should expose at least 1 endpoint.

    A sub-builder with 0 endpoints is either:
    (a) Pure UI / no actions (acceptable but rare)
    (b) Wired incorrectly (event handlers missing api_name)
    This test flags case (b).
    """
    by_file: dict[str, list[str]] = {}
    for ep in ALL_ENDPOINTS:
        by_file.setdefault(ep["file"], []).append(ep["api_name"])

    # Sub-builders known to have endpoints.
    #
    # 2026-06-15: per the household-wiring supersession, the 5
    # household endpoints (``switch_household``, ``after_switch_household``,
    # ``show_add_household``, ``cancel_add_household``,
    # ``create_household``) moved from inline ``app.py`` into
    # ``shopstack/ui/state/household_wiring.py::wire_household_handlers``.
    # The canonical entry point is now the dedicated sub-builder, so
    # the test asserts it has endpoints (not ``app.py``, which is now
    # a pure composition layer — see the module docstring of
    # ``shopstack/ui/state/household_wiring.py`` for the supersession
    # rationale).
    sub_builders_with_endpoints = {
        "app.py",
        "shopstack/ui/state/household_wiring.py",
        "shopstack/ui/tabs/ask_panel.py",
        "shopstack/ui/tabs/basket.py",
        "shopstack/ui/tabs/cookbook.py",
        "shopstack/ui/tabs/market.py",
        "shopstack/ui/tabs/memory_activity.py",
        "shopstack/ui/tabs/memory_data.py",
        "shopstack/ui/tabs/memory_history.py",
        "shopstack/ui/tabs/memory_notes.py",
        "shopstack/ui/tabs/memory_nutrition.py",
        "shopstack/ui/tabs/reconcile.py",
        "shopstack/ui/tabs/today.py",
        "shopstack/ui/locale_save.py",
        "shopstack/ui/household_settings.py",
    }

    # Root composition layer is allowed to have 0 endpoints when the
    # wiring is fully delegated to a sub-builder (see supersession
    # above). For ``app.py`` specifically: it is the root composition
    # layer per the household-wiring supersession, so 0 endpoints
    # is the expected state. Any other file with 0 endpoints is a
    # regression that needs investigation.
    root_composition_files = {"app.py"}
    zero_endpoint_files = [
        f for f in sub_builders_with_endpoints
        if f not in by_file or len(by_file[f]) == 0
    ]
    zero_endpoint_files = [
        f for f in zero_endpoint_files
        if f not in root_composition_files
    ]

    assert not zero_endpoint_files, (
        f"These sub-builders have 0 api_name endpoints: "
        f"{zero_endpoint_files}. They should each expose at "
        f"least 1 endpoint via .click() / .change() / .submit() / .load()."
    )