From 716231a7f35e7fbe92a125ff70524e094f7ee484 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Nov 2026 15:02:26 +0100 Subject: [PATCH] leetcode_coach: shared package, SQL shape gate Standalone utils subproject (pyproject, pre-commit, CI, install.sh, README with the library API). It no longer imports leetcode_guard: guard_bridge resolves the three read-only calls on demand. Q5: SQL problems skip req: and ask for an OUTPUT SHAPE (columns, rows, grouping) before code; algorithm problems keep req:. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo --- .../workflows/leetcode-coach-precommit.yml | 73 ++++++ .github/workflows/leetcode-coach-tests.yml | 34 +++ leetcode_coach/.pre-commit-config.yaml | 157 ++++++++++++ leetcode_coach/README.md | 132 ++++++++++ leetcode_coach/install.sh | 14 + leetcode_coach/leetcode_coach/SKILL.md | 4 +- leetcode_coach/leetcode_coach/cluster_cli.py | 8 +- leetcode_coach/leetcode_coach/gate_notes.py | 76 ++++++ leetcode_coach/leetcode_coach/guard_bridge.py | 62 +++++ leetcode_coach/leetcode_coach/phase.py | 63 ++--- leetcode_coach/leetcode_coach/py.typed | 0 .../leetcode_coach/relay/__init__.py | 4 +- .../leetcode_coach/relay/__main__.py | 8 +- .../leetcode_coach/relay/grading.py | 26 +- leetcode_coach/leetcode_coach/relay/probe.py | 12 +- .../leetcode_coach/relay/session.py | 1 + .../leetcode_coach/relay/toolbox_grade.py | 8 +- leetcode_coach/leetcode_coach/shape.py | 61 +++++ .../leetcode_coach/tests/_relay_flow.py | 16 +- .../leetcode_coach/tests/_relay_support.py | 69 +++-- .../leetcode_coach/tests/test_app_events.py | 3 +- .../leetcode_coach/tests/test_cluster_cli.py | 52 ++-- .../leetcode_coach/tests/test_guard_bridge.py | 144 +++++++++++ .../leetcode_coach/tests/test_link_script.py | 90 ------- .../leetcode_coach/tests/test_phase.py | 1 + .../tests/test_relay_boundary.py | 81 +----- .../leetcode_coach/tests/test_relay_e2e.py | 6 +- .../leetcode_coach/tests/test_relay_main.py | 17 +- .../tests/test_relay_phone_e2e.py | 6 +- .../leetcode_coach/tests/test_relay_units.py | 17 +- .../leetcode_coach/tests/test_session_log.py | 21 +- .../leetcode_coach/tests/test_shape.py | 176 +++++++++++++ leetcode_coach/pyproject.toml | 241 ++++++++++++++++++ leetcode_coach/requirements.txt | 20 ++ 34 files changed, 1366 insertions(+), 337 deletions(-) create mode 100644 .github/workflows/leetcode-coach-precommit.yml create mode 100644 .github/workflows/leetcode-coach-tests.yml create mode 100644 leetcode_coach/.pre-commit-config.yaml create mode 100644 leetcode_coach/README.md create mode 100755 leetcode_coach/install.sh create mode 100644 leetcode_coach/leetcode_coach/gate_notes.py create mode 100644 leetcode_coach/leetcode_coach/guard_bridge.py create mode 100644 leetcode_coach/leetcode_coach/py.typed create mode 100644 leetcode_coach/leetcode_coach/shape.py create mode 100644 leetcode_coach/leetcode_coach/tests/test_guard_bridge.py delete mode 100644 leetcode_coach/leetcode_coach/tests/test_link_script.py create mode 100644 leetcode_coach/leetcode_coach/tests/test_shape.py create mode 100644 leetcode_coach/pyproject.toml create mode 100644 leetcode_coach/requirements.txt diff --git a/.github/workflows/leetcode-coach-precommit.yml b/.github/workflows/leetcode-coach-precommit.yml new file mode 100644 index 0000000..8465214 --- /dev/null +++ b/.github/workflows/leetcode-coach-precommit.yml @@ -0,0 +1,73 @@ +name: leetcode-coach pre-commit + +on: + push: + branches: [main] + # The workflow's own file counts as leetcode_coach: editing only this file would + # otherwise trigger nothing, so a fix to the job itself could never prove + # itself. + paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-precommit.yml'] + pull_request: + branches: [main] + paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-precommit.yml'] + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + # Only pre-commit itself. Every linting hook runs in its own isolated + # env -- ruff, mypy, pylint, bandit and codespell each come from their + # own repo+rev, and the local hooks need nothing beyond bash and + # python3 -- so installing the full requirements.txt spent 9s a run on + # packages no pre-commit-stage hook imports. Verified by running all + # three configs green in a venv holding pre-commit and nothing else. + # The pin is read out of requirements.txt rather than repeated here, so + # it cannot drift from what the dependency-freshness gate governs. + - name: Install pre-commit + run: | + pip install "$(grep '^pre-commit==' leetcode_coach/requirements.txt)" + + # Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on + # hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three + # package jobs shared one key, raced to overwrite it with hook sets built + # from four different configs, and editing a per-package config did not + # invalidate it. The cache was therefore silently wrong rather than + # missing, and the hooks reinstalled from scratch on every run (38-49s). + - uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: >- + pre-commit-leetcode-coach-${{ env.pythonLocation }}-${{ + hashFiles('leetcode_coach/.pre-commit-config.yaml') }} + # Prefix fallback, or this fix would evaporate on the very + # event that motivated it: bumping a hook rev changes the + # exact key, and a total miss means reinstalling every hook + # again. pre-commit's store is keyed internally by hook + # repo+rev, so a superseded entry is ignored disk rather + # than a wrong hook, and cache still re-saves under the new + # exact key after a prefix-only hit. + restore-keys: pre-commit-leetcode-coach-${{ env.pythonLocation }}- + # mypy re-analyses the stdlib and every third-party stub from scratch + # without this, which measured 10s of the hook step in CI against 0.25s + # warm locally -- a 19MB cache dir does the whole difference. Keyed on + # the config, not the sources: mypy's own cache metadata records the + # interpreter, the flag set and a hash per source file, so it re-checks + # what changed and cannot pass stale code. Deliberately NOT keyed on + # github.sha -- that re-saves 19MB x3 every commit and would evict the + # 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps + # the expensive part, the dependency analysis, which is what costs 10s. + - uses: actions/cache@v4 + with: + path: .mypy_cache + key: >- + mypy-leetcode-coach-${{ env.pythonLocation }}-${{ + hashFiles('leetcode_coach/.pre-commit-config.yaml') }} + restore-keys: mypy-leetcode-coach-${{ env.pythonLocation }}- + - name: Run the hooks + run: >- + pre-commit run --config leetcode_coach/.pre-commit-config.yaml + --all-files --show-diff-on-failure diff --git a/.github/workflows/leetcode-coach-tests.yml b/.github/workflows/leetcode-coach-tests.yml new file mode 100644 index 0000000..afe25fe --- /dev/null +++ b/.github/workflows/leetcode-coach-tests.yml @@ -0,0 +1,34 @@ +name: leetcode-coach tests + +on: + push: + branches: [main] + # The workflow's own file counts as leetcode_coach: editing only this file would + # otherwise trigger nothing, so a fix to the job itself could never prove + # itself. + paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-tests.yml'] + pull_request: + branches: [main] + paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-tests.yml'] + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: leetcode_coach + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + cache: pip + cache-dependency-path: leetcode_coach/requirements.txt + + - name: Install dependencies + run: pip install -r requirements.txt && pip install --no-deps -e . + + - name: Run tests with coverage + run: python -m pytest -v diff --git a/leetcode_coach/.pre-commit-config.yaml b/leetcode_coach/.pre-commit-config.yaml new file mode 100644 index 0000000..190bab2 --- /dev/null +++ b/leetcode_coach/.pre-commit-config.yaml @@ -0,0 +1,157 @@ +# Pre-commit Configuration for leetcode_coach +# Install: pre-commit install && pre-commit install --hook-type pre-push +# Run: pre-commit run --all-files +# Update: pre-commit autoupdate + +# NOTE: this subproject lives inside the ~/src/utils monorepo, whose git root is +# one level up. pre-commit always runs hooks from the git root, so every path +# below is written relative to THAT, not to this directory, and the top-level +# `files` key keeps these hooks off the sibling subprojects. CI invokes this +# config exactly the same way: +# pre-commit run --config leetcode_coach/.pre-commit-config.yaml --all-files +files: ^leetcode_coach/ + +default_language_version: + python: python3 +default_stages: [pre-commit] +fail_fast: false + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-toml + - id: check-added-large-files + args: [--maxkb=2000] + - id: check-merge-conflict + - id: detect-private-key + - id: debug-statements + - id: name-tests-test + args: [--pytest-test-first] + # Shared, non-test helpers (fixtures, fakes) imported by the real test + # modules: deliberately not collected, hence the leading underscore. + exclude: ^leetcode_coach/leetcode_coach/tests/_[a-z][a-z_]*\.py$ + - id: check-ast + - id: mixed-line-ending + args: [--fix=lf] + - id: requirements-txt-fixer + + - repo: local + hooks: + - id: file-length-cap + name: file length <= 250 lines + entry: bash scripts/check_file_length.sh + language: system + pass_filenames: true + - id: subproject-scope + name: subproject scope regex still matches + entry: scripts/check_subproject_scope.sh leetcode_coach + language: script + # always_run so the top-level `files` filter cannot skip the one hook + # whose job is to notice that filter has stopped matching anything. + always_run: true + pass_filenames: false + - id: no-noqa + name: Block noqa comments + entry: '(?i)#\s*(noqa|type:\s*ignore)' + language: pygrep + types: [python] + - id: no-ruff-noqa + name: Block ruff noqa file-level comments + entry: '(?i)#\s*ruff:\s*noqa' + language: pygrep + types: [python] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.17.2 + hooks: + - id: ruff + args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes] + types_or: [python, pyi] + - id: ruff-format + types_or: [python, pyi] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.13.0 + hooks: + - id: mypy + # Isolated env: the requests stubs must be declared here too. + additional_dependencies: + - types-requests + args: + - --ignore-missing-imports + - --no-error-summary + - --disable-error-code=no-untyped-def + - --disable-error-code=no-untyped-call + - --disable-error-code=var-annotated + - --disable-error-code=no-any-unimported + - --disable-error-code=type-arg + - --disable-error-code=no-any-return + - --disable-error-code=misc + - --disable-error-code=unused-ignore + - --disable-error-code=unreachable + - --disable-error-code=assignment + - --disable-error-code=no-redef + - --disable-error-code=attr-defined + - --disable-error-code=arg-type + - --disable-error-code=union-attr + - --disable-error-code=call-overload + - --disable-error-code=return-value + - --disable-error-code=redundant-cast + - --disable-error-code=empty-body + - --disable-error-code=list-item + + - repo: https://github.com/pylint-dev/pylint + rev: v4.0.8 + hooks: + - id: pylint + args: + - --rcfile=leetcode_coach/pyproject.toml + - --fail-under=8.0 + - --jobs=4 + - leetcode_coach/leetcode_coach + # Whole package, never the staged subset: a score over an arbitrary + # handful of files is an arbitrary number. Kept in step with + # requirements.txt. + pass_filenames: false + additional_dependencies: + - pytest + - requests + - genanki + - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync + + - repo: https://github.com/PyCQA/bandit + rev: 1.9.4 + hooks: + - id: bandit + args: + - -c + - leetcode_coach/pyproject.toml + - --severity-level=high + - --confidence-level=medium + - --skip=B113 + additional_dependencies: ["bandit[toml]"] + exclude: ^(leetcode_coach/leetcode_coach/tests/|.*test.*\.py$) + + - repo: local + hooks: + - id: pytest-coverage + name: pytest with coverage enforcement + entry: scripts/run_subproject_tests.sh leetcode_coach + language: script + types: [python] + pass_filenames: false + require_serial: true + stages: [pre-push] + + - repo: https://github.com/codespell-project/codespell + rev: v2.3.0 + hooks: + - id: codespell + args: + - --skip=*.json,*.lock,.git,__pycache__,.venv diff --git a/leetcode_coach/README.md b/leetcode_coach/README.md new file mode 100644 index 0000000..12f6653 --- /dev/null +++ b/leetcode_coach/README.md @@ -0,0 +1,132 @@ +# leetcode_coach + +The `/leetcode` coach as a library: the Socratic method gates, the session log, +the grader and card builder, and the relay that serves them. Moved here from +`leetcode-guard` (with its git history) so the tutor server and leetcode-guard +share ONE coach. Python 3.14, `import leetcode_coach`. + +Specs stay in `~/src/leetcode-guard/DOCS-coach-{spec,ui-spec,relay}.md`. + +## Install + +| Who | How | +|---|---| +| leetcode-guard | pinned: `leetcode-coach @ git+https://github.com/kuhyx/utils@leetcode-coach-v0.1.0#subdirectory=leetcode_coach` (its `install.sh` pip-installs it) | +| tutor server / development | `./install.sh` (editable, system python) | + +Tests: `scripts/run_subproject_tests.sh leetcode_coach` (100% branch coverage). +Gate: `pre-commit run --config leetcode_coach/.pre-commit-config.yaml --all-files`. + +## Layers + +Stdlib only unless noted. Import only what you need. + +| Layer | Modules | +|---|---| +| Gates | `phase`, `shape`, `gate_notes`, `req_check`, `leak`, `toolbox`, `stop_gate`, `gate_detector` | +| Log + progress | `session_log`, `progress`, `report` | +| Content | `fetch_problem`, `toolbox.json`, `answer_key`, `clusters.json` | +| Cards | `toolbox_cards`, `card_rules`, `make_cards` (needs genanki), `anki_connect` | +| Models | `claude_cli` (haiku/sonnet via `claude -p`), `local_backend` (Ollama) | +| Relay | `relay.*`: state machine, grading, run-code, HTTP + RTDB (needs requests, crdt-sync) | +| Clients | `leetcode_repl` (`leetcode` command), `cluster_cli`, `local_session`, `how_to` | + +## Library API for the tutor server + +Everything keys off the session log: a JSONL file per problem and day, state is +derived from it (never held in memory), so any caller and a restart agree. + +```python +from leetcode_coach import fetch_problem, phase, session_log, toolbox, leak +``` + +**Statement.** `fetch_problem.fetch(slug) -> dict | None` (None / `isPaidOnly` +means paste it), `fetch_problem.render(q, lang) -> str` (markdown with +`Tags:` and `Languages available:` lines), `slug_from(url_or_slug)`. + +**Phase gate (req-before-code).** +`gate = phase.PhaseGate(log, toolbox.load_safe(), source=lambda: statement, ask=None)` + +- `gate.screen(block, *, is_code=None) -> (sent | None, note)`: `None` means + refuse and show `note`; otherwise forward `sent` to the coach and show `note` + if non-empty. Logs `req`, `req_check`, `code`, `reveal`. +- `gate.refusal_note()`, `gate.code_seen()`, `gate.events()`, `gate.sql()`, + `gate.leaks(reply, user_texts) -> [names]` (bounce and regenerate), + `gate.after_reply(text) -> text without the rung marker` (logs `rung`). +- `phase.req_state(events) -> ReqState(accepted, forced, tries, .open)`, + `phase.step2_attempts(events)`, `phase.regex_verdict(block) -> bool | None`. +- 3 incomplete lists force the gate open (`req_incomplete: true` on `code`). + +**Q5, SQL problems.** If the statement has the `Database` tag or offers a SQL +dialect (`shape.is_sql`), `req:` is skipped: the gate asks for an OUTPUT SHAPE, +a message starting `shape:` that says the **columns**, the **rows** (what one +row stands for) and the **grouping**. Judged mechanically +(`shape.check -> req_check.Check(verdict, missing, "shape")`, no model, never +fails open), logged as the same `req` / `req_check` events with +`via: "shape"`, forced after 3 tries like `req:`. A `req:` message on a SQL +problem counts as the shape. An unfetched statement (just a slug) is not SQL: +the stricter `req:` gate applies. Algorithm problems are unchanged. +The relay state's `session.kind` is `"sql"` or `"algorithm"`. + +**Leaks and code.** `leak.leaked_names(reply, allowed, box)` (toolbox names +too early), `stop_gate.verdict(raw, reply)`, `gate_detector.offending_snippets`. + +**Log.** `session_log.log_path(problem)`, `append(path, event, **fields)`, +`read(path)`, `current(events)`, `has_code(path)`. Events: `session_start`, +`req`, `req_check`, `code`, `rung`, `reveal`, `grade`, `chat`. The field set is +a FIXED contract read by `leetcode_guard._coach_log`: add a field only together +with that reader. + +**Progress.** `progress.history(slug)`, `due_retests()` (cold re-tests at +3 +and +10 days), `cluster_progress()`, `next_new(...)`, `cluster_of(slug)`. + +**Toolbox + cards.** `toolbox.load_safe() -> Toolbox` (`.entries`, +`.properties`), `toolbox_cards.cards_for(names) -> [Card]` (property list -> +operation), `toolbox_cards.append_entry(...)` (new entry, after kuhy confirms), +`make_cards.deliver(...)` (Anki deck "LeetCode", `.apkg` fallback). + +**Relay (the five-step coach).** `relay.coach.Coach(Deps(...))`: +`handle(Request) -> dict` for the seven kinds in `relay.protocol.KINDS` +(`session_start`, `session_end`, `chat`, `grade_step`, `hint`, `reveal`, +`run_code`), `state()`, `chat_after(id)`, `tick()` (Accepted probe). +`relay.app.Relay(Deps())` adds loopback HTTP (`start()`, `port`) and the RTDB +phone link (`connect_phone(make_rtdb)`). Every dependency is a `Deps` field: +`ask`, `local_chat`, `fetch`, `probe`, `run_code`, `answers`, `box`, +`state_dir`, `cards_dry_run`, `clock_ms`. The wire is `DOCS-coach-relay.md`. + +## What it needs from leetcode-guard + +Three read-only calls, resolved on demand in `guard_bridge` (a consumer +without leetcode-guard imports everything else; these raise +`GuardUnavailableError`, never a silent "nothing solved"): + +| Call | Used by | +|---|---| +| `solved_slugs(slugs)` | `cluster_cli` (skip already-Accepted problems) | +| `recent_accepted()` | relay step 4 probe (`AcceptedProbe`, at most 1/min) | +| `phone_id_token(app, timeout_seconds=)` | `relay.__main__` RTDB sign-in | + +No coach module imports `leetcode_guard` statically and no route can reach the +gate's ledger, credits or unlocks (`tests/test_guard_boundary.py` reads the +bridge's strings against an allow-list). Credit stays leetcode-guard's job. + +## Environment + +| Variable | Meaning | +|---|---| +| `LEETCODE_COACH_SESSIONS_DIR` | session logs (default `~/.local/share/leetcode-coach/sessions`) | +| `LEETCODE_COACH_LOG` | log the REPL exported for the Stop hook | +| `LEETCODE_COACH_CLUSTER` | cluster tag for new logs | +| `LEETCODE_CLAUDE_BIN` | the `claude` binary (tests: a fake) | +| `LEETCODE_COACH_RELAY_DIR` / `_PORT` / `_RTDB_URL` | relay state dir, port (default 8777), RTDB override | +| `LEETCODE_COACH_CARDS_DRY_RUN` | `1`: build cards, push nothing | + +## Commands (the live units use these paths) + +- `python3 -m leetcode_coach.relay`: the relay (leetcode-coach-relay.service) +- `python3 -P -m leetcode_coach.leetcode_repl ` (the `leetcode` command) +- `python3 -m leetcode_coach.stop_gate`: the Stop hook (shim in `hook_shims/`) +- `python3 -m leetcode_coach.toolbox_cards --help`, `make_cards`, `report`, + `cluster_cli`, `fetch_problem`: the skill's scripts +- The skill body is `leetcode_coach/SKILL.md`; the skill dir + `~/.claude/skills/leetcode` points at `leetcode_coach/leetcode_coach/`. diff --git a/leetcode_coach/install.sh b/leetcode_coach/install.sh new file mode 100755 index 0000000..cfc7208 --- /dev/null +++ b/leetcode_coach/install.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Editable user install so /usr/bin/python3 (the tutor server, the CLI, the +# Stop hook) can `import leetcode_coach` straight from this checkout. +# leetcode-guard installs the TAGGED copy instead (its pyproject pin); run +# this only to develop the coach against live callers. Idempotent. +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly REPO_DIR +readonly SYSTEM_PYTHON="/usr/bin/python3" + +"$SYSTEM_PYTHON" -m pip install --user --no-deps --break-system-packages -e "$REPO_DIR" +cd / +"$SYSTEM_PYTHON" -P -c 'import leetcode_coach; print(leetcode_coach.__file__)' diff --git a/leetcode_coach/leetcode_coach/SKILL.md b/leetcode_coach/leetcode_coach/SKILL.md index 78252c5..a8f2e02 100644 --- a/leetcode_coach/leetcode_coach/SKILL.md +++ b/leetcode_coach/leetcode_coach/SKILL.md @@ -66,7 +66,9 @@ kuhy is practising a fixed method; coach it in this order: (XOR, hash map, sort are names, not properties). The client checks the list and shows kuhy the missing properties; coach toward them with H1 questions, never by stating them. Code sent before an accepted list is - held back by the client and never reaches you. + held back by the client and never reaches you. **SQL problems** (tag + Database) skip `req:`: kuhy sends `shape:` naming the columns, the rows + and the grouping of the result; the client checks that mechanically. 3. Only then does kuhy search for an operation with those properties. **Never name a toolbox operation or structure before kuhy's first code** — diff --git a/leetcode_coach/leetcode_coach/cluster_cli.py b/leetcode_coach/leetcode_coach/cluster_cli.py index c2340df..f2bf8e4 100644 --- a/leetcode_coach/leetcode_coach/cluster_cli.py +++ b/leetcode_coach/leetcode_coach/cluster_cli.py @@ -27,9 +27,7 @@ import logging import os import sys -from leetcode_coach import how_to, progress -from leetcode_guard._live_solved import check_solved -from leetcode_guard._settings import build_client +from leetcode_coach import guard_bridge, how_to, progress _logger = logging.getLogger(__name__) @@ -53,11 +51,11 @@ Start = Callable[[list[str]], int] def accepted_on_leetcode(slug: str) -> bool: """Whether LeetCode confirms an Accepted submission; False if it cannot say.""" try: - evidence = check_solved(build_client().post, [slug]) + solved = guard_bridge.solved_slugs([slug]) except Exception: # pylint: disable=broad-exception-caught _logger.exception("live solved-check for %s raised; serving it", slug) return False - return slug in evidence.solved + return slug in solved def _due() -> int: diff --git a/leetcode_coach/leetcode_coach/gate_notes.py b/leetcode_coach/leetcode_coach/gate_notes.py new file mode 100644 index 0000000..4bc6321 --- /dev/null +++ b/leetcode_coach/leetcode_coach/gate_notes.py @@ -0,0 +1,76 @@ +"""The one-line notes kuhy reads from the phase gate: req list or output shape.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from leetcode_coach import leak, shape + +if TYPE_CHECKING: + from leetcode_coach.req_check import Check + from leetcode_coach.toolbox import Toolbox + + +def property_line(prop: str, box: Toolbox) -> str: + """`prop: meaning`, or just `prop` when the meaning names an entry. + + Some meanings illustrate with the operation itself (self-inverse: + "a ^ x ^ x = a"); shown to kuhy that is the answer, not a property. + """ + meaning = box.properties[prop] + return prop if leak.leaked_names(meaning, [], box) else f"{prop}: {meaning}" + + +def _forced(what: str, result: Check) -> str: + missing = ", ".join(result.missing) + return f"[{what} still incomplete ({missing}) -- code accepted anyway]" + + +def req_note( + result: Check, tries: int, *, forced: bool, limit: int, box: Toolbox +) -> str: + if result.via == shape.VIA: + return shape_note(result, tries, forced=forced, limit=limit) + if result.verdict == "pass": + return "[req list accepted -- code is open]" + if result.verdict == "unverified": + return "[req check unavailable -- continuing unverified, code is open]" + if forced: + return _forced("req list", result) + props = "\n".join(f" - {property_line(p, box)}" for p in result.missing) + return ( + f"[req list incomplete -- it does not yet say these properties:\n{props}\n" + f" revise with req: ({tries} of {limit})]" + ) + + +def shape_note(result: Check, tries: int, *, forced: bool, limit: int) -> str: + if result.verdict == "pass": + return "[output shape accepted -- code is open]" + if forced: + return _forced("output shape", result) + lines = "\n".join(f" - {h}" for h in shape.hints(result.missing)) + return ( + f"[output shape incomplete -- it does not yet say:\n{lines}\n" + f" revise with shape: ({tries} of {limit})]" + ) + + +def refusal(tries: int, limit: int, *, sql: bool) -> str: + if not tries: + if sql: + return ( + "[code held back: state the output shape first -- one message " + "starting with shape: that says the columns, the rows and the " + "grouping]" + ) + return ( + "[code held back: write the requirement list first -- one message " + "starting with req: that lists the properties the operation must " + "have, without naming an operation or structure]" + ) + gate = "output shape" if sql else "req list" + return ( + f"[code held back: the {gate} is still incomplete -- revise it " + f"with {'shape' if sql else 'req'}: ({tries} of {limit} so far)]" + ) diff --git a/leetcode_coach/leetcode_coach/guard_bridge.py b/leetcode_coach/leetcode_coach/guard_bridge.py new file mode 100644 index 0000000..8b4fc55 --- /dev/null +++ b/leetcode_coach/leetcode_coach/guard_bridge.py @@ -0,0 +1,62 @@ +"""The three read-only calls the coach borrows from leetcode-guard, on demand. + +Without leetcode-guard installed (tutor server, CI) every other module still +imports; these raise `GuardUnavailableError`, never a silent "nothing solved". +""" + +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING, Final, Protocol + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + from types import ModuleType + + class Submission(Protocol): + title_slug: str + timestamp: int + + class SolveProbe(Protocol): + @property + def usable(self) -> bool: ... + + reason: str + submissions: Sequence[Submission] + + +SETTINGS: Final = "leetcode_guard._settings" +LIVE_SOLVED: Final = "leetcode_guard._live_solved" +SUBMISSIONS: Final = "leetcode_guard._submissions" +SNAPSHOT_AUTH: Final = "leetcode_guard._snapshot_auth" +ALLOWED: Final = frozenset({SETTINGS, LIVE_SOLVED, SUBMISSIONS, SNAPSHOT_AUTH}) + + +class GuardUnavailableError(RuntimeError): + pass + + +def _load(name: str) -> ModuleType: + try: + return importlib.import_module(name) + except ImportError as exc: + msg = f"{name} needs leetcode-guard installed ({exc})" + raise GuardUnavailableError(msg) from exc + + +def solved_slugs(slugs: Sequence[str]) -> frozenset[str]: + client = _load(SETTINGS).build_client() + evidence = _load(LIVE_SOLVED).check_solved(client.post, list(slugs)) + return frozenset(evidence.solved) + + +def recent_accepted() -> SolveProbe: + client = _load(SETTINGS).build_client() + probe: SolveProbe = _load(SUBMISSIONS).fetch_recent_ac(client.post, client.username) + return probe + + +def phone_id_token(app_name: str, *, timeout_seconds: float) -> Callable[[], str]: + client = _load(SNAPSHOT_AUTH).connect(app_name, timeout_seconds=timeout_seconds) + token: Callable[[], str] = client.auth.id_token + return token diff --git a/leetcode_coach/leetcode_coach/phase.py b/leetcode_coach/leetcode_coach/phase.py index d9ac1eb..38deeaf 100644 --- a/leetcode_coach/leetcode_coach/phase.py +++ b/leetcode_coach/leetcode_coach/phase.py @@ -25,11 +25,13 @@ import re from typing import TYPE_CHECKING, Any from leetcode_coach import ( + gate_notes, leak, local_prompt, local_session, req_check, session_log, + shape, toolbox, ) @@ -52,7 +54,7 @@ OPERATOR_RE = re.compile( def regex_verdict(block: str) -> bool | None: """True for clear code, False for clear prose, None when it cannot tell.""" - if REQ_RE.match(block): + if REQ_RE.match(block) or shape.SHAPE_RE.match(block): return False if STRONG_RE.search(block): return True @@ -113,31 +115,6 @@ def step2_attempts(events: list[dict[str, Any]]) -> int: return req_state(events).tries + fails -def property_line(prop: str, box: toolbox.Toolbox) -> str: - """`prop: meaning`, or just `prop` when the meaning names an entry. - - Some meanings illustrate with the operation itself (self-inverse: - "a ^ x ^ x = a"); shown to kuhy that is the answer, not a property. - """ - meaning = box.properties[prop] - return prop if leak.leaked_names(meaning, [], box) else f"{prop}: {meaning}" - - -def _req_note(result: req_check.Check, state: ReqState, box: toolbox.Toolbox) -> str: - if result.verdict == "pass": - return "[req list accepted -- code is open]" - if result.verdict == "unverified": - return "[req check unavailable -- continuing unverified, code is open]" - if state.forced: - missing = ", ".join(result.missing) - return f"[req list still incomplete ({missing}) -- code accepted anyway]" - props = "\n".join(f" - {property_line(p, box)}" for p in result.missing) - return ( - f"[req list incomplete -- it does not yet say these properties:\n{props}\n" - f" revise with req: ({state.tries} of {MAX_LISTS})]" - ) - - @dataclass class PhaseGate: """One problem's gate: its log, toolbox and (lazily fetched) statement.""" @@ -185,7 +162,7 @@ class PhaseGate: if text.lower() == "reveal": session_log.append(self.log, "reveal") return block, "" - if REQ_RE.match(text): + if REQ_RE.match(text) or (shape.SHAPE_RE.match(text) and self.sql()): return self._req(block) verdict = regex_verdict(text) if is_code is None else is_code if verdict is None: @@ -201,7 +178,10 @@ class PhaseGate: def _req(self, block: str) -> tuple[str, str]: session_log.append(self.log, "req", text=block.strip()) - result = req_check.check(self.statement(), block, self.toolbox, self.ask) + if self.sql(): + result = shape.check(block) + else: + result = req_check.check(self.statement(), block, self.toolbox, self.ask) session_log.append( self.log, "req_check", @@ -213,24 +193,25 @@ class PhaseGate: tag = result.verdict if result.missing: tag += "; missing properties: " + ", ".join(result.missing) - return f"{block}\n\n[req check: {tag}]", _req_note(result, state, self.toolbox) + note = gate_notes.req_note( + result, + state.tries, + forced=state.forced, + limit=MAX_LISTS, + box=self.toolbox, + ) + return f"{block}\n\n[req check: {tag}]", note def refusal_note(self) -> str: """The line a refused code block gets now (the relay's ``note``).""" return self._refusal(req_state(self.events())) - @staticmethod - def _refusal(state: ReqState) -> str: - if not state.tries: - return ( - "[code held back: write the requirement list first -- one message " - "starting with req: that lists the properties the operation must " - "have, without naming an operation or structure]" - ) - return ( - "[code held back: the req list is still incomplete -- revise it " - f"with req: ({state.tries} of {MAX_LISTS} so far)]" - ) + def sql(self) -> bool: + """Whether this is a SQL problem (an unfetched statement counts as not).""" + return shape.is_sql(self.statement()) + + def _refusal(self, state: ReqState) -> str: + return gate_notes.refusal(state.tries, MAX_LISTS, sql=self.sql()) def after_reply(self, text: str) -> str: """Log the reply's rung marker and return the reply without it.""" diff --git a/leetcode_coach/leetcode_coach/py.typed b/leetcode_coach/leetcode_coach/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/leetcode_coach/leetcode_coach/relay/__init__.py b/leetcode_coach/leetcode_coach/relay/__init__.py index b55bfa8..a4cba93 100644 --- a/leetcode_coach/leetcode_coach/relay/__init__.py +++ b/leetcode_coach/leetcode_coach/relay/__init__.py @@ -6,6 +6,6 @@ both feed one paced serial lane (``lane``) into the session backend (``coach``), which grades the five steps and runs the coaching chat. Nothing here can reach the gate's ledger, credits or unlocks: the only -``leetcode_guard`` modules in the import closure are the read-only -submission probe and its client (``test_relay_boundary.py``). +``leetcode_guard`` modules reachable are the read-only ones `guard_bridge` +names, imported on demand (``test_guard_boundary.py``). """ diff --git a/leetcode_coach/leetcode_coach/relay/__main__.py b/leetcode_coach/leetcode_coach/relay/__main__.py index 02374b8..caf2fae 100644 --- a/leetcode_coach/leetcode_coach/relay/__main__.py +++ b/leetcode_coach/leetcode_coach/relay/__main__.py @@ -3,7 +3,7 @@ Logs to stderr (the journal) at INFO; SIGTERM stops taking requests and lets the running one finish (the unit's ``KillMode=mixed`` spares the claude child meanwhile). The phone path signs in as crdt_sync app -``leetcode_coach_relay`` via `_snapshot_auth.connect`, which heals from a +``leetcode_coach_relay`` via `guard_bridge.phone_id_token`, which heals from a sibling app's session; ``$LEETCODE_COACH_RTDB_URL`` skips sign-in (tests). """ @@ -16,11 +16,11 @@ import threading from crdt_sync import CONFIG_FILE, FirebaseConfig +from leetcode_coach import guard_bridge from leetcode_coach.relay import config from leetcode_coach.relay.app import Relay from leetcode_coach.relay.coach import Deps from leetcode_coach.relay.rtdb import Rtdb -from leetcode_guard import _snapshot_auth FAKE_ID = "relay-test" """What the relay hands the fake RTDB, which takes any non-empty id token.""" @@ -31,9 +31,9 @@ def make_rtdb() -> Rtdb: url = config.rtdb_url() if url: return Rtdb(url, lambda: FAKE_ID) - client = _snapshot_auth.connect(config.APP_NAME, timeout_seconds=15) + id_token = guard_bridge.phone_id_token(config.APP_NAME, timeout_seconds=15) database = FirebaseConfig.load(CONFIG_FILE).database_url - return Rtdb(database, client.auth.id_token) + return Rtdb(database, id_token) def main() -> int: diff --git a/leetcode_coach/leetcode_coach/relay/grading.py b/leetcode_coach/leetcode_coach/relay/grading.py index c80cbc0..1365ff1 100644 --- a/leetcode_coach/leetcode_coach/relay/grading.py +++ b/leetcode_coach/leetcode_coach/relay/grading.py @@ -12,7 +12,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, Final -from leetcode_coach import leak, phase +from leetcode_coach import gate_notes, leak, phase, shape from leetcode_coach.relay import cards, graders, steps, toolbox_grade, turns from leetcode_coach.relay.errors import CoachError from leetcode_coach.relay.session import grade_line @@ -36,22 +36,34 @@ NAMES_A_TOOL: Final = ( def _requirements(session: Session, answer: str, box: Toolbox) -> tuple[str, str, str]: - """Step 2 through the phase gate; a list that is code or names a tool fails.""" + """Step 2 through the phase gate; a list that is code or names a tool fails. + + A SQL problem's step 2 is the output shape (judged mechanically, `shape`). + """ text = answer.strip() - words = phase.REQ_RE.sub("", text, count=1).strip() + sql = session.gate.sql() + prefix = shape.SHAPE_RE if sql and shape.SHAPE_RE.match(text) else phase.REQ_RE + words = prefix.sub("", text, count=1).strip() if phase.regex_verdict(words) is True: return "fail", READS_AS_CODE, "toolbox" - if leak.leaked_names(words, [], box): + if not sql and leak.leaked_names(words, [], box): return "fail", NAMES_A_TOOL, "toolbox" - session.gate.screen(text if phase.REQ_RE.match(text) else f"req: {text}") + session.gate.screen(text if prefix.match(text) else f"req: {text}") check = [e for e in session.events() if e.get("event") == "req_check"][-1] via = str(check.get("via") or "unverified") if check.get("verdict") == "pass": return "pass", "", via if check.get("verdict") == "unverified": return "pass", graders.UNVERIFIED, "unverified" - missing = [str(m) for m in check.get("missing", []) if m in box.properties] - lines = [f"- {phase.property_line(m, box)}" for m in missing] + missing = [str(m) for m in check.get("missing", [])] + if sql: + lines = [f"- {h}" for h in shape.hints(missing)] + else: + lines = [ + f"- {gate_notes.property_line(m, box)}" + for m in missing + if m in box.properties + ] return "partial", "\n".join(["It does not yet say:", *lines]), via diff --git a/leetcode_coach/leetcode_coach/relay/probe.py b/leetcode_coach/leetcode_coach/relay/probe.py index 844c2a9..c841cc2 100644 --- a/leetcode_coach/leetcode_coach/relay/probe.py +++ b/leetcode_coach/leetcode_coach/relay/probe.py @@ -1,7 +1,7 @@ """Step 4: has LeetCode seen an Accepted for this problem since it started? -The harvester's own read-only probe (``fetch_recent_ac`` with the client -``_settings.build_client`` builds), at most once a minute: LeetCode answers +The harvester's own read-only probe (leetcode-guard's ``fetch_recent_ac``, +reached through `guard_bridge`), at most once a minute: LeetCode answers a rate limit exactly like a dead session, so polling harder manufactures the failure. Three answers, never two: ``accepted``, ``not_yet``, and ``unknown`` when the probe is unusable -- "could not check" is never "not @@ -15,12 +15,13 @@ import logging import time from typing import TYPE_CHECKING, Final -from leetcode_guard._settings import build_client -from leetcode_guard._submissions import SolveProbe, fetch_recent_ac +from leetcode_coach import guard_bridge if TYPE_CHECKING: from collections.abc import Callable + from leetcode_coach.guard_bridge import SolveProbe + _logger: Final = logging.getLogger(__name__) EVERY_S: Final = 60.0 @@ -31,8 +32,7 @@ UNKNOWN: Final = "unknown" def live_probe() -> SolveProbe: """One read of kuhy's recent accepted submissions.""" - client = build_client() - return fetch_recent_ac(client.post, client.username) + return guard_bridge.recent_accepted() class AcceptedProbe: diff --git a/leetcode_coach/leetcode_coach/relay/session.py b/leetcode_coach/leetcode_coach/relay/session.py index 977e6cb..75f73a5 100644 --- a/leetcode_coach/leetcode_coach/relay/session.py +++ b/leetcode_coach/leetcode_coach/relay/session.py @@ -95,6 +95,7 @@ class Session: # pylint: disable=too-many-instance-attributes "started": self.started, "ended": self.ended, "end_reason": self.end_reason, + "kind": "sql" if self.gate.sql() else "algorithm", } diff --git a/leetcode_coach/leetcode_coach/relay/toolbox_grade.py b/leetcode_coach/leetcode_coach/relay/toolbox_grade.py index 4c0aa3c..71983f1 100644 --- a/leetcode_coach/leetcode_coach/relay/toolbox_grade.py +++ b/leetcode_coach/leetcode_coach/relay/toolbox_grade.py @@ -14,14 +14,14 @@ part that names no entry ("merge") means only the combined entry counts. * fail: nothing right was picked. Feedback is the properties the uncovered key entries need that no pick has, -as property names (`phase.property_line`), never an entry name. +as property names (`gate_notes.property_line`), never an entry name. """ from __future__ import annotations from typing import TYPE_CHECKING, Final -from leetcode_coach import phase +from leetcode_coach import gate_notes if TYPE_CHECKING: from leetcode_coach.answer_key import Answer @@ -94,7 +94,9 @@ def _feedback( for prop in () if entry is None else entry.properties: if prop not in have and prop not in need: need.append(prop) - lines = [f"- {phase.property_line(p, box)}" for p in need if p in box.properties] + lines = [ + f"- {gate_notes.property_line(p, box)}" for p in need if p in box.properties + ] if lines: head = "The operation you need also has these properties, which no pick has:" text = "\n".join([head, *lines]) diff --git a/leetcode_coach/leetcode_coach/shape.py b/leetcode_coach/leetcode_coach/shape.py new file mode 100644 index 0000000..d1595e3 --- /dev/null +++ b/leetcode_coach/leetcode_coach/shape.py @@ -0,0 +1,61 @@ +"""SQL problems state the OUTPUT SHAPE (columns, rows, grouping) before any code. + +The SQL counterpart of the ``req:`` gate: a ``shape:`` message is judged +mechanically (no model, nothing to fail open), and the verdict rides the same +``req`` / ``req_check`` log events (``via: "shape"``), so readers need no change. +""" + +from __future__ import annotations + +import re +from typing import Final + +from leetcode_coach import req_check + +VIA: Final = "shape" +SHAPE_RE: Final = re.compile(r"^\s*shape\s*:", re.IGNORECASE) +SQL_LANGS: Final = frozenset( + {"mysql", "mssql", "oraclesql", "postgresql", "pythondata"} +) +LANGUAGES_RE: Final = re.compile(r"^Languages available:(.*)$", re.MULTILINE) +TAGS_RE: Final = re.compile(r"^Tags:(.*)$", re.MULTILINE) +FACETS: Final = { + "columns": ( + re.compile(r"\b(?:columns?|fields?|headers?|named)\b", re.IGNORECASE), + "which columns come back, named how", + ), + "rows": ( + re.compile(r"\b(?:rows?|records?|per)\b", re.IGNORECASE), + "what one row stands for (one row per ...)", + ), + "grouping": ( + re.compile( + r"\b(?:group\w*|aggregat\w*|distinct|unique|per|sum|count|ungrouped)\b", + re.IGNORECASE, + ), + "how rows are grouped or aggregated, or that nothing is", + ), +} + + +def _items(line: re.Match[str] | None) -> set[str]: + return set() if line is None else {p.strip() for p in line.group(1).split(",")} + + +def is_sql(statement: str) -> bool: + """A rendered statement offers a SQL dialect or carries the Database tag.""" + langs = _items(LANGUAGES_RE.search(statement)) + return bool(langs & SQL_LANGS) or "Database" in _items(TAGS_RE.search(statement)) + + +def missing_facets(text: str) -> tuple[str, ...]: + return tuple(name for name, (found, _) in FACETS.items() if not found.search(text)) + + +def check(text: str) -> req_check.Check: + missing = missing_facets(text) + return req_check.Check("incomplete" if missing else "pass", missing, VIA) + + +def hints(missing: tuple[str, ...] | list[str]) -> list[str]: + return [f"{name}: {FACETS[name][1]}" for name in missing if name in FACETS] diff --git a/leetcode_coach/leetcode_coach/tests/_relay_flow.py b/leetcode_coach/leetcode_coach/tests/_relay_flow.py index 3b5120a..6dad365 100644 --- a/leetcode_coach/leetcode_coach/tests/_relay_flow.py +++ b/leetcode_coach/leetcode_coach/tests/_relay_flow.py @@ -17,11 +17,11 @@ from typing import TYPE_CHECKING, Any from leetcode_coach import answer_key, session_log from leetcode_coach.tests import _relay_support as rs -from leetcode_guard import _coach_log if TYPE_CHECKING: from collections.abc import Callable +ACCEPTED_STEP = 4 OPENER = "Let's start. What limit binds here?\n" HINT = "What happens to a value combined with itself?\n" CODE = ( @@ -165,18 +165,18 @@ def run_flow(rig: rs.Rig, ends: Ends) -> None: events = session_log.read(log) assert [e["event"] for e in events].count("grade") == 10 assert all(e.get("sid") == sid for e in events if e["event"] in {"grade", "chat"}) - problem = _coach_log.read_session(log) - assert problem is not None - assert problem.max_rung == "H1" - assert problem.accepted is True - assert problem.grades is not None - assert {k: v["grade"] for k, v in problem.grades.items()} == { + rungs = [e["rung"] for e in events if e["event"] == "rung"] + assert max(rungs) == "H1" + last = {e["step"]: e for e in events if e["event"] == "grade"} + assert {e["grade"] for n, e in last.items() if n == ACCEPTED_STEP} == {"pass"} + names = {1: "constraint", 2: "requirements", 3: "toolbox", 5: "postmortem"} + assert {names[n]: last[n]["grade"] for n in names} == { "constraint": "fail", "requirements": "pass", "toolbox": "pass", "postmortem": "pass", } - assert problem.grades["constraint"]["forced"] is True + assert last[1]["forced"] is True coach_calls = [c for c in rs.calls(rig.extra["claude"]) if c["kind"] == "coach"] assert all(c["log"] == str(log) for c in coach_calls) key = answer_key.load_answers()["missing-number"] diff --git a/leetcode_coach/leetcode_coach/tests/_relay_support.py b/leetcode_coach/leetcode_coach/tests/_relay_support.py index 3c42878..a960665 100644 --- a/leetcode_coach/leetcode_coach/tests/_relay_support.py +++ b/leetcode_coach/leetcode_coach/tests/_relay_support.py @@ -2,9 +2,9 @@ The relay runs in-process with its real threads (coverage sees it), on port 0, with the 3 s pace and the minute between probes injected away. The -LeetCode guard's data dir is redirected into the test's tmp dir and seeded -(`guard_digest`), so "byte-identical afterwards" is checked without racing -the live harvester on the developer's real files. +LeetCode guard's default data dir sits under a redirected HOME and is seeded +(`guard_digest`), so "byte-identical afterwards" is checked on files the live +harvester cannot touch. """ from __future__ import annotations @@ -18,15 +18,15 @@ from pathlib import Path import time from typing import TYPE_CHECKING, Any +import pytest + from leetcode_coach.relay.app import Relay from leetcode_coach.relay.coach import Deps from leetcode_coach.relay.probe import AcceptedProbe from leetcode_coach.tests._relay_statements import MISSING_NUMBER -from leetcode_guard import _constants -from leetcode_guard._submissions import AcSubmission, ProbeStatus, SolveProbe if TYPE_CHECKING: - import pytest + from collections.abc import Sequence FAKE_CLAUDE = Path(__file__).with_name("_fake_claude.py") DEV = "3f2a7c1e-9b4d-4e6f-8a1b-2c3d4e5f6a7b" @@ -57,28 +57,41 @@ def calls(root: Path) -> list[dict[str, Any]]: return [json.loads(line) for line in lines] +@dataclass(frozen=True) +class FakeSubmission: + """One accepted submission, as the probe reports it.""" + + title_slug: str + timestamp: int + + +@dataclass(frozen=True) +class FakeSolve: + """Guard's ``SolveProbe``, structurally.""" + + usable: bool + submissions: Sequence[FakeSubmission] + reason: str + + @dataclass class FakeProbe: - """``fetch_recent_ac`` as a switch: unusable, nothing, or an Accepted.""" + """``recent_accepted`` as a switch: unusable, nothing, or an Accepted.""" status: str = "nothing" timestamp_s: int = 0 calls: int = 0 - def __call__(self) -> SolveProbe: + def __call__(self) -> FakeSolve: self.calls += 1 if self.status == "unusable": - return SolveProbe(ProbeStatus.UNVERIFIABLE, (), "session expired") + return FakeSolve(False, (), "session expired") rows = ( - ( - AcSubmission( - "1", "Missing Number", "missing-number", self.timestamp_s, "js" - ), - ) + (FakeSubmission("missing-number", self.timestamp_s),) if self.status == "accepted" else () ) - return SolveProbe(ProbeStatus.OK, rows, "ok") + return FakeSolve(True, rows, "ok") @dataclass @@ -170,10 +183,32 @@ def wait_for(check: Any, timeout: float = 5.0) -> Any: raise AssertionError(msg) +@pytest.fixture(autouse=True) +def _isolate_paths(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """A throwaway HOME, so the guard's default data dir is the test's own.""" + home = tmp_path / "home" + guard_dir_of(home).mkdir(parents=True) + monkeypatch.setenv("HOME", str(home)) + + +def guard_dir_of(home: Path) -> Path: + """Where leetcode-guard keeps its data under `home`.""" + return home / ".local" / "share" / "leetcode-guard" + + +def guard_dir() -> Path: + """The (redirected) guard data dir; refuses to name the real one.""" + root = guard_dir_of(Path.home()) + if "pytest-of-" not in str(root): + msg = f"{root} is not a pytest tmp dir: refusing to touch guard data" + raise RuntimeError(msg) + return root + + def guard_digest() -> str: """A hash of every file under the (redirected) guard data dir.""" digest = hashlib.sha256() - root = Path(_constants.DATA_DIR) + root = guard_dir() for path in sorted(root.rglob("*")): digest.update(str(path.relative_to(root)).encode()) if path.is_file(): @@ -183,7 +218,7 @@ def guard_digest() -> str: def seed_guard() -> None: """Put a ledger and caches where the guard would read them.""" - root = Path(_constants.DATA_DIR) + root = guard_dir() (root / "ledger.json").write_text( '{"entries": [{"k": "credit:1"}]}', encoding="utf-8" ) diff --git a/leetcode_coach/leetcode_coach/tests/test_app_events.py b/leetcode_coach/leetcode_coach/tests/test_app_events.py index a547f3d..6ecfd11 100644 --- a/leetcode_coach/leetcode_coach/tests/test_app_events.py +++ b/leetcode_coach/leetcode_coach/tests/test_app_events.py @@ -13,6 +13,7 @@ import pytest from leetcode_coach import ( claude_cli, + gate_notes, local_backend, phase, req_check, @@ -130,7 +131,7 @@ def test_screen_judges_only_the_current_session(tmp_path: Path) -> None: def test_property_line_is_public() -> None: box = toolbox.load_safe() prop = next(iter(box.properties)) - assert phase.property_line(prop, box).startswith(prop) + assert gate_notes.property_line(prop, box).startswith(prop) def test_ask_merges_the_extra_env( diff --git a/leetcode_coach/leetcode_coach/tests/test_cluster_cli.py b/leetcode_coach/leetcode_coach/tests/test_cluster_cli.py index 96e3f2e..ccbe6af 100644 --- a/leetcode_coach/leetcode_coach/tests/test_cluster_cli.py +++ b/leetcode_coach/leetcode_coach/tests/test_cluster_cli.py @@ -11,9 +11,14 @@ from typing import TYPE_CHECKING import pytest -from leetcode_coach import cluster_cli, leetcode_repl, local_session, progress +from leetcode_coach import ( + cluster_cli, + guard_bridge, + leetcode_repl, + local_session, + progress, +) from leetcode_coach.tests.test_progress import log, solve -from leetcode_guard import _leetcode, _live_solved if TYPE_CHECKING: from pathlib import Path @@ -171,43 +176,36 @@ def test_retest_of_an_unsolved_problem_is_refused( def test_live_check_fails_open(monkeypatch: pytest.MonkeyPatch) -> None: - def boom() -> object: + def boom(_slugs: list[str]) -> frozenset[str]: raise OSError - monkeypatch.setattr(cluster_cli, "build_client", boom) + monkeypatch.setattr(guard_bridge, "solved_slugs", boom) + assert cluster_cli.accepted_on_leetcode("missing-number") is False + + +def test_live_check_without_leetcode_guard_serves_the_problem( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def absent(_slugs: list[str]) -> frozenset[str]: + raise guard_bridge.GuardUnavailableError + + monkeypatch.setattr(guard_bridge, "solved_slugs", absent) assert cluster_cli.accepted_on_leetcode("missing-number") is False def test_live_check_reports_an_accepted_problem( monkeypatch: pytest.MonkeyPatch, ) -> None: - seen: list[object] = [] + seen: list[list[str]] = [] - class Client: - post = "post" + def solved(slugs: list[str]) -> frozenset[str]: + seen.append(slugs) + return frozenset({"missing-number"}) & frozenset(slugs) - def check(post: object, slugs: list[str]) -> _live_solved.LiveSolved: - seen.extend([post, *slugs]) - return _live_solved.LiveSolved(solved=frozenset({"missing-number"})) - - monkeypatch.setattr(cluster_cli, "build_client", Client) - monkeypatch.setattr(cluster_cli, "check_solved", check) + monkeypatch.setattr(guard_bridge, "solved_slugs", solved) assert cluster_cli.accepted_on_leetcode("missing-number") is True assert cluster_cli.accepted_on_leetcode("two-sum") is False - assert seen[:2] == ["post", "missing-number"] - - -def test_a_silent_live_check_serves_the_problem( - monkeypatch: pytest.MonkeyPatch, -) -> None: - def silent(_q: str, _v: dict[str, object]) -> _leetcode.GraphQLResult: - return _leetcode.GraphQLResult(data=None, transport_error="offline") - - class Client: - post = staticmethod(silent) - - monkeypatch.setattr(cluster_cli, "build_client", Client) - assert cluster_cli.accepted_on_leetcode("missing-number") is False + assert seen[0] == ["missing-number"] def test_both_retests_done_means_nothing_more( diff --git a/leetcode_coach/leetcode_coach/tests/test_guard_bridge.py b/leetcode_coach/leetcode_coach/tests/test_guard_bridge.py new file mode 100644 index 0000000..f2de26c --- /dev/null +++ b/leetcode_coach/leetcode_coach/tests/test_guard_bridge.py @@ -0,0 +1,144 @@ +"""The on-demand bridge into leetcode-guard, and the boundary around it.""" + +from __future__ import annotations + +import ast +import builtins +from pathlib import Path +import re +import sys +import types +from typing import TYPE_CHECKING, Any + +import pytest + +import leetcode_coach +from leetcode_coach import guard_bridge + +if TYPE_CHECKING: + from collections.abc import Callable + +PACKAGE = Path(leetcode_coach.__file__).resolve().parent +DENIED = re.compile( + r"_ledger.*|_balance|_debt|_harvest.*|_gate.*|_sync.*|_lock.*|_breakglass" + r"|_escape.*|_snapshot_push" +) + + +def _guard(monkeypatch: pytest.MonkeyPatch, **modules: Any) -> None: + """Install fake ``leetcode_guard._`` modules.""" + monkeypatch.setitem( + sys.modules, "leetcode_guard", types.ModuleType("leetcode_guard") + ) + for name, attrs in modules.items(): + module = types.ModuleType(f"leetcode_guard.{name}") + module.__dict__.update(attrs) + monkeypatch.setitem(sys.modules, f"leetcode_guard.{name}", module) + + +class Client: + username = "kuhy" + + @staticmethod + def post(*_a: object) -> None: + return None + + +def test_solved_slugs_asks_guards_checker_with_guards_client( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: list[Any] = [] + + def check_solved(post: object, slugs: list[str]) -> Any: + seen.append((post, slugs)) + return types.SimpleNamespace(solved={"a"}) + + _guard( + monkeypatch, + _settings={"build_client": Client}, + _live_solved={"check_solved": check_solved}, + ) + assert guard_bridge.solved_slugs(("a", "b")) == frozenset({"a"}) + assert seen == [(Client.post, ["a", "b"])] + + +def test_recent_accepted_returns_guards_probe(monkeypatch: pytest.MonkeyPatch) -> None: + probe = object() + + def fetch(post: object, username: str) -> object: + assert (post, username) == (Client.post, "kuhy") + return probe + + _guard( + monkeypatch, + _settings={"build_client": Client}, + _submissions={"fetch_recent_ac": fetch}, + ) + assert guard_bridge.recent_accepted() is probe + + +def test_phone_id_token_connects_as_the_named_app( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def connect(app: str, *, timeout_seconds: float) -> Any: + assert (app, timeout_seconds) == ("relay", 3) + return types.SimpleNamespace(auth=types.SimpleNamespace(id_token=lambda: "tok")) + + _guard(monkeypatch, _snapshot_auth={"connect": connect}) + assert guard_bridge.phone_id_token("relay", timeout_seconds=3)() == "tok" + + +def test_a_missing_guard_is_a_named_error(monkeypatch: pytest.MonkeyPatch) -> None: + real_import: Callable[..., Any] = builtins.__import__ + for name in list(sys.modules): + if name.startswith("leetcode_guard"): + monkeypatch.delitem(sys.modules, name) + + def refuse(name: str, *args: Any, **kwargs: Any) -> Any: + if name.startswith("leetcode_guard"): + raise ModuleNotFoundError(name) + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", refuse) + with pytest.raises( + guard_bridge.GuardUnavailableError, match="needs leetcode-guard" + ): + guard_bridge.solved_slugs(["a"]) + + +def _static_guard_imports(path: Path) -> list[str]: + found: list[str] = [] + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if isinstance(node, ast.Import): + found += [a.name for a in node.names if a.name.startswith("leetcode_guard")] + elif isinstance(node, ast.ImportFrom) and (node.module or "").startswith( + "leetcode_guard" + ): + found.append(node.module or "") + return found + + +def test_no_coach_module_imports_the_guard_statically() -> None: + sources = [p for p in PACKAGE.rglob("*.py") if "tests" not in p.parts] + offenders = { + p.name: _static_guard_imports(p) for p in sources if _static_guard_imports(p) + } + assert not offenders + + +def test_the_walk_would_catch_a_static_import(tmp_path: Path) -> None: + probe = tmp_path / "x.py" + probe.write_text( + "from leetcode_guard import _ledger\nimport leetcode_guard._gate\n", + encoding="utf-8", + ) + assert _static_guard_imports(probe) == ["leetcode_guard", "leetcode_guard._gate"] + + +def test_the_bridge_names_only_read_only_guard_modules() -> None: + text = (PACKAGE / "guard_bridge.py").read_text(encoding="utf-8") + named = set(re.findall(r"leetcode_guard\.\w+", text)) + assert named == guard_bridge.ALLOWED + assert not [m for m in named if DENIED.fullmatch(m.split(".")[1])] + assert DENIED.fullmatch("_ledger_io") + assert DENIED.fullmatch("_harvest_run") diff --git a/leetcode_coach/leetcode_coach/tests/test_link_script.py b/leetcode_coach/leetcode_coach/tests/test_link_script.py deleted file mode 100644 index 42d59b9..0000000 --- a/leetcode_coach/leetcode_coach/tests/test_link_script.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Tests for scripts/link_leetcode_coach.sh, the install step for the coach. - -Run against a throwaway HOME: the script must never be pointed at the real -~/.claude from a test. -""" - -from __future__ import annotations - -from pathlib import Path -import subprocess - -REPO = Path(__file__).resolve().parents[2] -SCRIPT = REPO / "scripts" / "link_leetcode_coach.sh" -COACH = REPO / "leetcode_coach" - - -def _run(home: Path) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [str(SCRIPT)], - env={"HOME": str(home), "PATH": "/usr/bin:/bin"}, - capture_output=True, - text=True, - check=False, - ) - - -def _old_skill(home: Path) -> Path: - old = home / ".claude" / "skills" / "leetcode" - old.mkdir(parents=True) - (old / "SKILL.md").write_text("---\nname: leetcode\n---\n", encoding="utf-8") - (old / "leetcode_repl.py").write_text("", encoding="utf-8") - return old - - -def test_fresh_home_gets_both_links(tmp_path: Path) -> None: - proc = _run(tmp_path) - assert proc.returncode == 0, proc.stderr - skill = tmp_path / ".claude" / "skills" / "leetcode" - assert skill.is_symlink() - assert skill.resolve() == COACH - assert (tmp_path / ".local" / "bin" / "leetcode").resolve() == COACH / "leetcode" - assert _run(tmp_path).returncode == 0 # idempotent - - -def test_old_skill_dir_is_backed_up_outside_skills(tmp_path: Path) -> None: - _old_skill(tmp_path) - bin_link = tmp_path / ".local" / "bin" / "leetcode" - bin_link.parent.mkdir(parents=True) - bin_link.symlink_to(tmp_path / ".claude" / "skills" / "leetcode" / "leetcode") - proc = _run(tmp_path) - assert proc.returncode == 0, proc.stderr - backups = list((tmp_path / ".claude" / "backups").glob("skill-leetcode-*")) - assert len(backups) == 1 - assert (backups[0] / "leetcode_repl.py").is_file() - assert sorted(p.name for p in (tmp_path / ".claude" / "skills").iterdir()) == [ - "leetcode" - ] - assert (tmp_path / ".claude" / "skills" / "leetcode").resolve() == COACH - assert bin_link.resolve() == COACH / "leetcode" - - -def test_existing_symlink_is_repointed(tmp_path: Path) -> None: - skills = tmp_path / ".claude" / "skills" - skills.mkdir(parents=True) - elsewhere = tmp_path / "elsewhere" - elsewhere.mkdir() - (skills / "leetcode").symlink_to(elsewhere) - assert _run(tmp_path).returncode == 0 - assert (skills / "leetcode").resolve() == COACH - assert list(elsewhere.iterdir()) == [] - - -def test_unknown_directory_is_refused(tmp_path: Path) -> None: - other = tmp_path / ".claude" / "skills" / "leetcode" - other.mkdir(parents=True) - (other / "notes.txt").write_text("mine", encoding="utf-8") - proc = _run(tmp_path) - assert proc.returncode == 1 - assert "not replacing it" in proc.stderr - assert (other / "notes.txt").is_file() - assert not other.is_symlink() - - -def test_regular_file_at_the_command_is_refused(tmp_path: Path) -> None: - bin_file = tmp_path / ".local" / "bin" / "leetcode" - bin_file.parent.mkdir(parents=True) - bin_file.write_text("#!/bin/sh\n", encoding="utf-8") - proc = _run(tmp_path) - assert proc.returncode == 1 - assert not bin_file.is_symlink() diff --git a/leetcode_coach/leetcode_coach/tests/test_phase.py b/leetcode_coach/leetcode_coach/tests/test_phase.py index f07bdfb..8601ca3 100644 --- a/leetcode_coach/leetcode_coach/tests/test_phase.py +++ b/leetcode_coach/leetcode_coach/tests/test_phase.py @@ -35,6 +35,7 @@ CODE = "function missingNumber(nums) {\n return 0;\n}" ("is it XOR?", False), ("I think (not sure) it is about pairs", False), ("req: a = b", False), + ("shape: one row per id (a = b)", False), ("is it x ^ y?", None), ("a = b", None), ("what does f(3) give", None), diff --git a/leetcode_coach/leetcode_coach/tests/test_relay_boundary.py b/leetcode_coach/leetcode_coach/tests/test_relay_boundary.py index e8046e5..cad3729 100644 --- a/leetcode_coach/leetcode_coach/tests/test_relay_boundary.py +++ b/leetcode_coach/leetcode_coach/tests/test_relay_boundary.py @@ -1,92 +1,17 @@ -"""No relay route reaches the ledger (DOCS-coach-ui-spec.md decision 2, contract 4). +"""The relay spawns nothing but claude and node (DOCS-coach-ui-spec.md decision 2). -Walks the import closure of every ``leetcode_coach.relay`` module by AST -- -through ``leetcode_coach`` and ``leetcode_guard`` alike, ``TYPE_CHECKING`` -imports included -- and holds the guard side to an allowlist: the read-only -submission probe, its client, and the Firebase sign-in. A credit still comes -only from a LeetCode Accepted the harvester sees. +The ledger boundary (no coach module imports leetcode_guard, the bridge names +only read-only guard modules) is ``test_guard_boundary.py``. """ from __future__ import annotations -import ast from pathlib import Path import re import leetcode_coach ROOT = Path(leetcode_coach.__file__).resolve().parent.parent -ALLOWED = { - "_submissions", - "_queries", - "_settings", - "_auth", - "_constants", - "_leetcode", - "_throttle", - "_snapshot_auth", -} -DENIED = re.compile( - r"_ledger.*|_balance|_debt|_harvest.*|_gate.*|_sync.*|_lock.*|_breakglass" - r"|_escape.*|_snapshot_push" -) -PACKAGES = ("leetcode_coach", "leetcode_guard") - - -def _file(module: str) -> Path | None: - base = ROOT.joinpath(*module.split(".")) - for path in (base.with_suffix(".py"), base / "__init__.py"): - if path.is_file(): - return path - return None - - -def _imports(path: Path) -> set[str]: - found: set[str] = set() - for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): - if isinstance(node, ast.Import): - found |= {alias.name for alias in node.names} - elif isinstance(node, ast.ImportFrom) and node.module: - found.add(node.module) - found |= {f"{node.module}.{alias.name}" for alias in node.names} - return {m for m in found if m.split(".")[0] in PACKAGES and _file(m)} - - -def closure() -> set[str]: - """Every first-party module the relay package can load.""" - start = { - f"leetcode_coach.relay.{p.stem}" - for p in (ROOT / "leetcode_coach" / "relay").glob("*.py") - } - seen: set[str] = set() - todo = sorted(start) - while todo: - module = todo.pop() - if module in seen: - continue - seen.add(module) - path = _file(module) - assert path is not None, module - todo += sorted(_imports(path) - seen) - return seen - - -def test_the_relay_reaches_only_the_read_only_guard_modules() -> None: - guard = {m.split(".")[1] for m in closure() if m.startswith("leetcode_guard.")} - assert guard <= ALLOWED, guard - ALLOWED - assert not [m for m in guard if DENIED.fullmatch(m)] - assert "_submissions" in guard # the walk does see the guard side - - -def test_the_walk_would_catch_a_ledger_import(tmp_path: Path) -> None: - probe = tmp_path / "x.py" - probe.write_text( - "from leetcode_guard import _ledger\nimport leetcode_guard._gate\n", - encoding="utf-8", - ) - assert _imports(probe) >= {"leetcode_guard._ledger", "leetcode_guard._gate"} - assert DENIED.fullmatch("_ledger_io") - assert DENIED.fullmatch("_harvest_run") def test_no_relay_module_spawns_anything_but_claude_and_node() -> None: diff --git a/leetcode_coach/leetcode_coach/tests/test_relay_e2e.py b/leetcode_coach/leetcode_coach/tests/test_relay_e2e.py index d58c769..e0a179c 100644 --- a/leetcode_coach/leetcode_coach/tests/test_relay_e2e.py +++ b/leetcode_coach/leetcode_coach/tests/test_relay_e2e.py @@ -13,13 +13,15 @@ import pytest from leetcode_coach.tests import _relay_flow as flow from leetcode_coach.tests import _relay_support as rs -from leetcode_guard.tests._paths_fixture import _isolate_paths +from leetcode_coach.tests._relay_support import _isolate_paths if TYPE_CHECKING: from collections.abc import Iterator from pathlib import Path -__all__ = ["_isolate_paths"] # the guard's paths, redirected for every test here +__all__ = [ + "_isolate_paths" +] # the guard's data dir, under a redirected HOME, for every test here @pytest.fixture diff --git a/leetcode_coach/leetcode_coach/tests/test_relay_main.py b/leetcode_coach/leetcode_coach/tests/test_relay_main.py index ff894a1..451d797 100644 --- a/leetcode_coach/leetcode_coach/tests/test_relay_main.py +++ b/leetcode_coach/leetcode_coach/tests/test_relay_main.py @@ -9,15 +9,16 @@ from typing import TYPE_CHECKING, Any import crdt_sync +from leetcode_coach import guard_bridge from leetcode_coach.relay import __main__ as entry from leetcode_coach.relay import app as app_mod from leetcode_coach.relay import config from leetcode_coach.relay.rtdb import Rtdb from leetcode_coach.tests import _relay_support as rs from leetcode_coach.tests._fake_rtdb import FakeRtdb -from leetcode_guard import _snapshot_auth if TYPE_CHECKING: + from collections.abc import Callable from pathlib import Path import pytest @@ -27,23 +28,15 @@ def test_make_rtdb_signs_in_as_its_own_app(monkeypatch: pytest.MonkeyPatch) -> N monkeypatch.setenv(config.RTDB_ENV, "") seen: list[str] = [] - class Auth: - @staticmethod - def id_token() -> str: - return "live" - - class Client: - auth = Auth() - - def connect(app: str, *, timeout_seconds: float) -> Client: + def phone_id_token(app: str, *, timeout_seconds: float) -> Callable[[], str]: seen.append(app) assert timeout_seconds == 15 - return Client() + return lambda: "live" class Config: database_url = "https://db.example" - monkeypatch.setattr(_snapshot_auth, "connect", connect) + monkeypatch.setattr(guard_bridge, "phone_id_token", phone_id_token) monkeypatch.setattr(crdt_sync.FirebaseConfig, "load", lambda _path: Config()) rtdb = entry.make_rtdb() assert seen == ["leetcode_coach_relay"] diff --git a/leetcode_coach/leetcode_coach/tests/test_relay_phone_e2e.py b/leetcode_coach/leetcode_coach/tests/test_relay_phone_e2e.py index 65e3825..692c148 100644 --- a/leetcode_coach/leetcode_coach/tests/test_relay_phone_e2e.py +++ b/leetcode_coach/leetcode_coach/tests/test_relay_phone_e2e.py @@ -17,13 +17,15 @@ from leetcode_coach.relay.rtdb import Rtdb from leetcode_coach.tests import _relay_flow as flow from leetcode_coach.tests import _relay_support as rs from leetcode_coach.tests._fake_rtdb import FakeRtdb -from leetcode_guard.tests._paths_fixture import _isolate_paths +from leetcode_coach.tests._relay_support import _isolate_paths if TYPE_CHECKING: from collections.abc import Iterator from pathlib import Path -__all__ = ["_isolate_paths"] # the guard's paths, redirected for every test here +__all__ = [ + "_isolate_paths" +] # the guard's data dir, under a redirected HOME, for every test here @pytest.fixture diff --git a/leetcode_coach/leetcode_coach/tests/test_relay_units.py b/leetcode_coach/leetcode_coach/tests/test_relay_units.py index 0dc8b97..0c68a0f 100644 --- a/leetcode_coach/leetcode_coach/tests/test_relay_units.py +++ b/leetcode_coach/leetcode_coach/tests/test_relay_units.py @@ -6,8 +6,9 @@ from typing import TYPE_CHECKING import pytest -from leetcode_coach import answer_key, leak, toolbox +from leetcode_coach import answer_key, guard_bridge, leak, toolbox from leetcode_coach.relay import chat, config, probe, toolbox_grade, turns +from leetcode_coach.tests import _relay_support as rs from leetcode_coach.tests._relay_direct import FakeAsk, make_coach, req, started from leetcode_coach.tests._relay_support import Clock, FakeProbe @@ -90,18 +91,12 @@ def test_the_probe_is_three_valued_and_spaced(caplog: pytest.LogCaptureFixture) assert "probe failed" in caplog.text -def test_the_live_probe_uses_the_harvesters_client( +def test_the_live_probe_reads_through_the_guard_bridge( monkeypatch: pytest.MonkeyPatch, ) -> None: - class Client: - post = staticmethod(lambda *_a: None) - username = "kuhy" - - seen: list[str] = [] - monkeypatch.setattr(probe, "build_client", Client) - monkeypatch.setattr(probe, "fetch_recent_ac", lambda _post, user: seen.append(user)) - probe.live_probe() - assert seen == ["kuhy"] + answer = rs.FakeSolve(True, (), "ok") + monkeypatch.setattr(guard_bridge, "recent_accepted", lambda: answer) + assert probe.live_probe() is answer def test_ticks_that_must_do_nothing( diff --git a/leetcode_coach/leetcode_coach/tests/test_session_log.py b/leetcode_coach/leetcode_coach/tests/test_session_log.py index 572915c..f5e2324 100644 --- a/leetcode_coach/leetcode_coach/tests/test_session_log.py +++ b/leetcode_coach/leetcode_coach/tests/test_session_log.py @@ -1,4 +1,4 @@ -"""Tests for the coach's JSONL session log (contract read by leetcode_guard).""" +"""Tests for the coach's JSONL session log (contract read by leetcode_guard._coach_log).""" from __future__ import annotations @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING import pytest from leetcode_coach import progress, session_log -from leetcode_guard import _coach_log if TYPE_CHECKING: from pathlib import Path @@ -82,21 +81,3 @@ def test_read_tolerates_a_missing_file_and_torn_lines( assert "not JSON" in caplog.text assert session_log.has_code(path) assert not session_log.has_code(tmp_path / "other.jsonl") - - -def test_the_status_reader_understands_what_is_written(tmp_path: Path) -> None: - path = tmp_path / "2026-10-23-missing-number.jsonl" - session_log.append(path, "req", text="req: x", cluster="bit-cancellation") - session_log.append(path, "req_check", verdict="incomplete", missing=["p"]) - session_log.append(path, "code", req_incomplete=True) - session_log.append(path, "rung", rung="H2") - problem = _coach_log.read_session(path) - assert problem == _coach_log.CoachProblem( - slug="missing-number", - cluster="bit-cancellation", - req_before_code=True, - max_rung="H2", - revealed=False, - req_check="incomplete", - day="2026-10-23", - ) diff --git a/leetcode_coach/leetcode_coach/tests/test_shape.py b/leetcode_coach/leetcode_coach/tests/test_shape.py new file mode 100644 index 0000000..18ace20 --- /dev/null +++ b/leetcode_coach/leetcode_coach/tests/test_shape.py @@ -0,0 +1,176 @@ +"""Q5: SQL problems ask for the OUTPUT SHAPE, algorithm problems keep ``req:``.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any, NoReturn + +import pytest + +from leetcode_coach import phase, req_check, session_log, shape, toolbox +from leetcode_coach.relay import grading +from leetcode_coach.tests._relay_direct import FakeAsk, grade, make_coach, started +from leetcode_coach.tests._relay_statements import MISSING_NUMBER + +if TYPE_CHECKING: + from pathlib import Path + +SQL_STATEMENT = """# 1179. Reformat Department Table (Easy) +Tags: Database +Languages available: mysql, mssql, oraclesql, postgresql, pythondata + +Reformat the table so there is a department id column and a revenue column +for each month. +""" +SHAPE = "shape: columns id plus Jan_Revenue..Dec_Revenue, one row per department id" +QUERY = "SELECT id, SUM(CASE WHEN month = 'Jan' THEN revenue END) FROM Department GROUP BY id;" + + +@pytest.mark.parametrize( + ("statement", "expected"), + [ + (SQL_STATEMENT, True), + ("# 176. Second Highest Salary (Medium)\nTags: Database\n", True), + ("# 1. X (Easy)\nTags: Array\nLanguages available: mysql, python3\n", True), + (MISSING_NUMBER, False), + ("missing-number", False), + ("", False), + ], +) +def test_is_sql(statement: str, expected: bool) -> None: + assert shape.is_sql(statement) is expected + + +@pytest.mark.parametrize( + ("text", "missing"), + [ + (SHAPE, ()), + ("shape: two columns, name and total; a row per customer, grouped by name", ()), + ("shape: the columns are id and name", ("rows", "grouping")), + ("shape: one row per id", ("columns",)), + ("shape: nothing yet", ("columns", "rows", "grouping")), + ], +) +def test_missing_facets(text: str, missing: tuple[str, ...]) -> None: + assert shape.missing_facets(text) == missing + + +def test_check_is_a_req_check_result_without_a_model() -> None: + assert shape.check(SHAPE) == req_check.Check("pass", (), "shape") + assert shape.check("shape: columns id") == req_check.Check( + "incomplete", ("rows", "grouping"), "shape" + ) + + +def _gate( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, text: str +) -> phase.PhaseGate: + def never(*_a: object) -> NoReturn: + pytest.fail("a SQL shape must never reach the requirement checker") + + monkeypatch.setattr(req_check, "check", never) + return phase.PhaseGate(tmp_path / "log.jsonl", toolbox.load_safe(), lambda: text) + + +def _events(gate: phase.PhaseGate) -> list[str]: + return [e["event"] for e in session_log.read(gate.log)] + + +def test_sql_code_is_held_back_until_a_shape_passes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + gate = _gate(tmp_path, monkeypatch, SQL_STATEMENT) + sent, note = gate.screen(QUERY) + assert sent is None + assert "shape:" in note + assert "req:" not in note + assert _events(gate) == [] + sent, note = gate.screen(SHAPE) + assert sent == f"{SHAPE}\n\n[req check: pass]" + assert "output shape accepted" in note + assert gate.screen(QUERY) == (QUERY, "") + assert _events(gate) == ["req", "req_check", "code"] + assert session_log.read(gate.log)[1]["via"] == "shape" + + +def test_a_req_list_on_a_sql_problem_counts_as_the_shape( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + gate = _gate(tmp_path, monkeypatch, SQL_STATEMENT) + _, note = gate.screen("req: " + SHAPE.removeprefix("shape: ")) + assert "output shape accepted" in note + assert gate.screen(QUERY) == (QUERY, "") + + +def test_an_incomplete_shape_names_the_missing_facets_then_forces( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + gate = _gate(tmp_path, monkeypatch, SQL_STATEMENT) + _, note = gate.screen("shape: the columns are id and name") + assert "rows" in note + assert "grouping" in note + assert "1 of 3" in note + refused, why = gate.screen(QUERY) + assert refused is None + assert "shape" in why + assert "1 of 3" in why + gate.screen("shape: columns again") + _, last = gate.screen("shape: columns once more") + assert "accepted anyway" in last + assert gate.screen(QUERY) == (QUERY, "") + assert session_log.read(gate.log)[-1]["req_incomplete"] is True + + +def test_algorithm_problems_keep_the_req_gate( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + gate = _gate(tmp_path, monkeypatch, MISSING_NUMBER) + sent, note = gate.screen(QUERY) + assert sent is None + assert "req:" in note + assert "shape" not in note + assert gate.screen(SHAPE) == (SHAPE, "") + assert _events(gate) == [] + + +def test_an_unfetched_statement_defaults_to_the_stricter_req_gate( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + gate = _gate(tmp_path, monkeypatch, "reformat-department-table") + _, note = gate.screen(QUERY) + assert "req:" in note + + +def _sql_coach(tmp_path: Path) -> tuple[Any, str]: + ask = FakeAsk([{"result": "Hi?"}] * 3, [{"result": json.dumps({"grade": "pass"})}]) + coach = make_coach(tmp_path, ask, fetch=lambda _s: ("Reformat", SQL_STATEMENT)) + return coach, started(coach) + + +def test_step_two_grades_the_shape_mechanically(tmp_path: Path) -> None: + coach, sid = _sql_coach(tmp_path) + grade(coach, sid, 1, answer="n is big") + partial = grade(coach, sid, 2, answer="the columns are id and name")["data"] + assert partial["grade"] == "partial" + assert "one row per" in partial["feedback"] + assert "group" in partial["feedback"] + passed = grade(coach, sid, 2, answer=SHAPE)["data"] + assert (passed["grade"], passed["next_step"]) == ("pass", 3) + assert coach.session is not None + assert coach.session.info()["kind"] == "sql" + assert coach.deps.ask.prompts("haiku") # step 1 only; step 2 asked no model + assert len(coach.deps.ask.prompts("haiku")) == 1 + + +def test_step_two_still_rejects_code_as_a_shape(tmp_path: Path) -> None: + coach, sid = _sql_coach(tmp_path) + grade(coach, sid, 1, answer="n is big") + got = grade(coach, sid, 2, answer=QUERY)["data"] + assert (got["grade"], got["feedback"]) == ("fail", grading.READS_AS_CODE) + + +def test_an_algorithm_session_reports_its_kind(tmp_path: Path) -> None: + coach = make_coach(tmp_path, FakeAsk([{"result": "Hi?"}])) + started(coach) + assert coach.session is not None + assert coach.session.info()["kind"] == "algorithm" diff --git a/leetcode_coach/pyproject.toml b/leetcode_coach/pyproject.toml new file mode 100644 index 0000000..a1472ca --- /dev/null +++ b/leetcode_coach/pyproject.toml @@ -0,0 +1,241 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "leetcode-coach" +version = "0.1.0" +description = "The /leetcode coach as a library: phase gate, leak and stop gates, session log, grading, cards, relay" +requires-python = ">=3.14" +# Everything below loads lazily (rtdb, make_cards, relay.__main__): importing +# phase, leak, toolbox, progress or session_log needs the stdlib only. +# leetcode_guard is NOT a dependency (guard_bridge imports it on demand, so the +# package never pulls the gate's ledger code into a tutor server). +dependencies = [ + "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync", + "genanki==0.13.1", + "requests==2.34.2", +] + +[tool.setuptools.packages.find] +include = ["leetcode_coach*"] + +[tool.setuptools.package-data] +# The skill body and the launcher are read/executed from the package dir. +leetcode_coach = ["SKILL.md", "leetcode", "*.json", "py.typed"] + +[tool.ruff] +target-version = "py314" +include = ["*.py", "**/*.py"] +# leetcode_coach/hook_shims holds the <=3-line files installed into +# ~/.claude/hooks: a docstring, an import and a one-line guarded exec. Three +# lines cannot satisfy ruff ALL (or the formatter, which would split the exec +# into five), and the shims carry no logic -- test_stop_gate.py runs the real +# one end to end. force-exclude makes the exclusion hold for the explicit file +# lists pre-commit passes. +exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs", "leetcode_coach/hook_shims"] +force-exclude = true + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "CPY001", + "D203", + "D213", + "COM812", + "ISC001", + "S603", +] +fixable = ["ALL"] +unfixable = [] + +[tool.ruff.lint.per-file-ignores] +"**/tests/**/*.py" = [ + "ANN", "ARG", "D", "DTZ011", "E501", "FBT", + "PLC0415", "PLR0913", "PLR2004", "PTH", "RUF003", "S101", "S104", "S105", + "SLF001", +] +"**/test_*.py" = [ + "ANN", "ARG", "D", "DTZ011", "E501", "FBT", + "PLC0415", "PLR0913", "PLR2004", "PTH", "RUF003", "S101", "S104", "S105", + "SLF001", +] +# Every one of these modules is a command line or a hook whose printed text IS +# its interface: the REPL session, the offline session, the card builder's +# table, the statement fetcher, the eval verdict table, `leetcode report`'s +# rows, and the Stop hook's one JSON decision line. T201 is auto-fixable under +# --unsafe-fixes, so without these entries the fixer deletes those prints and +# still exits 0. Their tests assert on stdout. +"leetcode_coach/leetcode_repl.py" = ["T201"] +"leetcode_coach/local_session.py" = ["T201"] +"leetcode_coach/make_cards.py" = ["T201"] +"leetcode_coach/fetch_problem.py" = ["S310", "T201"] +"leetcode_coach/eval_local.py" = ["T201"] +"leetcode_coach/stop_gate.py" = ["T201"] +"leetcode_coach/cluster_cli.py" = ["T201"] +"leetcode_coach/toolbox_cards.py" = ["T201"] +"leetcode_coach/report.py" = ["T201"] +# S310 (urlopen scheme audit): all three open a fixed literal URL -- +# AnkiConnect on 127.0.0.1, LeetCode's https GraphQL endpoint, and Ollama on +# localhost, where http_json additionally refuses any URL off OLLAMA_URL. They +# stay on stdlib urllib on purpose: fetch_problem runs by path with no install. +# ANN401: AnkiConnect's `result` is untyped JSON whose shape depends on the +# action, so `_call` returns Any and each caller indexes what its action sends. +"leetcode_coach/anki_connect.py" = ["ANN401", "S310"] +# The coach relay's lane, assembly, phone link and feedback check take one +# keyword argument per independently injectable seam (clock, pace, timings, +# what the feedback may not say): the E2E tests run the real threads at test +# speed through exactly these arguments. +"leetcode_coach/relay/lane.py" = ["PLR0913"] +"leetcode_coach/relay/app.py" = ["PLR0913"] +"leetcode_coach/relay/phone.py" = ["PLR0913"] +"leetcode_coach/relay/graders.py" = ["PLR0913"] +"leetcode_coach/local_backend.py" = ["S310"] +"leetcode_coach/guard_bridge.py" = ["D101", "D102", "D103"] +"leetcode_coach/shape.py" = ["D103"] +"leetcode_coach/gate_notes.py" = ["D103"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.isort] +force-single-line = false +force-sort-within-sections = true +known-first-party = ["leetcode_coach"] + +[tool.ruff.lint.flake8-quotes] +docstring-quotes = "double" +inline-quotes = "double" + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" +docstring-code-format = true + +[tool.mypy] +python_version = "3.14" +strict = true +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +disallow_any_unimported = true +disallow_any_explicit = false +disallow_any_generics = true +disallow_subclassing_any = true +strict_equality = true +extra_checks = true +ignore_missing_imports = true +show_error_codes = true +color_output = true +exclude = [".venv/", "leetcode_coach/hook_shims/"] + +[tool.pylint.main] +# The subproject root on the path, so pylint resolves the package's own +# imports the same way pytest does instead of reporting import-error. +init-hook = "import sys; sys.path.insert(0, 'leetcode_coach')" +analyse-fallback-blocks = true +persistent = true +jobs = 0 +py-version = "3.14" +ignore = [".venv", "__pycache__"] +# Tests are linted by ruff (same exemptions as above); `ignore-paths`, not +# `ignore`, because pre-commit hands pylint explicit file paths. +# hook_shims: see the ruff exclude above. +ignore-paths = ["^(.*/)?tests/.*$", "^(.*/)?hook_shims/.*$"] +ignore-patterns = [".*\\.pyi$"] + +[tool.pylint.basic] +# snake_case, plus the ``do_`` names http.server dispatches on. +method-rgx = "([^\\W\\dA-Z][^\\WA-Z]{2,}|_[^\\WA-Z]*|__[^\\W\\dA-Z][^\\WA-Z]+__|do_[A-Z]+)$" + +[tool.pylint.messages_control] +enable = "all" +disable = [ + "missing-class-docstring", + "missing-function-docstring", + "use-implicit-booleaness-not-comparison", + "use-implicit-booleaness-not-comparison-to-string", + "use-implicit-booleaness-not-comparison-to-zero", +] + +[tool.pylint.design] +min-public-methods = 0 +max-module-lines = 1000 +max-attributes = 10 + +[tool.pylint.typecheck] +generated-members = [ + ".*\\.assert_called_once_with", + ".*\\.assert_called_once", + ".*\\.assert_called", + ".*\\.assert_not_called", + ".*\\.assert_any_call", + ".*\\.call_args", + ".*\\.call_args_list", + ".*\\.call_count", +] + +[tool.bandit] +exclude_dirs = ["tests", ".venv"] + +[tool.pytest.ini_options] +testpaths = ["leetcode_coach/tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-v", + "--strict-markers", + "--strict-config", + "-ra", + "--cov=leetcode_coach", + "--cov-branch", + "--cov-report=term-missing", + "--cov-report=lcov", +] +filterwarnings = [ + # "error" is the point: a warning in this package is a bug. The + # DeprecationWarning exemption: urllib3/requests emit them from inside the + # dependency. + "error", + "ignore::DeprecationWarning", + "default::pytest.PytestUnraisableExceptionWarning", +] + +[tool.coverage.run] +source = ["leetcode_coach"] +branch = true +omit = [ + # Exec'd into a fresh interpreter, so coverage cannot see them; the stop + # gate's shim is run end to end by test_stop_gate.py instead. + "*/hook_shims/*", + "*/__pycache__/*", + "*/tests/*", + "*/.venv/*", +] + +[tool.coverage.report] +fail_under = 100 +show_missing = true +skip_covered = false +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "raise AssertionError", + "if TYPE_CHECKING:", + 'if __name__ == "__main__":', +] +partial_branches = ["pragma: no branch"] diff --git a/leetcode_coach/requirements.txt b/leetcode_coach/requirements.txt new file mode 100644 index 0000000..fbbe0d5 --- /dev/null +++ b/leetcode_coach/requirements.txt @@ -0,0 +1,20 @@ +# leetcode_coach: runtime + development dependencies, exact pins (the shared +# dependency-freshness gate). requests = relay RTDB client, genanki = the card +# builder, crdt-sync = the phone relay's Firebase sign-in. All three load +# lazily, so a library consumer that only imports phase/leak/toolbox pays for +# none of them at import time. +bandit[toml]==1.9.4 +codespell==2.4.3 +coverage==7.17.0 +crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync +genanki==0.13.1 +mypy==2.4.1 +pre-commit==4.7.0 +pylint==4.1.3 +pytest==9.2.0 +pytest-cov==7.1.0 +pytest-randomly==5.0.0 +pytest-xdist==3.8.0 +requests==2.34.2 +ruff==0.17.3 +types-requests==2.33.0.20261030