Files
leetcode/AGENTS.md
T

11 KiB
Raw Blame History

Repository Guidelines

Project Overview

LeetCode interview-prep campaign (8 weeks, 2026-08-17 → Oct 11) run as a repo: solution files under work/, a Blume docs site under apps/docs/, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (apps/api/) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 topic issues own ~161 problem sub-issues (set:core|optional|deferred, diff:*); milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.

Architecture & Data Flow

Three owners, one direction of truth:

flowchart LR
  work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx]
  work -->|close-solved.yml| PI[problem issues]
  work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)]
  PI -->|close-topics.yml| TI[topic issues]
  GH[GitHub issues = catalog] -->|nightly reconcile| D1
  D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
  Mail -->|GET /log| D1
  PI -->|/done webhook| D1
  D1 -->|best-effort mirror| Proj[GitHub Project fields]
  D1 -->|Sat midnight ET| Review[Review — Week N issue]
  D1 --> Charts[SVG charts + /api/stats]
  • Reconcile, don't react. close-solved.ts, close-topics.ts, and the Worker's catalog sync recompute desired state from scratch each run: re-runs are no-ops, backfills need no special casing, closing is one-directional. close-solved posts its whole implemented set to /admin/solved, not just the issues it closed this run.
  • Solving has three doors, one write path. A digest one-tap, a /done comment, and a solution landing in work/ all end in logAttempt(). Closing an issue is not one of them: the catalog reconcile never reads issue state, so a work/ push that skipped the email would otherwise leave stage='new' — invisible to the charts, the digest's solved ticks, and the drill/gate pools. Hence /admin/solved (source='commit', first solve only, no index needed for idempotency: it refuses any problem past stage new). close-topics needs no such call — D1 stores no topic completion, only problem rows.
  • The README charts are live, never committed. They are Worker endpoints reading D1 per request (Cache-Control: max-age=300, honored by GitHub Camo), so a solved push moves them within ~5 minutes with no commit and no workflow of their own.
  • D1 owns SRS state; GitHub issues own the catalog; apps/api/data/schedule.json owns the calendar. The catalog reconcile never invents rows and never overwrites SRS columns (stage, next_review). Project fields (Target Date, SRS Stage, First Attempt) are a best-effort mirror (apps/api/src/mirror.ts): failures are warnings, never lost D1 writes; topic rows' Target Date is never written.
  • Determinism = idempotency. Drill/gate sampling uses a seeded PRNG (rng() in apps/api/src/srs.ts, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in email_log; one-tap links are unique on (problem, date, kind).
  • DST-proof crons: each event has two UTC crons; code fires only on the computed ET hour (etHour), unit-checked in apps/api/src/srs.test.ts.
  • Chained workflows: GITHUB_TOKEN-driven issue closes fire no issues events, so close-topics.yml chains off workflow_run of Close Solved instead. The Worker's webhook uses its own GH_PAT, so its events flow normally.

Key Directories

Path Purpose
work/<Difficulty>/<Category>/<num>.<slug>.{js,py} Solutions, e.g. work/Easy/Array/1.two-sum.py. Machine-parsed header comment (title / Difficulty: / URL / rule / statement) — preserve its exact shape
apps/cli/ Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root bun run scripts delegate here
apps/docs/ Blume site (blume.config.ts, content/, islands/, public/). content/(<category>)/<num>-<slug>.mdx generated by sync
apps/api/ Cloudflare Worker workspace: src/ modules, data/schedule.json (day → topic issue, human-edited, bundled at deploy), migrations/, scripts/import-srs.ts
.github/workflows/ deploy (docs → Pages, Worker → Cloudflare, on every main push), close-solved (issues + /admin/solved, needs SRS_ADMIN_KEY), close-topics, sync-d1 (issue edits → /admin/reconcile)

Development Commands

All from the repo root (a Bun workspace over apps/*):

bun run pick            # scaffold a solution via leetcode-cli (fuzzy picker)
bun run test            # run ONE solution against LeetCode's judge (not a test suite)
bun run sync            # work/ → apps/docs/content/ pages
bun run dev|build       # Blume docs site (runs in apps/docs/)
bun run close-solved -- --dry-run    # issue reconcilers (also DRY_RUN=1)
bun run close-topics -- --dry-run
# close-solved also pushes the solved set to the Worker; point it at a local
# one and nothing production is touched:
SRS_API=http://localhost:8787 SRS_ADMIN_KEY=$LINK_KEY bun run close-solved -- --dry-run
bun run api:dev         # wrangler dev on :8787 (local D1); api:test = DST guard tests
bun run api:deploy      # deploy the Worker by hand (CI also deploys on main pushes)
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
  "localhost:8787/admin/digest?dry=1&date=2026-08-30"   # preview a digest, send nothing

Code Conventions & Common Patterns

  • Scripts are either entries or libraries. Entries (apps/cli/close-*.ts) use shebang + top-level await. Libraries (apps/cli/github.ts, apps/cli/report.ts, everything in apps/api/src/) are side-effect-free on import — nothing touches network/credentials until a factory (github(), projectMirror()) is called.
  • Shared plumbing: github() gives repo, api() (throws METHOD path -> status body), list() (paginating async generator), closeIssue() (comment first, then close — a failed PATCH still leaves a trace). report.ts gives printTable/markdownTable/writeStepSummary.
  • Narrow API payloads with guards, not inline casts: if (!("number" in value) || typeof value.number !== "number") return; (see readProblemIssue in close-solved.ts). Named-const casts only with a reason.
  • Dry-run everything: reconcilers take --dry-run/DRY_RUN=1; Worker admin routes take ?dry=1&force=1&date= (the SRS_TODAY equivalent) and wrangler dev runs against local D1 — exercise logic there before touching production state.
  • Dates: always ET calendar strings (YYYY-MM-DD), arithmetic anchored at noon UTC (atNoon in apps/api/src/srs.ts) to dodge DST. Never new Date() math directly.
  • Style: section-divider comments (// ── name ───), file-top doc comments explaining the why and invariants, 2-space JSON with trailing newline, Map for dynamic keys / Record for static tables, no tiny one-expression wrapper functions.
  • Known intentional duplication: close-solved.ts re-implements header stripping instead of importing from sync.ts because sync.ts runs its pipeline on import. Don't "deduplicate" it.

Important Files

  • apps/api/src/srs.ts — SRS domain: ladder (new → +2 → +5 → +10 → retired; fail resets to +2; first-ever log enters at +2), ET date math, seeded sampling, logAttempt() (the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at most REVIEW_CAP (3) reviews surface per day — levelReviews() (run by sendDigest on real sends only, never dry) gives everything past the cap a concrete future date, ≤ 3 per day, oldest first, instead of letting the due pile grow.
  • apps/api/src/index.ts — router + cron dispatch; apps/api/wrangler.jsonc — bindings (DB, EMAIL), crons, vars; secrets GH_PAT/WEBHOOK_SECRET/LINK_KEY via wrangler secret put.
  • apps/api/src/digest.ts / apps/api/src/email.tsx — the daily digest, split data/presentation. digest.ts reads D1 into a DigestData; email.tsx owns every colour and every sentence, and renders both the HTML and (via its own plainDigest, not React Email's plainText mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: DigestRow has no title and no issue field, so a review or drill line cannot leak the topic or a solution link. Subject is (Day N/56) LeetCode Daily Digest.
  • apps/api/src/png.ts — hand-rolled PNG encoder (RGB8, one IDAT, zlib via CompressionStream("deflate"), CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so /chart/heatmap.png rasters the heatmap for the digest while /chart/heatmap.svg keeps serving the README byte-for-byte. Both come from one heatmapCells() so the two pictures cannot drift.
  • apps/cli/sync.ts — work→docs contract: only ## Solution onward is script-owned on existing pages; human prose is never touched; never hand-write solution pages or edit inside ## Solution.
  • README.md live charts are Worker endpoints (/chart/*.svg, /badge/gate.svg, 5-min Camo cache); the docs /progress page fetches /api/stats client-side (apps/docs/islands/ProgressDashboard.tsx).
  • apps/docs/blume.config.ts — site base /leetcode; README.md campaign table doubles as topic-map input to sync.ts (readmeTopics(), TOPIC_ALIASES).
  • Old .github/srs/ JSON state and the srs-* Actions are RETIRED — do not resurrect; apps/api/scripts/import-srs.ts documents the migration.

Runtime/Tooling Preferences

  • Bun only: run scripts with bun apps/cli/<name>.ts (or the root bun run aliases), install with bun install --frozen-lockfile. One workspace (apps/*), one root bun.lock. Node 22 appears only in deploy.yml because Blume requires it.
  • No root tsconfig, no ESLint, no Prettier — match surrounding style by hand. apps/api/ has a strict tsconfig.json; Env types are generated (bunx wrangler types), never hand-written.
  • Near-zero runtime npm dependencies — SVG, PNG, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + fetch only. The one exception is the digest email: apps/api/src/email.tsx renders React Email (react, react-dom, @react-email/components, @react-email/render) inside the Worker, because hand-rolling table-based email HTML that survives Gmail and Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no nodejs_compat (it resolves to renderToReadableStream), and requires "jsx": "react-jsx" in apps/api/tsconfig.json. Do not extend this exception to any other module.
  • Local GitHub auth falls back to gh auth token; scripts run fine outside Actions.

Testing & QA

  • Repo-wide: no test framework — bun run test submits one solution to LeetCode's judge. Exception: apps/api/src/srs.test.ts (bun:test) proves the DST cron guards with fixed dates; run via bun run api:test.
  • QA is dry-runs + local state: Worker logic against wrangler dev + local D1 with ?dry=1&date=; reconcilers with --dry-run; idempotency check = run twice, cmp output.
  • Docs changes: bunx blume build --isolated (from apps/docs/) must pass with no new warnings (then rm -rf apps/docs/.blume-verify); after sync, review only created pages (prose transforms are heuristic).