Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .claude/hooks/format-lint-python.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,33 @@ elif command -v ruff &>/dev/null; then
ruff format "$FILE" 2>/dev/null || true
ruff check --fix "$FILE" 2>/dev/null || true
fi

# The project's own rules (lint/rules/), on the one file that just changed. Same
# rules ./doit.sh lint runs over everything, so the hook cannot drift from CI.
# Each rule decides whether the file is in its own scope.
command -v uv &>/dev/null || exit 0

# An environment that cannot run anything (fresh clone, offline uv cache) would
# otherwise fail every rule and block every edit on a uv traceback. The exit code
# cannot tell that apart afterwards: a rule that raises also exits 1. The ruff
# steps above skip for the same reason.
uv run python -c "" &>/dev/null || exit 0

FAILURES=""
collect() {
OUTPUT=$("$@" 2>&1) || FAILURES+="$OUTPUT"$'\n'
return 0
}

for RULE in lint/rules/*.py; do
[ -e "$RULE" ] || continue
collect uv run python "$RULE" "$FILE"
done
if compgen -G "lint/rules/*.yml" >/dev/null; then
collect uv run ast-grep scan "$FILE"
fi
if [[ -n "$FAILURES" ]]; then
printf '%s\n' "$FAILURES" >&2
exit 2
fi
exit 0
45 changes: 45 additions & 0 deletions .claude/rules/docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
paths:
- "docs/**/*.md"
- "README.md"
- ".claude/**/*.md"
---

# Documentation conventions

For *where* a fact belongs (docs vs rule vs skill vs hook vs ADR), load the
**ojhunt-update-env** skill. This rule covers how to write once you know the target.

## Write only what cannot be derived

Two questions kill most draft lines:

- Can an agent derive this by opening one file? Delete the line.
- Does a test, hook, or linter already assert it? Delete the line.

Docs capture intent — why a construct is load-bearing, why the obvious alternative is
wrong. Before writing a rule, try to make a test assert it instead: a test fails when
somebody breaks the rule, a doc line does not.

**Prefer deleting to adding.** Appending a clarification is the reflex; usually the file reads
better with the wrong line gone. A false observation — a flaky test that was really a bad
connection, a gotcha that was really local misconfiguration — is deleted, not corrected. A
paragraph saying an earlier paragraph was wrong leaves the reader two claims to adjudicate.

## README.md is a quick start

Keep it short and direct: what the tool is, how to run it, one example per interface.
Everything else is a link.

When a section grows past a screen, move the detail into `docs/` and leave a pointer.
Reference material belongs in [`docs/cli.md`](../../docs/cli.md),
[`docs/web.md`](../../docs/web.md) and [`docs/library.md`](../../docs/library.md) — not in
the README.

`docs/library.md` is generated by `./doit.sh gen-docs`: write library-facing prose in the
docstrings it compiles, never in the file itself. A unit test fails while the committed copy
is stale.

To check whether it is stale, run the task and read `.doit/gen-docs.log`: never suppress the
generator's stderr, because a crash leaves the file untouched and so looks exactly like
"already fresh".
7 changes: 6 additions & 1 deletion docs/dev/e2e.md → .claude/rules/e2e.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
---
paths:
- "tests/e2e/**/*.py"
---

# E2E tests (Playwright)

See [`docs/dev/testing.md`](testing.md) for shared pytest fixture and assertion conventions.
See [`tests.md`](tests.md) for shared pytest fixture and assertion conventions.

## Setup

Expand Down
30 changes: 30 additions & 0 deletions .claude/rules/hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
paths:
- ".claude/hooks/**"
- ".claude/settings.json"
---

# Project hooks

Hooks decide *when* a check runs. Check `.claude/settings.json` to see what is currently
active.

## Location

Scripts live in `.claude/hooks/`, wired in `.claude/settings.json` (checked
into git — not `settings.local.json`, not `~/.claude/`).

## Keep the check out of the hook

`format-lint-python.sh` runs ruff and then every rule in `lint/rules/` over the file that
changed. What each rule checks is defined there, not here, so the hook and `./doit.sh lint`
can never disagree. Adding a check to the hook alone gives you a rule that CI does not run and
nobody can test — write it in `lint/rules/` instead (see `.claude/rules/lint-rules.md`).

