9.2 KiB
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
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
await recordResult(db, subject, { solved: true, guessCount: count });
await clearGame(db, subject);
If clearGame throws but recordResult succeeded, a retried /semantle <target> 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
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 <different_word> 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
URLSearchParamsinbuildUrl— no string concat of user input. Filters outundefined/nullparams defensively. - Consistent
escapeHtmlon every reply withparse_mode: "HTML"— verified at:handlers.js:96(OOV),:117(solve board + message — viarenderBoard/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_giveupand win-reply board — per spec. Word2SimErrorcarries structured metadata (status,body,cause) andlogFaillogs 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
recordResultisn'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.jsreads from the registry, no manual wiring needed.npm run register:dryconfirms 4 public commands appear. - No
.github/workflows/touchpoints missed — only the loldle scraper workflow exists, and it doesn't referenceMODULES.
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)
- (optional) Add one test for
similarity: 0boundary (T1). - (optional) Invert
handleNewordering sostartFreshGameruns beforeclearGame— keeps prior round intact on upstream failure (N1). - (future PR) Refresh
docs/adding-a-module.mdexample 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.