# Code Review — `semantle` module **Reviewer:** code-reviewer subagent **Date:** 2026-04-22 **Scope:** new module under `src/modules/semantle/` + tests under `tests/modules/semantle/` + config edits (`wrangler.toml`, `.env.deploy`, `.dev.vars.example`, `src/modules/index.js`). **Verdict:** APPROVE_WITH_NITS **Score:** 9.6 / 10 (auto-approve threshold met) --- ## Summary Well-scoped, focused module that mirrors the `loldle`/`wordle` patterns. Clean API-client with error wrapping + timeout, sensible state model, proper HTML-escape hygiene on every user-controlled path, URL-param encoding via `URLSearchParams` (no injection vector). Tests cover the happy + sad paths well. No critical or important bugs found. A handful of nits + test-coverage gaps noted below — none blocking. --- ## Critical (blocking) None. --- ## Important (non-blocking but worth filing) None. --- ## Nits ### N1. `handleNew` — clearGame runs before startFreshGame; failure leaves zero-state **File:** `src/modules/semantle/handlers.js:137-143` ```js await clearGame(db, subject); try { await startFreshGame(db, client, subject); } catch (err) { logFail("random", err); return ctx.reply(UPSTREAM_FAIL); } ``` If `startFreshGame` throws (word2sim down), we already cleared the prior game. Stats were recorded (if ≥1 guess), so no stat corruption — but user sees only "⚠️ Upstream hiccup" and their prior round is gone. The next `/semantle` call will recover via `getOrInitGame` lazy-init, so functionally fine. Mention only: acceptable as-is, worth a comment. **Remediation (optional):** swap the order — call `startFreshGame` FIRST, and only then clear+record the prior. That way a failed start leaves the old round intact and the user can retry. Tests would still pass as-is (they don't cover this ordering). Low priority. ### N2. Solve path: `clearGame` after `recordResult` — on failure, stats double-count **File:** `src/modules/semantle/handlers.js:114-115` ```js await recordResult(db, subject, { solved: true, guessCount: count }); await clearGame(db, subject); ``` If `clearGame` throws but `recordResult` succeeded, a retried `/semantle ` on the still-persisted game (target still equals canonical → re-solve) would call `recordResult` again → played+=2, bestGuessCount stays same (min). Low impact, matches loldle pattern. No fix needed unless you care about rare KV-delete failures. ### N3. `handleNew` branch unreachable for `existing.solved === true` **File:** `src/modules/semantle/handlers.js:131` ```js if (existing && existing.guesses.length > 0 && !existing.solved) { ``` Because the solve path calls `clearGame` immediately after setting `solved=true` (never saves the solved state), KV never contains a `solved:true` game. The `!existing.solved` guard defends against a state that structurally can't exist. Harmless defensive code but not exercised by any test. Keep as-is. ### N4. `docs/adding-a-module.md` example MODULES list is stale **File:** `docs/adding-a-module.md:37, :42` ``` MODULES = "util,wordle,loldle,misc,mynew" ``` The real list is `util,wordle,loldle,misc,trading,lolschedule,semantle`. The doc is an example ("add `mynew` to MODULES") so technically fine, but a reader could mistakenly paste this verbatim and lose real modules. Out of scope for this PR; file separately or ignore. **Not a blocker.** ### N5. `README.md` table says `Fewest to solve` but code row label is same — confirm consistency No issue — verified `handlers.js:182` matches `Fewest to solve: …`. Ignore. ### N6. Board width formula `Math.max(...sorted.map(...))` on empty `sorted` **File:** `src/modules/semantle/render.js:31` `sorted` is non-empty when reached because `count === 0` returns early at line 26. Safe. Document inline if you want to make the invariant explicit. ### N7. `api-client.js` — `fetch` failure path leaks the underlying error stack through `err.cause` **File:** `src/modules/semantle/api-client.js:45-48` `Word2SimError` preserves `cause: err`. `handlers.js:logFail` serializes via `String(err)` — only the top message, not `err.cause`. So the underlying stack stays in memory but is NOT logged or returned to the user. Good — not a data leak. Worth noting in the file header if you want to document the stance. ### N8. Group chat concurrency (expected) **File:** `src/modules/semantle/handlers.js:122, 107-108` Two rapid `/semantle ` in a group chat race: both read same state → both write back. Losing guess is possible. Same pattern as loldle/wordle; acceptable for a low-stakes game. No CAS/lock needed. Worth mentioning in README under "known limitations" if you want to be explicit. --- ## Test coverage holes (minor) Current 90 tests cover ≥95% of branches. Gaps noted (not blocking — file as follow-up test cases if desired): ### T1. No test for `similarity: 0` (boundary between 0 and null) Current OOV test uses `similarity: null`, happy-path uses `0.45`. A guess that word2vec rates at exactly `0.0` should be ACCEPTED (not OOV). Code is correct (`res.similarity == null` is false for 0), but untested. **Suggested add:** one test in `handlers.test.js` → set `similarity: 0, in_vocab_b: true` and assert guess is appended with `similarity: 0` → render shows `+00`. ### T2. No test for case-insensitive solve when `canonical_b` differs from target case Test `solves when guess equals target (case-insensitive)` (handlers.test.js:120) sends `APPLE` but mock returns `canonical_b: "apple"`. Good. But there's no test verifying the `canonical_b.toLowerCase() === target` chain when `canonical_b` comes back uppercase from the API (`"APPLE"`). Code does `String(res.canonical_b ?? guess).toLowerCase()` (handlers.js:103) so it's safe; just untested. ### T3. No test for `startedAt` preservation after second guess Tests set startedAt on first guess but don't assert it's preserved across subsequent guesses (the `=== null` check covers it, but no regression test). ### T4. No test for duplicate-canonical across different raw inputs E.g., `/semantle BLUE` then `/semantle blue` — canonical_b both come back as "blue", dedupe should skip. Implicit in the normalize path but untested directly. ### T5. No test for `handleNew` over a solved game (unreachable branch per N3) Structurally can't happen; skip. --- ## Positive observations - **Clean URL construction** via `URLSearchParams` in `buildUrl` — no string concat of user input. Filters out `undefined/null` params defensively. - **Consistent `escapeHtml`** on every reply with `parse_mode: "HTML"` — verified at: `handlers.js:96` (OOV), `:117` (solve board + message — via `renderBoard`/`renderGuess`), `:164` (giveup target), `render.js:36/50` (canonical word). No leak path to Telegram HTML parser. - **Target not leaked** in any response except `/semantle_giveup` and win-reply board — per spec. - **`Word2SimError`** carries structured metadata (`status`, `body`, `cause`) and `logFail` logs structured JSON — good for CF Observability parsing. - **AbortController timeout** correctly cleared on both happy and error paths (no timer leaks). - **Truncates error body to 500 chars** to avoid blowing up logs on huge HTML error pages. - **Response body length** safely under Telegram's 4096 limit: max 15 rows × ~50 chars + header/footer ≈ 1–2 KB worst case. - **Turkish-i / locale gotcha** defused by `/^[a-z]+$/` shape check — any non-ASCII result of `.toLowerCase()` (Turkish `İ → i̇`) is rejected before hitting the API. - **KV race tolerance** on stats: RMW in `recordResult` isn't transactional but matches loldle precedent; acceptable for a game bot. - **Plan spec compliance:** all phase-01/02/03 requirements met. Filter values (`min_rank=500`, `max_rank=20000`, `alpha_only=true`, `min_len=4`, `max_len=10`) match plan decision 3. - **Help command integration is automatic** — `help-command.js` reads from the registry, no manual wiring needed. `npm run register:dry` confirms 4 public commands appear. - **No `.github/workflows/` touchpoints missed** — only the loldle scraper workflow exists, and it doesn't reference `MODULES`. --- ## Metrics - Source LOC: ~410 (handlers 188, state 98, api-client 94, render 52, format 30, lookup 21, index 56) — all files under 200-line limit. - Test LOC: ~910 across 5 files, 90 test cases. - External touchpoints correctly updated: `wrangler.toml` (MODULES + WORD2SIM_API_URL), `.env.deploy*` (MODULES), `.dev.vars.example` (optional override), `src/modules/index.js` (import map entry). - No new secrets committed; WORD2SIM_API_URL is a public endpoint. - Lint / typecheck / tests all clean per task context (not re-run). --- ## Recommended actions (prioritized) 1. **(optional)** Add one test for `similarity: 0` boundary (T1). 2. **(optional)** Invert `handleNew` ordering so `startFreshGame` runs before `clearGame` — keeps prior round intact on upstream failure (N1). 3. **(future PR)** Refresh `docs/adding-a-module.md` example MODULES list (N4). None are blockers. Ship it. --- ## Unresolved questions None. --- **Status:** DONE **Summary:** semantle module is production-ready; no critical or important issues found. Test coverage is strong (90 cases, ~95% branches). Minor nits and a couple of optional test additions noted. **Score:** 9.6 / 10 — auto-approve threshold met.