## Regex gotcha for command bans

Do NOT use `(^|[;&|[:space:]])word[[:space:]]` — this matches the word inside
quoted strings and heredocs (e.g. commit messages containing the banned word).

Use `^[[:space:]]*(sudo[[:space:]]+)?word[[:space:]]` to only match when the
word is the actual command at the start of the line.
51 changes: 51 additions & 0 deletions .claude/rules/lint-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
paths:
- "lint/**"
- "tests/lint/**"
---

# Project lint rules

Rules the project enforces on its own code, beyond what ruff covers. They live in
`lint/rules/`, run from `./doit.sh lint`, and run again from the edit-time hook on the single
file that changed — one definition, so the hook cannot drift from CI.

```bash
./doit.sh lint # every rule over the whole repo
./doit.sh lint-rules # the rules' own tests
uv run python lint/rules/<rule>.py <file>... # one rule, one file
```

## When a convention earns a rule

A rule pays for itself when the convention is objective and somebody will break it without
noticing. If judging a case needs context the parser does not have, leave the convention in
`.claude/rules/*.md` as prose — a rule that misfires teaches everyone to ignore it.

Before writing prose, check whether a rule can assert it instead: a rule fails when somebody
breaks the convention, a paragraph does not.

## Pick the form

| Form | Use when | Tests |
|------|----------|-------|
| `lint/rules/<id>.yml` | The match is purely structural — a node shape, nesting, a pattern | `lint/rule-tests/<id>-test.yml` |
| `lint/rules/<id>.py` | The match needs source position, file paths, or cross-file state | `tests/lint/<id>_test.py` |

Position is the usual reason to reach for Python. A YAML rule sees the parse tree, where a
trailing comment and an own-line comment are the same shape; only line and column separate
them. `comment_above_assert.py` exists for exactly that reason — its docstring says so.

A Python rule is a script: `find_violations(source, path)` for tests to call, and a `main()`
that takes file arguments, prints to stderr and returns 1 on a finding. Copy the shape from
`lint/rules/comment_above_assert.py`.

For ast-grep's rule syntax, node kinds and relational operators, query the ast-grep
documentation through Context7 rather than looking for it here.

## Every rule ships with its tests

`tests/lint/rules_are_tested_test.py` fails when a rule has no test file, so this is not a
convention you can forget. Cover the edge cases, not just the happy path — for anything
position-sensitive, pin **which line** each case is blamed on, and include the cases that must
stay silent.
18 changes: 13 additions & 5 deletions docs/dev/python.md → .claude/rules/python.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
---
paths:
- "**/*.py"
- "pyproject.toml"
---

# Python conventions

## Import order
Expand Down Expand Up @@ -30,7 +36,7 @@ Do not use `Dict`, `List`, `Optional` or `Union` from `typing` — ruff rewrites
(`UP006`, `UP007`, `UP035`, `UP045`), and the `format-lint-python.sh` hook applies that
rewrite on every edit. Take `Callable` and `Awaitable` from `collections.abc`, not `typing`.

See [ADR 0016](../adr/0016-adopt-ruff-default-rule-set.md) for why this reversed.
See [ADR 0016](../../docs/adr/0016-adopt-ruff-default-rule-set.md) for why this reversed.

## Prefer asserts and names over comments

Expand All @@ -45,7 +51,9 @@ Asserts are for what the code guarantees itself. Data arriving from outside —
argument, a judge's response — needs a real `raise`, because `python -O` strips asserts.

In tests, put the explanation in the assert message, not in a comment above it:
`assert "items" in listed, "dict's own attributes must survive"`.
`assert "items" in listed, "dict's own attributes must survive"`. This is enforced —
`lint/rules/comment_above_assert.py` flags a comment beside an `assert`, whether it sits
above the line or trails it.

Where a comment would explain *what* an expression produces, name the value instead:
`single_line_description = " ".join(text.split())`.
Expand All @@ -67,9 +75,9 @@ resolve. This applies to all packages including FastAPI, uvicorn, etc.

## Ruff removes imports before their usage

