Compare commits

..

26 Commits

Author SHA1 Message Date
9d6b17fd28 Make sickdays (in kuhyx/utils) text-free (textgat…
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sickdays pre-commit / pre-commit (push) Waiting to run
sickdays tests / test (push) Waiting to run
Todo: 2d0704cd
2026-11-10 19:07:37 +01:00
ae56037e41 Fix GMS gate APK mode false positive
Some checks are pending
data_transfer_flutter tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
Decide by AGP library marker files instead of dex strings:
play-services-base/basement reference GoogleSignInAccount without
linking play-services-auth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVQE5pYiASpRGgA3Kg
2026-11-10 18:00:23 +01:00
eef7af4c86 Bump file_picker 13.3.3 (dtf 0.2.2, ssui 0.6.3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:53:50 +01:00
9daa376254 Gate: no Google non-free sign-in in apps
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
scripts/check_no_gms_signin.sh fails when a pubspec.lock or android
gradle file pulls google_sign_in / play-services-auth / googleid, or a
built APK (--apk) carries GMS auth classes, and names
google_account_token as the fix. install_repo_contract_gate.sh now
installs its shim and an always-run hook in Flutter Android repos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVQE5pYiASpRGgA3Kg
2026-11-10 16:52:44 +01:00
17e300af0a focus_project: pin backing edge cases and pay
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Tests only: sql_drills/leetcode beats back, other-project and browser
beats don't, submissions back only leetcode projects, the cutover
midnight, a tutor chain leaves earlier reading unbacked; earned_time
pays 0 units for a timer-only post-cutover day (real focus_project).

Todo: fda71ed1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:38:48 +01:00
afcc1a47c7 focus_project 0.10.0: timer needs backing beats
A timer span earns only if a same-project tutor/sql_drills/leetcode
beat backs it (days from 2026-11-08). status() gains unbacked_minutes.
The 0.8.0 tag shipped only the tutor-chain half.

Todo: fda71ed1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 16:35:46 +01:00
0527bc680d focus_project 0.9.1: mock needs 90 min
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
A score row completes mock only after a contiguous >= 90 min
focus session (600 s gap rule) that ended by the score row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZUTC9Zzdzsx7VW7dm
2026-11-10 16:19:31 +01:00
21a0b5ce1c focus_project 0.9.0: interview milestones
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZUTC9Zzdzsx7VW7dm
2026-11-10 15:39:48 +01:00
4c1ffd900b focus_project: interview milestones, evidence
Project.kind (focus default; old rows load unchanged) plus interview
fields test_at, invite_url, trips, weekend_answers. Interview budget
30 min weekdays / 120 Sat-Sun, exempt from daily_floor, unlock clamped
after the 0.7.0 history ratchet. Milestones are generated (rules,
trip_ask, phone, mock, slot), never stored. Append-only evidence store
behind the _paths seam. Calendar dropped: slot = slot entry + slot_confirm.

Failure paths:
- unanswered trip question: fail closed (reads as a trip, phone due)
- late no-trip answer cannot erase an overdue phone milestone: closed
- phone source missing/stale/crashed: unknown, locks: fail closed
- trip_ask answer: evidence row first, then registry; a crash between
  leaves the question open: fail closed
- add_trip/answer_weekend loosening: ValueError, nothing written
- owed_lock with unreadable registry or evidence: fail open, logged
  (focus-guard's unreadable-registry rule); test day never locks
- status() milestones with unreadable evidence: empty list, logged
- torn or non-utf8 evidence lines: skipped and logged

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZUTC9Zzdzsx7VW7dm
2026-11-10 15:39:30 +01:00
5a87868763 Remove aur-publish and dopamine-ux modules
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Audit found no external consumers. Drop their refs in md_naming
migrate tooling and the pre-commit comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYFhDShgeHByh6yT9L
2026-11-10 14:36:47 +01:00
fe4e34287a Move code_runner to the tutor repo
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
It has one consumer, kuhyx/tutor (packages/code_runner). Tags
code-runner-v0.1.0 and -v0.1.1 stay so old pins still resolve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYFhDShgeHByh6yT9L
2026-11-10 14:14:58 +01:00
2f4f79bd6e leetcode_coach: pytest-randomly 5.1.0
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
leetcode-coach pre-commit / pre-commit (push) Has been cancelled
leetcode-coach tests / test (push) Has been cancelled
Clears the dependency-freshness gate (5.0.0 -> 5.1.0); 519 tests
pass at 100% coverage on the new pin.

Todo: fda71ed1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:17:27 +01:00
51b747a364 vmbox discovery test: Pass on pwrtune decoy
Fresh claude -p (opus-5-5, 2.1.319) loaded vmbox-testing before any
host sudo, sandboxed install+verify+undo, caveated the host. n=1.

Todo: fda71ed1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:15:27 +01:00
89f464828a crdt_sync_flutter 0.5.1, sync_settings_ui 0.6.2: crdt_sync 0.12.1
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
crdt_sync_dart tests / test (push) Has been cancelled
crdt_sync_flutter tests / test (push) Has been cancelled
sync_settings_ui tests / test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:25:53 +01:00
eaf7282ac1 dep_freshness: DEP_FRESHNESS_TTL_SECONDS override
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:25:39 +01:00
6641d85f05 crdt_sync_dart 0.12.1: serialize FileLogPersistence writes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:24:01 +01:00
46ab26fa15 pylint --fail-under=10 everywhere, and clear what…
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
leetcode-coach pre-commit / pre-commit (push) Waiting to run
leetcode-coach tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Todo: fda71ed1
2026-11-08 20:55:09 +01:00
f71b0532fd web_ui: bump npm pins to newest stable
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
ts_config tests / test (push) Has been cancelled
ts_core tests / test (push) Has been cancelled
web_ui tests / test (push) Has been cancelled
jsdom 31 rounds style numbers to 4 decimals; the range
slider test now compares percentages numerically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYDm7szKeXoi7rinEy
2026-11-08 18:43:33 +01:00
3017bbf220 ts_core: bump npm pins to newest stable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYDm7szKeXoi7rinEy
2026-11-08 18:41:28 +01:00
fd81332f27 ts_config: bump npm pins to newest stable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYDm7szKeXoi7rinEy
2026-11-08 18:40:59 +01:00
ebedbf4b27 sync_settings_ui 0.6.1: data_transfer 0.2.1
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
data_transfer_flutter tests / test (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-08 18:02:19 +01:00
d4fca83c9f data_transfer_flutter 0.2.1: file_picker 13.3.2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-08 18:01:34 +01:00
3121639732 Bump crdt_sync to v0.12.0
Some checks are pending
crdt_sync_dart tests / test (push) Waiting to run
crdt_sync_flutter tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-08 14:13:54 +01:00
59b33949e4 crdt_sync_dart: cut 0.12.0 (SYNC_NAMESPACE)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 14:08:43 +01:00
027e67b197 crdt_sync: SYNC_NAMESPACE sandbox define
Compile-time dart-define that prefixes every RTDB path with sandbox/<slot>; invalid values throw at startup. Empty keeps prod paths unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 13:48:02 +01:00
8dfc4fb55f design_system 0.3.1: page counter past 12
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
design_system tests / test (push) Has been cancelled
FitGrid drew one dot per page; tunedeck Downloads with 548 files had 46
dots running off the screen. Above 12 pages show "n / N" instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 13:04:41 +01:00
130 changed files with 5658 additions and 4995 deletions

View File

@ -1,73 +0,0 @@
name: code-runner pre-commit
on:
push:
branches: [main]
# The workflow's own file counts as code_runner: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['code_runner/**', '.github/workflows/code-runner-precommit.yml']
pull_request:
branches: [main]
paths: ['code_runner/**', '.github/workflows/code-runner-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==' code_runner/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-code-runner-${{ env.pythonLocation }}-${{
hashFiles('code_runner/.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-code-runner-${{ 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-code-runner-${{ env.pythonLocation }}-${{
hashFiles('code_runner/.pre-commit-config.yaml') }}
restore-keys: mypy-code-runner-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config code_runner/.pre-commit-config.yaml
--all-files --show-diff-on-failure

View File

@ -1,34 +0,0 @@
name: code-runner tests
on:
push:
branches: [main]
# The workflow's own file counts as code_runner: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['code_runner/**', '.github/workflows/code-runner-tests.yml']
pull_request:
branches: [main]
paths: ['code_runner/**', '.github/workflows/code-runner-tests.yml']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: code_runner
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
cache: pip
cache-dependency-path: code_runner/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

@ -13,7 +13,7 @@
# linters, and carry this same hook so committing with one of those configs is
# gated too. This root config is what `pre-commit install` wires up, and it is
# the only one that covers the subprojects without a config of their own
# (aur-publish/, guard-lib/, crdt_sync_dart/, file_length/, ...).
# (guard-lib/, crdt_sync_dart/, file_length/, ...).
#
# The path is repo-relative on purpose. pre-commit always runs hooks from the
# git root, so this resolves the same locally and on a CI runner -- an absolute

View File

@ -1,45 +0,0 @@
# aur-publish
Hands-off publishing of kuhy's Flutter apps to the AUR.
```bash
~/src/utils/aur-publish/publish.sh # wait for registration, then publish
~/src/utils/aur-publish/publish.sh --dry-run # everything except the push
~/src/utils/aur-publish/publish.sh --skip-wait # account already works
~/src/utils/aur-publish/publish.sh --only habit-stack
```
## What it does
1. Polls `/register` until AUR signup reopens, opens it in a browser and
prints the SSH key to paste. **Signup is the only manual step** — it
requires email confirmation.
2. Waits for `ssh aur@aur.archlinux.org` to authenticate.
3. Then, unattended, per package: resolve the newest `vX.Y.Z` tag → set
`pkgver` → `updpkgsums` → `makepkg -C` → `namcap` → AUR rule check →
`pacman -U` → launch check → `.SRCINFO` → commit → push.
Every package is built, installed and **launched** before it is pushed, so a
broken package cannot reach the AUR. A package failing any gate is skipped and
reported; the others still publish. Safe to re-run.
## anubis_gate.py
The AUR sits behind [Anubis](https://anubis.techaro.lol/), which answers HTML
requests with a proof-of-work challenge page carrying **HTTP 200**. A plain
`curl` therefore reports 200 for a page that is actually unreachable — which is
exactly how "is registration back?" gets answered wrong. This module solves the
challenge and reports the status of the *real* page behind it.
```bash
python3 anubis_gate.py /register # prints the true status code
```
## AUR rules enforced before pushing
The AUR does not review uploads, but per the submission guidelines "packages
that violate the rules may be deleted without warning". `check_aur_rules()`
blocks a push on the mechanically checkable ones: `# Maintainer:` line,
`x86_64` in `arch`, no `replaces=` (reserved for renames — use `conflicts`),
not already in an official repo, `pkgdesc`/`url`/`license` present, and
`.SRCINFO` matching the PKGBUILD.

View File

@ -1,138 +0,0 @@
"""Fetch an AUR URL from behind the Anubis anti-bot gate.
The AUR sits behind Anubis (https://anubis.techaro.lol/), which answers every
HTML request with a proof-of-work challenge page carrying HTTP **200**. A bare
`curl` therefore reports 200 for a page that is actually unreachable, which is
exactly how "is /register back up?" gets answered wrong.
This module solves the challenge and reports the status of the *real* page
behind it, so the caller can distinguish:
* 200 -> genuinely available
* 503 -> reached the site; that endpoint is disabled server-side
* other-> whatever the app actually said
Exit codes mirror the HTTP status so shell callers can branch on them:
0 for 200, 1 for anything else, 2 for a local/transport failure.
"""
from __future__ import annotations
import argparse
import hashlib
import http.cookiejar
import json
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BASE = "https://aur.archlinux.org"
UA = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
CHALLENGE_RE = re.compile(
r'<script id="anubis_challenge" type="application/json">(.*?)</script>',
re.S,
)
PASS_PATH = "/.within.website/x/cmd/anubis/api/pass-challenge"
# A solve at difficulty 4 takes ~0.01s; this ceiling only guards a server-side
# difficulty spike, so the poller degrades instead of spinning forever.
MAX_NONCE = 50_000_000
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""Keep redirects visible instead of silently following them."""
def redirect_request(self, *_args, **_kwargs): # noqa: D102 - see class doc
return None
def _opener() -> urllib.request.OpenerDirector:
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar), _NoRedirect
)
opener.addheaders = [
("User-Agent", UA),
("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"),
]
return opener
def _solve(random_data: str, difficulty: int) -> tuple[int, str, float]:
"""Find a nonce whose sha256 digest starts with `difficulty` zero hex chars."""
prefix = "0" * difficulty
started = time.time()
for nonce in range(MAX_NONCE):
digest = hashlib.sha256(f"{random_data}{nonce}".encode()).hexdigest()
if digest.startswith(prefix):
return nonce, digest, time.time() - started
raise RuntimeError(f"no nonce found below {MAX_NONCE} at difficulty {difficulty}")
def fetch(path: str, timeout: int = 30) -> tuple[int, str]:
"""Return (status, body) for `path`, clearing Anubis first if challenged."""
opener = _opener()
url = f"{BASE}{path}"
try:
with opener.open(url, timeout=timeout) as resp:
body = resp.read().decode("utf-8", "replace")
status = resp.status
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", "replace")
match = CHALLENGE_RE.search(body)
if not match:
return status, body
challenge = json.loads(match.group(1))["challenge"]
nonce, digest, elapsed = _solve(
challenge["randomData"], int(challenge["difficulty"])
)
query = urllib.parse.urlencode(
{
"id": challenge["id"],
"response": digest,
"nonce": str(nonce),
"redir": path,
"elapsedTime": str(int(elapsed * 1000)),
}
)
try:
opener.open(f"{BASE}{PASS_PATH}?{query}", timeout=timeout)
except urllib.error.HTTPError:
# A redirect or non-2xx here is fine; what matters is the cookie the
# opener's jar just captured.
pass
try:
with opener.open(url, timeout=timeout) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", "replace")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("path", help="AUR path, e.g. /register")
parser.add_argument(
"--print-body", action="store_true", help="write the page body to stdout"
)
args = parser.parse_args()
try:
status, body = fetch(args.path)
except (urllib.error.URLError, RuntimeError, TimeoutError) as exc:
print(f"transport error: {exc}", file=sys.stderr)
return 2
print(status)
if args.print_body:
sys.stdout.write(body)
return 0 if status == 200 else 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,107 +0,0 @@
#!/bin/bash
# ============================================================================
# Building, verifying and pushing one package to the AUR.
#
# Sourced by publish.sh, never executed: these functions read the readonly
# config (AUR_HOST, AUR_ROOT, DRY_RUN, PUBLISHED, FAILED) that the entry script declares, and
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
# ============================================================================
set -euo pipefail
publish_one() {
local pkg="$1" repo="$2" wrapper="$3" binary="$4" expected="$5"
local dir="$AUR_ROOT/$pkg"
local version
log "$pkg"
[[ -d "$repo" ]] || { warn "$pkg: no repo at $repo"; return 1; }
[[ -f "$dir/PKGBUILD" ]] || { warn "$pkg: no PKGBUILD at $dir"; return 1; }
version="$(newest_tag "$repo")"
[[ -n "$version" ]] || { warn "$pkg: no vX.Y.Z tag on the remote"; return 1; }
info "newest release tag: v$version"
cd "$dir"
sed -i -E "s/^pkgver=.*/pkgver=$version/" PKGBUILD
# The tarball for a tag contains the PKGBUILD, so the checksum can only be
# computed after the tag exists — never committed ahead of time.
updpkgsums >/dev/null 2>&1 || { warn "$pkg: updpkgsums failed (tag missing?)"; return 1; }
info "building"
if ! env PATH=/usr/bin:/bin:/usr/local/bin makepkg -Cf --noconfirm >/dev/null 2>&1; then
warn "$pkg: makepkg failed"
return 1
fi
local built
built="$(find . -maxdepth 1 -name "$pkg-$version-*.pkg.tar.zst" | head -1)"
[[ -n "$built" ]] || { warn "$pkg: no package produced"; return 1; }
# namcap's "ELF files outside of a valid path ('opt/')" is a false positive
# for /opt-installed apps, so only genuinely new errors are worth failing on.
local findings
findings="$(namcap "$built" 2>&1 | grep -E ' E: ' | grep -v "outside of a valid path" || true)"
if [[ -n "$findings" ]]; then
warn "$pkg: namcap errors:"
printf ' %s\n' "$findings"
return 1
fi
info "installing and launching"
sudo pacman -U --noconfirm "$built" >/dev/null 2>&1 \
|| { warn "$pkg: pacman -U failed"; return 1; }
verify_installed "$binary" "$expected" "$wrapper" || { warn "$pkg: launch check failed"; return 1; }
# Generated before the rule check, which verifies it matches the PKGBUILD.
makepkg --printsrcinfo > .SRCINFO
check_aur_rules "$pkg" || return 1
# The AUR repo is a separate remote living alongside the build dir; adding
# it here (rather than cloning into a fresh path) keeps the verified
# PKGBUILD and the thing being pushed byte-identical.
if [[ ! -d .git ]]; then
git init -q -b master .
git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
fi
git remote get-url origin >/dev/null 2>&1 \
|| git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
# Fetch first: an existing AUR package must be built on, not overwritten.
if git fetch -q origin master 2>/dev/null; then
git reset -q --soft FETCH_HEAD 2>/dev/null || true
fi
cat > .gitignore <<'EOF'
# Build products; the AUR tracks only the recipe.
*.pkg.tar.zst
*.tar.gz
src/
pkg/
EOF
git add PKGBUILD .SRCINFO .gitignore
if git diff --cached --quiet; then
info "already up to date on the AUR; nothing to push"
PUBLISHED+=("$pkg v$version (unchanged)")
return 0
fi
git -c user.name='Krzysztof Rudnicki' \
-c user.email='krzysztofrudnicki0@gmail.com' \
commit -q -m "$pkg $version-1"
if ((DRY_RUN)); then
info "DRY RUN — not pushing"
PUBLISHED+=("$pkg v$version (dry run)")
return 0
fi
info "pushing to the AUR"
git push -q origin master 2>&1 | tail -3 || { warn "$pkg: push failed"; return 1; }
PUBLISHED+=("$pkg v$version")
return 0
}

View File

@ -1,107 +0,0 @@
#!/bin/bash
# ============================================================================
# Waiting for AUR registration and SSH access to come up.
#
# Sourced by publish.sh, never executed: these functions read the readonly
# config (AUR_HOST, SSH_KEY, GATE, POLL_SECONDS) that the entry script declares, and
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
# ============================================================================
set -euo pipefail
register_status() {
python3 "$GATE" /register 2>/dev/null | head -1
}
open_browser() {
local url="$1"
local browser
for browser in xdg-open thorium-browser chromium google-chrome-stable firefox librewolf; do
if command -v "$browser" >/dev/null 2>&1; then
"$browser" "$url" >/dev/null 2>&1 &
info "Opened $url in $browser"
return 0
fi
done
warn "No browser found; open this yourself: $url"
}
ensure_key() {
if [[ ! -f "$SSH_KEY" ]]; then
log "Generating an AUR SSH key (none at $SSH_KEY)"
ssh-keygen -t ed25519 -f "$SSH_KEY" -N '' -C 'kuhy@aur'
fi
if ! grep -q "Host $AUR_HOST" "$HOME/.ssh/config" 2>/dev/null; then
log "Adding $AUR_HOST to ~/.ssh/config"
mkdir -p "$HOME/.ssh"
cat >> "$HOME/.ssh/config" <<EOF
# AUR uses a dedicated key so it stays separate from the GitHub identity.
Host $AUR_HOST
User aur
IdentityFile $SSH_KEY
IdentitiesOnly yes
EOF
chmod 600 "$HOME/.ssh/config"
fi
}
ssh_works() {
# The AUR refuses interactive shells; "Interactive shell is disabled" on a
# successful key auth is the success signal, not an error.
ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
-o ConnectTimeout=15 "aur@$AUR_HOST" help 2>&1 |
grep -qiE 'interactive shell is disabled|Welcome to AUR'
}
wait_for_registration() {
local status
status="$(register_status)"
if [[ "$status" == "200" ]]; then
log "AUR registration is OPEN"
else
log "AUR registration is closed (HTTP $status). Polling every ${POLL_SECONDS}s."
info "Ctrl-C to stop; re-run any time, nothing is lost."
while :; do
sleep "$POLL_SECONDS"
status="$(register_status)"
if [[ "$status" == "200" ]]; then
log "Registration is BACK (HTTP 200)"
break
fi
printf ' %s still HTTP %s\n' "$(date +%H:%M)" "$status"
done
fi
open_browser "https://$AUR_HOST/register"
cat <<EOF
------------------------------------------------------------------
Sign up, and paste this into the "SSH Public Key" field:
$(sed 's/^/ /' "$SSH_KEY.pub")
You will need to confirm the address from your email before the
account works. This script waits for that automatically.
------------------------------------------------------------------
EOF
}
wait_for_ssh() {
log "Waiting for the SSH key to authenticate against the AUR"
local waited=0
until ssh_works; do
sleep 30
waited=$((waited + 30))
if ((waited % 300 == 0)); then
info "still waiting ($((waited / 60)) min) — key not accepted yet"
fi
done
log "SSH authentication OK"
}
# ---------------------------------------------------------------------------
# Phase 2: build, verify and publish one package
# ---------------------------------------------------------------------------

View File

@ -1,122 +0,0 @@
#!/bin/bash
# ============================================================================
# Gates a package must pass before it is allowed near the AUR.
#
# Sourced by publish.sh, never executed: these functions read the readonly
# config (AUR_ROOT, DRY_RUN) that the entry script declares, and
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
# ============================================================================
set -euo pipefail
newest_tag() {
# Newest clean vX.Y.Z on the remote. Build tags (vX.Y.Z-build.N) and the
# grafted upstream Flutter SDK tags in diet-guard are excluded on purpose.
git -C "$1" ls-remote --tags origin 2>/dev/null |
awk '{print $2}' |
sed 's|refs/tags/||' |
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
sed 's/^v//' |
sort -V |
tail -1
}
verify_installed() {
local binary="$1" expected="$2" wrapper="$3" output
# An instance left over from a previous run would make the wrapper print
# "already running" and hand off, which passes trivially and turns the
# strongest gate in this script into the weakest. Clear it first so the
# check always exercises a real cold start.
#
# Resolved through /proc/<pid>/exe rather than any form of name or
# command-line matching. Every argv-based approach selects the wrong
# processes here:
# * `pkill -x` compares against the kernel's 15-character comm field, and
# two of these wrappers are longer (habit_stack_desktop is 19), so a
# name match would silently never fire.
# * `pkill -f`, and equally `ps -eo args` piped through a pattern, match
# any process whose command line merely *mentions* the path — this
# script, the shell running it, and the matching tool itself all
# qualify, so the run kills its own shell instead of the app. A
# $$/$PPID guard does not cover it, because the offending shell is
# often a more distant ancestor.
# /proc/<pid>/exe is a kernel-maintained symlink to the binary actually
# being executed, so it cannot collide with an incidental mention.
# The wrapper name is unique per app, so the /opt/<pkg>/ segment is left as
# a glob instead of threading the package name through another parameter.
#
# The link is read without `-f`: it is already fully resolved, and by the
# time this runs `pacman -U` has replaced the binary, so a leftover process
# points at an unlinked inode that the kernel reports with a " (deleted)"
# suffix. That suffix has to be stripped or the match silently misses the
# one process this exists to kill.
local pid exe killed=()
for pid in /proc/[0-9]*; do
pid="${pid#/proc/}"
exe="$(readlink "/proc/$pid/exe" 2>/dev/null)" || continue
exe="${exe% (deleted)}"
[[ "$exe" == /opt/*/bin/"$wrapper" ]] || continue
kill -TERM "$pid" 2>/dev/null && killed+=("$pid")
done
if ((${#killed[@]})); then
info "stopped a running instance so the launch check is meaningful"
# `kill` only reports that the signal was sent. Waiting for the process
# to actually go keeps a slow exit from failing the cold start below on
# a port that is still bound.
local waited=0
while ((waited < 100)) && kill -0 "${killed[@]}" 2>/dev/null; do
sleep 0.1
((waited++))
done
fi
output="$(timeout 15 "/usr/bin/$binary" 2>&1 | head -3 || true)"
if grep -qF "$expected" <<<"$output"; then
return 0
fi
warn "unexpected startup output: $output"
return 1
}
# The AUR does not review uploads, but "packages that violate the rules may be
# deleted without warning" (AUR submission guidelines). These are the rules that
# are mechanically checkable, enforced here so a violation blocks the push
# rather than surfacing later as a deletion request.
check_aur_rules() {
local pkg="$1" problems=()
grep -q '^# Maintainer:' PKGBUILD \
|| problems+=("missing '# Maintainer:' comment on line 1")
grep -qE "^arch=\(.*x86_64" PKGBUILD \
|| problems+=("x86_64 not in arch=() — packages without it are not allowed")
# `replaces` makes pacman rip out the named package on the next -Sy, which
# is only correct for a genuine rename. `conflicts` is the right tool.
grep -q '^replaces=' PKGBUILD \
&& problems+=("uses replaces=; AUR rules reserve it for renames — use conflicts=")
# A package already in an official repo must never be duplicated.
if pacman -Si "$pkg" >/dev/null 2>&1; then
problems+=("$pkg exists in an official repo — must not be submitted")
fi
grep -qE '^(pkgdesc|url|license)=' PKGBUILD \
|| problems+=("missing pkgdesc/url/license")
# A stale .SRCINFO is the single most common upload rejection.
if ! diff -q <(makepkg --printsrcinfo) .SRCINFO >/dev/null 2>&1; then
problems+=(".SRCINFO does not match PKGBUILD")
fi
if ((${#problems[@]})); then
warn "$pkg: AUR rule violations:"
printf ' - %s\n' "${problems[@]}"
return 1
fi
info "AUR rule checks passed"
return 0
}

View File

@ -1,148 +0,0 @@
#!/bin/bash
# ============================================================================
# Publish kuhy's three Flutter apps to the AUR, hands-off.
#
# Run this once AUR registration is back. It:
# 1. Waits for https://aur.archlinux.org/register to stop returning 503,
# then opens it in a browser and prints the SSH key to paste.
# (Signup is the ONLY manual step: it needs an email confirmation, and
# creating accounts is not something this script will do for you.)
# 2. Waits for `ssh aur@aur.archlinux.org` to authenticate.
# 3. From there, fully unattended, for each package:
# newest release tag -> pkgver -> updpkgsums -> makepkg -C -> namcap
# -> pacman -U -> launch check -> .SRCINFO -> clone AUR repo
# -> commit -> push.
#
# Every package is BUILT, INSTALLED and LAUNCHED before it is pushed, so a
# broken package cannot reach the AUR. A package that fails any gate is
# skipped and reported; the others still publish.
#
# Idempotent and resumable: re-running skips work that is already done, and
# an already-published package is updated rather than duplicated.
# ============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly GATE="$SCRIPT_DIR/anubis_gate.py"
readonly AUR_HOST="aur.archlinux.org"
readonly SSH_KEY="$HOME/.ssh/aur"
readonly AUR_ROOT="$HOME/sdk/aur"
# package:repo:wrapper-process:cli-name:expected-startup-string
# The wrapper process name is NOT derivable from the package name
# (diet-guard-app runs diet_guard_desktop), so it is listed explicitly.
readonly PACKAGES=(
"todo-flutter:$HOME/src/todo:todo_desktop:todo:serving on http://localhost:8730"
"habit-stack:$HOME/src/habit_stack:habit_stack_desktop:habit-stack:serving on http://localhost:8731"
"diet-guard-app:$HOME/src/diet-guard:diet_guard_desktop:diet-guard-app:serving on http://localhost:8732"
)
POLL_SECONDS=300
SKIP_WAIT=0
DRY_RUN=0
ONLY=""
FAILED=()
PUBLISHED=()
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
Options:
--skip-wait Assume the account + SSH key already work; go straight
to building and publishing.
--dry-run Do everything except the final 'git push' to the AUR.
--only NAME Publish just one package (todo-flutter, habit-stack,
diet-guard-app).
--poll SECONDS How often to re-check /register (default: $POLL_SECONDS).
-h, --help Show this help.
EOF
exit 0
}
log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; }
info() { printf ' %s\n' "$*"; }
warn() { printf '\033[1;33m ! %s\033[0m\n' "$*" >&2; }
die() { printf '\033[1;31m==> ERROR: %s\033[0m\n' "$*" >&2; exit 1; }
require_tools() {
local missing=()
local tool
for tool in git makepkg updpkgsums namcap ssh python3 curl; do
command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
done
if ((${#missing[@]})); then
info "Installing missing tools: ${missing[*]}"
# namcap/updpkgsums come from namcap + pacman-contrib; the rest are base.
sudo pacman -S --needed --noconfirm namcap pacman-contrib git openssh python \
|| die "could not install: ${missing[*]}"
fi
}
# ---------------------------------------------------------------------------
# Phase 1: wait for registration, then for the key to work
# ---------------------------------------------------------------------------
# The rest lives in lib/, sourced (not executed) so every function sees the
# readonly config above and the mutable FAILED/PUBLISHED arrays main() reads.
# shellcheck source=lib/aur_registration.sh
source "$SCRIPT_DIR/lib/aur_registration.sh"
# shellcheck source=lib/aur_verify.sh
source "$SCRIPT_DIR/lib/aur_verify.sh"
# shellcheck source=lib/aur_publish_one.sh
source "$SCRIPT_DIR/lib/aur_publish_one.sh"
main() {
require_tools
[[ -f "$GATE" ]] || die "missing $GATE"
mkdir -p "$AUR_ROOT"
ensure_key
if ((SKIP_WAIT)); then
ssh_works || die "SSH key is not accepted yet; drop --skip-wait to wait for it"
else
wait_for_registration
wait_for_ssh
fi
local entry pkg repo wrapper binary expected
for entry in "${PACKAGES[@]}"; do
IFS=: read -r pkg repo wrapper binary expected <<<"$entry"
[[ -z "$ONLY" || "$ONLY" == "$pkg" ]] || continue
if ! publish_one "$pkg" "$repo" "$wrapper" "$binary" "$expected"; then
FAILED+=("$pkg")
fi
done
log "Summary"
if ((${#PUBLISHED[@]})); then
printf ' published: %s\n' "${PUBLISHED[@]}"
fi
if ((${#FAILED[@]})); then
printf '\033[1;31m FAILED: %s\033[0m\n' "${FAILED[@]}"
info "Nothing broken was pushed; fix and re-run (safe to repeat)."
return 1
fi
if ((${#PUBLISHED[@]} == 0)); then
warn "nothing to do"
return 1
fi
printf '\n Check: https://aur.archlinux.org/packages/todo-flutter\n'
return 0
}
while [[ $# -gt 0 ]]; do
case $1 in
--skip-wait) SKIP_WAIT=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--only) ONLY="$2"; shift 2 ;;
--poll) POLL_SECONDS="$2"; shift 2 ;;
-h|--help) usage ;;
*) die "Unknown option: $1" ;;
esac
done
main "$@"

View File

@ -1,143 +0,0 @@
# Pre-commit Configuration for code_runner
# 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 code_runner/.pre-commit-config.yaml --all-files
files: ^code_runner/
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]
- 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 code_runner
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
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=code_runner/pyproject.toml
- --fail-under=10
- --jobs=4
additional_dependencies:
- pytest
- repo: https://github.com/PyCQA/bandit
rev: 1.9.4
hooks:
- id: bandit
args:
- -c
- code_runner/pyproject.toml
- --severity-level=high
- --confidence-level=medium
- --skip=B113
additional_dependencies: ["bandit[toml]"]
exclude: ^(code_runner/code_runner/tests/|.*test.*\.py$)
- repo: local
hooks:
- id: pytest-coverage
name: pytest with coverage enforcement
entry: scripts/run_subproject_tests.sh code_runner
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

View File

@ -1,12 +0,0 @@
# code_runner
Runs a learner's solution (Python 3, JavaScript, TypeScript) against a problem statement's examples inside bubblewrap: no network, read-only `/usr`, scratch dir, CPU/memory/process/file/time limits.
```python
import code_runner as cr
statement = ... # markdown from ~/.claude/skills/leetcode/fetch_problem.py
results = cr.run("python3", code, cr.extract_starter(statement), cr.parse_examples(statement))
```
`run(lang, code, starter, examples) -> list[CaseResult]` with `n, input, expected, actual, ok, stdout, error, ms`. One sandbox process per example. TypeScript runs through node's type stripping (no type check, no enums). Needs `bwrap`, `prlimit`, `/usr/bin/python3`, node >= 22.18. Install: `./install.sh`.

View File

@ -1,20 +0,0 @@
"""Run a learner's solution against a problem's examples in a bubblewrap sandbox."""
from __future__ import annotations
from code_runner._examples import parse_examples
from code_runner._model import CaseResult, Example, Limits, RunnerError
from code_runner._run import run
from code_runner._starter import Entry, extract_starter, parse_starter
__all__ = [
"CaseResult",
"Entry",
"Example",
"Limits",
"RunnerError",
"extract_starter",
"parse_examples",
"parse_starter",
"run",
]

View File

@ -1,89 +0,0 @@
"""Output comparison: exact JSON equality, float tolerance, order rules."""
from __future__ import annotations
import json
import math
TOLERANCE = 1e-5 # LeetCode accepts floating answers within 1e-5
MAX_DEPTH = 64 # real answers nest a handful of levels; deeper is hostile
class TooDeepError(ValueError):
"""A compared value nests deeper than ``MAX_DEPTH``."""
def too_deep(value: object, limit: int = MAX_DEPTH) -> bool:
"""Whether ``value`` nests lists/dicts deeper than ``limit`` (iterative)."""
stack: list[tuple[object, int]] = [(value, 0)]
while stack:
node, depth = stack.pop()
if isinstance(node, list | dict):
if depth >= limit:
return True
children = node if isinstance(node, list) else node.values()
stack.extend((child, depth + 1) for child in children)
return False
def _as_number(value: object) -> float | None:
if isinstance(value, bool) or not isinstance(value, int | float):
return None
return value
def _key(value: object) -> str:
return json.dumps(value, sort_keys=True, ensure_ascii=False)
def _deep_sorted(value: object) -> object:
if isinstance(value, list):
return sorted((_deep_sorted(item) for item in value), key=_key)
return value
def _equal(expected: object, actual: object) -> bool:
want, got = _as_number(expected), _as_number(actual)
if want is not None and got is not None:
return math.isclose(want, got, rel_tol=TOLERANCE, abs_tol=TOLERANCE)
if isinstance(expected, list) and isinstance(actual, list):
return len(expected) == len(actual) and all(
_equal(e, a) for e, a in zip(expected, actual, strict=True)
)
if isinstance(expected, dict) and isinstance(actual, dict):
return expected.keys() == actual.keys() and all(
_equal(expected[k], actual[k]) for k in expected
)
return type(expected) is type(actual) and expected == actual
def _top_sorted(value: object) -> object:
if isinstance(value, list):
return sorted(value, key=_key)
return value
def matches(expected: object, actual: object, order: str) -> bool:
"""Whether ``actual`` is an accepted answer for ``expected``.
Args:
expected: the statement's answer (JSON value).
actual: what the solution returned (JSON value).
order: "exact", "any" (list order free) or "deep" (nested order free too).
Raises:
ValueError: ``order`` is none of those.
TooDeepError: a value nests deeper than ``MAX_DEPTH`` (the recursive
walks below would blow the stack).
"""
if too_deep(expected) or too_deep(actual):
msg = f"value nested deeper than {MAX_DEPTH} levels"
raise TooDeepError(msg)
if order == "exact":
return _equal(expected, actual)
if order == "any":
return _equal(_top_sorted(expected), _top_sorted(actual))
if order == "deep":
return _equal(_deep_sorted(expected), _deep_sorted(actual))
msg = f"unknown order {order!r}"
raise ValueError(msg)

View File

@ -1,75 +0,0 @@
from __future__ import annotations
import ast
import json
import logging
import re
from typing import Final
from code_runner._model import Example, Order
from code_runner._split import split_top_level
_logger: Final = logging.getLogger(__name__)
_LABEL: Final = re.compile(r"^(Input|Output)\s*:\s*(.*)$")
_PAIR: Final = re.compile(r"^([A-Za-z_]\w*)\s*=\s*(.+)$", re.DOTALL)
_ANY_ORDER: Final = re.compile(r"any order|order of the output", re.IGNORECASE)
_DEEP_ORDER: Final = re.compile(
r"order of the (?:triplets|quadruplets|subsets|groups|pairs|elements)",
re.IGNORECASE,
)
def _value(text: str) -> object:
try:
return json.loads(text)
except ValueError:
try:
return ast.literal_eval(text)
except (ValueError, SyntaxError) as exc:
raise ValueError(text[:40]) from exc
def _arguments(text: str) -> dict[str, object]:
args: dict[str, object] = {}
for part in split_top_level(text):
pair = _PAIR.match(part)
if pair is None:
msg = f"not a name = value pair: {part[:40]!r}"
raise ValueError(msg)
args[pair.group(1)] = _value(pair.group(2))
return args
def _labels(body: str) -> list[tuple[str, str]]:
cleaned = (
line.replace("**", "").replace("`", "").strip() for line in body.splitlines()
)
matched = (_LABEL.match(line) for line in cleaned)
return [(m.group(1), m.group(2).strip()) for m in matched if m is not None]
def _order(body: str) -> Order:
if _DEEP_ORDER.search(body):
return "deep"
return "any" if _ANY_ORDER.search(body) else "exact"
def parse_examples(statement: str) -> list[Example]:
body = re.split(r"^## ", statement, maxsplit=1, flags=re.MULTILINE)[0]
order = _order(body)
found: list[Example] = []
pending: str | None = None
number = 0
for label, text in _labels(body):
if label == "Input":
pending = text
continue
if pending is None:
continue
number += 1
try:
found.append(Example(number, _arguments(pending), _value(text), order))
except ValueError as exc:
_logger.warning("example %d skipped, not runnable: %s", number, exc)
pending = None
return found

View File

@ -1,119 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from importlib import resources
import json
import os
from pathlib import Path
import shutil
from typing import TYPE_CHECKING
from code_runner._model import RunnerError
from code_runner._sandbox import WORK
if TYPE_CHECKING:
from code_runner._starter import Entry
MEGABYTE = 1024 * 1024
_NODE_FLAGS = (
"--permission",
f"--allow-fs-read={WORK}",
f"--allow-fs-write={WORK}",
"--max-old-space-size=128",
)
@dataclass(frozen=True)
class Language:
name: str
solution_file: str
harness_file: str
program: str
binary_env: str
binary_default: str
flags: tuple[str, ...]
memory_bytes: int
def resolve(self) -> Path:
for candidate in (
os.environ.get(self.binary_env),
self.binary_default,
shutil.which(self.program),
):
if candidate and Path(candidate).is_file():
return Path(candidate).resolve()
msg = f"{self.program} not found (set {self.binary_env})"
raise RunnerError(msg)
def command(self, binary: Path) -> list[str]:
return [str(binary), *self.flags, f"{WORK}/{self.harness_file}", WORK]
def ro_binds(self, binary: Path) -> tuple[str, ...]:
if binary.is_relative_to("/usr"):
return ()
return (str(binary.parent.parent),)
def prepare(
self, scratch: Path, code: str, entry: Entry, args: list[object]
) -> None:
harness = resources.files("code_runner").joinpath("harness", self.harness_file)
(scratch / self.harness_file).write_text(harness.read_text(encoding="utf-8"))
text = code
if self.name == "typescript":
text += f"\nmodule.exports = {{ __entry: {entry.cls or entry.name} }};\n"
(scratch / self.solution_file).write_text(text, encoding="utf-8")
spec = {
"cls": entry.cls,
"fn": entry.name,
"args": args,
"typescript": self.name == "typescript",
}
(scratch / "input.json").write_text(json.dumps(spec), encoding="utf-8")
PYTHON = Language(
"python3",
"solution.py",
"harness.py",
"python3",
"CODE_RUNNER_PYTHON",
"/usr/bin/python3",
("-I", "-B"),
512 * MEGABYTE,
)
JAVASCRIPT = Language(
"javascript",
"solution.js",
"harness.js",
"node",
"CODE_RUNNER_NODE",
"/usr/bin/node",
_NODE_FLAGS,
2048 * MEGABYTE,
)
TYPESCRIPT = Language(
"typescript",
"solution.cts",
"harness.js",
"node",
"CODE_RUNNER_NODE",
"/usr/bin/node",
_NODE_FLAGS,
2048 * MEGABYTE,
)
_BY_NAME = {
"python3": PYTHON,
"python": PYTHON,
"javascript": JAVASCRIPT,
"js": JAVASCRIPT,
"typescript": TYPESCRIPT,
"ts": TYPESCRIPT,
}
def get_language(name: str) -> Language:
try:
return _BY_NAME[name.lower()]
except KeyError:
msg = f"unsupported language {name!r}; use python3, javascript or typescript"
raise RunnerError(msg) from None

View File

@ -1,40 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
# "any": list order is free; "deep": the order inside its items is free too.
Order = Literal["exact", "any", "deep"]
MAX_CAPTURE = 64 * 1024
class RunnerError(Exception):
pass
@dataclass(frozen=True)
class Example:
n: int
args: dict[str, object]
expected: object
order: Order = "exact"
@dataclass(frozen=True)
class CaseResult:
n: int
input: dict[str, object]
expected: object
actual: object
ok: bool
stdout: str = ""
error: str | None = None
ms: float = 0.0
@dataclass(frozen=True)
class Limits:
timeout_s: float = 2.0
nproc: int = 64
fsize_bytes: int = 1024 * 1024
nofile: int = 256

View File

@ -1,134 +0,0 @@
from __future__ import annotations
from collections.abc import Callable, Sequence
from dataclasses import dataclass
import json
import os
from pathlib import Path
import stat
import tempfile
from code_runner._compare import TooDeepError, matches, too_deep
from code_runner._languages import Language, get_language
from code_runner._model import MAX_CAPTURE, CaseResult, Example, Limits
from code_runner._sandbox import BoxResult, execute
from code_runner._starter import Entry, parse_starter
Box = Callable[[list[str], Path, Limits, int, tuple[str, ...]], BoxResult]
_OUT_OF_MEMORY = ("out of memory", "allocation failed")
_MAX_ERROR = 200
# Everything hostile JSON can raise on the host: deep nesting, huge numbers.
_HOSTILE = (ValueError, TypeError, RecursionError, MemoryError, OverflowError)
def _positional(example: Example, entry: Entry) -> list[object]:
if set(example.args) == set(entry.params):
return [example.args[name] for name in entry.params]
if len(example.args) == len(entry.params):
return list(example.args.values())
given, wanted = len(example.args), len(entry.params)
msg = f"ArgumentError: example gives {given} arguments, {entry.name} takes {wanted}"
raise ValueError(msg)
def _read_result(scratch: Path) -> dict[str, object] | None:
# The scratch dir is the solution's: never follow a link it planted there.
flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
try:
fd = os.open(scratch / "result.json", flags)
except OSError:
return None
with os.fdopen(fd, "rb") as handle:
if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
return None
raw = handle.read(MAX_CAPTURE * 16)
try:
payload = json.loads(raw)
except _HOSTILE: # ValueError, or RecursionError on "[" * 200000
return None
if not isinstance(payload, dict) or "ok" not in payload:
return None
return None if too_deep(payload) else payload
def _crash(box: BoxResult) -> str:
low = box.stderr.lower()
if any(marker in low for marker in _OUT_OF_MEMORY):
return "MemoryLimitExceeded"
return f"Crashed (exit {box.returncode})"
def _took(ms: object, fallback: float) -> float:
if isinstance(ms, bool) or not isinstance(ms, int | float):
return fallback
try:
return float(ms)
except OverflowError: # an int too big for a float
return fallback
def _outcome(scratch: Path, box: BoxResult) -> tuple[object, str | None, float]:
if box.timed_out:
return None, "TimeLimitExceeded", box.elapsed_ms
payload = _read_result(scratch)
if payload is None:
return None, _crash(box), box.elapsed_ms
took = _took(payload.get("ms"), box.elapsed_ms)
if payload["ok"] is True:
return payload.get("value"), None, took
return None, str(payload.get("error", "Error"))[:_MAX_ERROR], took
@dataclass(frozen=True)
class _Job:
language: Language
binary: Path
code: str
entry: Entry
limits: Limits
box: Box
def _case(job: _Job, example: Example) -> CaseResult:
try:
args = _positional(example, job.entry)
except ValueError as exc:
return CaseResult(
example.n, example.args, example.expected, None, ok=False, error=str(exc)
)
language = job.language
with tempfile.TemporaryDirectory(prefix="code-runner-") as tmp:
scratch = Path(tmp)
language.prepare(scratch, job.code, job.entry, args)
done = job.box(
language.command(job.binary),
scratch,
job.limits,
language.memory_bytes,
language.ro_binds(job.binary),
)
actual, error, ms = _outcome(scratch, done)
ok = False
if error is None:
try:
ok = matches(example.expected, actual, example.order)
except (TooDeepError, RecursionError, MemoryError) as exc:
actual, error = None, f"InvalidResult: {type(exc).__name__}"
return CaseResult(
example.n, example.args, example.expected, actual, ok, done.stdout, error, ms
)
def run(
lang: str,
code: str,
starter: str,
examples: Sequence[Example],
*,
limits: Limits | None = None,
box: Box = execute,
) -> list[CaseResult]:
language = get_language(lang)
entry = parse_starter(language.name, starter)
job = _Job(language, language.resolve(), code, entry, limits or Limits(), box)
return [_case(job, example) for example in examples]

View File

@ -1,147 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
import functools
import math
import os
from pathlib import Path
import signal
import subprocess
import tempfile
import time
from typing import IO
from code_runner._model import MAX_CAPTURE, Limits, RunnerError
WORK = "/work"
PRLIMIT = "/usr/bin/prlimit"
_ROOT_LINKS = ("bin", "sbin", "lib", "lib64")
@dataclass(frozen=True)
class BoxResult:
returncode: int
timed_out: bool
stdout: str
stderr: str
elapsed_ms: float
def _root_links() -> list[str]:
args: list[str] = []
for name in _ROOT_LINKS:
path = Path("/") / name
if path.is_symlink():
args += ["--symlink", str(path.readlink()), str(path)]
elif path.is_dir():
args += ["--ro-bind", str(path), str(path)]
return args
def _base_argv() -> list[str]:
return [
"bwrap",
"--unshare-all", # no --share-net: no network at all, loopback included
"--die-with-parent",
"--new-session",
"--clearenv",
"--ro-bind",
"/usr",
"/usr",
*_root_links(),
"--proc",
"/proc",
"--dev",
"/dev",
"--tmpfs",
"/tmp",
]
def bwrap_argv(
command: list[str],
scratch: Path,
limits: Limits,
memory_bytes: int,
ro_binds: tuple[str, ...] = (),
) -> list[str]:
argv = _base_argv()
for bind in ro_binds:
argv += ["--ro-bind", bind, bind]
argv += ["--bind", str(scratch), WORK, "--remount-ro", "/", "--chdir", WORK]
for name, value in (
("HOME", WORK),
("PATH", "/usr/bin"),
("LANG", "C.UTF-8"),
("PYTHONIOENCODING", "utf-8"),
):
argv += ["--setenv", name, value]
cpu = math.ceil(limits.timeout_s) + 1
return [
*argv,
"--",
PRLIMIT,
f"--as={memory_bytes}",
f"--nproc={limits.nproc}",
f"--fsize={limits.fsize_bytes}",
f"--nofile={limits.nofile}",
f"--cpu={cpu}",
"--core=0",
"--",
*command,
]
@functools.cache
def check_sandbox() -> None:
if not Path(PRLIMIT).is_file():
msg = f"{PRLIMIT} is missing (util-linux)"
raise RunnerError(msg)
try:
probe = subprocess.run(
[*_base_argv(), "/usr/bin/true"],
capture_output=True,
check=False,
timeout=20,
)
except OSError as exc:
msg = f"bubblewrap (bwrap) cannot start: {exc}"
raise RunnerError(msg) from exc
if probe.returncode != 0:
detail = probe.stderr.decode(errors="replace").strip()
msg = f"bubblewrap cannot create the sandbox: {detail}"
raise RunnerError(msg)
def _read(handle: IO[bytes]) -> str:
handle.seek(0)
return handle.read(MAX_CAPTURE).decode("utf-8", errors="replace")
def execute(
command: list[str],
scratch: Path,
limits: Limits,
memory_bytes: int,
ro_binds: tuple[str, ...] = (),
) -> BoxResult:
check_sandbox()
argv = bwrap_argv(command, scratch, limits, memory_bytes, ro_binds)
with tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err:
started = time.monotonic()
with subprocess.Popen(
argv,
stdin=subprocess.DEVNULL,
stdout=out,
stderr=err,
env={"PATH": "/usr/bin:/bin"},
start_new_session=True,
) as proc:
timed_out = False
try:
proc.wait(timeout=limits.timeout_s)
except subprocess.TimeoutExpired:
timed_out = True
os.killpg(proc.pid, signal.SIGKILL)
elapsed = (time.monotonic() - started) * 1000
return BoxResult(proc.returncode, timed_out, _read(out), _read(err), elapsed)

View File

@ -1,29 +0,0 @@
from __future__ import annotations
_OPEN = "([{<"
_CLOSE = ")]}>"
def split_top_level(text: str) -> list[str]:
parts: list[str] = []
depth = 0
quote = ""
escaped = False
start = 0
for i, ch in enumerate(text):
if escaped:
escaped = False
elif quote:
escaped = ch == "\\"
quote = "" if ch == quote else quote
elif ch in "\"'":
quote = ch
elif ch in _OPEN:
depth += 1
elif ch in _CLOSE and not (ch == ">" and text[i - 1] in "=-"):
depth = max(depth - 1, 0)
elif ch == "," and depth == 0:
parts.append(text[start:i])
start = i + 1
parts.append(text[start:])
return [part.strip() for part in parts]

View File

@ -1,108 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
import re
from code_runner._model import RunnerError
from code_runner._split import split_top_level
_IDENT = r"[A-Za-z_$][\w$]*"
_BLOCK_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)
_LINE_COMMENT = re.compile(r"^\s*(?://|#).*$", re.MULTILINE)
_PY_CLASS = re.compile(r"^class\s+(\w+)", re.MULTILINE)
_PY_DEF = re.compile(r"\bdef\s+(?!__)(\w+)\s*\(")
_PY_FUNC = re.compile(r"^def\s+(\w+)\s*\(", re.MULTILINE)
_JS_CLASS = re.compile(rf"^\s*class\s+({_IDENT})", re.MULTILINE)
_JS_METHOD = re.compile(
rf"^\s+(?:async\s+)?(?!constructor\b)({_IDENT})\s*\(", re.MULTILINE
)
_JS_ASSIGNED = re.compile(
rf"\b(?:var|let|const)\s+({_IDENT})\s*(?::[^=]+)?=\s*(?:async\s+)?(?:function\b\s*\w*\s*)?\("
)
_JS_DECLARED = re.compile(rf"\bfunction\s+({_IDENT})\s*\(")
_NOT_METHODS = {"if", "for", "while", "switch", "catch", "return", "function"}
@dataclass(frozen=True)
class Entry:
cls: str | None
name: str
params: tuple[str, ...]
def _no_entry(lang: str) -> RunnerError:
return RunnerError(f"no entry point found in the {lang} starter code")
def _balanced(text: str, open_at: int) -> str:
depth = 0
for i in range(open_at, len(text)):
ch = text[i]
if ch in "([{" or (ch == "<" and depth > 0):
depth += 1
elif ch in ")]}" or (ch == ">" and text[i - 1] != "=" and depth > 1):
depth -= 1
if depth == 0:
return text[open_at + 1 : i]
return text[open_at + 1 :]
def _params(code: str, open_at: int) -> tuple[str, ...]:
names: list[str] = []
for part in split_top_level(_balanced(code, open_at)):
found = re.match(_IDENT, part.strip())
if found:
names.append(found.group())
return tuple(names)
def _python(text: str) -> Entry | None:
code = _LINE_COMMENT.sub("", text)
owner = _PY_CLASS.search(code)
if owner is None:
found = _PY_FUNC.search(code)
return found and Entry(None, found.group(1), _params(code, found.end() - 1))
found = _PY_DEF.search(code, owner.end())
if found is None:
return None
params = _params(code, found.end() - 1)
return Entry(
owner.group(1), found.group(1), tuple(p for p in params if p != "self")
)
def _javascript(text: str) -> Entry | None:
code = _LINE_COMMENT.sub("", _BLOCK_COMMENT.sub("", text))
owner = _JS_CLASS.search(code)
if owner is not None:
for method in _JS_METHOD.finditer(code, owner.end()):
if method.group(1) not in _NOT_METHODS:
return Entry(
owner.group(1), method.group(1), _params(code, method.end() - 1)
)
return None
found = _JS_ASSIGNED.search(code) or _JS_DECLARED.search(code)
return found and Entry(None, found.group(1), _params(code, found.end() - 1))
def parse_starter(lang: str, text: str) -> Entry:
entry = _python(text) if lang == "python3" else _javascript(text)
if entry is None:
raise _no_entry(lang)
return entry
def extract_starter(statement: str) -> str:
lines = statement.splitlines(keepends=True)
heads = [i for i, line in enumerate(lines) if line.startswith("## Starter code")]
if not heads:
return ""
body = lines[heads[0] + 1 :]
if body and body[0].startswith("```"):
body = body[1:]
end = next(
(i for i, line in enumerate(body) if line.startswith("```")), len(body)
)
return "".join(body[:end])
end = next((i for i, line in enumerate(body) if line.startswith("## ")), len(body))
return "".join(body[:end])

View File

@ -1,45 +0,0 @@
"use strict";
// Runs inside the sandbox: call the solution once, write result.json.
const fs = require("node:fs");
const path = require("node:path");
const root = process.argv[2];
const spec = JSON.parse(fs.readFileSync(path.join(root, "input.json"), "utf8"));
const IDENT = /^[A-Za-z_$][\w$]*$/;
function describe(e) {
const name = e && typeof e.name === "string" ? e.name : "Error";
const message = e && e.message !== undefined ? e.message : String(e);
return `${name}: ${message}`.slice(0, 300);
}
function writeResult(payload) {
// Unique name + rename: a symlink planted at result.json is replaced, never followed.
const unique = path.join(root, `.result-${process.pid}-${Math.random().toString(36).slice(2)}`);
fs.writeFileSync(unique, JSON.stringify(payload), { flag: "wx" });
fs.renameSync(unique, path.join(root, "result.json"));
}
function load(target) {
if (!IDENT.test(target)) throw new TypeError("bad entry name");
if (spec.typescript) return require(path.join(root, "solution.cts")).__entry;
const source = fs.readFileSync(path.join(root, "solution.js"), "utf8");
return new Function(`${source}\n;return ${target};`)();
}
function call() {
const entry = load(spec.cls || spec.fn);
if (spec.cls) return new entry()[spec.fn](...spec.args);
return entry(...spec.args);
}
const started = process.hrtime.bigint();
let payload;
try {
const value = call();
payload = { ok: true, value: value === undefined ? null : JSON.parse(JSON.stringify(value)) };
} catch (e) {
payload = { ok: false, error: describe(e) };
}
payload.ms = Number(process.hrtime.bigint() - started) / 1e6;
writeResult(payload);

View File

@ -1,69 +0,0 @@
"""Runs inside the sandbox: call the solution once, write result.json."""
from __future__ import annotations
import json
import os
from pathlib import Path
import sys
import tempfile
import time
from typing import Any
PRELUDE = (
"from typing import *\n"
"import bisect, collections, functools, heapq, itertools, math\n"
"import operator, re, string\n"
)
MAX_MESSAGE = 300
def describe(exc: BaseException) -> str:
return f"{type(exc).__name__}: {exc}"[:MAX_MESSAGE]
def write_result(root: Path, payload: dict[str, object]) -> None:
# Unique name + rename: a symlink the solution planted at result.json is
# replaced by this file, never followed.
handle, name = tempfile.mkstemp(dir=root, prefix=".result-")
with os.fdopen(handle, "w", encoding="utf-8") as out:
json.dump(payload, out)
Path(name).replace(root / "result.json")
def call(root: Path, spec: dict[str, Any]) -> object:
namespace: dict[str, Any] = {"__name__": "solution"}
exec(PRELUDE, namespace)
source = (root / "solution.py").read_text(encoding="utf-8")
exec(compile(source, "solution.py", "exec"), namespace)
owner, name = spec["cls"], str(spec["fn"])
if owner is None:
target = namespace.get(name)
else:
cls = namespace.get(str(owner))
target = getattr(cls() if cls else None, name, None)
if target is None:
message = f"name {owner or name!r} is not defined"
raise NameError(message)
return target(*spec["args"])
def main(directory: str) -> None:
root = Path(directory)
spec = json.loads((root / "input.json").read_text(encoding="utf-8"))
started = time.perf_counter()
try:
value = call(root, spec)
payload: dict[str, object] = {
"ok": True,
"value": json.loads(json.dumps(value)),
}
except BaseException as exc:
payload = {"ok": False, "error": describe(exc)}
payload["ms"] = (time.perf_counter() - started) * 1000
sys.stdout.flush()
write_result(root, payload)
if __name__ == "__main__":
main(sys.argv[1])

View File

@ -1,70 +0,0 @@
"""Shared fixtures: real-sandbox tests are skipped where bubblewrap cannot run."""
from __future__ import annotations
import shutil
import pytest
from code_runner._model import Example, RunnerError
from code_runner._sandbox import check_sandbox
def _bwrap_works() -> bool:
try:
check_sandbox()
except RunnerError:
return False
return True
needs_sandbox = pytest.mark.skipif(not _bwrap_works(), reason="bwrap/userns unusable")
needs_node = pytest.mark.skipif(
not _bwrap_works() or not shutil.which("node"), reason="node or bwrap missing"
)
TWO_SUM_STARTER = {
"python3": "class Solution:\n def twoSum(self, nums: list[int], target: int) -> list[int]:\n",
"javascript": "var twoSum = function(nums, target) {\n\n};",
"typescript": "function twoSum(nums: number[], target: number): number[] {\n};",
}
TWO_SUM_CODE = {
"python3": (
"class Solution:\n"
" def twoSum(self, nums, target):\n"
" seen = {}\n"
" for i, n in enumerate(nums):\n"
" if target - n in seen:\n"
" return [i, seen[target - n]]\n"
" seen[n] = i\n"
),
"javascript": (
"var twoSum = function(nums, target) {\n"
" const seen = new Map();\n"
" for (let i = 0; i < nums.length; i++) {\n"
" if (seen.has(target - nums[i])) return [i, seen.get(target - nums[i])];\n"
" seen.set(nums[i], i);\n"
" }\n"
"};"
),
"typescript": (
"function twoSum(nums: number[], target: number): number[] {\n"
" const seen = new Map<number, number>();\n"
" for (let i = 0; i < nums.length; i++) {\n"
" const j = seen.get(target - nums[i]);\n"
" if (j !== undefined) return [i, j];\n"
" seen.set(nums[i], i);\n"
" }\n"
" return [];\n"
"}"
),
}
@pytest.fixture
def two_sum_examples() -> list[Example]:
return [
Example(1, {"nums": [2, 7, 11, 15], "target": 9}, [0, 1], "any"),
Example(2, {"nums": [3, 2, 4], "target": 6}, [1, 2], "any"),
Example(3, {"nums": [3, 3], "target": 6}, [0, 1], "any"),
]

View File

@ -1,65 +0,0 @@
import pytest
from code_runner._compare import MAX_DEPTH, TooDeepError, matches, too_deep
@pytest.mark.parametrize(
("expected", "actual", "order", "want"),
[
([0, 1], [0, 1], "exact", True),
([0, 1], [1, 0], "exact", False),
([0, 1], [1, 0], "any", True),
([0, 1], [1, 0, 2], "any", False),
([[1, 2], [3]], [[3], [1, 2]], "any", True),
([[1, 2], [3]], [[3], [2, 1]], "any", False),
([[1, 2], [3]], [[3], [2, 1]], "deep", True),
([[1, 2], [3]], [[3], [2, 9]], "deep", False),
(3, 3.0, "exact", True),
(0.1 + 0.2, 0.3, "exact", True),
(2.0, 2.001, "exact", False),
(True, 1, "exact", False),
(1, True, "exact", False),
(True, True, "exact", True),
(None, None, "exact", True),
(None, 0, "exact", False),
("ab", "ab", "exact", True),
("ab", "ba", "exact", False),
("1", 1, "exact", False),
({"a": [1]}, {"a": [1]}, "exact", True),
({"a": 1}, {"b": 1}, "exact", False),
({"a": 1}, {"a": 2}, "exact", False),
(5, 5, "any", True),
([1], 1, "any", False),
([1.0, 2.0], [2.0, 1.0000001], "any", True),
],
)
def test_matches(expected: object, actual: object, order: str, *, want: bool) -> None:
assert matches(expected, actual, order) is want
def test_unknown_order_is_refused() -> None:
with pytest.raises(ValueError, match="order"):
matches(1, 1, "sideways")
def _nest(depth: int) -> object:
value: object = 1
for _ in range(depth):
value = [value]
return value
@pytest.mark.parametrize("order", ["exact", "any", "deep"])
def test_deep_nesting_is_refused_not_recursed(order: str) -> None:
deep = _nest(100_000) # would raise RecursionError in the recursive walks
with pytest.raises(TooDeepError):
matches(deep, deep, order)
with pytest.raises(TooDeepError):
matches(1, deep, order)
def test_nesting_up_to_the_limit_still_compares() -> None:
assert matches(_nest(MAX_DEPTH), _nest(MAX_DEPTH), "deep") is True
assert too_deep(_nest(MAX_DEPTH + 1)) is True
assert too_deep({"a": [{"b": _nest(MAX_DEPTH)}]}) is True
assert too_deep({"a": [{"b": 1}]}) is False

View File

@ -1,106 +0,0 @@
from code_runner._examples import parse_examples
from code_runner._model import Example
TWO_SUM = """# 1. Two Sum (Easy)
Tags: Array, Hash Table
Return indices.
You can return the answer in any order.
**Example 1:**
**Input:** `nums = [2,7,11,15], target = 9`
**Output:** `[0,1]`
**Explanation:** 2 + 7.
**Example 2:**
**Input:** `nums = [3,2,4], target = 6`
**Output:** `[1,2]`
## Starter code (python3)
```
class Solution:
def twoSum(self, nums, target):
```
"""
FENCED = """# 5. Pal
Output order does not matter.
**Example 1:**
```
Input: s = "a,b", flag = true
Output: "b"
Explanation: Output: not an example
```
**Example 2:**
```
Input: s = 'x', flag = false
Output: None
```
"""
THREE_SUM = """# 15. 3Sum
Notice that the order of the output and the order of the triplets does not matter.
**Example 1:**
**Input:** nums = [-1,0,1,2,-1,-4]
**Output:** [[-1,-1,2],[-1,0,1]]
"""
SKIPPED = """# 2. Add Two Numbers
**Example 1:**
**Input:** l1 = 1->2
**Output:** [1,2]
**Example 2:**
**Input:** a = 1, b = 2
**Output:** 3.00000
**Example 3:**
**Input:** a = 1
**Output:** 1->2
**Example 4:**
**Input:** a
**Output:** 1
"""
def test_two_sum_examples_are_typed_and_ordered_any() -> None:
found = parse_examples(TWO_SUM)
assert found == [
Example(1, {"nums": [2, 7, 11, 15], "target": 9}, [0, 1], "any"),
Example(2, {"nums": [3, 2, 4], "target": 6}, [1, 2], "any"),
]
def test_fenced_examples_and_python_literals() -> None:
found = parse_examples(FENCED)
assert [e.args for e in found] == [
{"s": "a,b", "flag": True},
{"s": "x", "flag": False},
]
assert [e.expected for e in found] == ["b", None]
assert {e.order for e in found} == {"exact"}
def test_triplets_sentence_means_deep_order() -> None:
assert parse_examples(THREE_SUM)[0].order == "deep"
def test_unparseable_examples_are_skipped_but_numbering_is_kept() -> None:
found = parse_examples(SKIPPED)
assert found == [Example(2, {"a": 1, "b": 2}, 3.0, "exact")]
def test_output_without_input_and_empty_statement() -> None:
assert parse_examples("**Output:** 1\n") == []
assert parse_examples("") == []
def test_starter_section_is_never_scanned() -> None:
text = "# t\n## Starter code (python3)\n```\nInput: a = 1\nOutput: 2\n```\n"
assert parse_examples(text) == []

View File

@ -1,235 +0,0 @@
"""Branches that a healthy sandbox never reaches: crashes, bad results, no bwrap."""
from __future__ import annotations
import dataclasses
import os
from pathlib import Path
import subprocess
from typing import TYPE_CHECKING
import pytest
from code_runner import Example, Limits, RunnerError, run
from code_runner._examples import parse_examples
from code_runner._languages import JAVASCRIPT, PYTHON, get_language
from code_runner._run import Box, _read_result
from code_runner._sandbox import BoxResult, bwrap_argv, check_sandbox
from code_runner._starter import parse_starter
if TYPE_CHECKING:
from collections.abc import Iterator
STARTER = "def f(a):\n"
EXAMPLE = Example(1, {"a": 1}, 1)
@pytest.fixture(autouse=True)
def _fresh_sandbox_check() -> Iterator[None]:
check_sandbox.cache_clear()
yield
check_sandbox.cache_clear()
def _fake(result: BoxResult, files: dict[str, str] | None = None) -> Box:
def box(
command: list[str],
scratch: Path,
limits: Limits,
memory: int,
binds: tuple[str, ...],
) -> BoxResult:
for name, text in (files or {}).items():
(scratch / name).write_text(text)
return result
return box
def _box(**kw: object) -> BoxResult:
fields = {
"returncode": 0,
"timed_out": False,
"stdout": "",
"stderr": "",
"elapsed_ms": 7.0,
}
return BoxResult(**(fields | kw))
def _run(box: Box) -> object:
return run("python3", "", STARTER, [EXAMPLE], box=box)[0]
def test_crash_without_result_names_the_exit_code() -> None:
result = _run(_fake(_box(returncode=139)))
assert (result.ok, result.actual, result.error, result.ms) == (
False,
None,
"Crashed (exit 139)",
7.0,
)
def test_out_of_memory_crash_is_named() -> None:
stderr = "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory"
assert (
_run(_fake(_box(returncode=134, stderr=stderr))).error == "MemoryLimitExceeded"
)
def test_timeout_wins_over_a_result_file() -> None:
files = {"result.json": '{"ok": true, "value": 1}'}
result = _run(_fake(_box(timed_out=True, elapsed_ms=1000.0), files))
assert (result.error, result.ms) == ("TimeLimitExceeded", 1000.0)
def test_result_file_variants() -> None:
good = _run(
_fake(_box(stdout="hi"), {"result.json": '{"ok": true, "value": 1, "ms": 2.5}'})
)
assert (good.ok, good.ms, good.stdout) == (True, 2.5, "hi")
no_ms = _run(_fake(_box(), {"result.json": '{"ok": true, "value": 1}'}))
assert no_ms.ms == 7.0
failed = _run(_fake(_box(), {"result.json": '{"ok": false}'}))
assert failed.error == "Error"
named = _run(
_fake(_box(), {"result.json": '{"ok": false, "error": "KeyError: x"}'})
)
assert named.error == "KeyError: x"
def test_hostile_result_files_cost_one_example_not_the_run() -> None:
hostile = {
"bomb": "[" * 200_000,
"deep": '{"ok": true, "value": ' + "[" * 5000 + "]" * 5000 + "}",
"huge_ms": '{"ok": true, "value": 1, "ms": 1' + "0" * 400 + "}",
"bool_ms": '{"ok": true, "value": 1, "ms": true}',
"long_error": '{"ok": false, "error": "' + "x" * 5000 + '"}',
}
results = {
name: _run(_fake(_box(), {"result.json": text}))
for name, text in hostile.items()
}
assert results["bomb"].error == "Crashed (exit 0)"
assert results["deep"].error == "Crashed (exit 0)"
assert (results["huge_ms"].ok, results["huge_ms"].ms) == (True, 7.0)
assert results["bool_ms"].ms == 7.0
assert len(results["long_error"].error) == 200
def test_too_deep_expected_or_actual_is_that_examples_error() -> None:
deep: object = 1
for _ in range(100):
deep = [deep]
files = {"result.json": '{"ok": true, "value": 1}'}
cases = [Example(1, {"a": 1}, deep), EXAMPLE]
results = run("python3", "", STARTER, cases, box=_fake(_box(), files))
assert (results[0].ok, results[0].actual) == (False, None)
assert results[0].error == "InvalidResult: TooDeepError"
assert results[1].ok is True # the run went on
def test_recursion_in_compare_is_that_examples_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def boom(*args: object) -> bool:
raise RecursionError
monkeypatch.setattr("code_runner._run.matches", boom)
files = {"result.json": '{"ok": true, "value": 1}'}
result = _run(_fake(_box(), files))
assert result.error == "InvalidResult: RecursionError"
def test_unreadable_result_files_are_a_crash(tmp_path: Path) -> None:
secret = tmp_path / "secret"
secret.write_text('{"ok": true, "value": "stolen"}')
(tmp_path / "result.json").symlink_to(secret)
assert _read_result(tmp_path) is None
(tmp_path / "result.json").unlink()
os.mkfifo(tmp_path / "result.json")
assert _read_result(tmp_path) is None
(tmp_path / "result.json").unlink()
for text in ("not json", "[1]", '{"no": "ok"}'):
(tmp_path / "result.json").write_text(text)
assert _read_result(tmp_path) is None
assert _read_result(tmp_path / "missing") is None
def test_binary_resolution(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
ghost = dataclasses.replace(
PYTHON, binary_default="/nope", program="no-such-program-xyz"
)
monkeypatch.delenv(ghost.binary_env, raising=False)
with pytest.raises(RunnerError, match="no-such-program-xyz not found"):
ghost.resolve()
tool = tmp_path / "bin" / "python3"
tool.parent.mkdir()
tool.write_text("#!/bin/sh\n")
monkeypatch.setenv(ghost.binary_env, str(tool))
assert ghost.resolve() == tool
assert ghost.ro_binds(tool) == (str(tool.parent.parent),)
assert PYTHON.ro_binds(Path("/usr/bin/python3")) == ()
assert get_language("TS").name == "typescript"
def _has(argv: list[str], seq: list[str]) -> bool:
return any(argv[i : i + len(seq)] == seq for i in range(len(argv)))
def test_argv_binds_links_and_limits(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr("code_runner._sandbox._ROOT_LINKS", ("usr", "no-such-root-dir"))
argv = bwrap_argv(["prog"], tmp_path, Limits(timeout_s=1.2), 123, ("/opt/x",))
assert _has(argv, ["--ro-bind", "/usr", "/usr"])
assert _has(argv, ["--ro-bind", "/opt/x", "/opt/x"])
assert _has(argv, ["--bind", str(tmp_path), "/work"])
assert "--share-net" not in argv
assert {"--unshare-all", "--die-with-parent", "--clearenv", "--remount-ro"} <= set(
argv
)
assert "--as=123" in argv
assert "--cpu=3" in argv
assert argv[-1] == "prog"
def test_sandbox_preflight_failures(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("code_runner._sandbox.PRLIMIT", "/no/such/prlimit")
with pytest.raises(RunnerError, match="prlimit"):
check_sandbox()
monkeypatch.undo()
def missing(*args: object, **kwargs: object) -> None:
message = "bwrap"
raise FileNotFoundError(message)
monkeypatch.setattr(subprocess, "run", missing)
with pytest.raises(RunnerError, match="cannot start"):
check_sandbox()
def refused(*args: object, **kwargs: object) -> subprocess.CompletedProcess[bytes]:
return subprocess.CompletedProcess(args, 1, b"", b"bwrap: No permissions\n")
monkeypatch.setattr(subprocess, "run", refused)
with pytest.raises(RunnerError, match="No permissions"):
check_sandbox()
def test_statement_and_starter_edge_cases() -> None:
escaped = parse_examples('**Input:** s = "a\\"b,c", t = 1\n**Output:** "x"\n')
assert escaped[0].args == {"s": 'a"b,c', "t": 1}
assert parse_starter("python3", "def f(a, b").params == ("a", "b")
skipped = "class S {\n if (x) {\n solve(a) {\n }\n}\n"
assert parse_starter("javascript", skipped).name == "solve"
def test_typescript_class_entry_is_exported(tmp_path: Path) -> None:
entry = parse_starter(
"typescript", "class Solution {\n solve(a: number): number {\n }\n}\n"
)
get_language("typescript").prepare(tmp_path, "class Solution {}", entry, [1])
assert "__entry: Solution" in (tmp_path / "solution.cts").read_text()
JAVASCRIPT.prepare(tmp_path, "x", entry, [1])
assert (tmp_path / "solution.js").read_text() == "x"

View File

@ -1,206 +0,0 @@
"""Escape attempts: each one runs hostile code in the real sandbox and must fail."""
from __future__ import annotations
from pathlib import Path
import socket
import subprocess
import time
from typing import Any, cast
import pytest
from code_runner import CaseResult, Example, Limits, run
from code_runner.tests.conftest import needs_node, needs_sandbox
pytestmark = needs_sandbox
STARTER = "def f(a):\n"
FAST = Limits(timeout_s=1.0)
def _one(code: str, lang: str = "python3", limits: Limits = FAST) -> CaseResult:
starter = {"python3": STARTER, "javascript": "var f = function(a) {};"}[lang]
return run(lang, code, starter, [Example(1, {"a": 0}, 1)], limits=limits)[0]
def _pgrep(pattern: str) -> int:
done = subprocess.run(
["/usr/bin/pgrep", "-fc", pattern], capture_output=True, text=True, check=False
)
return int(done.stdout.strip() or 0)
def test_no_network_not_even_host_loopback() -> None:
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
listener.listen()
listener.settimeout(0)
port = listener.getsockname()[1]
code = (
"import socket\n"
"def f(a):\n"
" out = []\n"
f" for host, port in [('127.0.0.1', {port}), ('1.1.1.1', 53), ('::1', {port})]:\n"
" try:\n"
" socket.create_connection((host, port), timeout=1).close()\n"
" out.append('connected')\n"
" except OSError:\n"
" out.append('blocked')\n"
" return out\n"
)
result = _one(code)
with pytest.raises(BlockingIOError):
listener.accept()
assert result.actual == ["blocked", "blocked", "blocked"]
@needs_node
def test_no_network_from_node() -> None:
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
listener.listen()
port = listener.getsockname()[1]
code = (
"var f = function(a) {\n"
" const net = process.getBuiltinModule('node:net');\n"
" let seen = 'pending';\n"
f" const s = net.connect({port}, '127.0.0.1');\n"
" s.on('connect', () => { seen = 'connected'; });\n"
" s.on('error', () => {});\n"
" return seen;\n"
"};"
)
result = _one(code, "javascript")
listener.settimeout(0.2)
with pytest.raises(TimeoutError):
listener.accept()
assert result.actual == "pending"
def test_writes_outside_scratch_never_reach_the_host(tmp_path: Path) -> None:
marker = tmp_path / "marker"
code = (
"def f(a):\n"
" out = []\n"
f" for path in ['/usr/escape', '/etc/escape', '/escape', {str(marker)!r}]:\n"
" try:\n"
" open(path, 'w').write('x')\n"
" out.append('wrote')\n"
" except OSError:\n"
" out.append('denied')\n"
" return out\n"
)
result = _one(code)
assert result.actual == ["denied", "denied", "denied", "denied"]
assert not marker.exists()
assert not Path("/usr/escape").exists()
def test_host_files_and_environment_are_invisible(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
secret = tmp_path / "secret.txt"
secret.write_text("hunter2")
monkeypatch.setenv("CODE_RUNNER_SECRET", "hunter2")
code = (
"import os\n"
"def f(a):\n"
f" return [os.path.exists({str(secret)!r}), os.path.exists({str(Path.home())!r}),\n"
" 'CODE_RUNNER_SECRET' in os.environ, sorted(os.environ), os.getpid() < 20,\n"
" os.path.exists('/proc/1/environ') and 'hunter2' in open('/proc/1/environ').read()]\n"
)
values = cast("list[Any]", _one(code).actual)
exists, home, env_leak, names, own_pidns, proc_leak = values
assert (exists, home, env_leak, own_pidns, proc_leak) == (
False,
False,
False,
True,
False,
)
assert set(names) <= {"HOME", "LANG", "PATH", "PWD", "PYTHONIOENCODING"}
def test_symlink_in_scratch_cannot_exfiltrate_a_host_file(tmp_path: Path) -> None:
secret = tmp_path / "secret.txt"
secret.write_text("hunter2-secret-contents")
code = (
"import os\n"
"def f(a):\n"
f" os.symlink({str(secret)!r}, '/work/result.json')\n"
" return 7\n"
)
result = _one(code)
assert "hunter2" not in repr(result)
assert result.actual == 7 # the harness replaced the link, never followed it
def test_fork_bomb_is_contained_and_leaves_nothing_behind() -> None:
before = _pgrep("python3 -I /work/harness.py")
code = (
"import os, subprocess\n"
"def f(a):\n"
" subprocess.Popen(['sleep', '97'])\n"
" while True:\n"
" os.fork()\n"
)
started = time.monotonic()
result = _one(code)
assert time.monotonic() - started < 6
assert result.ok is False
assert result.error is not None
assert result.error.startswith(("TimeLimitExceeded", "BlockingIOError"))
time.sleep(0.3)
assert _pgrep("python3 -I /work/harness.py") == before
assert _pgrep("^sleep 97$") == 0
def test_memory_bomb_is_a_visible_error() -> None:
result = _one("def f(a):\n return len(bytearray(8 * 1024**3))\n")
assert result.ok is False
assert result.error is not None
assert result.error.startswith("MemoryError")
@needs_node
def test_node_heap_bomb_is_a_visible_error() -> None:
code = "var f = function(a) { const x = []; for (;;) x.push(new Array(1e6).fill(1)); };"
result = _one(code, "javascript", Limits(timeout_s=5.0))
assert result.ok is False
assert result.error in {"MemoryLimitExceeded", "RangeError: Invalid array length"}
def test_output_flood_is_capped() -> None:
code = "def f(a):\n while True:\n print('x' * 1000)\n"
started = time.monotonic()
result = _one(code)
assert time.monotonic() - started < 6
assert result.ok is False
assert len(result.stdout) <= 64 * 1024
def test_disk_fill_is_capped_by_the_file_size_limit() -> None:
code = (
"def f(a):\n"
" with open('/work/big', 'wb') as fh:\n"
" while True:\n"
" fh.write(b'x' * 65536)\n"
)
result = _one(code)
assert result.ok is False
assert result.error is not None
assert "OSError" in result.error
def test_timeout_kills_every_process_the_solution_started() -> None:
code = (
"import subprocess\n"
"def f(a):\n"
" subprocess.Popen(['sleep', '97'])\n"
" while True:\n"
" pass\n"
)
result = _one(code)
assert result.error == "TimeLimitExceeded"
time.sleep(0.3)
assert _pgrep("^sleep 97$") == 0

View File

@ -1,227 +0,0 @@
"""End to end through the real sandbox: every language, every kind of outcome."""
from __future__ import annotations
import pytest
from code_runner import CaseResult, Example, Limits, RunnerError, run
from code_runner.tests.conftest import (
TWO_SUM_CODE,
TWO_SUM_STARTER,
needs_node,
needs_sandbox,
)
LANGS = [
pytest.param("python3", marks=needs_sandbox),
pytest.param("javascript", marks=needs_node),
pytest.param("typescript", marks=needs_node),
]
FAST = Limits(timeout_s=1.0)
def _two_sum(lang: str, code: str | None, examples: list[Example]) -> list[CaseResult]:
return run(lang, code or TWO_SUM_CODE[lang], TWO_SUM_STARTER[lang], examples)
@pytest.mark.parametrize("lang", LANGS)
def test_correct_solution_passes_every_example(
lang: str, two_sum_examples: list[Example]
) -> None:
results = _two_sum(lang, None, two_sum_examples)
assert [r.ok for r in results] == [True, True, True]
assert [r.n for r in results] == [1, 2, 3]
first = results[0]
assert first.input == {"nums": [2, 7, 11, 15], "target": 9}
assert first.expected == [0, 1]
assert sorted(first.actual) == [0, 1] # any order: [1, 0] is fine
assert first.error is None
assert first.ms >= 0
WRONG = {
"python3": "class Solution:\n def twoSum(self, nums, target):\n return [0, 0]\n",
"javascript": "var twoSum = function(nums, target) { return [0, 0]; };",
"typescript": "function twoSum(nums: number[], target: number): number[] { return [0, 0]; }",
}
@pytest.mark.parametrize("lang", LANGS)
def test_wrong_answer_is_reported_with_actual_value(
lang: str, two_sum_examples: list[Example]
) -> None:
results = _two_sum(lang, WRONG[lang], two_sum_examples)
assert [r.ok for r in results] == [False, False, False]
assert results[0].actual == [0, 0]
assert results[0].error is None
THROWS = {
"python3": "class Solution:\n def twoSum(self, nums, target):\n return nums[99]\n",
"javascript": "var twoSum = function(nums, target) { throw new RangeError('boom'); };",
"typescript": "function twoSum(nums: number[], target: number): number[] { throw new RangeError('boom'); }",
}
@pytest.mark.parametrize("lang", LANGS)
def test_exception_becomes_an_error_per_case(
lang: str, two_sum_examples: list[Example]
) -> None:
results = _two_sum(lang, THROWS[lang], two_sum_examples[:1])
only = results[0]
assert only.ok is False
assert only.actual is None
assert only.error is not None
assert only.error.split(":")[0] in {"IndexError", "RangeError"}
BROKEN = {
"python3": "class Solution:\n def twoSum(self, nums, target) return 1\n",
"javascript": "var twoSum = function(nums, target) { return ; ;; }}",
"typescript": "function twoSum(nums: number[], target: number): number[] { return [ }",
}
@pytest.mark.parametrize("lang", LANGS)
def test_syntax_error_is_reported(lang: str, two_sum_examples: list[Example]) -> None:
results = _two_sum(lang, BROKEN[lang], two_sum_examples[:2])
assert [r.ok for r in results] == [False, False]
assert all(r.error and r.error.startswith("SyntaxError") for r in results)
PRINTS = {
"python3": "class Solution:\n def twoSum(self, nums, target):\n print('hi', target)\n return [0, 1]\n",
"javascript": "var twoSum = function(nums, target) { console.log('hi', target); return [0, 1]; };",
"typescript": "function twoSum(nums: number[], target: number): number[] { console.log('hi', target); return [0, 1]; }",
}
@pytest.mark.parametrize("lang", LANGS)
def test_prints_land_in_stdout_per_case(
lang: str, two_sum_examples: list[Example]
) -> None:
results = _two_sum(lang, PRINTS[lang], two_sum_examples[:2])
assert results[0].stdout.strip() == "hi 9"
assert results[1].stdout.strip() == "hi 6"
@pytest.mark.parametrize("lang", LANGS)
def test_missing_entry_point_in_code_is_a_case_error(
lang: str, two_sum_examples: list[Example]
) -> None:
code = {
"python3": "x = 1\n",
"javascript": "var other = 1;",
"typescript": "const other = 1;",
}
results = _two_sum(lang, code[lang], two_sum_examples[:1])
assert results[0].ok is False
assert results[0].error is not None
assert "twoSum" in results[0].error or "Solution" in results[0].error
def test_class_style_javascript_and_function_style_python() -> None:
js = run(
"javascript",
"class Solution { solve(a, b) { return a + b; } }",
"class Solution {\n solve(a, b) {\n }\n}",
[Example(1, {"a": 1, "b": 2}, 3)],
)
py = run(
"python3",
"def add(a, b):\n return a + b\n",
"def add(a: int, b: int) -> int:\n",
[Example(1, {"a": 1, "b": 2}, 3)],
)
assert js[0].ok is True
assert py[0].ok is True
def test_python_prelude_and_aliases() -> None:
code = "class Solution:\n def f(self, xs: List[int]) -> int:\n return collections.Counter(xs)[1]\n"
starter = "class Solution:\n def f(self, xs: List[int]) -> int:\n"
out = run("python", code, starter, [Example(1, {"xs": [1, 1, 2]}, 2)])
assert out[0].ok is True
def test_positional_fallback_when_statement_names_differ() -> None:
out = run(
"python3",
"def add(a, b):\n return a - b\n",
"def add(a, b):\n",
[Example(1, {"x": 5, "y": 2}, 3)],
)
assert out[0].ok is True
def test_argument_count_mismatch_is_a_case_error_without_running() -> None:
out = run(
"python3",
"def add(a, b):\n return a\n",
"def add(a, b):\n",
[Example(1, {"only": 5}, 3)],
)
assert out[0].ok is False
assert out[0].error is not None
assert out[0].error.startswith("ArgumentError")
def test_non_json_result_is_a_case_error() -> None:
out = run(
"python3",
"def f(a):\n return {1, 2}\n",
"def f(a):\n",
[Example(1, {"a": 1}, [1, 2])],
)
assert out[0].error is not None
assert out[0].error.startswith("TypeError")
def test_deep_order_and_float_tolerance() -> None:
out = run(
"python3",
"def f(a):\n return [[2, 1], [0, -1]]\n",
"def f(a):\n",
[Example(1, {"a": 0}, [[-1, 0], [1, 2]], "deep")],
)
assert out[0].ok is True
def test_timeout_is_per_case() -> None:
out = run(
"python3",
"def f(a):\n while a:\n pass\n return 1\n",
"def f(a):\n",
[Example(1, {"a": 1}, 1), Example(2, {"a": 0}, 1)],
limits=FAST,
)
assert out[0].error == "TimeLimitExceeded"
assert out[0].ok is False
assert out[0].ms >= 900
assert out[1].ok is True
def test_unsupported_language_and_empty_examples() -> None:
with pytest.raises(RunnerError, match="language"):
run("cobol", "", "", [])
assert run("python3", "def f(a): pass\n", "def f(a):\n", []) == []
@needs_sandbox
def test_json_bomb_result_costs_that_example_only() -> None:
code = (
"import os\n"
"def f(a):\n"
" if a:\n"
" open('/work/result.json', 'w').write('[' * 200000)\n"
" os._exit(0)\n"
" return 1\n"
)
out = run(
"python3",
code,
"def f(a):\n",
[Example(1, {"a": 1}, 1), Example(2, {"a": 0}, 1)],
)
assert (out[0].ok, out[0].error) == (False, "Crashed (exit 0)")
assert out[1].ok is True

View File

@ -1,78 +0,0 @@
import pytest
from code_runner._model import RunnerError
from code_runner._starter import Entry, extract_starter, parse_starter
PY_CLASS = """class Solution:
def twoSum(self, nums: list[int], target: int) -> list[int]:
"""
PY_COMMENTED = """# Definition for singly-linked list.
# class ListNode:
# def __init__(self, val=0, next=None):
class Solution:
def solve(self, grid: Dict[str, Tuple[int, int]], k: int = 3, *rest) -> int:
pass
"""
PY_FUNC = "def add(a: int, b: int = 2) -> int:\n pass\n"
JS_VAR = """/**
* @param {number[]} nums
*/
var twoSum = function(nums, target) {
};"""
JS_FUNC = "function isPalindrome(x, y = 3) {\n}\n"
JS_ARROW = "const f = (a, b) => {\n}\n"
JS_CLASS = "class Solution {\n constructor() {}\n solve(nums, ...rest) {\n }\n}\n"
TS_FUNC = "function twoSum(nums: number[], target: number): number[] {\n};"
TS_ARROW = "const f = (a: Map<string, number>, b?: number): number => {\n}\n"
@pytest.mark.parametrize(
("lang", "text", "want"),
[
("python3", PY_CLASS, Entry("Solution", "twoSum", ("nums", "target"))),
("python3", PY_COMMENTED, Entry("Solution", "solve", ("grid", "k"))),
("python3", PY_FUNC, Entry(None, "add", ("a", "b"))),
("javascript", JS_VAR, Entry(None, "twoSum", ("nums", "target"))),
("javascript", JS_FUNC, Entry(None, "isPalindrome", ("x", "y"))),
("javascript", JS_ARROW, Entry(None, "f", ("a", "b"))),
("javascript", JS_CLASS, Entry("Solution", "solve", ("nums",))),
("typescript", TS_FUNC, Entry(None, "twoSum", ("nums", "target"))),
("typescript", TS_ARROW, Entry(None, "f", ("a", "b"))),
],
)
def test_parse_starter(lang: str, text: str, want: Entry) -> None:
assert parse_starter(lang, text) == want
@pytest.mark.parametrize(
("lang", "text"),
[
("python3", "x = 1\n"),
("python3", "class Solution:\n pass\n"),
("javascript", "// nothing here\n"),
("javascript", "class Solution {\n constructor() {}\n}\n"),
("typescript", ""),
],
)
def test_starter_without_entry_is_refused(lang: str, text: str) -> None:
with pytest.raises(RunnerError, match="entry point"):
parse_starter(lang, text)
def test_extract_starter_takes_the_fenced_block() -> None:
statement = (
"# 1. Two Sum (Easy)\n\nbody\n\n## Starter code (python3)\n```\n"
+ PY_CLASS
+ "```\n\n## Site hints\nnope\n"
)
assert extract_starter(statement) == PY_CLASS
def test_extract_starter_without_section_is_empty() -> None:
assert extract_starter("# 1. Two Sum\n\nno starter\n") == ""
def test_extract_starter_unfenced_runs_to_next_section() -> None:
text = "intro\n## Starter code (javascript)\nvar f = function(a) {};\n## Hints\nx\n"
assert extract_starter(text) == "var f = function(a) {};\n"

View File

@ -1,12 +0,0 @@
#!/bin/bash
# Editable user install so /usr/bin/python3 (the tutor server, the CLI) can
# `import code_runner`. Stdlib only; 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" -c 'import code_runner; print(code_runner.__file__)'

View File

@ -1,198 +0,0 @@
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "code_runner"
version = "0.1.1"
description = "Run a learner solution (Python, JavaScript, TypeScript) against a problem's examples in a bubblewrap sandbox"
requires-python = ">=3.14"
# Stdlib only on purpose: the tutor server imports it from the system python.
dependencies = []
[tool.setuptools.packages.find]
include = ["code_runner*"]
[tool.setuptools.package-data]
code_runner = ["py.typed", "harness/*"]
[tool.ruff]
target-version = "py314"
include = ["*.py", "**/*.py"]
exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs"]
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"CPY001",
"D100",
"D101",
"D102",
"D103",
"D104",
"D105",
"D107",
"D203",
"D213",
"COM812",
"ISC001",
"S603",
]
fixable = ["ALL"]
unfixable = []
[tool.ruff.lint.per-file-ignores]
"scripts/*.py" = ["INP001"]
# The sandbox-side harness is a program of its own: it reads its job from files.
# The sandbox mounts its own private tmpfs at /tmp.
"**/_sandbox.py" = ["S108"]
"**/harness/*.py" = ["INP001", "T201", "S102", "BLE001", "PTH123"]
# Tests embed the learner programs they run as source strings.
"**/tests/**/*.py" = ["ARG", "D", "E501", "PLC0415", "PLR2004", "S101", "SLF001"]
"**/test_*.py" = ["ARG", "D", "E501", "PLC0415", "PLR2004", "S101", "SLF001"]
[tool.ruff.lint.pylint]
max-args = 6
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.ruff.lint.isort]
force-single-line = false
force-sort-within-sections = true
known-first-party = ["code_runner"]
[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/"]
[tool.pylint.main]
# The repo root on the path, so pylint resolves the package's own imports the
# same way pytest does instead of reporting import-error for them.
init-hook = "import sys; sys.path.insert(0, 'code_runner')"
analyse-fallback-blocks = true
persistent = true
jobs = 0
py-version = "3.14"
ignore = [".venv", "__pycache__"]
# Test suites intentionally use patterns (protected-access, fixture-arg
# shadowing) that don't apply to source code; ruff's per-file-ignores carry
# the equivalent exemptions, so tests stay linted -- by ruff. This is the same
# source-only pylint scope wake-alarm / diet-guard / testsAndMisc use. It has
# to be `ignore-paths`, not `ignore = ["tests"]`: `ignore` only matches the
# basename of what pylint is *given*, and pre-commit hands it explicit file
# paths, so a directory name in `ignore` never fires under the hook.
# harness/ runs inside the sandbox and must exec the solution it is given.
ignore-paths = ["^(.*/)?tests/.*$", "^(.*/)?harness/.*$"]
ignore-patterns = [".*\\.pyi$"]
[tool.pylint.messages_control]
enable = "all"
disable = [
"missing-module-docstring",
"missing-class-docstring",
"missing-function-docstring",
"use-implicit-booleaness-not-comparison-to-zero",
]
[tool.pylint.design]
max-args = 6
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]
markers = ["sandbox: needs bubblewrap with unprivileged user namespaces (skipped otherwise)"]
testpaths = ["code_runner/tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-v",
"--strict-markers",
"--strict-config",
"-ra",
"--cov=code_runner",
"--cov-branch",
"--cov-report=term-missing",
"--cov-report=lcov",
]
filterwarnings = [
"error",
"ignore::DeprecationWarning",
"default::pytest.PytestUnraisableExceptionWarning",
]
[tool.coverage.run]
source = ["code_runner"]
branch = true
omit = [
# Runs inside the sandbox in another process; behaviour is tested through run().
"*/harness/*",
"*/__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

@ -1,10 +0,0 @@
# Exact pins, enforced by the shared dependency-freshness gate: a `>=`
# floor is satisfied by anything newer, so it tells a checker nothing
# about whether this repo is actually current.
bandit[toml]==1.9.4
mypy==2.4.1
pre-commit==4.7.0
pylint==4.1.3
pytest==9.2.0
pytest-cov==7.1.0
ruff==0.17.3

View File

@ -118,6 +118,16 @@ The public/private split is not cosmetic: every repo in this fleet is public.
APK — the security rules, not secrecy, protect the data. The account email and
password identify *a person* and are entered once per device.
### Sandbox namespace for test builds
`--dart-define=SYNC_NAMESPACE=sandbox/slot3` moves every RTDB read and write
under `/sandbox/slot3/...`, so a test build never touches real data. Sign-in
is unchanged (the real account). Empty, the default, keeps the exact current
paths. Only `^sandbox/[a-z0-9_-]+$` is accepted; anything else throws
`ConfigException` at startup (`firebaseClientFor` / `FirebaseRestClient`), never
falling back to production paths. The shipped rules (root-level uid pin) already
allow these paths.
### `password: ''` is legal
The Google one-tap path and the seeded-session path both store an empty

View File

@ -20,4 +20,5 @@ export 'src/remote_store.dart';
export 'src/secure_credential_store.dart';
export 'src/store.dart';
export 'src/sync.dart';
export 'src/sync_namespace.dart';
export 'src/sync_state.dart';

View File

@ -19,6 +19,7 @@ import 'firebase_auth_rest.dart';
import 'firebase_errors.dart';
import 'firebase_keys.dart';
import 'remote_store.dart';
import 'sync_namespace.dart';
const _httpUnauthorized = 401;
const _httpForbidden = 403;
@ -33,13 +34,22 @@ class FirebaseRestClient implements RemoteStore, BulkMapReader {
required String databaseUrl,
required this.auth,
http.Client? httpClient,
String namespace = syncNamespace,
}) : _databaseUrl = databaseUrl.endsWith('/')
? databaseUrl.substring(0, databaseUrl.length - 1)
: databaseUrl,
_prefix = _prefixFor(checkSyncNamespace(namespace)),
_http = httpClient ?? http.Client();
final String _databaseUrl;
/// `<namespace>/` or empty: put in front of every RTDB path. Every request
/// goes through [_uri], so this is the single choke point.
final String _prefix;
static String _prefixFor(String namespace) =>
namespace.isEmpty ? '' : '$namespace/';
/// Mints the ID token every request carries. Public so a settings screen
/// can drive `signIn` / `signOut` through the same instance the client uses.
final FirebaseTokenProvider auth;
@ -47,8 +57,12 @@ class FirebaseRestClient implements RemoteStore, BulkMapReader {
Future<Uri> _uri(String path, {Map<String, String> query = const {}}) async {
final encoded = encodePath(path);
// A bare namespace (the root probe) must not leave a trailing slash.
final full = encoded.isEmpty && _prefix.isNotEmpty
? _prefix.substring(0, _prefix.length - 1)
: '$_prefix$encoded';
return Uri.parse(
'$_databaseUrl/$encoded.json',
'$_databaseUrl/$full.json',
).replace(queryParameters: {'auth': await auth.idToken(), ...query});
}

View File

@ -20,6 +20,7 @@ import 'firebase_credentials.dart';
import 'firebase_client.dart';
import 'mirror_store.dart';
import 'remote_store.dart';
import 'sync_namespace.dart';
/// The shared config is missing a field, or still holds a scaffold placeholder.
class ConfigException implements Exception {
@ -121,6 +122,8 @@ Future<FirebaseRestClient> firebaseClientFor({
String? expectedUid,
http.Client? httpClient,
}) async {
// Before any sign-in: a bad SYNC_NAMESPACE must stop the app at startup.
checkSyncNamespace(syncNamespace);
final auth = FirebaseTokenProvider(
apiKey: config.apiKey,
store: store,

View File

@ -7,8 +7,11 @@ import 'store.dart';
/// Writes atomically -- to a per-process temp file, then `rename`d over the
/// real path -- so a concurrent reader (e.g. a background sync isolate writing
/// while the foreground app also writes) never observes a half-written file.
/// The pid in the temp name keeps two writers from colliding. This generalizes
/// diet_guard's hand-rolled `LogStorageService` write scheme.
/// The pid in the temp name keeps two processes from colliding; within one
/// instance, writes are serialized in issue order (last issued wins), so
/// overlapping calls (e.g. per-keystroke saves) never share the temp file
/// mid-flight. This generalizes diet_guard's hand-rolled `LogStorageService`
/// write scheme.
///
/// Lives behind the `package:crdt_sync/crdt_sync_io.dart` entrypoint, not the
/// main barrel, because it imports `dart:io` and so is unavailable on web --
@ -20,6 +23,10 @@ class FileLogPersistence implements LogPersistence {
final File _file;
/// Completes when the most recently issued write has settled. Never
/// rejects, so one failed write cannot block the ones queued after it.
Future<void> _tail = Future<void>.value();
@override
Future<String?> read() async {
if (!_file.existsSync()) return null;
@ -31,7 +38,14 @@ class FileLogPersistence implements LogPersistence {
}
@override
Future<void> write(String text) async {
Future<void> write(String text) {
final result = _tail.then((_) => _writeNow(text));
// The caller still sees its own error via [result]; the chain ignores it.
_tail = result.then((_) {}, onError: (Object _) {});
return result;
}
Future<void> _writeNow(String text) async {
await _file.parent.create(recursive: true);
final tmp = File('${_file.path}.$pid.tmp');
await tmp.writeAsString(text);

View File

@ -0,0 +1,26 @@
/// Compile-time sandbox namespace for the Realtime Database.
///
/// Build a test app with `--dart-define=SYNC_NAMESPACE=sandbox/slot3` and every
/// RTDB path the library reads or writes moves under `/sandbox/slot3/`, so a
/// test build can never touch real data. Sign-in is unchanged (real account).
/// Empty, the default, leaves every path exactly as it was.
library;
import 'firebase_config.dart';
/// The `SYNC_NAMESPACE` dart-define; empty means production paths.
const String syncNamespace = String.fromEnvironment('SYNC_NAMESPACE');
final RegExp _allowed = RegExp(r'^sandbox/[a-z0-9_-]+$');
/// Returns [namespace] when it is empty or `sandbox/<slot>`, else throws.
///
/// Fails closed: a typo must stop the app at startup, never quietly fall back
/// to production paths. A trailing newline is rejected too.
String checkSyncNamespace(String namespace) {
if (namespace.isEmpty || _allowed.hasMatch(namespace)) return namespace;
throw ConfigException(
'SYNC_NAMESPACE "$namespace" is invalid: it must be empty or match '
r'^sandbox/[a-z0-9_-]+$ (e.g. sandbox/slot3)',
);
}

View File

@ -2,7 +2,7 @@ name: crdt_sync
description: >-
Shared CRDT merge scheme + GitHub-Contents-API sync transport for
cross-device apps. Pure Dart, no Flutter dependency.
version: 0.11.3
version: 0.12.1
publish_to: 'none'
environment:

View File

@ -128,5 +128,32 @@ void main() {
await store.write('{"hello":"world"}');
expect(await store.read(), '{"hello":"world"}');
});
test('50 overlapping writes all succeed and the last issued wins',
() async {
// Regression: every call shared one `<file>.<pid>.tmp`, so the first
// rename moved it away and a later rename threw PathNotFoundException.
final store = FileLogPersistence(File('${dir.path}/log.json'));
await Future.wait([
for (var i = 0; i < 50; i++) store.write('{"n":$i}'),
]);
expect(await store.read(), '{"n":49}');
expect(dir.listSync().map((e) => e.uri.pathSegments.last),
['log.json']);
});
test('a failed write rejects its caller but does not block later writes',
() async {
final target = File('${dir.path}/log.json');
// A directory at the target path makes the rename fail.
Directory(target.path).createSync();
final store = FileLogPersistence(target);
await expectLater(
store.write('{"n":0}'), throwsA(isA<FileSystemException>()));
Directory(target.path).deleteSync();
await store.write('{"n":1}');
expect(await store.read(), '{"n":1}');
});
});
}

View File

@ -0,0 +1,170 @@
import 'dart:convert';
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:test/test.dart';
const _databaseUrl = 'https://example-default-rtdb.firebasedatabase.app';
FirebaseTokenProvider _auth() => FirebaseTokenProvider(
apiKey: 'fake-api-key',
store: InMemoryCredentialStore(
FirebaseCredentials(
idToken: 'id-token',
refreshToken: 'refresh-token',
expiresAt: DateTime.utc(2099),
),
),
httpClient: http_testing.MockClient(
(_) async => throw StateError('auth must not be contacted'),
),
);
/// Client over an in-memory transport that records every request path.
(FirebaseRestClient, List<http.Request>) _client(String? namespace) {
final requests = <http.Request>[];
final mock = http_testing.MockClient((request) async {
requests.add(request);
return http.Response(jsonEncode(request.method == 'GET' ? null : 'x'), 200);
});
final client = namespace == null
? FirebaseRestClient(
databaseUrl: _databaseUrl,
auth: _auth(),
httpClient: mock,
)
: FirebaseRestClient(
databaseUrl: _databaseUrl,
auth: _auth(),
httpClient: mock,
namespace: namespace,
);
return (client, requests);
}
void main() {
group('compile-time default', () {
test('is empty when SYNC_NAMESPACE is not defined', () {
expect(syncNamespace, isEmpty);
});
test('empty namespace leaves paths byte-for-byte unchanged', () async {
final (client, requests) = _client(null);
await client.getFileText('devices/pc/log.json');
expect(requests.single.url.path, '/devices/pc/log~2Ejson.json');
});
});
group('prefixing', () {
test('every operation lands under the namespace', () async {
final (client, requests) = _client('sandbox/slot3');
await client.getFileText('devices/pc/log.json');
await client.putFileText('devices/pc/log.json', 't', message: 'm');
await client.listDirectory('devices');
await client.getStringMap('revs');
await client.patchValues('revs', {'a': 'b'});
await client.deleteFile('devices/pc/log.json');
await client.canAccessRemote();
expect(requests.map((r) => r.url.path), [
'/sandbox/slot3/devices/pc/log~2Ejson.json',
'/sandbox/slot3/devices/pc/log~2Ejson.json',
'/sandbox/slot3/devices.json',
'/sandbox/slot3/revs.json',
'/sandbox/slot3/revs.json',
'/sandbox/slot3/devices/pc/log~2Ejson.json',
'/sandbox/slot3.json',
]);
});
test('an explicitly empty namespace is the unprefixed path', () async {
final (client, requests) = _client('');
await client.getStringMap('revs');
expect(requests.single.url.path, '/revs.json');
});
});
group('validation (fail closed)', () {
const bad = [
'sandbox',
'sandbox/',
'sandbox/Slot3',
'sandbox/slot 3',
'sandbox/slot3/',
'sandbox/a/b',
'/sandbox/slot3',
'prod/slot3',
'sandbox/slot3\n',
'Sandbox/slot3',
'sandbox/slot.3',
'sandbox/..',
];
for (final value in bad) {
test('rejects ${jsonEncode(value)}', () {
expect(
() => checkSyncNamespace(value),
throwsA(isA<ConfigException>()),
);
expect(
() => FirebaseRestClient(
databaseUrl: _databaseUrl,
auth: _auth(),
namespace: value,
),
throwsA(isA<ConfigException>()),
);
});
}
test('the message names the define and the required shape', () {
expect(
() => checkSyncNamespace('typo'),
throwsA(
isA<ConfigException>().having(
(e) => e.message,
'message',
allOf(
contains('SYNC_NAMESPACE'),
contains(r'^sandbox/[a-z0-9_-]+$'),
),
),
),
);
});
test('accepts the allowed alphabet', () {
expect(() => checkSyncNamespace('sandbox/slot3'), returnsNormally);
expect(() => checkSyncNamespace('sandbox/a_b-c9'), returnsNormally);
expect(() => checkSyncNamespace(''), returnsNormally);
});
test(
'a bad compile-time define stops firebaseClientFor at startup',
() async {
// The define is compile-time, so only a fresh process can set it.
final result = await Process.run(Platform.resolvedExecutable, [
'-DSYNC_NAMESPACE=sandbox/Typo',
'run',
'tool/namespace_probe.dart',
]);
expect(result.exitCode, isNot(0));
expect(
result.stderr,
contains('SYNC_NAMESPACE "sandbox/Typo" is invalid'),
);
},
);
test('a good compile-time define reaches the request path', () async {
final result = await Process.run(Platform.resolvedExecutable, [
'-DSYNC_NAMESPACE=sandbox/slot3',
'run',
'tool/namespace_probe.dart',
]);
expect(result.stderr, isEmpty);
expect(result.stdout, '/sandbox/slot3/devices.json\n');
});
});
}

View File

@ -0,0 +1,31 @@
// Helper for test/sync_namespace_test.dart: the SYNC_NAMESPACE define is
// compile-time, so the test runs this in a fresh process with -D set. It signs
// in from an in-memory session (no network) and prints the first request path.
import 'package:crdt_sync/crdt_sync.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
Future<void> main() async {
final mock = http_testing.MockClient((request) async {
print(request.url.path);
return http.Response('null', 200);
});
final client = await firebaseClientFor(
config: const FirebaseConfig(
apiKey: 'k',
databaseUrl: 'https://example.firebasedatabase.app',
projectId: 'p',
uid: 'u',
email: 'e@x.y',
),
store: InMemoryCredentialStore(
FirebaseCredentials(
idToken: 'i',
refreshToken: 'r',
expiresAt: DateTime.utc(2099),
),
),
httpClient: mock,
);
await client.listDirectory('devices');
}

View File

@ -61,11 +61,11 @@ packages:
dependency: "direct main"
description:
path: crdt_sync_dart
ref: "crdt_sync_dart-v0.11.3"
resolved-ref: a2e7334dfec5131170a084510d70361d53dc40bc
ref: "crdt_sync_dart-v0.12.0"
resolved-ref: "59b33949e4a130bb76c77e7434ddf4bbf8cde3a3"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.11.3"
version: "0.12.0"
crypto:
dependency: "direct main"
description:

View File

@ -4,7 +4,7 @@ description: >-
persisted per-install device id, and a one-call bootstrap. Removes the
~150 lines of firebase_backend.dart every consuming app was copying.
publish_to: 'none'
version: 0.4.0
version: 0.5.1
environment:
sdk: ^3.12.2
@ -13,7 +13,7 @@ dependencies:
crdt_sync:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.11.3
ref: crdt_sync_dart-v0.12.1
path: crdt_sync_dart
crypto: 3.0.8
flutter:

View File

@ -11,7 +11,7 @@ dependencies:
data_transfer_flutter:
git:
url: https://github.com/kuhyx/utils
ref: data_transfer_flutter-v0.2.0
ref: data_transfer_flutter-v0.2.1
path: data_transfer_flutter
```

View File

@ -4,13 +4,13 @@ description: >-
Flutter apps: system Save-as on Android/iOS (never cloud-only), a save
dialog on desktop, a download on web, plus a versioned export envelope.
publish_to: 'none'
version: 0.2.0
version: 0.2.2
environment:
sdk: ^3.12.2
dependencies:
file_picker: 13.3.1
file_picker: 13.3.3
file_selector: 1.1.0
flutter:
sdk: flutter

View File

@ -91,6 +91,8 @@ NPM_ACCEPT: Final = "application/vnd.npm.install-v1+json"
# --- Cache ------------------------------------------------------------------
CACHE_PATH_ENV: Final = "DEP_FRESHNESS_CACHE"
# Seconds; overrides every ecosystem TTL (a repo that wants a once-a-day gate).
TTL_ENV: Final = "DEP_FRESHNESS_TTL_SECONDS"
DEFAULT_CACHE_DIR: Final = "~/.cache/dep-freshness"
CACHE_FILE: Final = "registry.json"
TTL_SECONDS: Final = 6 * 3600

View File

@ -21,6 +21,7 @@ from dep_freshness._tables import (
DEFAULT_CACHE_DIR,
GITCOMMIT,
GITTAG,
TTL_ENV,
TTL_GITTAG_SECONDS,
TTL_SECONDS,
)
@ -33,7 +34,14 @@ def cache_dir() -> Path:
def ttl_for(ecosystem: str) -> int:
"""Git refs move rarely and cost a subprocess, so they cache longer."""
"""Git refs move rarely and cost a subprocess, so they cache longer.
`DEP_FRESHNESS_TTL_SECONDS` overrides both; garbage is ignored, because a
typo in an env var must not break a commit hook.
"""
override = os.environ.get(TTL_ENV, "")
if override.isdigit() and int(override) > 0:
return int(override)
return TTL_GITTAG_SECONDS if ecosystem in (GITTAG, GITCOMMIT) else TTL_SECONDS

View File

@ -12,6 +12,7 @@ from pathlib import Path
import pytest
from dep_freshness._tables import TTL_SECONDS
from dep_freshness.cache import Cache, Entry, ttl_for
from dep_freshness.models import Dep
from dep_freshness.registries import http
@ -139,3 +140,16 @@ def _offline(*_args):
def _explode(*_args):
raise RuntimeError("boom")
def test_ttl_env_overrides_the_registry_ttl(monkeypatch) -> None:
"""A repo can stretch the refresh window (once-a-day pre-push gates)."""
monkeypatch.setenv("DEP_FRESHNESS_TTL_SECONDS", "86400")
assert ttl_for("npm") == 86400
assert ttl_for("gittag") == 86400
def test_ttl_env_garbage_falls_back_to_the_default(monkeypatch) -> None:
"""A malformed value must never break a commit hook."""
monkeypatch.setenv("DEP_FRESHNESS_TTL_SECONDS", "soon")
assert ttl_for("npm") == TTL_SECONDS

View File

@ -8,9 +8,10 @@ import 'package:flutter/material.dart';
///
/// Rows per page come from the height the parent gives it; the overflow moves
/// to further pages in a horizontal [PageView], marked by dots once there is
/// more than one. [itemBuilder] gets the global item index and must fill the
/// cell it is given: a square of up to column width plus [labelHeight], the
/// rows stretching to use up the leftover height.
/// more than one (a "n / N" counter past 12 pages). [itemBuilder] gets the
/// global item index and must fill the cell it is given: a square of up to
/// column width plus [labelHeight], the rows stretching to use up the
/// leftover height.
class FitGrid extends StatefulWidget {
/// Creates the grid.
const FitGrid({
@ -54,6 +55,10 @@ class FitGrid extends StatefulWidget {
const double _dotSize = AppSpacing.sm;
const double _dotsHeight = AppSpacing.md + AppSpacing.xs;
// Above this many pages a row of dots outgrows a phone width, so the
// indicator becomes a "n / N" counter instead.
const int _maxDots = 12;
class _FitGridState extends State<FitGrid> {
final _controller = PageController();
var _page = 0;
@ -159,22 +164,35 @@ class _Dots extends StatelessWidget {
return ExcludeSemantics(
child: SizedBox(
height: _dotsHeight,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < count; i++)
Container(
key: ValueKey<String>('fit-grid-dot-$i'),
width: _dotSize,
height: _dotSize,
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == current ? scheme.primary : scheme.outlineVariant,
child: count > _maxDots
? Center(
child: Text(
'${current + 1} / $count',
key: const ValueKey<String>('fit-grid-page-counter'),
style: Theme.of(context).textTheme.labelSmall,
textScaler: TextScaler.noScaling,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < count; i++)
Container(
key: ValueKey<String>('fit-grid-dot-$i'),
width: _dotSize,
height: _dotSize,
margin: const EdgeInsets.symmetric(
horizontal: AppSpacing.xs,
),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == current
? scheme.primary
: scheme.outlineVariant,
),
),
],
),
],
),
),
);
}

View File

@ -5,7 +5,7 @@ description: >-
helpers that were each reimplemented in three or more repos. Owns the tokens
-- consumers import them rather than transcribing the token table by hand.
publish_to: 'none'
version: 0.3.0
version: 0.3.1
environment:
sdk: ^3.12.2

View File

@ -126,6 +126,30 @@ void main() {
expect(find.text('item $perPage').hitTestable(), findsNothing);
});
testWidgets('more than 12 pages: "n / N" counter instead of dots', (
tester,
) async {
await _pump(tester, count: 30);
final perPage = _onScreen(tester);
final count = perPage * 13;
await _pump(tester, count: count);
expect(_dot, findsNothing);
final counter = find.byKey(const ValueKey('fit-grid-page-counter'));
expect(tester.widget<Text>(counter).data, '1 / 13');
await tester.fling(find.byType(PageView), const Offset(-250, 0), 1000);
await tester.pumpAndSettle();
expect(tester.widget<Text>(counter).data, '2 / 13');
expect(tester.takeException(), isNull);
});
testWidgets('exactly 12 pages still shows dots', (tester) async {
await _pump(tester, count: 30);
final perPage = _onScreen(tester);
await _pump(tester, count: perPage * 12);
expect(_dot, findsNWidgets(12));
expect(tester.takeException(), isNull);
});
testWidgets('swiping left shows the next page and moves the dot', (
tester,
) async {

View File

@ -1,165 +0,0 @@
# Dopamine UX — session prompts
Ten pasteable prompts derived from *"The Dopamine Architecture: How UX Designers
Hold User Attention in 2026"* (london-post.co.uk), applied to kuhy's repos.
Paste **one file's contents** into a fresh Claude session. Each is
self-contained: it restates the findings it depends on, so no session needs the
context of another.
## The two tests — every change must pass both
The article's four mechanics (variable rewards, micro-animations, sound design,
near-miss) are in scope, aimed at **kuhy's own stated goals**. They are gated by
two questions, not by an allow/deny list:
1. **Is the displayed state true?** A progress ring at 93% is legitimate *iff*
you are actually at 93%. A streak is legitimate iff it counts real days.
Dopamine may come from framing, timing and salience — **never from a
fabricated number**.
2. **Is the variance real?** A random pick from a real pool (next workout, next
task) is legitimate. Injected random bonus points to manufacture uncertainty
are not.
### The load-bearing corollary
**Reward the recording, never the number recorded.** In diet-guard the
satisfying moment fires on *logging a meal*, not on hitting a calorie target —
otherwise the app rewards eating less rather than recording honestly, and
corrupts its own data. Same shape in workout_app (reward logging a workout, not
beating a PB) and todo (reward capture, not completion count).
## Running order
`01` gates everything. `07` gates `08`. Everything else is independent.
| # | File | Repo | Findings |
|---|---|---|---|
| 01 | `01-motion-tokens.md` | `~/src/utils` | 3 |
| 02 | `02-structural-check.md` | `~/src/utils` | 2 |
| 03 | todo note `85ca329b-9809-412c-a47f-eddf055322cf` | `~/src/diet-guard` | 4 |
| 04 | todo note `73a20837-6480-4b31-b966-1898a4e7272e` | `~/src/screen-locker` | 3 |
| 05 | todo note `e50d6a03-ae7d-4da5-bbd3-c33b17f6f074` | `~/src/screen-locker/stronglift_replacement/workout_app` | 4 |
| 06 | `06-todo.md` | `~/src/todo` | 3 |
| 07 | `07-dufs-theme.md` | `~/src/dufs-cloud` | 2 |
| 08 | todo note `e301e090-b2ee-4e26-8387-00f356b63521` | `~/src/dufs-cloud` | 4 |
| 09 | todo note `9e1e4922-9f29-4bad-bcf4-96826aa8ffa4` | `~/src/wake-alarm` | 2 |
**27 ranked findings total.**
### Dependency graph
```
01-motion-tokens ──┬─→ 03, 04, 05, 09 (transcribe tokens locally)
├─→ 06 (bump design_system-v0.2.0)
└─→ 07 ─→ 08 (bump web_ui-v0.3.2)
02-structural-check (independent; run after 01 so it has motion tokens to check)
```
## The top three findings fleet-wide
Ranked by value-over-effort from the survey data:
1. **screen-locker streak render** (prompt 04) — `snapshot.streak` is already
populated and no renderer reads it. Zero new state, zero conftest change,
zero new dependency.
2. **diet-guard `_onLogMeal` feedback** (prompt 03) — the flagship case.
Dependency-free via `HapticFeedback` from `flutter/services.dart`.
3. **workout_app finish-workout cue** (prompt 05) — `audioplayers`, `vibration`,
the `assets:` block and the VIBRATE permission are **already present**. Only
the call is missing.
## Distributed copies, and the completion protocol
Each prompt is **also copied into the repo it acts on**, at
`<repo>/prompts/dopamine-ux-*.md`, so you can open a repo and say
"do dopamine-ux-<name>" without going hunting. **This directory stays the source
of truth** — if a prompt needs correcting, fix it here and re-distribute.
| Prompt | Distributed to |
|---|---|
| 01 | `~/src/utils/prompts/dopamine-ux-01-motion-tokens.md` — deleted (done) |
| 02 | `~/src/utils/prompts/dopamine-ux-02-structural-check.md` |
| 03 | `~/src/diet-guard/prompts/dopamine-ux-diet-guard.md` |
| 04 | `~/src/screen-locker/prompts/dopamine-ux-04-screen-locker.md` — deleted (done) |
| 05 | `~/src/screen-locker/prompts/dopamine-ux-05-workout-app.md` — deleted (done) |
| 06 | `~/src/todo/prompts/dopamine-ux-todo.md` |
| 07 | `~/src/dufs-cloud/prompts/dopamine-ux-07-theme.md` |
| 08 | `~/src/dufs-cloud/prompts/dopamine-ux-08-motion.md` |
| 09 | `~/src/wake-alarm/prompts/dopamine-ux-wake-alarm.md` |
When a session finishes a prompt it **deletes its repo copy** (in the same
commit as the implementation) and **appends a row below**. A finished prompt left
in a repo is indistinguishable from a pending one, and the next session re-runs
it. Partial completion is never recorded as DONE — the file is edited to state
what remains instead.
### Completion log
| Prompt | Status | Impl commit | Note |
|---|---|---|---|
| 01-motion-tokens.md | DONE 2026-08-22 | `f741896` | `motion.md` frozen; `AppDuration`/`AppCurve` + `--duration-*`/`--ease-*` shipped. Tags: `design_system-v0.2.0`, `web_ui-v0.3.2`. |
| 02-structural-check.md | not started | — | — |
| todo note `85ca329b-9809-412c-a47f-eddf055322cf` | not started | — | — |
| todo note `73a20837-6480-4b31-b966-1898a4e7272e` | DONE 2026-11-01 | `89c65bd` | Status window `_section_streak` + week-transition reward text (kept in `extra_benefits_state.json`); the dead 09:00 early-bird claim is not shown. Per kuhy: no daily-streak state, no shutdown time, no freezes. Commit is on a worktree branch, unpushed. |
| todo note `e50d6a03-ae7d-4da5-bbd3-c33b17f6f074` | DONE 2026-11-01 | `e938201` | Set/warmup `selectionClick`, finish `mediumImpact` + generated chime (music_theory, pinned tag) + dialog arrival, `AppDuration`/`AppCurve` in theme.dart, streak counts up, reduced motion honoured, one Settings "Sound" switch for chime and break-end (foreground service too). Worktree commits, unpushed; `62c1c2c` covers old export/import gaps. |
| 06-todo.md | PARTIAL 2026-11-02 (step 1 DONE; step 2 sound REJECTED by kuhy) | `kuhyx/todo-app 2ffae26` | haptic + checkmark on capture, appRoute motion tokens, animated wizard bar; no sound by decision |
| 07-dufs-theme.md | not started | — | — |
| todo note `e301e090-b2ee-4e26-8387-00f356b63521` | not started | — | — |
| todo note `9e1e4922-9f29-4bad-bcf4-96826aa8ffa4` | not started | — | — |
## Keeping these prompts honest
Every path and symbol cited across these files is machine-checked:
```
python3 ~/src/utils/dopamine-ux/verify_prompts.py
```
Exit 0 means every cited path resolves and every cited symbol is still greppable
in the file it is attributed to. **Re-run it after any refactor of the surveyed
repos** — a prompt pointing at a renamed function is worse than no prompt, and a
path check alone cannot catch that (which is why it greps symbols too).
Verified 2026-08-16: 146 paths, 30 symbols, all passing.
## Rules that apply to every prompt
1. **Tag cuts and version bumps are explicit steps.** `todo` consumes
`design_system` at git ref `design_system-v0.1.0`; `dufs-cloud/web` consumes
`@kuhyx/web-ui` at `web_ui-v0.3.1`. Adding tokens to those packages does
nothing for consumers until a new tag is cut. Prompt 01 ends by cutting
`design_system-v0.2.0` and `web_ui-v0.3.2`; prompts 06 and 08 begin by
bumping to those literal refs.
2. **Anchor on symbols, not line numbers.** Line numbers in these prompts are
"as of 2026-08-16" and will drift. The symbol name is the real anchor —
grep for it rather than trusting the line.
3. **Reduce-motion is mandatory and is NOT the sound toggle.** Web:
`@media (prefers-reduced-motion: reduce)`. Flutter:
`MediaQuery.of(context).disableAnimations`. The OS motion preference is
honoured automatically; the sound opt-out is a separate user-facing toggle.
4. **Motion and haptics first, sound second.** Haptics need no dependency;
sound needs a pubspec dep, an `assets:` block, and a settings toggle. Each
prompt stages them separately so a short session still lands the valuable
half, and sound stays independently revertable.
5. **Sound ships on (opt-out), never in screen-locker enforcement paths.**
6. **250-line cap** applies to code and prose alike (`~/src/utils/file_length`).
## Source
Article findings, verbatim from the piece:
- **Variable reward schedules** — unpredictable outcomes produce stronger
dopaminergic activity than guaranteed ones. Duolingo streaks, feed ordering,
pull-to-refresh.
- **Micro-animations** — "a button that bounces on press, a like counter that
ticks upward with a slight delay". Replaces latency with feedback, so waiting
reads as responsiveness rather than frustration.
- **Sound design** — "operates below the threshold of conscious attention";
the article's judgement is that it is more powerful than visual design and
gets less critical attention.
- **Near-miss effects** — a fitness ring at ninety-three per cent creates
discomfort that drives re-engagement, and works *even when the user is
explicitly told the mechanism*.
- Core claim: **dopamine is released in anticipation of a reward, not on
receipt. The uncertainty is the trigger.**

View File

@ -1,209 +0,0 @@
#!/usr/bin/env python3
"""Verify every path and symbol cited in the dopamine-ux prompt files.
A copy-pasteable prompt that points at a file which does not exist is worse than
no prompt, so this gate adjudicates with an exit code rather than by eye.
Two checks:
1. Every cited on-disk path resolves.
2. Every cited symbol is greppable in the file it is attributed to. A path
check alone cannot catch a stale line number; a symbol grep can.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
PROMPT_DIR = Path.home() / "src/utils" / "dopamine-ux"
HOME = Path.home()
PATH_RE = re.compile(r"`(~?/[^`\s]+|[\w.\-]+/[\w./\-]+\.\w+)`")
# Not on-disk paths despite matching the shape above.
SKIP_SUBSTRINGS = (
"://",
"$",
"*",
"<",
">",
"…",
"devices/", # Firebase/crdt key path
"settings/advanced", # Firebase key path
"android.permission",
"android.intent",
"/sdcard/",
"/etc/",
"/dev/input/",
"/opt/",
"com.kuhy",
"app-release.apk",
"build/app/outputs", # build outputs
"flutter/services.dart",
"flutter/material.dart", # package imports
)
# Files a prompt CREATES. Citing them forward is correct, so absence is expected
# -- but pairing each with its creating prompt still catches a typo.
CREATED_BY_PROMPT = {
"~/src/utils/unified-design-system/motion.md": "01",
"~/src/utils/unified-design-system/scripts/structural_check.py": "02",
}
# Cited precisely to state that they do NOT exist.
ASSERTED_ABSENT = {
"~/gatelock",
# Distributed prompt copies are `git rm`d on completion (see the protocol in
# 00-INDEX.md), but 00-INDEX.md keeps citing them in its "Distributed copies"
# table. Asserting ABSENT rather than dropping the row keeps the check
# two-directional: a finished prompt reappearing in a repo is itself a
# failure, because the next session would re-run it. Move a row here as each
# prompt completes.
"~/src/utils/prompts/dopamine-ux-01-motion-tokens.md",
}
# Generic prose fragments rather than citations of one specific file.
PROSE_FRAGMENTS = {"lib/ui/theme.dart", "lib/main.dart", "pubspec.yaml"}
# Symbols each prompt claims exist, as (repo-relative file, symbol).
SYMBOL_CLAIMS = [
("~/src/diet-guard/app/lib/screens/log_meal_screen.dart", "_onLogMeal"),
("~/src/diet-guard/app/lib/widgets/today_progress_card.dart", "TodayProgressCard"),
("~/src/diet-guard/app/lib/widgets/streak_summary_row.dart", "StreakSummaryRow"),
("~/src/diet-guard/app/lib/screens/log_meal_progress.dart", "buildTodayProgress"),
("~/src/diet-guard/app/lib/services/app_settings_service.dart", "_writeToDisk"),
("~/src/screen-locker/screen_locker/_status_types.py", "StatusSnapshot"),
("~/src/screen-locker/screen_locker/_extra_benefits.py", "process_week_transition"),
("~/src/screen-locker/screen_locker/_extra_benefits.py", "current_streak"),
("~/src/screen-locker/screen_locker/_unlock_view.py", "unlock_screen"),
("~/src/screen-locker/screen_locker/_log_mixin.py", "write_signed_entry"),
("~/src/screen-locker/screen_locker/tests/conftest.py", "_ISOLATED_STATE"),
("~/src/screen-locker/screen_locker/_weekly_check.py", "WEEKLY_WORKOUT_MINIMUM"),
(
"~/src/screen-locker/stronglift_replacement/workout_app/lib/screens/"
"workout_screen_session.dart",
"_playBreakEndCue",
),
(
"~/src/screen-locker/stronglift_replacement/workout_app/lib/screens/"
"workout_screen_finish.dart",
"_persistFinishedWorkout",
),
(
"~/src/screen-locker/stronglift_replacement/workout_app/lib/widgets/"
"workout_summary_dialog.dart",
"WorkoutSummaryDialog",
),
(
"~/src/screen-locker/stronglift_replacement/workout_app/lib/services/"
"storage_service.dart",
"_getSetting",
),
(
"~/src/screen-locker/stronglift_replacement/workout_app/lib/widgets/"
"exercise_tile_rows.dart",
"AnimatedContainer",
),
("~/src/todo/lib/data/app_settings.dart", "withAdvancedMode"),
("~/src/todo/lib/ui/settings_screen.dart", "SwitchListTile"),
("~/src/dufs-cloud/web/src/hooks/use-cloud-index.ts", "walkInto"),
("~/src/dufs-cloud/app/lib/services/cloud_index.dart", "buildCloudIndex"),
("~/src/dufs-cloud/web/src/lib/download.ts", "buildSelectionZip"),
("~/src/dufs-cloud/app/lib/services/download_zip.dart", "buildSelectionZip"),
("~/src/wake-alarm/phone_app/lib/screens/home_screen.dart", "_setPhoneAlarm"),
("~/src/wake-alarm/wake_alarm/_audio.py", "_play_on_all_sinks"),
("~/src/utils/unified-design-system/scripts/palette_check.py", "NON_COLOUR_CSS"),
("~/src/utils/unified-design-system/scripts/palette_map.py", "PALETTE"),
("~/src/utils/design_system/lib/src/feedback.dart", "showToast"),
("~/src/utils/design_system/lib/src/tokens.dart", "AppSpacing"),
("~/src/utils/gatelock/gatelock/_window.py", "LockConfig"),
]
def expand(raw: str) -> Path:
return HOME / raw[2:] if raw.startswith("~/") else Path(raw)
def candidate_paths(text: str) -> set[str]:
return {
raw
for raw in PATH_RE.findall(text)
if not any(s in raw for s in SKIP_SUBSTRINGS)
and raw not in PROSE_FRAGMENTS
and not re.search(r"\.\w+:\d+$", raw) # trailing :line suffix
}
def prompt_roots(text: str) -> list[Path]:
roots = []
for label in ("Repo", "App", "Flutter app"):
for m in re.finditer(rf"{label}: `(~/[^`]+)`", text):
roots.append(expand(m.group(1)))
return roots
def check_paths() -> list[str]:
failures = []
checked = 0
for prompt in sorted(PROMPT_DIR.glob("*.md")):
text = prompt.read_text()
roots = prompt_roots(text)
for raw in sorted(candidate_paths(text)):
if raw in ASSERTED_ABSENT:
if expand(raw).exists():
failures.append(
f"{prompt.name}: `{raw}` is asserted ABSENT but EXISTS"
)
continue
if raw in CREATED_BY_PROMPT:
owner = CREATED_BY_PROMPT[raw]
cites_owner = re.search(rf"[Pp]rompt {owner}", text) is not None
if not prompt.name.startswith(owner) and not cites_owner:
failures.append(
f"{prompt.name}: cites `{raw}` without noting prompt "
f"{owner} creates it"
)
continue
checked += 1
if raw.startswith(("~/", "/")):
if not expand(raw).exists():
failures.append(f"{prompt.name}: `{raw}` MISSING")
elif not any((r / raw).exists() for r in roots):
failures.append(
f"{prompt.name}: `{raw}` not found under any of "
f"{[str(r) for r in roots]}"
)
print(f"paths: checked {checked}")
return failures
def check_symbols() -> list[str]:
failures = []
for raw_file, symbol in SYMBOL_CLAIMS:
path = expand(raw_file)
if not path.exists():
failures.append(f"symbol source MISSING: {raw_file}")
continue
if symbol not in path.read_text(errors="replace"):
failures.append(f"symbol `{symbol}` NOT FOUND in {raw_file}")
print(f"symbols: checked {len(SYMBOL_CLAIMS)}")
return failures
def main() -> int:
n_prompts = len(list(PROMPT_DIR.glob("*.md")))
print(f"verifying {n_prompts} prompt files in {PROMPT_DIR}")
failures = check_paths() + check_symbols()
if failures:
print(f"\nFAIL -- {len(failures)} problem(s):")
for f in failures:
print(f" {f}")
return 1
print("\nPASS -- every cited path exists and every cited symbol is present")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -199,6 +199,7 @@ their `max_gaming_minutes` + pure bonuses again (240/120 min = 2/min; full day =
from the start day, cutting all of it, 180 with LeetCode's formerly free hour (0.20.0); maturity never delays it. Ledger `~/.local/share/focus_project/ledger.json`:
signed `credit` rows, `entry_id` `<project>-<YYYYMMDD>-m<n>`, `detail.minutes` (positive int),
`detail.ended_at` (else `day`). `focus_project` unusable = no project (logged).
Unbacked timer time pays 0 from 2026-11-08 (focus_project 0.10.0).
## Adding a gate

View File

@ -0,0 +1,88 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Regression: a timer-only focus day after the anti-cheat cutover pays 0.
Runs the REAL ``focus_project`` (the sibling package of this monorepo):
timer rows -> harvester -> signed ledger -> ``credit_units``. earned_time
itself does not change; this pins that the gate reaches pay.
"""
from __future__ import annotations
from datetime import date, datetime
import importlib
import json
from pathlib import Path
import sys
from typing import TYPE_CHECKING
import pytest
from earned_time import credit_units, earners_for
if TYPE_CHECKING:
from types import ModuleType
DAY = date(2026, 11, 9) # after focus_project.BACKING_FROM
_SIBLING = Path(__file__).resolve().parents[3] / "focus_project"
def at(hour: int, minute: int) -> float:
return datetime(2026, 11, 9, hour, minute).astimezone().timestamp()
@pytest.fixture
def real_fp(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path
) -> ModuleType:
"""The real focus_project on redirected state (HOME, data dir, key)."""
monkeypatch.setenv("HOME", str(tmp_path / "home"))
monkeypatch.setenv("FOCUS_PROJECT_DATA", str(tmp_path / "data"))
monkeypatch.setenv("FOCUS_PROJECT_KEY", str(key_file))
monkeypatch.setenv("FOCUS_PROJECT_LEETCODE_LEDGER", str(tmp_path / "lc.json"))
monkeypatch.delitem(sys.modules, "focus_project") # the conftest's fake
monkeypatch.syspath_prepend(str(_SIBLING))
fp = importlib.import_module("focus_project")
fp.create_project(
"hr",
name="HR",
start=DAY,
end=DAY,
replaces=["leetcode"],
daily_minutes=120,
unlock_minutes=30,
)
return fp
def pay(fp: ModuleType, tmp_path: Path, key_file: Path, *rows: object) -> int | None:
log = tmp_path / "data" / "activity.jsonl"
log.write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8")
harvest = importlib.import_module("focus_project.harvest")
assert harvest.harvest(now=at(23, 0)) == 0
(item,) = (e for e in earners_for(DAY) if e.name == "focus")
return credit_units(item, tmp_path / "data" / "ledger.json", key_file, DAY)
def row(source: str, kind: str, ts: float) -> dict[str, object]:
return {"ts": ts, "source": source, "kind": kind, "project": "hr"}
TIMER = (
row("timer", "start", at(9, 0)),
row("timer", "checkin", at(9, 5)),
row("timer", "stop", at(9, 10)),
)
def test_timer_only_day_pays_zero(
real_fp: ModuleType, tmp_path: Path, key_file: Path
) -> None:
assert date(2026, 11, 8) == real_fp.BACKING_FROM
assert pay(real_fp, tmp_path, key_file, *TIMER) == 0
def test_backed_timer_day_still_pays(
real_fp: ModuleType, tmp_path: Path, key_file: Path
) -> None:
beat = row("tutor", "heartbeat", at(9, 5))
assert pay(real_fp, tmp_path, key_file, *TIMER, beat) == 10

View File

@ -36,5 +36,18 @@ uncovers today. A NEW project starting today may replace gates and set any targe
new obligation, not a reduction (but see the floor).
Floor (0.7.1, coordinator decision for kuhy): `daily_minutes >= 30 x replaced gates` (min 30; `book` = `reading`),
enforced on create and update (`daily_floor(replaces)`), so a day cannot be made cheap.
Backed timer (0.10.0, days from `BACKING_FROM` = 2026-11-08): a timer span earns only if a
same-project `tutor`/`sql_drills`/`leetcode` beat (or accepted submission) is in it, 5 min
before it, or bridges it (beats <= 20 min apart). `unbacked_minutes_for(day)` /
`status()["unbacked_minutes"]` show the rest. A beat backs only the spans it touches (the tutor chain makes a graded card back the whole stretch). Known gap: any coach turn backs.
Open the work: `python3 -m focus_project.open [--no-site]` (tutor focus screen, `work_url`).
Install: `./install.sh` (stdlib only, editable user install for `/usr/bin/python3`).
Interview prep (`kind="interview"`, 0.9): `create_project(..., kind="interview", test_at=, invite_url=, trips=, weekend_answers=)`;
`end` must be the test day. Budget: 30 min weekdays / 120 Sat-Sun (`daily_on`), exempt from `daily_floor`, unlock clamped.
Milestones are generated, never stored: `milestones(project, now)` -> `rules`, `trip_ask:<sat>`, `phone:<trip start>`, `mock`, `slot`
with states pending/done/missed/late_done/unknown. `owed_lock(now)` = earliest overdue uncompleted one (never on test day; wire it
after focus-guard's sick/free-day stand-down). `status(day)` gains `milestones` and `missed`.
Evidence is append-only (`evidence/<id>.jsonl` + copied files): `record_evidence(id, milestone, kind, value, file=, now=)`
(`file`, `tap`, `score`, `slot`, `slot_confirm`, tutor's `phone_check`). `add_trip` / `answer_weekend` only get stricter (ValueError).
`run_checks(id)` runs the `phone` source (reads the latest `phone_check` row) and latches changes; `register_source` swaps it.

View File

@ -23,26 +23,44 @@ from focus_project._api import (
stands_down,
)
from focus_project._errors import RegistryError
from focus_project._milestones import Milestone, milestones, owed_lock
from focus_project._model import Limits, Project
from focus_project._mutate import add_trip, answer_weekend, record_evidence
from focus_project._trips import Trip
from focus_project._update import Update, apply_update, update_project
from focus_project.activity import record_activity
from focus_project.minutes import minutes_for
from focus_project.checks import register_source, run_checks
from focus_project.evidence import Evidence
from focus_project.minutes import minutes_for, unbacked_minutes_for
from focus_project.sources.activity import BACKING_FROM
from focus_project.status import status
__all__ = [
"BACKING_FROM",
"Evidence",
"Limits",
"Milestone",
"Project",
"RegistryError",
"Trip",
"Update",
"active",
"add_trip",
"answer_weekend",
"apply_update",
"create_project",
"daily_floor",
"end_project",
"list_projects",
"milestones",
"minutes_for",
"owed_lock",
"record_activity",
"record_evidence",
"register_source",
"run_checks",
"stands_down",
"status",
"unbacked_minutes_for",
"update_project",
]

View File

@ -4,19 +4,24 @@
from __future__ import annotations
from dataclasses import replace
from datetime import date # noqa: TC003
import logging
import re
import time
from urllib.parse import urlsplit
from typing import TYPE_CHECKING
from focus_project import _clock, _store
from focus_project import _clock, _interview, _store
from focus_project._errors import RegistryError
from focus_project._kind import FOCUS
from focus_project._model import GATES, Project, normalize_gate
if TYPE_CHECKING:
from datetime import date, datetime
from focus_project._trips import Trip
_LOG = logging.getLogger(__name__)
_ID = re.compile(r"[a-z0-9][a-z0-9-]*")
MAX_WORK_URL = 2000
MAX_WORK_URL = _interview.MAX_URL
# A project must ask real work: the focus earner pays per minute of ``daily``.
MIN_DAILY_PER_GATE = 30
@ -53,17 +58,7 @@ def stands_down(gate: str, day: date) -> bool:
def _validate_work_url(url: str) -> None:
"""Empty, or a plain http(s) URL with a host: it goes to ``xdg-open``."""
if not url:
return
parts = urlsplit(url) if url.isprintable() and " " not in url else None
if (
parts is None
or len(url) > MAX_WORK_URL
or parts.scheme not in {"http", "https"}
or not parts.netloc
):
msg = "work_url must be an http(s) URL without spaces (or empty)"
raise ValueError(msg)
_interview.check_http_url(url, "work_url")
def daily_floor(replaces: tuple[str, ...]) -> int:
@ -85,8 +80,9 @@ def _validate(project: Project) -> None:
if not project.replaces or not set(project.replaces) <= GATES:
msg = f"replaces must be a non-empty subset of gates {sorted(GATES)}"
raise ValueError(msg)
_interview.validate(project)
floor = daily_floor(project.replaces)
if project.daily_minutes < floor:
if project.kind == FOCUS and project.daily_minutes < floor:
msg = (
f"daily_minutes must be at least {floor} "
f"({MIN_DAILY_PER_GATE} per replaced gate)"
@ -109,8 +105,19 @@ def create_project( # noqa: PLR0913
subjects: list[str] | None = None,
site_patterns: list[str] | None = None,
work_url: str = "",
kind: str = FOCUS,
test_at: datetime | None = None,
invite_url: str = "",
trips: list[Trip] | None = None,
weekend_answers: dict[date, bool] | None = None,
) -> Project:
"""Add a project; ValueError on bad input, a duplicate id or an overlap."""
"""Add a project; ValueError on bad input, a duplicate id or an overlap.
``kind="interview"`` takes ``test_at`` (naive local; its date must be
``end``), ``invite_url``, ``trips`` and ``weekend_answers``; an interview
is exempt from :func:`daily_floor` and runs the 30/120 budget.
"""
created_ms = time.time_ns() // 1_000_000
project = Project(
id=project_id,
name=name,
@ -122,7 +129,15 @@ def create_project( # noqa: PLR0913
subjects=tuple(subjects or ()),
site_patterns=tuple(site_patterns or ()),
work_url=work_url,
created_ms=time.time_ns() // 1_000_000,
created_ms=created_ms,
kind=kind,
test_at=test_at,
invite_url=invite_url,
trips=tuple(
t if t.entered_ms else replace(t, entered_ms=created_ms)
for t in trips or ()
),
weekend_answers=tuple(sorted((weekend_answers or {}).items())),
)
_validate(project)
with _store.locked():

View File

@ -0,0 +1,89 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Validation of the interview-only fields."""
from __future__ import annotations
from datetime import date, timedelta
from typing import TYPE_CHECKING
from urllib.parse import urlsplit
from focus_project._kind import FOCUS, KINDS, WEEKDAY_MINUTES
if TYPE_CHECKING:
from focus_project._model import Project
MAX_URL = 2000
SATURDAY = 5
def check_http_url(url: str, label: str) -> None:
"""Empty, or a plain http(s) URL with a host: it goes to ``xdg-open``."""
if not url:
return
parts = urlsplit(url) if url.isprintable() and " " not in url else None
if (
parts is None
or len(url) > MAX_URL
or parts.scheme not in {"http", "https"}
or not parts.netloc
):
msg = f"{label} must be an http(s) URL without spaces (or empty)"
raise ValueError(msg)
def saturdays(project: Project) -> list[date]:
"""The Saturdays whose weekend lies inside D1..T-1."""
if project.test_at is None:
return []
last = project.test_at.date() - timedelta(days=1)
day = project.start + timedelta(days=(SATURDAY - project.start.weekday()) % 7)
found: list[date] = []
while day <= last:
found.append(day)
day += timedelta(days=7)
return found
def _has_interview_fields(project: Project) -> bool:
return bool(
project.test_at
or project.invite_url
or project.trips
or project.weekend_answers
)
def _validate_interview(project: Project) -> None:
if project.test_at is None or project.test_at.tzinfo is not None:
msg = "an interview needs test_at as a naive local datetime"
raise ValueError(msg)
test_day = project.test_at.date()
if project.end != test_day:
msg = f"end must be the test day {test_day}"
raise ValueError(msg)
if project.daily_minutes < WEEKDAY_MINUTES:
msg = f"daily_minutes must be at least {WEEKDAY_MINUTES}"
raise ValueError(msg)
for trip in project.trips:
if trip.end < trip.start or trip.start > test_day:
msg = f"trip {trip.start}..{trip.end} must start by the test day"
raise ValueError(msg)
weekends = saturdays(project)
for day, _ in project.weekend_answers:
if day not in weekends:
msg = f"{day} is not a weekend inside the project before the test day"
raise ValueError(msg)
def validate(project: Project) -> None:
"""ValueError unless the kind and its interview fields are coherent."""
if project.kind not in KINDS:
msg = f"kind must be one of {sorted(KINDS)}"
raise ValueError(msg)
check_http_url(project.invite_url, "invite_url")
if project.kind == FOCUS:
if _has_interview_fields(project):
msg = "test_at, invite_url, trips, weekend_answers need kind='interview'"
raise ValueError(msg)
return
_validate_interview(project)

View File

@ -0,0 +1,23 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Project kinds and the interview budget (the one place the numbers live)."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import date
FOCUS = "focus"
INTERVIEW = "interview"
KINDS = frozenset({FOCUS, INTERVIEW})
# kuhy, 2026-11-10: near-zero weekday energy, weekends are for learning.
WEEKDAY_MINUTES = 30
WEEKEND_MINUTES = 120
_FIRST_WEEKEND_DAY = 5 # Saturday
def interview_minutes(day: date) -> int:
"""The daily target of an interview project on ``day`` (Sat/Sun = 120)."""
return WEEKEND_MINUTES if day.weekday() >= _FIRST_WEEKEND_DAY else WEEKDAY_MINUTES

View File

@ -0,0 +1,235 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Interview milestones: generated from the project, judged from evidence.
Nothing here is stored or editable. ``due`` and the state are recomputed from
the project (kind, test_at, trips, weekend answers) and the append-only
evidence rows, so a milestone cannot be moved or ticked off by editing a row.
Failure modes (R8): an unanswered trip question reads as a trip (fail
closed); a check that could not run is ``unknown`` and counts as not done
(fail closed); an unreadable registry or evidence file arms no lock (fail
open, logged), matching focus-guard's "unreadable registry" rule.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta
import logging
from typing import TYPE_CHECKING, Any, Literal
from focus_project import _api, _interview, evidence
from focus_project._errors import RegistryError
from focus_project._kind import INTERVIEW
from focus_project.sources import activity
if TYPE_CHECKING:
from collections.abc import Callable
from focus_project._model import Project
_LOG = logging.getLogger(__name__)
State = Literal["pending", "done", "missed", "late_done", "unknown"]
PHONE_LEAD = timedelta(hours=12)
MOCK_SESSION_S = 90 * 60
_RED = frozenset({"missed", "unknown"})
@dataclass(frozen=True)
class Milestone:
"""One generated obligation of an interview project."""
id: str
due: datetime
state: State
completed: bool
@property
def red(self) -> bool:
"""Past due and not done (also: the check could not run)."""
return self.state in _RED
def to_dict(self) -> dict[str, Any]:
"""The status() row: id, due ISO, state."""
return {"id": self.id, "due": self.due.isoformat(), "state": self.state}
def _at(day: date, hour: int, minute: int = 0) -> datetime:
return datetime.combine(day, time(hour, minute))
def _trip_due(trip_start: date, entered_ms: int) -> datetime:
"""Start - 12 h, or the moment it was entered if that left < 12 h."""
nominal = _at(trip_start, 0) - PHONE_LEAD
if entered_ms > 0:
entered = evidence.from_ms(entered_ms)
if nominal - entered < PHONE_LEAD:
return entered
return nominal
def _is_trip(
*,
answered: bool | None,
answer_ms: int | None,
ask_due: datetime,
phone_due: datetime,
now: datetime,
) -> bool:
"""Whether the weekend counts as a trip (fail closed).
Unanswered once the question is due = a trip. A no-trip answer given
only after the phone prep was already due cannot erase that overdue
milestone: the answer row's own timestamp decides, not a stored flag.
"""
if answered is None:
return now >= ask_due
if answered:
return True
return answer_ms is not None and answer_ms >= evidence.to_ms(phone_due)
def plan(
project: Project, test_day: date, now: datetime, rows: list[evidence.Evidence]
) -> dict[str, datetime]:
"""Milestone id -> due; ``project`` must be a validated interview."""
dues = {
"rules": _at(project.start, 23, 59),
"mock": _at(test_day - timedelta(days=2), 23, 59),
"slot": _at(test_day - timedelta(days=1), 12),
}
answers = dict(project.weekend_answers)
for sat in _interview.saturdays(project):
ask_due = _at(sat - timedelta(days=2), 23, 59)
phone_due = _at(sat, 0) - PHONE_LEAD
dues[f"trip_ask:{sat}"] = ask_due
covered = any(t.covers(sat) for t in project.trips)
row = evidence.first(rows, f"trip_ask:{sat}", "answer")
answer_ms = None if row is None else row.ms
if not covered and _is_trip(
answered=answers.get(sat),
answer_ms=answer_ms,
ask_due=ask_due,
phone_due=phone_due,
now=now,
):
dues[f"phone:{sat}"] = phone_due
for trip_row in project.trips:
dues[f"phone:{trip_row.start}"] = _trip_due(trip_row.start, trip_row.entered_ms)
return dues
def _when(row: evidence.Evidence | None) -> datetime | None:
return None if row is None else row.at
Rows = list[evidence.Evidence]
Completion = tuple[datetime | None, bool]
def _by_evidence(kind: str) -> Callable[[str, Rows, Project], Completion]:
"""Done at the first evidence row of ``kind`` (rules file, mock score)."""
def judge(mid: str, rows: Rows, _project: Project) -> Completion:
return _when(evidence.first(rows, mid, kind)), False
return judge
def _mock(mid: str, rows: Rows, project: Project) -> Completion:
"""Done at the first score row that follows a >= 90 min focus session.
The session (contiguous beats, :func:`activity.sessions`) must END at or
before the score row; a score with no such session completes nothing.
"""
ends = [hi for lo, hi in activity.sessions(project.id) if hi - lo >= MOCK_SESSION_S]
for row in rows:
if (row.milestone, row.kind) == (mid, "score") and any(
end <= row.ms / 1000 for end in ends
):
return row.at, False
return None, False
def _trip_ask(mid: str, rows: Rows, project: Project) -> Completion:
"""Done at the answer row; an answer with no row was given at creation."""
if date.fromisoformat(mid.partition(":")[2]) not in dict(project.weekend_answers):
return None, False
return _when(evidence.first(rows, mid, "answer")) or evidence.from_ms(0), False
def _slot(mid: str, rows: Rows, _project: Project) -> Completion:
"""Done once the latest slot entry has a confirm tap at or after it."""
slot = evidence.last(rows, mid, "slot")
if slot is None:
return None, False
confirm = evidence.first(rows, mid, "slot_confirm", since_ms=slot.ms)
return (None if confirm is None else max(slot.at, confirm.at)), False
def _phone(mid: str, rows: Rows, _project: Project) -> Completion:
"""Done when the offline tap and a passing auto check both exist.
A tap with no verdict at all (or a dark source) is ``unknown``.
"""
tap = evidence.first(rows, mid, "tap")
passed, latest, _ = evidence.check_summary(rows, mid)
if tap is not None and passed is not None:
return max(tap.at, evidence.from_ms(passed)), False
return None, tap is not None and latest is None
_JUDGES = {
"rules": _by_evidence("file"),
"mock": _mock,
"trip_ask": _trip_ask,
"slot": _slot,
"phone": _phone,
}
def _judge(
mid: str, due: datetime, rows: Rows, project: Project, now: datetime
) -> Milestone:
done_at, unknown = _JUDGES[mid.partition(":")[0]](mid, rows, project)
if done_at is not None:
state: State = "done" if done_at <= due else "late_done"
return Milestone(mid, due, state, completed=True)
if now < due:
return Milestone(mid, due, "pending", completed=False)
return Milestone(mid, due, "unknown" if unknown else "missed", completed=False)
def milestones(project: Project, now: datetime) -> list[Milestone]:
"""The project's milestones at ``now`` (naive local), earliest due first.
Empty for a focus project. May raise OSError reading the evidence file.
"""
if project.kind != INTERVIEW or project.test_at is None:
return []
rows = evidence.read(project.id)
dues = plan(project, project.test_at.date(), now, rows)
found = [_judge(mid, due, rows, project, now) for mid, due in dues.items()]
return sorted(found, key=lambda m: (m.due, m.id))
def owed_lock(now: datetime) -> Milestone | None:
"""The earliest overdue, uncompleted milestone that locks, or None.
Locks (missed or unknown) from the moment it is due, every day of the
project except the test day itself, where nothing locks. It does not
consult sickdays/freedays: focus-guard's ``_off(today)`` precondition
already stands the lock down on those days, so wire this after it.
Unreadable state arms nothing (fail open, logged).
"""
try:
project = _api.active(now.date())
if project is None or project.test_at is None:
return None
if now.date() >= project.test_at.date():
return None
owed = [m for m in milestones(project, now) if not m.completed and now >= m.due]
except (RegistryError, OSError) as exc:
_LOG.warning("interview state unreadable, no milestone lock: %s", exc)
return None
return owed[0] if owed else None

View File

@ -4,10 +4,12 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from datetime import date, datetime
from typing import Any
from focus_project._errors import RegistryError
from focus_project._kind import FOCUS, INTERVIEW, interview_minutes
from focus_project._trips import Trip
GATES = frozenset({"leetcode", "reading", "tutor"})
_ALIASES = {"book": "reading"}
@ -18,6 +20,17 @@ def normalize_gate(gate: str) -> str:
return _ALIASES.get(gate, gate)
def _answers(raw: dict[str, Any] | None) -> tuple[tuple[date, bool], ...]:
"""Parse the saturday -> bool map; a non-bool answer is a bad row."""
pairs = []
for key, value in (raw or {}).items():
if not isinstance(value, bool):
msg = f"weekend answer for {key} is not true/false"
raise TypeError(msg)
pairs.append((date.fromisoformat(key), value))
return tuple(sorted(pairs))
@dataclass(frozen=True)
class Limits:
"""The minute targets that held up to and including ``until``."""
@ -70,19 +83,30 @@ class Project:
work_url: str = ""
# Earlier targets, oldest first: an edit applies from its day, never back.
history: tuple[Limits, ...] = ()
# "focus" (every pre-interview row) or "interview"; the rest is interview-only.
kind: str = FOCUS
# Local naive datetime of the test; its date is the project's last day.
test_at: datetime | None = None
invite_url: str = ""
trips: tuple[Trip, ...] = ()
# Saturday -> True (trip that weekend) / False (no trip); sorted pairs.
weekend_answers: tuple[tuple[date, bool], ...] = ()
def _limits_on(self, day: date) -> Limits | None:
return next((h for h in self.history if day <= h.until), None)
def daily_on(self, day: date) -> int:
"""The daily target that held on ``day``."""
if self.kind == INTERVIEW:
return interview_minutes(day)
past = self._limits_on(day)
return self.daily_minutes if past is None else past.daily_minutes
def unlock_on(self, day: date) -> int:
"""The unlock target that held on ``day``."""
"""The unlock target that held on ``day`` (clamped for an interview)."""
past = self._limits_on(day)
return self.unlock_minutes if past is None else past.unlock_minutes
held = self.unlock_minutes if past is None else past.unlock_minutes
return min(held, interview_minutes(day)) if self.kind == INTERVIEW else held
def replaces_on(self, day: date) -> tuple[str, ...]:
"""The gates this project switched off on ``day``."""
@ -104,7 +128,7 @@ class Project:
def to_dict(self) -> dict[str, Any]:
"""The projects.json row."""
return {
row: dict[str, Any] = {
"id": self.id,
"name": self.name,
"start": self.start.isoformat(),
@ -118,13 +142,23 @@ class Project:
"created_ms": self.created_ms,
"work_url": self.work_url,
"history": [h.to_dict() for h in self.history],
"kind": self.kind,
}
if self.kind == INTERVIEW:
row["test_at"] = self.test_at.isoformat() if self.test_at else None
row["invite_url"] = self.invite_url
row["trips"] = [t.to_dict() for t in self.trips]
row["weekend_answers"] = {
day.isoformat(): trip for day, trip in self.weekend_answers
}
return row
@classmethod
def from_dict(cls, row: Any) -> Project: # noqa: ANN401
"""Parse a registry row, or raise :class:`RegistryError`."""
try:
ended = row.get("ended_on")
test_at = row.get("test_at")
return cls(
id=str(row["id"]),
name=str(row["name"]),
@ -139,6 +173,11 @@ class Project:
created_ms=int(row.get("created_ms", 0)),
work_url=str(row.get("work_url") or ""),
history=tuple(Limits.from_dict(h) for h in row.get("history", ())),
kind=str(row.get("kind") or FOCUS),
test_at=datetime.fromisoformat(test_at) if test_at else None,
invite_url=str(row.get("invite_url") or ""),
trips=tuple(Trip.from_dict(t) for t in row.get("trips", ())),
weekend_answers=_answers(row.get("weekend_answers")),
)
except (KeyError, TypeError, ValueError, AttributeError) as exc:
msg = f"bad project row {row!r}: {exc}"

View File

@ -0,0 +1,178 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Interview mutations: trips, weekend answers, evidence. Stricter only."""
from __future__ import annotations
from dataclasses import replace
from datetime import date, datetime
import math
from typing import TYPE_CHECKING
from focus_project import _interview, _milestones, _store, evidence
from focus_project._kind import INTERVIEW
from focus_project._trips import Trip, add
if TYPE_CHECKING:
from pathlib import Path
from focus_project._model import Project
# Which evidence kinds each milestone family accepts from a caller.
# ``check`` and ``answer`` are internal: written by run_checks/answer_weekend.
_ACCEPTS = {
"rules": {"file"},
"mock": {"score"},
"slot": {"slot", "slot_confirm"},
"phone": {"tap"},
}
# tutor's worker reports phone readiness under the bare id "phone".
_PHONE_REPORT = "phone"
def local_now(now: datetime | None) -> datetime:
"""``now`` or the clock's local time."""
return evidence.clock_now() if now is None else now
def find(existing: list[Project], project_id: str) -> tuple[int, Project]:
"""Index and interview project; KeyError unknown, ValueError focus kind."""
for i, project in enumerate(existing):
if project.id == project_id:
if project.kind != INTERVIEW:
msg = f"{project_id!r} is not an interview project (no milestones)"
raise ValueError(msg)
return i, project
raise KeyError(project_id)
def add_trip(
project_id: str, start: date, end: date, *, now: datetime | None = None
) -> Project:
"""Announce a trip; it may only grow (see :func:`focus_project._trips.add`)."""
stamp = local_now(now)
with _store.locked():
existing = _store.read_all()
i, project = find(existing, project_id)
trips = add(project.trips, Trip(start, end, evidence.to_ms(stamp)))
updated = replace(project, trips=trips)
_interview.validate(updated)
existing[i] = updated
_store.write_all(existing)
return updated
def answer_weekend(
project_id: str, saturday: date, *, trip: bool, now: datetime | None = None
) -> Project:
"""Answer the trip question of a weekend; True never flips back to False."""
stamp = local_now(now)
with _store.locked():
existing = _store.read_all()
i, project = find(existing, project_id)
if saturday not in _interview.saturdays(project):
msg = f"{saturday} is not a weekend of {project_id!r} before its test day"
raise ValueError(msg)
answers = dict(project.weekend_answers)
current = answers.get(saturday)
if current is trip:
return project
if current is True:
msg = "a trip answer may only get stricter: it stays True"
raise ValueError(msg)
if not trip and any(t.covers(saturday) for t in project.trips):
msg = f"an announced trip covers {saturday}: it cannot be a no-trip weekend"
raise ValueError(msg)
updated = replace(
project, weekend_answers=tuple(sorted({**answers, saturday: trip}.items()))
)
# Evidence first: a crash between the two leaves the question open
# (fail closed), never an answer with no time.
evidence.append(project_id, f"trip_ask:{saturday}", "answer", trip, stamp)
existing[i] = updated
_store.write_all(existing)
return updated
def _number(value: object) -> float:
if (
isinstance(value, bool)
or not isinstance(value, int | float)
or not math.isfinite(value)
or value < 0
):
msg = "a score must be a finite, non-negative number"
raise ValueError(msg)
return value
def _count(value: object, label: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
msg = f"{label} must be a non-negative integer"
raise ValueError(msg)
return value
def _clean(
project_id: str, kind: str, value: object, file: Path | None, stamp: datetime
) -> object:
"""The value to store for ``kind``; ValueError if it is not acceptable."""
if kind == "file":
if file is None or not file.is_file() or not file.stat().st_size:
msg = "a non-empty file is required"
raise ValueError(msg)
return evidence.store_file(project_id, file, stamp)
if kind == "score":
return _number(value)
if kind == "slot":
moment = datetime.fromisoformat(str(value)) if isinstance(value, str) else None
if moment is None or moment.tzinfo is not None:
msg = "slot must be a naive local ISO datetime"
raise ValueError(msg)
return moment.isoformat()
if kind == "phone_check":
if not isinstance(value, dict) or not isinstance(value.get("plan"), bool):
msg = "phone_check needs {plan: bool, due: int, seen_ms: int}"
raise ValueError(msg)
return {
"plan": value["plan"],
"due": _count(value.get("due"), "due"),
"seen_ms": _count(value.get("seen_ms"), "seen_ms"),
}
if kind == "slot_confirm" and value not in {True, None}:
msg = "slot_confirm is a tap: value True"
raise ValueError(msg)
return True # tap, slot_confirm
def record_evidence( # noqa: PLR0913
project_id: str,
milestone: str,
kind: str,
value: object = None,
*,
file: Path | None = None,
now: datetime | None = None,
) -> evidence.Evidence:
"""Append evidence for a milestone; ValueError if it does not fit.
Kinds: ``file`` (rules, copied from ``file``), ``tap`` (phone:*),
``score`` (mock), ``slot`` (ISO datetime) and ``slot_confirm`` (slot),
``phone_check`` ({plan, due, seen_ms}, milestone "phone", from tutor).
There is no way to delete a row.
"""
stamp = local_now(now)
_, project = find(_store.read_all(), project_id)
if milestone == _PHONE_REPORT:
accepted = {"phone_check"}
else:
known = {m.id for m in _milestones.milestones(project, stamp)}
accepted = _ACCEPTS.get(milestone.partition(":")[0], set())
if milestone not in known:
msg = f"{milestone!r} is not a milestone of {project_id!r}"
raise ValueError(msg)
if kind not in accepted:
msg = f"{milestone!r} takes evidence kinds {sorted(accepted)}, not {kind!r}"
raise ValueError(msg)
return evidence.append(
project_id, milestone, kind, _clean(project_id, kind, value, file, stamp), stamp
)

View File

@ -30,3 +30,18 @@ def activity_path() -> Path:
def lock_path() -> Path:
"""The registry's writer lock."""
return data_dir() / "projects.lock"
def evidence_dir() -> Path:
"""Interview-prep evidence: one jsonl per project plus copied files."""
return data_dir() / "evidence"
def evidence_path(project_id: str) -> Path:
"""The append-only evidence rows of one project."""
return evidence_dir() / f"{project_id}.jsonl"
def evidence_files_dir(project_id: str) -> Path:
"""Where evidence files (screenshots) of one project are copied."""
return evidence_dir() / project_id

View File

@ -0,0 +1,61 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Trips: days away from the PC. They can only grow (ratchet)."""
from __future__ import annotations
from dataclasses import dataclass, replace
from datetime import date
from typing import Any
@dataclass(frozen=True)
class Trip:
"""An inclusive away range; ``entered_ms`` is when it was announced."""
start: date
end: date
entered_ms: int = 0
def covers(self, day: date) -> bool:
"""Whether ``day`` is inside the trip."""
return self.start <= day <= self.end
def to_dict(self) -> dict[str, Any]:
"""The projects.json row."""
return {
"start": self.start.isoformat(),
"end": self.end.isoformat(),
"entered_ms": self.entered_ms,
}
@classmethod
def from_dict(cls, row: dict[str, Any]) -> Trip:
"""Parse a trip row (errors surface as RegistryError upstream)."""
return cls(
start=date.fromisoformat(row["start"]),
end=date.fromisoformat(row["end"]),
entered_ms=int(row.get("entered_ms", 0)),
)
def add(trips: tuple[Trip, ...], new: Trip) -> tuple[Trip, ...]:
"""``trips`` plus ``new``; ValueError if that would loosen an old trip.
A new range touching an old trip must cover it: it replaces the old one
(keeping its entry time when the start did not move earlier). Anything
that starts later or ends earlier than what is on file is refused.
"""
kept: list[Trip] = []
for old in trips:
if old.start > new.end or new.start > old.end:
kept.append(old)
elif new.start <= old.start and new.end >= old.end:
if new.start == old.start:
new = replace(new, entered_ms=old.entered_ms)
else:
msg = (
f"a trip may only get stricter: pass a range that covers "
f"{old.start}..{old.end}"
)
raise ValueError(msg)
return tuple(sorted([*kept, new], key=lambda t: t.start))

View File

@ -0,0 +1,106 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Pluggable check sources for the auto-checked milestones, and latching.
A source is ``fn(project, now) -> True | False | None``; ``None`` means "could
not check" (logged) and counts as not done. Only ``phone`` is auto-checked
(the calendar slot check was dropped, 2026-11-10: ``slot`` is two taps).
:func:`run_checks` is the only place sources run: status()/milestones() stay
cheap and read the latched ``check`` rows it appends (only on a change).
"""
from __future__ import annotations
from collections.abc import Callable
from datetime import datetime, timedelta
import logging
from focus_project import _milestones, _mutate, _store, evidence
from focus_project._model import Project
_LOG = logging.getLogger(__name__)
CheckSource = Callable[[Project, datetime], bool | None]
DAY_MS = 24 * 3600 * 1000
# A phone check starts 24 h before the milestone is due.
WINDOW = timedelta(hours=24)
def _report_ok(value: object, report_ms: int) -> bool:
"""A plan exists, cards are due, the phone was seen <= 24 h before the report."""
if not isinstance(value, dict):
return False
try:
return bool(
value["plan"] is True
and int(value["due"]) > 0
and report_ms - int(value["seen_ms"]) <= DAY_MS
)
except KeyError, TypeError, ValueError:
return False
def phone_source(project: Project, now: datetime) -> bool | None:
"""Read tutor's latest ``phone_check`` report (<= 24 h old).
No report, a stale one or one stamped ahead of the clock = ``None``
(the worker is not reporting). A fresh one passes only if the plan
exists, cards are due and the phone was seen within 24 h of the report.
"""
report = evidence.last(evidence.read(project.id), "phone", "phone_check")
if report is None:
_LOG.warning("phone check: no tutor report for %s", project.id)
return None
if not 0 <= evidence.to_ms(now) - report.ms <= DAY_MS:
_LOG.warning("phone check: tutor report for %s is stale or ahead", project.id)
return None
return _report_ok(report.value, report.ms)
SOURCES: dict[str, CheckSource] = {"phone": phone_source}
def register_source(name: str, source: CheckSource) -> None:
"""Replace the callable behind an auto check (``phone``)."""
if name not in SOURCES:
msg = f"unknown check source {name!r}; known: {sorted(SOURCES)}"
raise ValueError(msg)
SOURCES[name] = source
def _run(project: Project, now: datetime) -> bool | None:
try:
return SOURCES["phone"](project, now)
except Exception as exc: # noqa: BLE001 (pluggable code: any failure = unknown)
_LOG.warning("phone check source failed: %s", exc)
return None
def run_checks(
project_id: str, *, now: datetime | None = None
) -> dict[str, bool | None]:
"""Run the auto checks of open phone milestones; latch what changed.
Returns milestone id -> verdict for the ones checked (inside their
24 h window, not yet done). KeyError for an unknown project; a focus
project has nothing to check.
"""
stamp = _mutate.local_now(now)
project = next((p for p in _store.read_all() if p.id == project_id), None)
if project is None:
raise KeyError(project_id)
open_ids = [
m.id
for m in _milestones.milestones(project, stamp)
if m.id.startswith("phone:")
and m.state not in {"done", "late_done"}
and stamp >= m.due - WINDOW
]
if not open_ids:
return {}
verdict = _run(project, stamp)
rows = evidence.read(project_id)
for mid in open_ids:
_, latest, seen = evidence.check_summary(rows, mid)
if verdict != latest and not (verdict is None and not seen):
evidence.append(project_id, mid, "check", verdict, stamp)
return dict.fromkeys(open_ids, verdict)

View File

@ -0,0 +1,159 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The interview evidence store: append-only rows plus copied files.
One jsonl per project (``{ms, milestone, kind, value}``) under the
:mod:`focus_project._paths` seam. There is deliberately no delete or rewrite
function: a milestone once evidenced stays evidenced.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import itertools
import json
import logging
import os
import re
import shutil
from typing import TYPE_CHECKING
from focus_project import _clock, _paths
if TYPE_CHECKING:
from pathlib import Path
_LOG = logging.getLogger(__name__)
@dataclass(frozen=True)
class Evidence:
"""One evidence row."""
ms: int
milestone: str
kind: str
value: object
@property
def at(self) -> datetime:
"""When it was recorded, as a naive local datetime."""
return from_ms(self.ms)
def to_ms(moment: datetime) -> int:
"""Unix milliseconds of a naive local datetime."""
return int(moment.timestamp() * 1000)
def from_ms(ms: int) -> datetime:
"""Naive local datetime of unix milliseconds."""
return datetime.fromtimestamp(ms / 1000, tz=UTC).astimezone().replace(tzinfo=None)
def clock_now() -> datetime:
"""The clock seam (:mod:`focus_project._clock`) as a naive local datetime."""
return from_ms(int(_clock.now() * 1000))
def append(
project_id: str, milestone: str, kind: str, value: object, now: datetime
) -> Evidence:
"""Append one row (fsynced) and return it."""
row = Evidence(to_ms(now), milestone, kind, value)
path = _paths.evidence_path(project_id)
path.parent.mkdir(parents=True, exist_ok=True)
line = json.dumps(
{"ms": row.ms, "milestone": milestone, "kind": kind, "value": value}
)
with path.open("a", encoding="utf-8") as handle:
handle.write(line + "\n")
handle.flush()
os.fsync(handle.fileno())
return row
def store_file(project_id: str, source: Path, now: datetime) -> str:
"""Copy ``source`` beside the rows under a name that never collides."""
target = _paths.evidence_files_dir(project_id)
target.mkdir(parents=True, exist_ok=True)
safe = re.sub(r"[^A-Za-z0-9._-]", "_", source.name)[:80]
stamp = to_ms(now)
for attempt in itertools.count():
name = f"{stamp}-{safe}" if not attempt else f"{stamp}-{attempt}-{safe}"
try:
with (target / name).open("xb") as out, source.open("rb") as src:
shutil.copyfileobj(src, out)
out.flush()
os.fsync(out.fileno())
except FileExistsError:
continue
return name
raise AssertionError # pragma: no cover (itertools.count never ends)
def _parse(line: str) -> Evidence | None:
try:
row = json.loads(line)
if not isinstance(row, dict) or isinstance(row["ms"], bool):
return None
return Evidence(
int(row["ms"]), str(row["milestone"]), str(row["kind"]), row["value"]
)
except ValueError, KeyError, TypeError:
return None
def read(project_id: str) -> list[Evidence]:
"""Every row in file order; a missing file is empty, bad lines are skipped.
A torn last line (crash mid-append) must not wipe out the whole history,
so malformed lines are logged and ignored; an unreadable file raises.
"""
try:
path = _paths.evidence_path(project_id)
text = path.read_text(encoding="utf-8", errors="replace")
except FileNotFoundError:
return []
rows: list[Evidence] = []
for number, line in enumerate(text.splitlines(), start=1):
parsed = _parse(line)
if parsed is None:
_LOG.warning(
"evidence %s line %d: skipping malformed row", project_id, number
)
else:
rows.append(parsed)
return rows
def first(
rows: list[Evidence], milestone: str, kind: str, since_ms: int = 0
) -> Evidence | None:
"""The earliest matching row at or after ``since_ms``."""
return next(
(
r
for r in rows
if (r.milestone, r.kind) == (milestone, kind) and r.ms >= since_ms
),
None,
)
def last(rows: list[Evidence], milestone: str, kind: str) -> Evidence | None:
"""The latest matching row."""
return next(
(r for r in reversed(rows) if (r.milestone, r.kind) == (milestone, kind)),
None,
)
def check_summary(
rows: list[Evidence], milestone: str
) -> tuple[int | None, bool | None, bool]:
"""``(first passing ms, latest verdict, any check row)`` for a milestone."""
checks = [r for r in rows if (r.milestone, r.kind) == (milestone, "check")]
passed = next((r.ms for r in checks if r.value is True), None)
latest = checks[-1].value if checks else None
return passed, (latest if isinstance(latest, bool) else None), bool(checks)

View File

@ -8,6 +8,10 @@ minutes (floor). Time not yet lived is never credited: every interval is
clipped at "now", so an event stamped ahead of the clock (a fast phone clock)
earns only once the real clock reaches it. An unreadable registry reads as 0
(logged): fail closed.
Unbacked minutes (0.10.0): timer time the anti-cheat gate refused
(:mod:`focus_project.sources.activity`) and no other source covers, on the
local day up to "now"; carry and the cap play no part. Shown, never paid.
"""
from __future__ import annotations
@ -15,9 +19,10 @@ from __future__ import annotations
from datetime import date # noqa: TC003
import logging
from focus_project import _api, _clock, carry
from focus_project import _api, _clock, carry, sources
from focus_project._errors import RegistryError
from focus_project._own import union_seconds
from focus_project.sources import activity
_LOG = logging.getLogger(__name__)
@ -38,4 +43,26 @@ def minutes_for(day: date, now: float | None = None) -> int:
return carry.balance(day, moment).counted(project.daily_on(day))
__all__ = ["minutes_for", "union_seconds"]
def unbacked_minutes_for(day: date, now: float | None = None) -> int:
"""Whole minutes of timer time on ``day`` that the gate did not count.
0 with no project, before :data:`~focus_project.sources.activity.BACKING_FROM`,
or with an unreadable registry (logged).
"""
try:
project = _api.active(day)
if project is None:
return 0
lo, hi = sources.day_bounds(day)
bounds = (lo, min(hi, _clock.now() if now is None else now))
unbacked = sources.clip(activity.split_timer(day, project)[1], bounds)
counted = sources.clip(sources.collect(day, project), bounds)
except (RegistryError, OSError) as exc:
_LOG.warning("focus_project registry unreadable, 0 unbacked: %s", exc)
return 0
# |unbacked minus counted| = |unbacked union counted| - |counted|.
gap = union_seconds(unbacked + counted) - union_seconds(counted)
return int(gap // 60)
__all__ = ["minutes_for", "unbacked_minutes_for", "union_seconds"]

View File

@ -13,23 +13,36 @@ heartbeat covering the five minutes before it. Only events recorded for
the open segment, like a ``checkin``: within :data:`MAX_GAP_S` of the last
beat it extends the segment; later it ends the segment at that last beat. It
never opens a segment. Other heartbeat sources never chain the timer.
0.10.0 anti-cheat (days from :data:`BACKING_FROM`): a timer span earns only
if real activity of the same project backs it, whole span or nothing. A
backing event is a ``tutor``/``sql_drills``/``leetcode`` heartbeat or an
accepted LeetCode submission (leetcode projects only); ``browser`` never
backs. A span ``(a, b)`` is backed if a backing event lies in
``[a - 300, b]``, or between two consecutive backing events at most
:data:`BRIDGE_S` apart. Check-ins alone earn 0. Earlier days keep the old
rule (every span counts), so paid days never change.
"""
from __future__ import annotations
from datetime import date
import importlib
from itertools import pairwise
import json
import logging
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Final
from focus_project._paths import activity_path
from focus_project.sources import Interval, heartbeat_interval
from focus_project.sources import HEARTBEAT_WINDOW_S, Interval, heartbeat_interval
if TYPE_CHECKING:
from datetime import date
from focus_project._model import Project
MAX_GAP_S = 600.0
BACKING_FROM: Final = date(2026, 11, 8)
BACKING_SOURCES: Final = frozenset({"tutor", "sql_drills", "leetcode"})
BRIDGE_S: Final = 1200.0
_LOG = logging.getLogger(__name__)
@ -84,12 +97,68 @@ def _is_beat(event: dict[str, Any]) -> bool:
return event.get("source") == "tutor" and event.get("kind") == "heartbeat"
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
"""Timer segments plus heartbeats of ``project`` (the registry clips)."""
events = read_events(project.id)
def sessions(project_id: str) -> list[Interval]:
"""Contiguous focus sessions of ``project_id``, time-ordered, as (start, end).
Built from the same timer/tutor-beat segments (and so the same
:data:`MAX_GAP_S` rule) that credit minutes; spans that touch are one
session, which ends at its last beat.
"""
events = read_events(project_id)
timer = [e for e in events if e.get("source") == "timer" or _is_beat(e)]
merged: list[Interval] = []
for lo, hi in timer_intervals(timer):
if merged and lo <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(hi, merged[-1][1]))
else:
merged.append((lo, hi))
return merged
def backing_times(events: list[dict[str, Any]], project: Project) -> list[float]:
"""Sorted times of the events that may back a timer span.
The LeetCode ledger is read in its own guard: if it fails, only the
submissions are lost, never the heartbeats (fail closed, narrow).
"""
times = [float(e["ts"]) for e in events if e.get("source") in BACKING_SOURCES]
try:
# Imported lazily: it pulls in earned_time, and the timer must keep
# working (heartbeat backing only) where that is not installed.
leetcode = importlib.import_module("focus_project.sources.leetcode")
times += leetcode.submission_times(project)
except Exception: # noqa: BLE001 (lose the submissions, keep the beats)
_LOG.exception("leetcode backing unavailable, heartbeats only")
return sorted(times)
def is_backed(span: Interval, times: list[float]) -> bool:
"""Whether a backing event lies in or just before ``span``, or bridges it."""
lo, hi = span
if any(lo - HEARTBEAT_WINDOW_S <= t <= hi for t in times):
return True
return any(a <= lo and hi <= b and b - a <= BRIDGE_S for a, b in pairwise(times))
def split_timer(
day: date, project: Project, events: list[dict[str, Any]] | None = None
) -> tuple[list[Interval], list[Interval]]:
"""``(backed, unbacked)`` timer spans of ``project``, judged for ``day``."""
found = read_events(project.id) if events is None else events
chain = [e for e in found if e.get("source") == "timer" or _is_beat(e)]
spans = timer_intervals(chain)
if day < BACKING_FROM:
return spans, []
times = backing_times(found, project)
backed = [s for s in spans if is_backed(s, times)]
return backed, [s for s in spans if s not in backed]
def intervals(day: date, project: Project) -> list[Interval]:
"""Backed timer segments plus heartbeats of ``project`` (the registry clips)."""
events = read_events(project.id)
beats = [e for e in events if e.get("source") != "timer"]
return [
*timer_intervals(timer),
*split_timer(day, project, events)[0],
*(heartbeat_interval(float(e["ts"])) for e in beats),
]

View File

@ -55,8 +55,8 @@ def _submitted_at(row: object, key: bytes) -> float | None:
return None
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
"""One heartbeat per accepted submission (the registry clips to the day)."""
def submission_times(project: Project) -> list[float]:
"""Times of verified accepted submissions; [] for a non-leetcode project."""
if not any("leetcode" in pattern.lower() for pattern in project.site_patterns):
return []
key = ledger.read_key()
@ -64,4 +64,9 @@ def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
_LOG.warning("leetcode source: no signing key, contributes 0")
return []
stamps = (_submitted_at(row, key) for row in ledger.read_rows(ledger_file()))
return [heartbeat_interval(ts) for ts in stamps if ts is not None]
return [ts for ts in stamps if ts is not None]
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
"""One heartbeat per accepted submission (the registry clips to the day)."""
return [heartbeat_interval(ts) for ts in submission_times(project)]

View File

@ -3,10 +3,32 @@
from __future__ import annotations
from datetime import date # noqa: TC003
from typing import Any
import logging
from typing import TYPE_CHECKING, Any
from focus_project import _api, carry
from focus_project import _api, _milestones, carry, evidence, minutes
if TYPE_CHECKING:
from datetime import date
from focus_project._model import Project
_LOG = logging.getLogger(__name__)
def _milestone_block(project: Project) -> tuple[list[dict[str, Any]], bool]:
"""Milestone rows and whether any is red; unreadable evidence shows none.
Display path, so it fails open (logged): the lock path, ``owed_lock``,
has its own handling.
"""
now = evidence.clock_now()
try:
items = _milestones.milestones(project, now)
except OSError as exc:
_LOG.warning("interview evidence unreadable, no milestones shown: %s", exc)
return [], False
return [m.to_dict() for m in items], any(m.red for m in items)
def status(day: date) -> dict[str, Any]:
@ -14,7 +36,8 @@ def status(day: date) -> dict[str, Any]:
``minutes`` is what was really worked plus what the day took from the
bank, so it may exceed ``daily_minutes`` (121/120); ``counted_minutes``
is the capped figure that pays.
is the capped figure that pays. ``unbacked_minutes`` is timer time the
anti-cheat gate refused (shown as "not counted", never paid).
"""
project = _api.active(day)
if project is None:
@ -30,9 +53,13 @@ def status(day: date) -> dict[str, Any]:
"unlock_minutes": None,
"ends": None,
"replaces": [],
"unbacked_minutes": 0,
"milestones": [],
"missed": False,
}
daily = project.daily_on(day)
bank = carry.balance(day)
rows, missed = _milestone_block(project)
return {
"project": project.id,
"name": project.name,
@ -47,4 +74,8 @@ def status(day: date) -> dict[str, Any]:
"unlock_minutes": project.unlock_on(day),
"ends": project.last_day.isoformat(),
"replaces": list(project.replaces_on(day)),
"unbacked_minutes": minutes.unbacked_minutes_for(day),
# Interview projects: [{id, due ISO, state}] and whether any is red.
"milestones": rows,
"missed": missed,
}

View File

@ -11,7 +11,7 @@ from typing import TYPE_CHECKING
import pytest
from focus_project import _clock
from focus_project import _clock, _paths, checks
if TYPE_CHECKING:
from pathlib import Path
@ -40,3 +40,16 @@ def _far_future_clock(monkeypatch: pytest.MonkeyPatch) -> None:
def data(tmp_path: Path) -> Path:
"""The redirected data directory (may not exist yet)."""
return tmp_path / "data"
@pytest.fixture(autouse=True)
def _interview_isolation(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, _redirect_state: None
) -> None:
"""Evidence lives under the redirected data dir; sources are a private copy.
Fails closed (R3): if the path seam ever resolves outside ``tmp_path`` the
test errors before it can write a production evidence row.
"""
assert _paths.evidence_dir().is_relative_to(tmp_path)
monkeypatch.setattr(checks, "SOURCES", dict(checks.SOURCES))

View File

@ -0,0 +1,65 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Shared fixtures-as-functions for the interview-prep tests."""
from __future__ import annotations
from datetime import date, datetime, time, timedelta
import json
from typing import TYPE_CHECKING
import focus_project as fp
from focus_project._paths import activity_path
if TYPE_CHECKING:
from pathlib import Path
START = date(2026, 11, 10) # Tuesday: D1
TEST_DAY = date(2026, 11, 27) # Friday: T
TEST_AT = datetime.combine(TEST_DAY, time(10, 0))
SAT1 = date(2026, 11, 14)
SAT2 = date(2026, 11, 21)
def at(day: int, hour: int = 0, minute: int = 0) -> datetime:
"""A November 2026 local datetime."""
return datetime.combine(date(2026, 11, day), time(hour, minute))
def make(pid: str = "iv", /, **kw: object) -> fp.Project:
"""Create an interview project D1 = Nov 10, T = Nov 27."""
args: dict[str, object] = {
"name": "Interview",
"start": START,
"end": TEST_DAY,
"replaces": ["leetcode", "reading", "tutor"],
"daily_minutes": 120,
"unlock_minutes": 30,
"kind": "interview",
"test_at": TEST_AT,
"invite_url": "https://app.codility.com/c/run/abc",
}
args.update(kw)
return fp.create_project(pid, **args) # type: ignore[arg-type]
def shot(tmp_path: Path, name: str = "rules.png") -> Path:
"""A non-empty screenshot stand-in."""
path = tmp_path / name
path.write_bytes(b"\x89PNG rules")
return path
def states(project: fp.Project, now: datetime) -> dict[str, str]:
"""Milestone id -> state."""
return {m.id: m.state for m in fp.milestones(project, now)}
def session(start: datetime, minutes: int, *, step: int = 5, pid: str = "iv") -> None:
"""A timer session start -> check-in every ``step`` min -> stop."""
activity_path().parent.mkdir(parents=True, exist_ok=True)
with activity_path().open("a", encoding="utf-8") as handle:
for offset in range(0, minutes + 1, step):
kind = "start" if offset == 0 else "checkin"
when = start + timedelta(minutes=offset)
row = {"ts": when.timestamp(), "source": "timer", "kind": kind}
handle.write(json.dumps({**row, "project": pid, "dev": "pc"}) + "\n")

View File

@ -137,6 +137,9 @@ def test_status_active_reports_the_worked_minutes() -> None:
"unlock_minutes": 30,
"ends": "2026-11-05",
"replaces": ["leetcode"],
"unbacked_minutes": 0,
"milestones": [],
"missed": False,
}
@ -158,6 +161,9 @@ def test_status_inactive() -> None:
"unlock_minutes",
"ends",
"replaces",
"unbacked_minutes",
"milestones",
"missed",
}

View File

@ -0,0 +1,181 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""0.10.0: a timer span earns only if a same-project beat backs it."""
from __future__ import annotations
from datetime import date
from typing import TYPE_CHECKING
import focus_project as fp
from focus_project import _api, minutes
from focus_project.sources import activity
from focus_project.tests import test_minutes as tm
if TYPE_CHECKING:
import pytest
DAY = date(2026, 11, 8) # BACKING_FROM
def at(hour: int, minute: int = 0) -> float:
return tm.at(hour, minute, day=DAY)
def proj() -> None:
fp.create_project(
"hr",
name="HR prep",
start=DAY,
end=date(2026, 11, 12),
replaces=["leetcode"],
daily_minutes=120,
unlock_minutes=30,
site_patterns=["leetcode.com"],
)
def rows(*items: tuple[str, str, float]) -> None:
tm.events(*items)
def timer_only() -> None:
rows(
("timer", "start", at(9)),
("timer", "checkin", at(9, 5)),
("timer", "stop", at(9, 10)),
)
def test_timer_only_stretch_pays_zero_and_shows_unbacked() -> None:
proj()
timer_only()
assert minutes.minutes_for(DAY) == 0
assert minutes.unbacked_minutes_for(DAY) == 10
assert fp.status(DAY)["unbacked_minutes"] == 10
def test_a_beat_in_the_span_backs_all_of_it() -> None:
proj()
timer_only()
rows(("sql_drills", "heartbeat", at(9, 5)))
assert minutes.unbacked_minutes_for(DAY) == 0
assert minutes.minutes_for(DAY) == 10
def test_a_beat_backs_only_the_spans_it_touches() -> None:
proj()
timer_only()
rows(("sql_drills", "heartbeat", at(9, 7)))
# 9:00-9:05 stays unbacked, but the beat itself pays 9:02-9:07.
assert minutes.unbacked_minutes_for(DAY) == 2
def test_a_beat_at_the_end_of_the_span_backs_it() -> None:
proj()
rows(
("timer", "start", at(9)),
("timer", "stop", at(9, 10)),
("sql_drills", "heartbeat", at(9, 10)),
)
assert minutes.unbacked_minutes_for(DAY) == 0
assert minutes.minutes_for(DAY) == 10
def test_a_beat_just_before_the_span_backs_it() -> None:
proj()
rows(
("sql_drills", "heartbeat", at(8, 56)), # within 300 s before 9:00
("timer", "start", at(9)),
("timer", "stop", at(9, 5)),
)
assert minutes.unbacked_minutes_for(DAY) == 0
def test_a_late_beat_does_not_back_it() -> None:
proj()
timer_only()
rows(("sql_drills", "heartbeat", at(9, 40)))
assert minutes.unbacked_minutes_for(DAY) == 10
def test_beats_up_to_twenty_minutes_apart_bridge_the_span() -> None:
proj()
rows(
("tutor", "heartbeat", at(8, 50)),
("timer", "start", at(9)),
("timer", "checkin", at(9, 5)),
("timer", "stop", at(9, 8)),
("tutor", "heartbeat", at(9, 10)),
)
assert minutes.unbacked_minutes_for(DAY) == 0
def test_beats_over_twenty_minutes_apart_do_not_bridge() -> None:
proj()
rows(
("tutor", "heartbeat", at(8, 30)),
("timer", "start", at(9)),
("timer", "stop", at(9, 8)),
("tutor", "heartbeat", at(9, 40)),
)
assert minutes.unbacked_minutes_for(DAY) == 8
def test_browser_never_backs() -> None:
proj()
timer_only()
rows(("browser", "heartbeat", at(9, 7)))
# The beat pays its own 9:02-9:07 but backs no timer span.
assert minutes.unbacked_minutes_for(DAY) == 5
assert minutes.minutes_for(DAY) == 5
def test_days_before_backing_from_keep_the_old_rule() -> None:
fp.create_project(
"hr",
name="Old",
start=date(2026, 11, 2),
end=date(2026, 11, 5),
replaces=["leetcode"],
daily_minutes=120,
unlock_minutes=30,
site_patterns=["leetcode.com"],
)
old = date(2026, 11, 2)
rows(("timer", "start", tm.at(9)), ("timer", "stop", tm.at(9, 10)))
assert minutes.minutes_for(old) == 10
assert minutes.unbacked_minutes_for(old) == 0
def test_no_project_is_zero_unbacked() -> None:
assert minutes.unbacked_minutes_for(DAY) == 0
assert fp.status(DAY)["unbacked_minutes"] == 0
def test_unbacked_is_clipped_at_now() -> None:
proj()
timer_only()
assert minutes.unbacked_minutes_for(DAY, now=at(9, 4)) == 4
def test_split_timer_returns_backed_and_unbacked() -> None:
proj()
timer_only()
project = _api.active(DAY)
assert project is not None
backed, unbacked = activity.split_timer(DAY, project)
assert backed == []
assert unbacked == [(at(9), at(9, 5)), (at(9, 5), at(9, 10))]
def test_backing_from_is_exported() -> None:
assert date(2026, 11, 8) == fp.BACKING_FROM
assert fp.unbacked_minutes_for is minutes.unbacked_minutes_for
def test_registry_error_reads_zero(monkeypatch: pytest.MonkeyPatch) -> None:
def boom(_day: date) -> None:
raise OSError
monkeypatch.setattr("focus_project._api.active", boom)
assert minutes.unbacked_minutes_for(DAY) == 0

View File

@ -0,0 +1,131 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""More 0.10.0 backing cases: sources, projects, the cutover midnight, chains.
The core rule lives in ``test_backing.py``; these pin the edges the spec
named (C5-C7) with leetcode-ledger and other-project fixtures.
"""
from __future__ import annotations
from datetime import date
import json
from typing import TYPE_CHECKING
import pytest
import focus_project as fp
from focus_project import minutes
from focus_project._paths import activity_path
from focus_project.tests.test_leetcode_source import ac_row, write
from focus_project.tests.test_minutes import at
if TYPE_CHECKING:
from pathlib import Path
DAY = date(2026, 11, 9) # after the cutover
PRE = date(2026, 11, 7) # before it
@pytest.fixture(autouse=True)
def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Nothing may fall back to the real home directory."""
monkeypatch.setenv("HOME", str(tmp_path / "home"))
def make(pid: str = "hr", patterns: tuple[str, ...] = ("leetcode.com",)) -> None:
fp.create_project(
pid,
name=pid,
start=date(2026, 11, 6),
end=date(2026, 11, 12),
replaces=["leetcode"],
daily_minutes=240,
unlock_minutes=30,
site_patterns=list(patterns),
)
def rows(*items: tuple[str, str, float] | tuple[str, str, float, str]) -> None:
"""Append ``(source, kind, ts[, project])`` events (project default hr)."""
activity_path().parent.mkdir(parents=True, exist_ok=True)
with activity_path().open("a", encoding="utf-8") as handle:
for item in items:
source, kind, ts = item[:3]
pid = item[3] if len(item) == 4 else "hr"
row = {"ts": ts, "source": source, "kind": kind, "project": pid}
handle.write(json.dumps(row) + "\n")
def timer(day: date, *marks: tuple[int, int]) -> None:
"""A segment: start at the first mark, check-ins, stop at the last."""
kinds = ["start", *["checkin"] * (len(marks) - 2), "stop"]
rows(
*(
("timer", k, at(h, m, day=day))
for k, (h, m) in zip(kinds, marks, strict=True)
)
)
def beat(source: str, h: int, m: int, day: date = DAY, pid: str = "hr") -> None:
rows((source, "heartbeat", at(h, m, day=day), pid))
@pytest.mark.parametrize("source", ["sql_drills", "leetcode"])
def test_sql_drills_and_leetcode_heartbeats_back_too(source: str) -> None:
make()
timer(DAY, (9, 0), (9, 5), (9, 10))
beat(source, 9, 5)
assert minutes.minutes_for(DAY) == 10
def test_browser_and_other_project_beats_do_not_back() -> None:
make() # "other" needs no registry entry: rows are filtered by id
timer(DAY, (9, 0), (9, 5), (9, 10))
beat("browser", 9, 4)
beat("tutor", 9, 4, pid="other")
assert minutes.minutes_for(DAY) == 5 # the browser beat's own window only
assert minutes.unbacked_minutes_for(DAY) == 6 # 9:04-9:10
def test_accepted_submission_backs_a_leetcode_project(tmp_path: Path) -> None:
make()
timer(DAY, (9, 0), (9, 5), (9, 10))
write(tmp_path, ac_row(at(9, 4, day=DAY)))
assert minutes.minutes_for(DAY) == 11 # 8:59-9:10
def test_submission_does_not_back_a_non_leetcode_project(tmp_path: Path) -> None:
make("hr", ("sqlzoo.net",))
timer(DAY, (9, 0), (9, 5), (9, 10))
write(tmp_path, ac_row(at(9, 4, day=DAY)))
assert minutes.minutes_for(DAY) == 0
def test_span_across_the_cutover_midnight_follows_the_day() -> None:
make()
rows(
("timer", "start", at(23, 58, day=date(2026, 11, 7))),
("timer", "checkin", at(0, 3, day=date(2026, 11, 8))),
("timer", "stop", at(0, 5, day=date(2026, 11, 8))),
)
assert minutes.minutes_for(date(2026, 11, 7)) == 2
assert minutes.minutes_for(date(2026, 11, 8)) == 0
assert minutes.unbacked_minutes_for(date(2026, 11, 8)) == 5
def test_tutor_chained_span_is_backed_but_earlier_reading_is_not() -> None:
"""A tutor beat chains the segment (0.8.0) and backs the span it ends.
start 9:00, checkin 9:08, tutor beat 9:16: the span 9:08-9:16 ends at a
backing beat and earns; 9:00-9:08 has no beat in or 5 min before it and
nothing bridges it, so it shows as unbacked.
"""
make()
rows(
("timer", "start", at(9, 0, day=DAY)),
("timer", "checkin", at(9, 8, day=DAY)),
)
beat("tutor", 9, 16)
assert minutes.minutes_for(DAY) == 8
assert minutes.unbacked_minutes_for(DAY) == 8

View File

@ -0,0 +1,193 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Pluggable check sources, the phone source, and latching."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
import focus_project as fp
from focus_project import _paths, checks, evidence
from focus_project.tests.helpers import SAT1, at, make
if TYPE_CHECKING:
from collections.abc import Callable
from datetime import datetime
MID = "phone:2026-11-14"
def _ms(moment: datetime) -> int:
return int(moment.timestamp() * 1000)
def _report(
moment: datetime,
*,
plan: bool = True,
due: int = 5,
seen: datetime | None = None,
) -> None:
value = {"plan": plan, "due": due, "seen_ms": _ms(seen or moment)}
fp.record_evidence("iv", "phone", "phone_check", value, now=moment)
def _phone(now: datetime) -> bool | None:
return checks.phone_source(fp.list_projects()[0], now)
def test_phone_source_without_a_report_is_unknown(
caplog: pytest.LogCaptureFixture,
) -> None:
make()
with caplog.at_level("WARNING"):
assert _phone(at(13, 9)) is None
assert "phone" in caplog.text
def test_phone_source_passes_on_a_fresh_good_report() -> None:
make()
_report(at(13, 8), seen=at(12, 20))
assert _phone(at(13, 9)) is True
@pytest.mark.parametrize(
"bad",
[{"plan": False}, {"due": 0}, {"seen": at(11, 7)}],
)
def test_phone_source_fails_on_any_bad_part(bad: dict[str, object]) -> None:
make()
_report(at(13, 8), **bad) # type: ignore[arg-type]
assert _phone(at(13, 9)) is False
def test_phone_source_stale_or_future_report_is_unknown() -> None:
make()
_report(at(13, 8))
assert _phone(at(14, 8, 1)) is None # the worker stopped reporting
assert _phone(at(13, 7)) is None # stamped ahead of the clock
def test_phone_source_uses_the_latest_report() -> None:
make()
_report(at(13, 8), plan=False)
_report(at(13, 9))
assert _phone(at(13, 10)) is True
def test_phone_source_hand_edited_row_is_a_failure() -> None:
make()
_report(at(13, 8))
path = _paths.evidence_path("iv")
row = {"ms": _ms(at(13, 9)), "milestone": "phone", "kind": "phone_check"}
path.write_text(path.read_text() + json.dumps({**row, "value": "ok"}) + "\n")
assert _phone(at(13, 10)) is False
def _patch(
monkeypatch: pytest.MonkeyPatch, fn: Callable[[fp.Project, datetime], bool | None]
) -> None:
monkeypatch.setitem(checks.SOURCES, "phone", fn)
def _trip_project() -> None:
make()
fp.answer_weekend("iv", SAT1, trip=True, now=at(11, 9))
def test_run_checks_latches_a_pass(monkeypatch: pytest.MonkeyPatch) -> None:
_trip_project()
_patch(monkeypatch, lambda p, n: True)
assert fp.run_checks("iv", now=at(13, 9)) == {MID: True}
assert fp.run_checks("iv", now=at(13, 10)) == {MID: True}
rows = [r for r in evidence.read("iv") if r.kind == "check"]
assert [(r.milestone, r.value) for r in rows] == [(MID, True)]
def test_run_checks_only_inside_the_window(monkeypatch: pytest.MonkeyPatch) -> None:
_trip_project()
_patch(monkeypatch, lambda p, n: True)
assert fp.run_checks("iv", now=at(12, 11, 59)) == {} # >24 h before due
assert fp.run_checks("iv", now=at(12, 12)) == {MID: True}
def test_run_checks_records_changes_not_every_tick(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_trip_project()
answers: list[bool | None] = [None, False, False, None, None]
_patch(monkeypatch, lambda p, n: answers.pop(0))
for minute in range(5):
fp.run_checks("iv", now=at(13, 9, minute))
rows = [r.value for r in evidence.read("iv") if r.kind == "check"]
assert rows == [False, None] # leading None writes nothing
def test_run_checks_source_crash_is_unknown_and_loud(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
_trip_project()
def boom(p: fp.Project, n: datetime) -> bool:
msg = "tutor down"
raise OSError(msg)
_patch(monkeypatch, boom)
with caplog.at_level("WARNING"):
assert fp.run_checks("iv", now=at(13, 9)) == {MID: None}
assert "tutor down" in caplog.text
def test_run_checks_skips_finished_milestones(monkeypatch: pytest.MonkeyPatch) -> None:
_trip_project()
fp.record_evidence("iv", MID, "tap", value=True, now=at(12, 9))
evidence.append("iv", MID, "check", True, at(12, 10))
_patch(monkeypatch, lambda p, n: pytest.fail("already done"))
assert fp.run_checks("iv", now=at(13, 9)) == {}
def test_run_checks_ignores_focus_projects() -> None:
fp.create_project(
"f",
name="F",
start=at(10).date(),
end=at(12).date(),
replaces=["leetcode"],
daily_minutes=30,
unlock_minutes=10,
)
assert fp.run_checks("f", now=at(11, 9)) == {}
with pytest.raises(KeyError):
fp.run_checks("ghost", now=at(11, 9))
def test_register_source_swaps_the_callable(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(checks, "SOURCES", dict(checks.SOURCES))
def fn(p: fp.Project, n: datetime) -> bool:
return True
fp.register_source("phone", fn)
assert checks.SOURCES["phone"] is fn
with pytest.raises(ValueError, match="source"):
fp.register_source("calendar", fn)
def test_end_to_end_phone_milestone_goes_done() -> None:
_trip_project()
fp.record_evidence("iv", MID, "tap", value=True, now=at(12, 9))
_report(at(13, 9))
assert fp.run_checks("iv", now=at(13, 9, 30)) == {MID: True}
state = {m.id: m.state for m in fp.milestones(fp.list_projects()[0], at(13, 10))}
assert state[MID] == "done"
def test_report_missing_a_field_fails() -> None:
make()
path = _paths.evidence_path("iv")
path.parent.mkdir(parents=True, exist_ok=True)
row = {"ms": _ms(at(13, 8)), "milestone": "phone", "kind": "phone_check"}
path.write_text(json.dumps({**row, "value": {"plan": True}}) + "\n")
assert _phone(at(13, 9)) is False

View File

@ -0,0 +1,126 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The append-only evidence store and record_evidence validation."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
import focus_project as fp
from focus_project import _paths, evidence
from focus_project.tests.helpers import at, make, shot
if TYPE_CHECKING:
from pathlib import Path
def test_paths_are_isolated(tmp_path: Path) -> None:
assert _paths.evidence_dir().is_relative_to(tmp_path)
assert _paths.evidence_path("iv").name == "iv.jsonl"
assert _paths.evidence_files_dir("iv").name == "iv"
def test_file_is_copied_and_row_appended(tmp_path: Path) -> None:
make()
src = shot(tmp_path)
row = fp.record_evidence("iv", "rules", "file", file=src, now=at(10, 9))
assert row.kind == "file"
stored = _paths.evidence_files_dir("iv") / str(row.value)
assert stored.read_bytes() == src.read_bytes()
line = json.loads(_paths.evidence_path("iv").read_text().splitlines()[0])
assert set(line) == {"ms", "milestone", "kind", "value"}
assert line["milestone"] == "rules"
def test_rows_append_never_rewrite() -> None:
make()
fp.record_evidence("iv", "mock", "score", 71, now=at(24, 9))
fp.record_evidence("iv", "mock", "score", 80.5, now=at(24, 10))
assert [r.value for r in evidence.read("iv")] == [71, 80.5]
assert not hasattr(fp, "delete_evidence")
assert not hasattr(evidence, "delete")
def test_same_name_files_do_not_overwrite(tmp_path: Path) -> None:
make()
one = fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(10, 9))
two = fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(10, 9))
assert one.value != two.value
@pytest.mark.parametrize(
("milestone", "kind", "value"),
[
("rules", "tap", True), # wrong kind for the milestone
("rules", "check", True), # internal kind
("trip_ask:2026-11-14", "answer", True), # use answer_weekend
("nope", "tap", True), # not a milestone of the project
("mock", "score", "high"),
("mock", "score", True),
("mock", "score", float("nan")),
("mock", "score", -1),
("slot", "slot", "tomorrow"),
("slot", "slot", "2026-11-26T10:00:00+02:00"), # aware
("slot", "slot_confirm", False),
("phone", "phone_check", {"plan": True}),
("phone", "phone_check", {"plan": "yes", "due": 1, "seen_ms": 1}),
("phone", "phone_check", "ok"),
],
)
def test_bad_evidence_refused(milestone: str, kind: str, value: object) -> None:
make()
with pytest.raises(ValueError): # noqa: PT011
fp.record_evidence("iv", milestone, kind, value, now=at(12, 9))
def test_file_kind_needs_a_real_nonempty_file(tmp_path: Path) -> None:
make()
empty = tmp_path / "e.png"
empty.write_bytes(b"")
for bad in (None, tmp_path / "missing.png", empty, tmp_path):
with pytest.raises(ValueError, match="file"):
fp.record_evidence("iv", "rules", "file", file=bad, now=at(10, 9))
def test_unknown_project_is_keyerror() -> None:
with pytest.raises(KeyError):
fp.record_evidence("ghost", "mock", "score", 1, now=at(24, 9))
def test_focus_project_has_no_milestones() -> None:
fp.create_project(
"f",
name="F",
start=at(10).date(),
end=at(12).date(),
replaces=["leetcode"],
daily_minutes=30,
unlock_minutes=10,
)
with pytest.raises(ValueError, match="milestone"):
fp.record_evidence("f", "mock", "score", 1, now=at(10, 9))
def test_torn_and_foreign_lines_are_skipped(caplog: pytest.LogCaptureFixture) -> None:
make()
fp.record_evidence("iv", "mock", "score", 70, now=at(24, 9))
path = _paths.evidence_path("iv")
path.write_text(path.read_text() + '{"ms": 1, "milest\n[1]\n{"ms":"x"}\n')
with caplog.at_level("WARNING"):
rows = evidence.read("iv")
assert [r.value for r in rows] == [70]
assert "skipping" in caplog.text
def test_missing_file_reads_empty() -> None:
assert evidence.read("nobody") == []
def test_garbage_bytes_are_skipped_not_fatal() -> None:
make()
fp.record_evidence("iv", "mock", "score", 70, now=at(24, 9))
path = _paths.evidence_path("iv")
path.write_bytes(path.read_bytes() + b"\xff\xfe\x80 not utf-8\n")
assert [r.value for r in evidence.read("iv")] == [70]

View File

@ -0,0 +1,175 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Project.kind, interview fields, validation, budget."""
from __future__ import annotations
from datetime import date
import json
from typing import TYPE_CHECKING
import pytest
import focus_project as fp
from focus_project import _interview, _milestones
from focus_project._errors import RegistryError
from focus_project.tests.helpers import SAT1, START, TEST_AT, TEST_DAY, at, make
if TYPE_CHECKING:
from pathlib import Path
LIVE_ROW = {
"id": "hackerrank-prep",
"name": "HackerRank prep",
"start": "2026-11-02",
"end": "2026-11-05",
"replaces": ["leetcode", "reading", "tutor"],
"daily_minutes": 120,
"unlock_minutes": 30,
"subjects": ["a"],
"site_patterns": ["leetcode.com"],
"ended_on": None,
"created_ms": 1793603609345,
"work_url": "https://www.hackerrank.com/dashboard",
"history": [],
}
def test_live_projects_json_shape_loads(data: Path) -> None:
data.mkdir()
(data / "projects.json").write_text(json.dumps({"projects": [LIVE_ROW]}))
[p] = fp.list_projects()
assert p.kind == "focus"
assert p.test_at is None
assert p.trips == ()
assert p.weekend_answers == ()
assert p.daily_on(date(2026, 11, 3)) == 120
snap = fp.status(date(2026, 11, 3))
assert snap["milestones"] == []
assert snap["missed"] is False
def test_focus_row_stays_free_of_interview_keys(data: Path) -> None:
fp.create_project(
"f",
name="F",
start=START,
end=TEST_DAY,
replaces=["leetcode"],
daily_minutes=30,
unlock_minutes=10,
)
row = json.loads((data / "projects.json").read_text())["projects"][0]
assert row["kind"] == "focus"
assert not {"test_at", "invite_url", "trips", "weekend_answers"} & set(row)
def test_interview_roundtrip_and_file_shape(data: Path) -> None:
p = make(
trips=[fp.Trip(date(2026, 11, 20), date(2026, 11, 22))],
weekend_answers={SAT1: False},
)
[back] = fp.list_projects()
assert back == p
assert back.kind == "interview"
assert back.test_at == TEST_AT
assert back.trips[0].entered_ms > 0
row = json.loads((data / "projects.json").read_text())["projects"][0]
assert row["test_at"] == "2026-11-27T10:00:00"
assert row["trips"][0]["start"] == "2026-11-20"
assert row["weekend_answers"] == {"2026-11-14": False}
assert row["invite_url"].startswith("https://")
@pytest.mark.parametrize(
"bad",
[
{"test_at": None},
{"test_at": at(26, 10)}, # end != test day
{"test_at": at(9, 10), "end": date(2026, 11, 9)},
{"invite_url": "ftp://x.example/t"},
{"invite_url": "javascript:alert(1)"},
{"invite_url": "https://a b.example"},
{"kind": "bogus"},
{"trips": [fp.Trip(date(2026, 11, 28), date(2026, 11, 29))]},
{"trips": [fp.Trip(date(2026, 11, 20), date(2026, 11, 19))]},
{"weekend_answers": {date(2026, 11, 15): True}}, # a Sunday
{"weekend_answers": {date(2026, 11, 28): True}}, # after T
{"daily_minutes": 29},
],
)
def test_interview_validation(bad: dict[str, object]) -> None:
with pytest.raises(ValueError): # noqa: PT011
make(**bad)
def test_focus_kind_rejects_interview_fields() -> None:
with pytest.raises(ValueError, match="interview"):
make(kind="focus")
def test_empty_invite_url_is_allowed() -> None:
assert make(invite_url="").invite_url == ""
def test_budget_30_weekdays_120_weekend() -> None:
p = make(unlock_minutes=60)
assert p.daily_on(date(2026, 11, 10)) == 30 # Tue
assert p.daily_on(date(2026, 11, 13)) == 30 # Fri
assert p.daily_on(SAT1) == 120
assert p.daily_on(date(2026, 11, 15)) == 120 # Sun
assert p.unlock_on(date(2026, 11, 10)) == 30 # clamped to the day's target
assert p.unlock_on(SAT1) == 60
def test_budget_flows_through_status() -> None:
make()
assert fp.status(date(2026, 11, 10))["daily_minutes"] == 30
assert fp.status(SAT1)["daily_minutes"] == 120
def test_interview_exempt_from_floor_but_focus_is_not() -> None:
make(daily_minutes=30, unlock_minutes=30) # 3 gates, floor 90
with pytest.raises(ValueError, match="at least"):
fp.create_project(
"f",
name="F",
start=date(2026, 12, 1),
end=date(2026, 12, 2),
replaces=["leetcode", "reading"],
daily_minutes=30,
unlock_minutes=10,
)
def test_non_interview_has_no_weekends() -> None:
focus = fp.create_project(
"f",
name="F",
start=START,
end=TEST_DAY,
replaces=["leetcode"],
daily_minutes=30,
unlock_minutes=10,
)
assert _interview.saturdays(focus) == []
def test_trip_without_entry_time_uses_the_nominal_due() -> None:
assert _milestones._trip_due(date(2026, 11, 13), 0) == at(12, 12)
def test_non_boolean_weekend_answer_is_a_bad_row(data: Path) -> None:
make()
path = data / "projects.json"
rows = json.loads(path.read_text())
rows["projects"][0]["weekend_answers"] = {"2026-11-14": "false"}
path.write_text(json.dumps(rows))
with pytest.raises(RegistryError):
fp.list_projects()
def test_interview_unlock_edit_keeps_the_ratchet() -> None:
make(unlock_minutes=60)
fp.update_project("iv", today=SAT1, unlock_minutes=10)
assert fp.list_projects()[0].unlock_on(SAT1) == 60 # lower holds from tomorrow
assert fp.list_projects()[0].unlock_on(date(2026, 11, 15)) == 10

View File

@ -14,6 +14,8 @@ import pytest
import focus_project as fp
from focus_project import minutes, sources
from focus_project.sources import leetcode
from focus_project.tests.test_backing import DAY, proj, rows
from focus_project.tests.test_backing import at as day_at
from focus_project.tests.test_minutes import D1, at, project
KEY = b"k" * 32
@ -127,3 +129,29 @@ def test_read_only_replay_of_real_ledger(monkeypatch: pytest.MonkeyPatch) -> Non
assert got
assert all(b - a == sources.HEARTBEAT_WINDOW_S for a, b in got)
assert REAL.stat().st_mtime_ns == before
def test_accepted_submission_backs_a_timer_span(tmp_path: Path) -> None:
"""0.10.0: an accepted submission backs timer time on a backed day."""
proj()
rows(("timer", "start", day_at(9)), ("timer", "stop", day_at(9, 10)))
assert minutes.unbacked_minutes_for(DAY) == 10
write(tmp_path, ac_row(day_at(9, 10)))
assert minutes.unbacked_minutes_for(DAY) == 0
assert minutes.minutes_for(DAY) == 10
def test_unreadable_ledger_leaves_heartbeat_backing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def boom(_project: object) -> list[float]:
raise OSError
monkeypatch.setattr(leetcode, "submission_times", boom)
proj()
rows(
("timer", "start", day_at(9)),
("timer", "stop", day_at(9, 10)),
("tutor", "heartbeat", day_at(9, 10)),
)
assert minutes.unbacked_minutes_for(DAY) == 0

View File

@ -0,0 +1,228 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Milestone generation, states and the lock that falls out of them."""
from __future__ import annotations
from datetime import date
from typing import TYPE_CHECKING
import focus_project as fp
from focus_project import evidence
from focus_project.tests.helpers import SAT1, SAT2, at, make, session, shot, states
if TYPE_CHECKING:
from pathlib import Path
import pytest
def _due(p: fp.Project, now_day: int = 10) -> dict[str, str]:
return {m.id: m.due.isoformat() for m in fp.milestones(p, at(now_day, 8))}
def test_table_of_dues() -> None:
assert _due(make()) == {
"rules": "2026-11-10T23:59:00",
"trip_ask:2026-11-14": "2026-11-12T23:59:00",
"trip_ask:2026-11-21": "2026-11-19T23:59:00",
"mock": "2026-11-25T23:59:00",
"slot": "2026-11-26T12:00:00",
}
def test_sorted_by_due_and_all_pending_at_start() -> None:
project = make()
items = fp.milestones(project, at(10, 8))
assert [m.due for m in items] == sorted(m.due for m in items)
assert {m.state for m in items} == {"pending"}
assert items[0].to_dict() == {
"id": "rules",
"due": "2026-11-10T23:59:00",
"state": "pending",
}
def test_focus_project_has_none() -> None:
p = fp.create_project(
"f",
name="F",
start=date(2026, 11, 10),
end=date(2026, 11, 12),
replaces=["leetcode"],
daily_minutes=30,
unlock_minutes=10,
)
assert fp.milestones(p, at(11, 8)) == []
def test_unanswered_weekend_is_a_trip_once_ask_is_due() -> None:
p = make()
assert "phone:2026-11-14" not in states(p, at(12, 23, 58))
got = states(p, at(13, 8))
assert got["trip_ask:2026-11-14"] == "missed"
assert got["phone:2026-11-14"] == "pending" # due Fri 12:00
assert states(p, at(13, 12))["phone:2026-11-14"] == "missed"
assert _due(p, 13)["phone:2026-11-14"] == "2026-11-13T12:00:00"
def test_answers_decide_the_phone_milestone() -> None:
make()
no = fp.answer_weekend("iv", SAT1, trip=False, now=at(11, 9))
assert "phone:2026-11-14" not in states(no, at(13, 13))
assert states(no, at(13, 13))["trip_ask:2026-11-14"] == "done"
yes = fp.answer_weekend("iv", SAT1, trip=True, now=at(11, 10))
assert states(yes, at(11, 11))["phone:2026-11-14"] == "pending"
def test_explicit_trip_covers_its_saturday() -> None:
make()
p = fp.add_trip("iv", date(2026, 11, 13), date(2026, 11, 15), now=at(10, 9))
got = states(p, at(13, 8))
assert "phone:2026-11-13" in got
assert "phone:2026-11-14" not in got
assert _due(p)["phone:2026-11-13"] == "2026-11-12T12:00:00"
def test_late_trip_is_due_the_moment_it_is_entered() -> None:
make()
p = fp.add_trip("iv", date(2026, 11, 13), date(2026, 11, 14), now=at(12, 8))
assert _due(p, 12)["phone:2026-11-13"] == "2026-11-12T08:00:00"
assert states(p, at(12, 8))["phone:2026-11-13"] == "missed"
far = fp.add_trip("iv", date(2026, 11, 20), date(2026, 11, 22), now=at(12, 9))
assert _due(far, 12)["phone:2026-11-20"] == "2026-11-19T12:00:00"
def _clean(tmp_path: Path) -> None:
"""Everything due before the slot is done on time; weekend 2 is no trip."""
fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(10, 9))
fp.answer_weekend("iv", SAT1, trip=False, now=at(11, 9))
fp.answer_weekend("iv", SAT2, trip=False, now=at(11, 9))
session(at(24, 7), 90)
fp.record_evidence("iv", "mock", "score", 70, now=at(24, 9))
def test_slot_is_due_t_minus_1_at_noon_and_locks_that_evening(tmp_path: Path) -> None:
make()
_clean(tmp_path)
assert fp.owed_lock(at(26, 11, 59)) is None
owed = fp.owed_lock(at(26, 12))
assert owed is not None
assert owed.id == "slot"
assert owed.state == "missed"
assert fp.owed_lock(at(26, 20)) is not None
def test_test_day_never_locks_but_shows_red(tmp_path: Path) -> None:
make()
assert fp.owed_lock(at(27, 9)) is None
assert fp.status(date(2026, 11, 27))["milestones"] is not None
assert {m.state for m in fp.milestones(fp.list_projects()[0], at(27, 9))} >= {
"missed"
}
def test_owed_lock_is_the_earliest_missed() -> None:
make()
owed = fp.owed_lock(at(13, 8))
assert owed is not None
assert owed.id == "rules"
def test_late_completion_unlocks(tmp_path: Path) -> None:
make()
fp.answer_weekend("iv", SAT1, trip=False, now=at(11, 9))
owed = fp.owed_lock(at(11, 10))
assert owed is not None
assert owed.id == "rules"
fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(11, 10))
p = fp.list_projects()[0]
assert states(p, at(11, 10))["rules"] == "late_done"
assert fp.owed_lock(at(11, 10, 1)) is None
def test_on_time_is_done_at_the_boundary(tmp_path: Path) -> None:
make()
fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(10, 23, 58))
p = fp.list_projects()[0]
assert states(p, at(11, 8))["rules"] == "done"
assert states(p, at(10, 23, 59))["rules"] == "done"
def test_slot_needs_entry_and_confirm_tap() -> None:
p = make()
fp.record_evidence("iv", "slot", "slot", "2026-11-26T20:00:00", now=at(25, 9))
assert states(p, at(25, 10))["slot"] == "pending"
assert states(p, at(26, 12))["slot"] == "missed" # entered, never confirmed
fp.record_evidence("iv", "slot", "slot_confirm", value=True, now=at(25, 11))
assert states(p, at(25, 12))["slot"] == "done"
fp.record_evidence("iv", "slot", "slot", "2026-11-26T21:00:00", now=at(25, 13))
assert states(p, at(25, 14))["slot"] == "pending" # moved: confirm again
fp.record_evidence("iv", "slot", "slot_confirm", value=True, now=at(26, 13))
assert states(p, at(26, 14))["slot"] == "late_done"
assert not [s for s in states(p, at(26, 14)).values() if s == "unknown"]
def _phone_setup() -> None:
make()
fp.answer_weekend("iv", SAT1, trip=True, now=at(11, 9))
def test_phone_milestone_states() -> None:
_phone_setup()
p = fp.list_projects()[0]
mid = "phone:2026-11-14"
assert states(p, at(13, 13))[mid] == "missed" # no tap, no check
fp.record_evidence("iv", mid, "tap", value=True, now=at(12, 9))
assert states(p, at(13, 13))[mid] == "unknown" # tap but nothing checked
evidence.append("iv", mid, "check", value=False, now=at(13, 11))
assert states(p, at(13, 13))[mid] == "missed" # checked and failing
evidence.append("iv", mid, "check", value=None, now=at(13, 11, 30))
assert states(p, at(13, 13))[mid] == "unknown" # source went dark
evidence.append("iv", mid, "check", value=True, now=at(13, 11, 40))
assert states(p, at(13, 13))[mid] == "done"
def test_phone_passed_after_due_is_late_done() -> None:
_phone_setup()
mid = "phone:2026-11-14"
fp.record_evidence("iv", mid, "tap", value=True, now=at(12, 9))
evidence.append("iv", mid, "check", value=True, now=at(13, 14))
assert states(fp.list_projects()[0], at(13, 15))[mid] == "late_done"
def test_unknown_locks_like_missed(tmp_path: Path) -> None:
_phone_setup()
mid = "phone:2026-11-14"
fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(10, 9))
fp.answer_weekend("iv", SAT2, trip=False, now=at(11, 9))
fp.record_evidence("iv", mid, "tap", value=True, now=at(12, 9))
owed = fp.owed_lock(at(13, 13))
assert owed is not None
assert (owed.id, owed.state) == (mid, "unknown")
def test_unreadable_registry_fails_open_and_loud(
data: Path, caplog: pytest.LogCaptureFixture
) -> None:
data.mkdir()
(data / "projects.json").write_text("{nope")
with caplog.at_level("WARNING"):
assert fp.owed_lock(at(13, 8)) is None
assert "unreadable" in caplog.text
def test_no_project_no_lock() -> None:
assert fp.owed_lock(at(13, 8)) is None
def test_focus_project_never_locks_on_milestones() -> None:
fp.create_project(
"f",
name="F",
start=date(2026, 11, 10),
end=date(2026, 11, 20),
replaces=["leetcode"],
daily_minutes=30,
unlock_minutes=10,
)
assert fp.owed_lock(at(13, 8)) is None

View File

@ -0,0 +1,61 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The mock milestone: a score counts only after a >= 90 min focus session."""
from __future__ import annotations
import focus_project as fp
from focus_project.tests.helpers import at, make, session, states
def test_mock_needs_a_score_after_a_long_session() -> None:
p = make()
assert states(p, at(26, 8))["mock"] == "missed"
session(at(25, 20), 90)
fp.record_evidence("iv", "mock", "score", 64, now=at(26, 8))
assert states(fp.list_projects()[0], at(26, 9))["mock"] == "late_done"
def test_score_without_a_session_does_not_complete_mock() -> None:
p = make()
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
assert states(p, at(24, 10))["mock"] == "pending"
assert states(p, at(26, 8))["mock"] == "missed"
assert fp.owed_lock(at(26, 8)) is not None
def test_short_session_does_not_complete_mock() -> None:
p = make()
session(at(24, 7), 89)
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
assert states(p, at(24, 10))["mock"] == "pending"
def test_session_broken_by_a_gap_does_not_complete_mock() -> None:
p = make()
session(at(24, 6), 60) # 06:00-07:00, then an 11 min hole (> 600 s)
session(at(24, 7, 11), 60)
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
assert states(p, at(24, 10))["mock"] == "pending"
def test_session_must_end_before_the_score() -> None:
p = make()
session(at(24, 8), 90) # ends 09:30
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
assert states(p, at(24, 10))["mock"] == "pending"
fp.record_evidence("iv", "mock", "score", 66, now=at(24, 9, 30))
assert states(p, at(24, 10))["mock"] == "done" # ends exactly at the score
def test_other_projects_sessions_do_not_count() -> None:
p = make()
session(at(24, 7), 90, pid="other")
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
assert states(p, at(24, 10))["mock"] == "pending"
def test_session_on_time_but_score_late_is_late_done() -> None:
p = make()
session(at(25, 20), 100)
fp.record_evidence("iv", "mock", "score", 64, now=at(26, 0, 5))
assert states(p, at(26, 1))["mock"] == "late_done"

View File

@ -0,0 +1,172 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""add_trip / answer_weekend only ever get stricter."""
from __future__ import annotations
from datetime import date
import pytest
import focus_project as fp
from focus_project import evidence
from focus_project.tests.helpers import SAT1, at, make, states
def _ms(day: int, hour: int) -> int:
return int(at(day, hour).timestamp() * 1000)
def test_add_trip_records_when_it_was_entered() -> None:
make()
p = fp.add_trip("iv", date(2026, 11, 20), date(2026, 11, 22), now=at(10, 9))
assert p.trips == (fp.Trip(date(2026, 11, 20), date(2026, 11, 22), _ms(10, 9)),)
assert fp.list_projects() == [p]
def test_extending_keeps_entry_time_moving_earlier_resets_it() -> None:
make()
fp.add_trip("iv", date(2026, 11, 20), date(2026, 11, 21), now=at(10, 9))
later = fp.add_trip("iv", date(2026, 11, 20), date(2026, 11, 23), now=at(11, 9))
assert later.trips == (fp.Trip(date(2026, 11, 20), date(2026, 11, 23), _ms(10, 9)),)
earlier = fp.add_trip("iv", date(2026, 11, 19), date(2026, 11, 23), now=at(12, 9))
assert earlier.trips == (
fp.Trip(date(2026, 11, 19), date(2026, 11, 23), _ms(12, 9)),
)
def test_second_disjoint_trip_and_idempotent_repeat() -> None:
make()
fp.add_trip("iv", date(2026, 11, 13), date(2026, 11, 14), now=at(10, 9))
fp.add_trip("iv", date(2026, 11, 20), date(2026, 11, 21), now=at(10, 10))
again = fp.add_trip("iv", date(2026, 11, 20), date(2026, 11, 21), now=at(11, 9))
assert len(again.trips) == 2
assert again.trips[1].entered_ms == _ms(10, 10)
@pytest.mark.parametrize(
("start", "end"),
[
(date(2026, 11, 21), date(2026, 11, 22)), # starts later: loosens
(date(2026, 11, 20), date(2026, 11, 21)), # ends earlier: shortens
(date(2026, 11, 21), date(2026, 11, 21)), # strictly inside
(date(2026, 11, 22), date(2026, 11, 25)), # partial overlap, later start
],
)
def test_trip_may_not_shrink_or_start_later(start: date, end: date) -> None:
make()
before = fp.add_trip("iv", date(2026, 11, 20), date(2026, 11, 22), now=at(10, 9))
with pytest.raises(ValueError, match=r"stricter|cover"):
fp.add_trip("iv", start, end, now=at(11, 9))
assert fp.list_projects() == [before]
@pytest.mark.parametrize(
("start", "end"),
[
(date(2026, 11, 20), date(2026, 11, 19)),
(date(2026, 11, 28), date(2026, 11, 29)),
],
)
def test_bad_trip_ranges(start: date, end: date) -> None:
make()
with pytest.raises(ValueError): # noqa: PT011
fp.add_trip("iv", start, end, now=at(10, 9))
def test_trip_on_wrong_project() -> None:
with pytest.raises(KeyError):
fp.add_trip("ghost", date(2026, 11, 20), date(2026, 11, 21), now=at(10, 9))
fp.create_project(
"f",
name="F",
start=date(2026, 12, 1),
end=date(2026, 12, 3),
replaces=["leetcode"],
daily_minutes=30,
unlock_minutes=10,
)
with pytest.raises(ValueError, match="interview"):
fp.add_trip("f", date(2026, 12, 2), date(2026, 12, 3), now=at(10, 9))
def test_answer_false_to_true_ok_true_to_false_refused() -> None:
make()
fp.answer_weekend("iv", SAT1, trip=False, now=at(11, 9))
yes = fp.answer_weekend("iv", SAT1, trip=True, now=at(11, 10))
assert dict(yes.weekend_answers) == {SAT1: True}
with pytest.raises(ValueError, match="stricter"):
fp.answer_weekend("iv", SAT1, trip=False, now=at(11, 11))
assert dict(fp.list_projects()[0].weekend_answers) == {SAT1: True}
def test_repeating_an_answer_writes_no_second_row() -> None:
make()
fp.answer_weekend("iv", SAT1, trip=True, now=at(11, 9))
fp.answer_weekend("iv", SAT1, trip=True, now=at(11, 10))
rows = [r for r in evidence.read("iv") if r.milestone == "trip_ask:2026-11-14"]
assert [(r.kind, r.value) for r in rows] == [("answer", True)]
@pytest.mark.parametrize(
"day", [date(2026, 11, 15), date(2026, 11, 7), date(2026, 11, 28)]
)
def test_answer_only_for_a_weekend_of_the_project(day: date) -> None:
make()
with pytest.raises(ValueError, match="weekend"):
fp.answer_weekend("iv", day, trip=True, now=at(11, 9))
def test_no_trip_answer_contradicting_an_explicit_trip_is_refused() -> None:
make()
fp.add_trip("iv", date(2026, 11, 13), date(2026, 11, 15), now=at(10, 9))
with pytest.raises(ValueError, match="trip covers"):
fp.answer_weekend("iv", SAT1, trip=False, now=at(11, 9))
def test_late_answer_unlocks_but_is_late_done() -> None:
p = make()
assert states(p, at(13, 8))["trip_ask:2026-11-14"] == "missed"
p = fp.answer_weekend("iv", SAT1, trip=False, now=at(13, 8))
assert states(p, at(13, 9))["trip_ask:2026-11-14"] == "late_done"
def test_answer_on_wrong_project() -> None:
with pytest.raises(KeyError):
fp.answer_weekend("ghost", SAT1, trip=True, now=at(11, 9))
def test_find_skips_other_projects() -> None:
fp.create_project(
"f",
name="F",
start=date(2026, 10, 1),
end=date(2026, 10, 3),
replaces=["leetcode"],
daily_minutes=30,
unlock_minutes=10,
)
make()
p = fp.answer_weekend("iv", SAT1, trip=True, now=at(11, 9))
assert dict(p.weekend_answers) == {SAT1: True}
def test_late_no_trip_answer_cannot_erase_an_overdue_phone_milestone() -> None:
make()
assert states(fp.list_projects()[0], at(13, 13))["phone:2026-11-14"] == "missed"
p = fp.answer_weekend("iv", SAT1, trip=False, now=at(13, 13))
got = states(p, at(13, 14))
assert got["trip_ask:2026-11-14"] == "late_done"
assert got["phone:2026-11-14"] == "missed"
def test_no_trip_answer_before_the_phone_is_due_drops_it() -> None:
make()
p = fp.answer_weekend("iv", SAT1, trip=False, now=at(13, 11, 59))
assert "phone:2026-11-14" not in states(p, at(13, 14))
def test_no_trip_answer_given_at_creation_has_no_phone() -> None:
p = make(weekend_answers={SAT1: False})
got = states(p, at(13, 14))
assert got["trip_ask:2026-11-14"] == "done"
assert "phone:2026-11-14" not in got

View File

@ -0,0 +1,72 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""status(day) carries the milestones for i3bar / daily-limits / tutor."""
from __future__ import annotations
from datetime import date
from typing import TYPE_CHECKING
import focus_project as fp
from focus_project import _clock, evidence
from focus_project.tests.helpers import at, make
if TYPE_CHECKING:
from pathlib import Path
import pytest
def _now(monkeypatch: pytest.MonkeyPatch, day: int, hour: int) -> None:
stamp = at(day, hour).timestamp()
monkeypatch.setattr(_clock, "now", lambda: stamp)
def test_idle_status_has_the_keys() -> None:
snap = fp.status(date(2026, 11, 10))
assert snap["milestones"] == []
assert snap["missed"] is False
def test_status_lists_milestones_and_goes_red(monkeypatch: pytest.MonkeyPatch) -> None:
make()
_now(monkeypatch, 10, 9)
snap = fp.status(date(2026, 11, 10))
assert snap["missed"] is False
assert snap["milestones"][0]["id"] == "rules"
assert set(snap["milestones"][0]) == {"id", "due", "state"}
_now(monkeypatch, 11, 9)
snap = fp.status(date(2026, 11, 11))
assert snap["missed"] is True
assert snap["milestones"][0]["state"] == "missed"
assert snap["milestones"][0]["due"] == "2026-11-10T23:59:00"
def test_status_stays_red_on_test_day(monkeypatch: pytest.MonkeyPatch) -> None:
make()
_now(monkeypatch, 27, 9)
assert fp.status(date(2026, 11, 27))["missed"] is True
assert fp.owed_lock(at(27, 9)) is None
def test_status_survives_unreadable_evidence(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
make()
_now(monkeypatch, 11, 9)
def boom(project_id: str) -> list[evidence.Evidence]:
msg = "disk"
raise OSError(msg)
monkeypatch.setattr(evidence, "read", boom)
with caplog.at_level("WARNING"):
snap = fp.status(date(2026, 11, 11))
assert snap["milestones"] == []
assert snap["missed"] is False
assert "evidence unreadable" in caplog.text
assert fp.owed_lock(at(11, 9)) is None
def test_isolation_canary(tmp_path: Path) -> None:
make()
assert not (tmp_path / "data" / "evidence").exists()

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "focus_project"
version = "0.8.0"
version = "0.10.0"
description = "Time-boxed focus projects: replace chosen gates, pay a daily-minutes gate"
requires-python = ">=3.14"
# Stdlib only on purpose: guards import it from the system python.

View File

@ -112,8 +112,10 @@ repos:
- id: pylint
args:
- --rcfile=leetcode_coach/pyproject.toml
- --fail-under=8.0
- --jobs=4
- --fail-under=10
# --jobs=1: pylint's parallel mode pickles its state with dill,
# which crashes on Python 3.15 (no code.co_lnotab).
- --jobs=1
- 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

View File

@ -15,6 +15,7 @@ write is logged and retried in order; the relay never exits over it.
from __future__ import annotations
import dataclasses
import functools
import json
import logging
@ -54,6 +55,14 @@ def _pairs(event: sse.Event) -> list[tuple[list[str], object]]:
return [(base, event.data)]
@dataclasses.dataclass
class _ChatMirror:
"""Which session's chat RTDB holds, and the line keys already sent."""
sid: str | None = None
sent: set[str] = dataclasses.field(default_factory=set)
class PhoneLink:
"""Mirrors the relay into RTDB and feeds RTDB requests to the lane."""
@ -78,15 +87,14 @@ class PhoneLink:
self._rescan_every = rescan_s
self.outbox = Outbox()
self._mirror_lock = threading.Lock()
self._chat_sid: str | None = None
self._sent: set[str] = set()
self._chat = _ChatMirror()
def attach(self) -> None:
"""Listen to the lane and the backend; then mirror everything once."""
self.lane.on_result.append(self.on_result)
self.lane.on_busy.append(lambda _busy: self.beat())
self.coach.listeners.append(self.on_state)
self._chat_sid = None
self._chat.sid = None
self.on_state(self.coach.state(), self.coach.session)
def beat(self) -> None:
@ -111,18 +119,18 @@ class PhoneLink:
text = json.dumps(state, ensure_ascii=False)
with self._mirror_lock:
sid = None if session is None else session.sid
if sid != self._chat_sid:
if sid != self._chat.sid:
self.outbox.put(
"chat reset", functools.partial(self.rtdb.delete, "chat")
)
self._chat_sid, self._sent = sid, set()
self._chat = _ChatMirror(sid)
lines = {} if session is None else dict(session.chat)
new: dict[str, object] = {
k: v for k, v in lines.items() if k not in self._sent
k: v for k, v in lines.items() if k not in self._chat.sent
}
if new:
self.outbox.put("chat", functools.partial(self.rtdb.patch, "chat", new))
self._sent |= set(new)
self._chat.sent |= set(new)
put = functools.partial(self.rtdb.put, "state", text)
self.outbox.put("state", put, latest="state")
self.beat()

View File

@ -8,10 +8,18 @@ mechanically (no model, nothing to fail open), and the verdict rides the same
from __future__ import annotations
import re
from typing import Final
from typing import Final, NamedTuple
from leetcode_coach import req_check
class Facet(NamedTuple):
"""One part of an output shape: how to spot it, and what to ask for."""
pattern: re.Pattern[str]
hint: str
VIA: Final = "shape"
SHAPE_RE: Final = re.compile(r"^\s*shape\s*:", re.IGNORECASE)
SQL_LANGS: Final = frozenset(
@ -20,15 +28,15 @@ SQL_LANGS: Final = frozenset(
LANGUAGES_RE: Final = re.compile(r"^Languages available:(.*)$", re.MULTILINE)
TAGS_RE: Final = re.compile(r"^Tags:(.*)$", re.MULTILINE)
FACETS: Final = {
"columns": (
"columns": Facet(
re.compile(r"\b(?:columns?|fields?|headers?|named)\b", re.IGNORECASE),
"which columns come back, named how",
),
"rows": (
"rows": Facet(
re.compile(r"\b(?:rows?|records?|per)\b", re.IGNORECASE),
"what one row stands for (one row per ...)",
),
"grouping": (
"grouping": Facet(
re.compile(
r"\b(?:group\w*|aggregat\w*|distinct|unique|per|sum|count|ungrouped)\b",
re.IGNORECASE,
@ -49,7 +57,9 @@ def is_sql(statement: str) -> bool:
def missing_facets(text: str) -> tuple[str, ...]:
return tuple(name for name, (found, _) in FACETS.items() if not found.search(text))
return tuple(
name for name, facet in FACETS.items() if not facet.pattern.search(text)
)
def check(text: str) -> req_check.Check:
@ -58,4 +68,4 @@ def check(text: str) -> req_check.Check:
def hints(missing: tuple[str, ...] | list[str]) -> list[str]:
return [f"{name}: {FACETS[name][1]}" for name in missing if name in FACETS]
return [f"{name}: {FACETS[name].hint}" for name in missing if name in FACETS]

View File

@ -5,7 +5,7 @@
# none of them at import time.
bandit[toml]==1.9.4
codespell==2.4.3
coverage==7.17.0
coverage==7.17.2
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
genanki==0.13.1
mypy==2.4.1
@ -13,8 +13,8 @@ pre-commit==4.7.0
pylint==4.1.3
pytest==9.2.0
pytest-cov==7.1.0
pytest-randomly==5.0.0
pytest-randomly==5.1.0
pytest-xdist==3.8.0
requests==2.34.2
ruff==0.17.3
ruff==0.17.4
types-requests==2.33.0.20261030

View File

@ -132,10 +132,7 @@ def rewrite(text: str, old: Path, new: Path) -> str:
#: The remaining files are plain reference docs, renamed mechanically to
#: DOCS-<existing-slug>.md. The one exception is the dopamine-ux programme:
#: 00-INDEX records 8 of its 9 parts as "not started", so those parts are
#: outstanding work and become TODO, not DOCS. The index itself is a record.
AUTO_TASK_PREFIXES = ("utils/dopamine-ux/0",)
#: DOCS-<existing-slug>.md.
def slugify(stem: str) -> str:
@ -150,8 +147,6 @@ def auto_name(rel: str) -> tuple[str, str]:
# A misspelled README is a README, not a doc needing a new namespace.
if stem.lower() == "readme":
return "README.md", "record"
if rel.startswith(AUTO_TASK_PREFIXES) and "INDEX" not in stem:
return f"TODO-{slugify(stem)}.md", "task"
return f"DOCS-{slugify(stem)}.md", "record"

View File

@ -42,7 +42,6 @@
"testsAndMisc/docs/shell-split-verification.md": "DOCS-shell-split-verification.md",
"testsAndMisc/linux_configuration/docs/SUMMARY.md": "DOCS-pacman-security-layers.md",
"utils/gatelock/docs/scrollable-viewport.md": "DOCS-scrollable-viewport.md",
"utils/dopamine-ux/00-INDEX.md": "DOCS-dopamine-ux-index.md",
"digital-wellbeing/llm-notes/units-and-tasks.md": "DOCS-shutdown-units-reference.md",
"digital-wellbeing/README_COMPULSIVE_BLOCK_LLM.md": "DOCS-compulsive-block-reference.md",
"digital-wellbeing/README_MIDNIGHT_SHUTDOWN_LLM.md": "DOCS-midnight-shutdown-reference.md",

View File

@ -90,12 +90,6 @@ def test_auto_name_fixes_readme_casing() -> None:
assert auto_name("x/Readme.md") == ("README.md", "record")
def test_dopamine_ux_parts_are_tasks_but_index_is_not() -> None:
"""00-INDEX records the programme; 01-09 are its unstarted parts."""
assert auto_name("utils/dopamine-ux/03-diet-guard.md")[1] == "task"
assert auto_name("utils/dopamine-ux/00-INDEX.md")[1] == "record"
def test_classified_paths_do_not_overlap() -> None:
"""A file cannot be both a task and a record."""
assert not set(classify.TASKS) & set(classify.RECORDS)

View File

@ -93,6 +93,14 @@ web-relevant file is staged (`web.DEFAULT_FILES`, overridable with
degrades to a pass, and the gate defers to that workflow when run inside
another GitHub Actions job.
## Flutter apps: no Google non-free sign-in
A repo with a `pubspec.yaml` beside an `android/` dir (root or one level
down) also gets `scripts/check_no_gms_signin.sh` (a shim) and the always-run
`no-gms-signin` hook (`install_gms.py`). The shared gate fails on
google_sign_in / play-services-auth / googleid in pubspec.lock or gradle, or
in a built APK with `--apk`; apps use `google_account_token` instead.
## Flutter apps: the launcher rules
Decided by kuhy 2026-10-23 (`launcher.py`). They apply to every tracked

View File

@ -25,6 +25,7 @@ from dep_freshness.install import NO_WORKFLOW_MARKER
from repo_contract import bootstrap
from repo_contract.check import violations
from repo_contract.infer import propose
from repo_contract.install_gms import install_gms, plan_gms
from repo_contract.install_web import install_web, plan_web
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
from repo_contract.stacks import detect_stacks
@ -64,6 +65,7 @@ def plan(repo: Path) -> list[str]:
A website (detected or declared in repo-contract.toml) also gets the
AgentReady gate: config, shim, workflow and hook (see install_web.py).
A Flutter Android app also gets the no-GMS-sign-in gate (install_gms.py).
"""
todo = []
if _needs_shim(repo):
@ -72,7 +74,7 @@ def plan(repo: Path) -> list[str]:
todo.append(str(WORKFLOW))
if needs_hook(repo, HOOK_ID):
todo.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
return todo + plan_web(repo)
return todo + plan_web(repo) + plan_gms(repo)
def install(repo: Path) -> list[str]:
@ -92,7 +94,7 @@ def install(repo: Path) -> list[str]:
if needs_hook(repo, HOOK_ID):
write_hook(repo, _template("pre-commit-snippet.yaml"))
done.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
return done + install_web(repo)
return done + install_web(repo) + install_gms(repo)
def bootstrap_commands(repo: Path) -> list[str]:

View File

@ -0,0 +1,62 @@
"""The no-GMS-sign-in half of the installer: Flutter Android apps get the gate.
Why it rides on install_repo_contract_gate.sh: that installer is the one step
every new repo gets after `git init`, so a new Flutter app cannot come out of
scaffolding able to re-add google_sign_in (13 apps were migrated off it onto
utils/google_account_token in November 2026). Pieces: the
scripts/check_no_gms_signin.sh delegate and an always-run pre-commit hook.
Only repos with a pubspec.yaml next to an android/ dir (at the root or one
level down, e.g. tutor/app) get it; utils itself holds the real gate.
"""
from __future__ import annotations
from pathlib import Path
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
TEMPLATES = Path(__file__).resolve().parent / "templates"
SHIM = Path("scripts/check_no_gms_signin.sh")
HOOK_ID = "no-gms-signin"
HOOK_PIECE = f"{PRECOMMIT} ({HOOK_ID} hook)"
SHIM_TEMPLATE = "check_no_gms_signin.shim.sh"
def _template(name: str) -> str:
return (TEMPLATES / name).read_text(encoding="utf-8")
def is_flutter_android(repo: Path) -> bool:
"""A pubspec.yaml beside an android/ dir, at the root or one level down."""
if (repo / "repo_contract" / "check.py").is_file():
return False # utils: SHIM is the real gate, never a delegate
pubspecs = [repo / "pubspec.yaml", *repo.glob("*/pubspec.yaml")]
return any(p.is_file() and (p.parent / "android").is_dir() for p in pubspecs)
def plan_gms(repo: Path) -> list[str]:
"""Which no-GMS-sign-in gate pieces are missing or drifted."""
if not is_flutter_android(repo):
return []
todo = []
shim = repo / SHIM
if not shim.is_file() or shim.read_text(encoding="utf-8") != _template(
SHIM_TEMPLATE
):
todo.append(str(SHIM))
if needs_hook(repo, HOOK_ID):
todo.append(HOOK_PIECE)
return todo
def install_gms(repo: Path) -> list[str]:
"""Write every missing or drifted no-GMS-sign-in piece. Returns them."""
todo = plan_gms(repo)
if str(SHIM) in todo:
target = repo / SHIM
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(_template(SHIM_TEMPLATE), encoding="utf-8")
target.chmod(0o755)
if HOOK_PIECE in todo:
write_hook(repo, _template("no-gms-signin-hook.yaml"))
return todo

View File

@ -0,0 +1,27 @@
#!/bin/bash
# ============================================================================
# Fail if this app links Google's non-free sign-in (google_sign_in,
# play-services-auth, googleid) instead of google_account_token.
#
# Thin delegate to the shared gate in ~/src/utils, which owns the package
# list and the fix message. Copy this file to scripts/check_no_gms_signin.sh
# in a repo; never copy the shared gate itself.
# ============================================================================
set -euo pipefail
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/src/utils}/scripts/check_no_gms_signin.sh"
main() {
if [[ ! -x "$SHARED_GATE" ]]; then
echo "Error: shared no-gms-signin gate not found at $SHARED_GATE" >&2
echo " Clone github.com/kuhyx/utils to ~/src/utils, or set" >&2
echo " UTILS_ROOT to where it lives." >&2
exit 1
fi
exec bash "$SHARED_GATE" "$@"
}
main "$@"

View File

@ -0,0 +1,12 @@
# Added under `repos: - repo: local: hooks:` by install_repo_contract_gate.sh
# for any repo with a pubspec.yaml beside an android/ dir (a Flutter app).
# No Google non-free sign-in: google_sign_in links play-services-auth /
# googleid into the APK; apps use utils/google_account_token instead.
# always_run rather than `files:`: a poisoned pubspec.lock can arrive by
# pull or merge, and a staged-only trigger would never look again.
- id: no-gms-signin
name: no google_sign_in / GMS auth (use google_account_token)
entry: scripts/check_no_gms_signin.sh .
language: system
pass_filenames: false
always_run: true

Some files were not shown because too many files have changed in this diff Show More