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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
This commit is contained in:
Krzysztof Rudnicki 2026-11-02 15:02:26 +01:00
parent 11cb1d81d9
commit 716231a7f3
34 changed files with 1366 additions and 337 deletions

View File

@ -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

View File

@ -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

View File

@ -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

132
leetcode_coach/README.md Normal file
View File

@ -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 <url>` (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/`.

14
leetcode_coach/install.sh Executable file
View File

@ -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__)'

View File

@ -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** —

View File

@ -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:

View File

@ -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)]"
)

View File

@ -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

View File

@ -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."""

View File

View File

@ -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``).
"""

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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",
}

View File

@ -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])

View File

@ -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]

View File

@ -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<!-- rung:H0 -->"
HINT = "What happens to a value combined with itself?\n<!-- rung:H1 -->"
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"]

View File

@ -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"
)

View File

@ -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(

View File

@ -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(

View File

@ -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._<name>`` 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")

View File

@ -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()

View File

@ -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),

View File

@ -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:

View File

@ -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

View File

@ -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"]

View File

@ -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

View File

@ -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(

View File

@ -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",
)

View File

@ -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"

View File

@ -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_<METHOD>`` 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"]

View File

@ -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