Ruff (pre-commit hook) runs between edits and strips imports that appear unused at
that moment. If you add an import in one `Edit` call and the code that uses it in a
second call, ruff will delete the import between the two calls.
Ruff runs between edits and strips imports that appear unused at that moment. If you add an
import in one `Edit` call and the code that uses it in a second call, ruff will delete the
import between the two calls.

**Fix:** always add new imports and the code that uses them in the same `Edit` call.

Expand Down
18 changes: 16 additions & 2 deletions docs/dev/testing.md → .claude/rules/tests.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
---
paths:
- "**/*_test.py"
- "tests/**/*.py"
---

# Pytest conventions

Crawler-specific test conventions live in [`docs/dev/crawlers.md`](crawlers.md) and the
**ojhunt-crawlers** skill. Playwright e2e tests live in [`docs/dev/e2e.md`](e2e.md).
Crawler-specific test conventions live in [`docs/dev/crawlers.md`](../../docs/dev/crawlers.md)
and the **ojhunt-crawlers** skill. Playwright e2e tests have their own rule in
[`e2e.md`](e2e.md).

## Running tests

Expand Down Expand Up @@ -47,6 +54,13 @@ client = TestClient(app, follow_redirects=False)
files = {"field": ("name.pdf", bytes_content, "application/pdf")}
```

## Prefer discovery over a hardcoded list

A test that enumerates today's routes, crawlers, or files goes stale in silence: the thing it
was meant to catch is exactly the new entry nobody added to the list. Walk the router or glob
the directory instead. When a shape genuinely cannot be discovered, assert that no such shape
exists yet, so the day one appears the suite says so.

## Markdown doc tests

Python fenced blocks in `README.md` and `docs/` are collected by `pytest-markdown-docs`
Expand Down
97 changes: 97 additions & 0 deletions .claude/rules/web.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
---
paths:
- "src/ojhunt/web/**"
---

# Web layer conventions

Dev server lifecycle and environment variables are operational, not code conventions —
they live in [`docs/dev/web.md`](../../docs/dev/web.md). For user-facing usage see
[`docs/web.md`](../../docs/web.md).

## PDF internals

- `extract_data(pdf_bytes)` returns `PdfEmbeddedData` — has `settings` and `history` only.
It does **not** have a `snapshot`; the snapshot is never embedded in the PDF. Build one
manually from history if needed.
- For page routes returning `application/pdf` on success and HTML on error: return explicit
`Response(content=..., media_type="application/pdf")` or `HTMLResponse(...)` — do not use
`response_class=HTMLResponse` on the decorator.
- When adding form-based page endpoints accessible to agents, document them in `llms.txt`.

## Minimal JS principle

Keep JavaScript minimal. Business logic belongs in Python; JS handles only browser-specific
concerns.

- Day boundary / timezone computation → backend (frontend sends IANA timezone string)
- History merge/dedup → backend
- Pydantic schemas on all new API endpoints (not loose dicts)
- Prefer a POST to the server over inline JS computation

JS is appropriate for: reading local files, computing timezone name via
`Intl.DateTimeFormat().resolvedOptions().timeZone`, triggering downloads, reactive UI state.

## FastAPI trailing slashes + StaticFiles

When `StaticFiles` is mounted at `"/"`, FastAPI's `redirect_slashes` is suppressed.
`Mount("/")` matches every path first and returns 404 for paths that aren't real files —
the redirect never fires.

- `fetch()` URLs in `app.js` must exactly match the route path in `api.py` (no trailing
slashes unless the route has one)
- HTML `href` attributes are harmless (browsers follow 307 redirects), but `fetch()` calls
can silently fail because StaticFiles returns a non-JSON 404 body that breaks `response.json()`

## Reverse-proxy rate limiting (429)

The app itself never emits HTTP 429 — rate limiting is enforced by the production reverse
proxy, and its 429 body is **not** JSON. Any client `fetch` that reaches the server can
therefore receive a 429 with an HTML/plain-text body.

- Client `fetch` handlers must special-case `response.status === 429` **before** calling
`response.json()` — otherwise the non-JSON body makes `response.json()` throw and the user
sees a confusing parse error instead of a rate-limit message.
- Established guards live in `app.js`: `executeQuery`, `calculateReport`, `downloadReport`.

## CSS conventions

CSS files live at `src/ojhunt/web/static/assets/`:

| File | What belongs here |
|------|-------------------|
| `base.css` | Design-system tokens (`:root`, `[data-accent=...]`), page resets, `.topbar`, `.page`, `.header`, `.footer`, `dialog`/`.dlg-*`, `.card` base layout (including `::before` stripe and `:hover`), `[x-cloak]`, `:focus-visible`, responsive media queries for shared components |
| `index.css` | Everything used *only* by the home page: `.step`, `.report-slot`, `.grid`, card internals (`.c-hd`, `.c-body-row`, `.c-ft`, `.c-err-msg`, `.solved-link`, `.subs-val`, `.iconbtn`, `.loading-dots`, `.card-empty`), card status variants (`.card.r-ok::before` etc.), `.download-card`/`.summary`/`.dc-*`/`.stat`, `.composer`, `.field`, `.btn` (all variants), `.step-actions` |

**Short templates** (crawlers, about, pdf pages) keep their inline `<style>` blocks — only extract when a template's styles grow long.

**The critical rule:** anything from the original `base.html.jinja` `<style>` block that *any* page besides `index.html` uses must stay in `base.css`. The `about` page uses `.card`, `.card::before`, and `.card:hover` — these are in `base.css`.

**Referencing CSS in templates:**

```html
<link rel="stylesheet" href="/assets/base.css?v={{ static_version }}">
```

`static_version` is a Jinja2 global injected via `jinja_env.globals["static_version"] = STATIC_VERSION` in `pages.py` — no need to pass it in individual `.render()` calls.

**Visual regression tests** live in `tests/e2e/test_visual.py` (local-only, skipped in CI). After any CSS change:

```bash
# Update baselines (dangerouslyDisableSandbox: true)
./doit.sh update-snapshots

# Verify no unintended visual diff (dangerouslyDisableSandbox: true)
./doit.sh test-visual
```

Baselines are stored in `tests/e2e/__snapshots__/`. Commit baseline PNGs alongside the test or CSS change that necessitates them.

## Project history context (for UI copy)

When writing UI copy that refers to "the old site":
- **Old site** = `github.com/Liu233w/acm-statistics` deployment (also known as *ACM Statistics*,
*OJ Analyzer*, *OJHunt*)
- **Not** npuacm.info (built by Jiduo Zhang; unrelated to this codebase)
- VPS compromise: October 2025 — data after 2025-10-22 was lost
- `legacy.db` preserves history up to 2025-10-22; web + CLI export available
2 changes: 1 addition & 1 deletion .claude/skills/ojhunt-crawlers/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ This skill is the **procedure**. The reference material it points to —
`__crawler_meta__` fields, login types, code templates, HTML parsing, SSL, license header,
archived-crawler rules — lives in **`docs/dev/crawlers.md`**. Read that alongside these steps.

For general pytest conventions see **`docs/dev/testing.md`**. For ADR guidance on significant
For general pytest conventions see **`.claude/rules/tests.md`**. For ADR guidance on significant
design decisions, invoke the **ojhunt-commit** skill.

## Implementing a New Crawler
Expand Down
5 changes: 3 additions & 2 deletions .claude/skills/ojhunt-implement/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ description: End-to-end feature workflow — plan (in plan mode) → approval

This skill is the **procedure** for taking a feature from request to committed code.
It only orchestrates — the *conventions* for each area (Python style, tests, web
layer, crawlers, …) live in `docs/dev/*.md`, indexed by the routing table in
layer, crawlers, …) load from `.claude/rules/*.md` on a file match, or sit in `docs/dev/*.md`
indexed by the routing table in
`docs/development.md`. Read the matching doc **before** you write code in that area.

## User request
Expand All @@ -22,7 +23,7 @@ make the change reviewable — don't skip ahead.
1. **Plan first, and get approval.** If you are not already in plan mode, enter it
(EnterPlanMode) — planning before editing is what lets the user redirect the
approach cheaply, before any code is written. Explore the relevant code, read the
matching `docs/dev/*.md`, then draft a concrete plan and present it for approval
matching rule or `docs/dev/*.md`, then draft a concrete plan and present it for approval
(ExitPlanMode). **Do not implement until the user approves** — the request above
is a starting point, not a mandate to build immediately.

Expand Down
Loading