Skip to content

feat(article): a MediaWiki client with nothing implicit - #51

Closed
Willi363363 wants to merge 1 commit into
feat/rewrite-phase-3-parityfrom
feat/rewrite-phase-3-mediawiki
Closed

Willi363363 wants to merge 1 commit into
feat/rewrite-phase-3-parityfrom
feat/rewrite-phase-3-mediawiki

Conversation

@Willi363363

Copy link
Copy Markdown
Owner

Step 3.2 of plans/rewrite/phase-03-article.md.

Stacked on #50. Base is feat/rewrite-phase-3-parity, so the diff here is step
3.2 alone.

The defect is worse than the sheet says

The sheet says the flag-report checker "silently queries Wikipedia in another
language depending on call order"
. The code says something sharper:
flag_verifier.py:27 never sets either global at all. Only scraper.py:96
does.

So on a freshly restarted process, before any game has been generated, a report
about a French article is fact-checked against the English Wikipedia — with
the wikipedia library's default user agent, which Wikimedia's policy refuses.
Not "depending on call order": English until the first game.

The same function also calls wikipedia.page(results[0]) without
auto_suggest=False, so a lookup can resolve to a different article than the one
searched for.

Recorded as D13 in the debt register and the contract's defect list. That
matters beyond bookkeeping: the D list is what gates entry into phase 10, and a
defect the plan mentions only in a phase sheet has nothing checking it was
closed.

Language and user agent, per call

There is no global to be wrong. The type will not let a caller omit either:

export interface WikiRequest {
  readonly language: string;
  readonly userAgent: string;
}

The language reaches a hostname, so it is validated rather than interpolated
— fr.evil.example and fr/../en are refused and nothing is sent. Same for a
blank user agent: a refused request must not reach Wikipedia at all, which the
test asserts by checking no URL was recorded.

Failure as a value, with reasons that differ

A caller has to tell "this topic does not exist" from "Wikipedia is down" —
the first means try another topic, the second means stop trying. The current code
wraps three nested try blocks around that distinction and then loses it to a
bare except Exception.

not_found · no_results · unreachable · rate_limited · unexpected_response

429 is its own reason rather than a generic error, so a retry loop can back off
instead of hammering Wikimedia.

The transport is injected

No test touches the network — and, more useful than that, every test can read
the URL that was built
. The bug being prevented is a request going to the wrong
Wikipedia, and the only way to check that is to look at where the request went:

expect(urls.map((url) => new URL(url).host)).toEqual([
  'fr.wikipedia.org', 'en.wikipedia.org', 'fr.wikipedia.org',
]);

Verified biting on both defects reproduced:

reproduced test that fails
a remembered language, first caller wins sends each call to the wiki it was asked for
a search parameter on the page lookup asks for the exact title, and follows redirects only

Redirects are followed, and that is not auto-suggestion: a redirect is a page
saying where it moved, not a guess about what you meant.

Checks

CI=true pnpm test   article 57 · db 61 · domain 233 · protocol 220 · config 19 · env 7
pnpm typecheck      6 packages
pnpm lint           6 packages
pnpm format:check
bash scripts/checks.sh staged

No new dependency — the client uses fetch.

One typing note: the test's fake transport is typed by typeof globalThis.fetch
rather than by naming RequestInfo, which is not available without a DOM lib this
package does not load.

Not run: the Python backend tests — no pytest here. This touches no Python.

Next

3.4 (falsification via generateObject), 3.5 (end-to-end parity), 3.6 (Redis
cache), 3.7 (counters).

🤖 Generated with Claude Code

https://claude.ai/code/session_01533WTTbgmfCaDP5tmK8Gkv

Step 3.2 of phase 3. Language and user agent are parameters on every call,
and the type will not let a caller omit them.

The defect this closes is worse than the phase sheet describes. The sheet says
the flag-report checker "silently queries Wikipedia in another language
depending on call order"; the code says `flag_verifier.py` never sets either
global at all. Only `scraper.py` does. So on a freshly restarted process,
before any game has been generated, a report about a French article is
fact-checked against the **English** Wikipedia — with the library's default
user agent, which Wikimedia's policy refuses. Recorded as D13, along with the
`auto_suggest` left on in the same function, which can resolve a search to a
different article than the one asked for.

Failure is a value with a closed set of reasons, because a caller has to tell
"this topic does not exist" from "Wikipedia is down": the first means try
another topic, the second means stop trying. The current code wraps three
nested `try` blocks around that distinction and then loses it to a bare
`except Exception`.

The transport is injected, so no test touches the network — and, more useful,
every test can read the URL that was built. The bug being prevented is a
request going to the wrong Wikipedia, and the only way to check that is to
look at where the request went. Verified biting: reintroducing a remembered
language makes the leakage test fail, and adding a search parameter to the
page lookup fails the no-auto-suggestion test.

The language reaches a hostname, so it is validated rather than interpolated:
`fr.evil.example` and `fr/../en` are refused, and nothing is sent. Same for a
blank user agent — a refused request must not reach Wikipedia at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01533WTTbgmfCaDP5tmK8Gkv
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add explicit MediaWiki client with per-call language/user-agent and typed failures

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Add a MediaWiki client that requires language and User-Agent on every call.
• Return typed failure reasons (not_found, unreachable, rate_limited, etc.) instead of exceptions.
• Add tests to prevent language leakage, enforce no auto-suggestion, and verify built URLs.
Diagram

graph TD
  A["Callers"] --> B["mediawiki.ts"] --> C["callApi()"] --> D["WikiTransport.fetch"] --> E{{"Wikimedia API"}}
  B --> F["result.ts"]
  T["mediawiki.test.ts"] --> B
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Adopt an existing MediaWiki npm client
  • ➕ Less custom request/response parsing code to maintain
  • ➕ Potentially broader API coverage out of the box
  • ➖ May reintroduce implicit defaults (UA, language) depending on library design
  • ➖ Harder to inject transport and assert exact built URLs for regression tests
  • ➖ Less control over mapping failures to the domain-specific, closed reason set
2. Use MediaWiki REST endpoints instead of action API parse/search
  • ➕ REST may provide simpler endpoints for some use-cases
  • ➕ Potentially clearer HTTP semantics for caching and error handling
  • ➖ Feature parity differs vs action=parse (rendered HTML and redirect handling)
  • ➖ May require different parsing/fixtures and could complicate step-wise rewrite parity
3. Return exceptions with typed subclasses instead of Result
  • ➕ More idiomatic in some TS codebases for exceptional control flow
  • ➕ Avoids explicit ok checks at call sites
  • ➖ Harder to enforce “missing page is ordinary” semantics; encourages catch-all handling
  • ➖ Less explicit call-site branching on failure reason (e.g., retry/backoff on 429)

Recommendation: Keep the PR’s approach. The project goal (“nothing implicit”) is best served by per-call request parameters plus injected transport, and the Result union forces callers to handle not_found vs unreachable vs rate_limited explicitly. Existing clients or exception-based APIs are more likely to hide defaults or make it harder to test the exact hostname/headers, which is the core regression being prevented (wrong-language wiki + missing/anonymous User-Agent).

Files changed (7) +510 / -3

Enhancement (2) +38 / -2
index.tsExport MediaWiki client and typed Result API +5/-2

Export MediaWiki client and typed Result API

• Updates the package entrypoint to export the new MediaWiki client functions and types (WikiRequest/WikiTransport/RenderedPage) along with the new Result/FailureReason helpers.

packages/article/src/index.ts

result.tsAdd Result<T> with closed FailureReason union +33/-0

Add Result<T> with closed FailureReason union

• Defines a Result discriminated union and helpers (ok/failed) to represent failures as values. Provides a closed set of failure reasons to preserve caller-significant distinctions like not_found vs unreachable vs rate_limited.

packages/article/src/result.ts

Bug fix (1) +193 / -0
mediawiki.tsImplement explicit MediaWiki client with validated language and per-call User-Agent +193/-0

Implement explicit MediaWiki client with validated language and per-call User-Agent

• Adds a MediaWiki client that validates language codes before building per-language API hostnames and requires a non-blank User-Agent. Implements searchTitles() and fetchRenderedPage() on the action API with redirect-following but no auto-suggestion, and maps network/HTTP/parse errors into a closed set of failure reasons.

packages/article/src/mediawiki.ts

Tests (1) +256 / -0
mediawiki.test.tsAdd transport-injected tests for language isolation and failure reasons +256/-0

Add transport-injected tests for language isolation and failure reasons

• Introduces a recorder-based fake transport to assert the exact URLs and headers built. Adds coverage for language/user-agent validation, typed failure mapping (not_found/unreachable/rate_limited/unexpected_response), no auto-suggestion semantics, and search behavior.

packages/article/src/mediawiki.test.ts

Documentation (3) +23 / -1
05-known-debt.mdRecord D13: wrong-language/anonymous Wikipedia requests in flag verification +11/-0

Record D13: wrong-language/anonymous Wikipedia requests in flag verification

• Adds a debt entry describing the production defect: global language/user-agent leakage and auto-suggest behavior causing wrong wiki queries and policy-violating anonymous requests.

plans/current-state/05-known-debt.md

01-contract-to-preserve.mdAdd D13 to contract defect list and link closure to step 3.2 +7/-0

Add D13 to contract defect list and link closure to step 3.2

• Documents D13 in the rewrite contract, describing the root cause and stating that phase 3 step 3.2 closes it via per-call parameters and no auto-suggestion.

plans/rewrite/01-contract-to-preserve.md

phase-03-article.mdMark step 3.2 complete and clarify the defect’s true trigger condition +5/-1

Mark step 3.2 complete and clarify the defect’s true trigger condition

• Marks 3.2 as done and updates the narrative to reflect that the defect is “English until first game generation,” not merely call-order dependent, and ties it to D13.

plans/rewrite/phase-03-article.md

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Search hits shape crash 🐞 Bug ☼ Reliability
Description
searchTitles() assumes query.search is an array and calls .map() without runtime validation, so a
JSON response where query.search is non-array will throw instead of returning a typed
unexpected_response failure. This breaks the intended contract that even bad/changed API responses
should be handled as Result failures rather than exceptions.
Code

packages/article/src/mediawiki.ts[R123-126]

+  const titles = hits
+    .map((hit) => hit.title)
+    .filter((title): title is string => typeof title === 'string');
+
Evidence
The function uses a type cast (body.value as SearchBody) which does not validate at runtime, then
immediately calls .map() on hits with no Array.isArray guard; if the API returns JSON where
query.search is present but not an array, this will throw.

packages/article/src/mediawiki.ts[99-129]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`searchTitles()` casts the JSON body to `SearchBody` and then assumes `query.search` is an array, calling `.map()` directly. Since the JSON is untrusted at runtime, `query.search` can be a non-array value (or even `null`) and this will throw, defeating the purpose of returning `Result` failures instead of exceptions.

### Issue Context
This module is explicitly designed around “failure as a value” (typed reasons like `unexpected_response`). Any runtime throw from parsing/shape assumptions undermines that goal.

### Fix Focus Areas
- packages/article/src/mediawiki.ts[119-129]

### Implementation notes
- After reading `hits`, validate with `Array.isArray(hits)`.
- If it is not an array, return `failed('unexpected_response', 'query.search was not an array')` (or similar).
- Optionally also validate that each hit is an object before reading `hit.title` (to avoid crashes on non-object items).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Null error/parse throws 🐞 Bug ☼ Reliability
Description
fetchRenderedPage() dereferences answer.error.code and parsed.title/revid/text without guarding
against null or non-object values, so certain JSON responses can still throw instead of returning
unexpected_response. This can crash the process in exactly the scenarios this client is trying to
handle gracefully (API changes, partial outages, or edge-case responses).
Code

packages/article/src/mediawiki.ts[R169-174]

+  if (answer.error !== undefined) {
+    // `missingtitle` is the ordinary "no such page"; anything else is a problem
+    // with the request rather than with the topic.
+    return answer.error.code === 'missingtitle'
+      ? failed('not_found', `no page titled ${trimmed}`)
+      : failed('unexpected_response', `API error ${String(answer.error.code)}`);
Evidence
The code checks only answer.error !== undefined and parsed === undefined, but not
null/non-object cases; property access on null will throw. Because the response is parsed as
JSON unknown and then cast, these are real runtime risks on unexpected but valid JSON.

packages/article/src/mediawiki.ts[55-97]
packages/article/src/mediawiki.ts[132-185]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`fetchRenderedPage()` assumes `answer.error` (when present) is an object with a `code` property, and assumes `answer.parse` (when present) is a non-null object before reading `parsed.title`, `parsed.revid`, and `parsed.text`. Because the body is untrusted JSON, `error`/`parse` can be `null` or another type, causing runtime exceptions.

### Issue Context
The design intent is to return `Result` failures for malformed/unexpected API responses (`unexpected_response`) rather than throwing.

### Fix Focus Areas
- packages/article/src/mediawiki.ts[168-185]

### Implementation notes
- Validate `answer.error` with something like:
 - `if (answer.error != null && typeof answer.error === 'object' && 'code' in answer.error) { ... }`
 - otherwise, if `answer.error !== undefined` but not the expected shape, return `failed('unexpected_response', 'API error block had unexpected shape')`.
- Validate `parsed` similarly:
 - ensure `parsed != null && typeof parsed === 'object'` before reading properties.
- Consider also accepting `revid` as a numeric string if MediaWiki ever serializes it that way (optional), otherwise keep strict number but return a failure, not a throw.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
Review mode: ⚖️ Balanced: Downgraded extended -> standard: change is below the extended eligibility bar (hunks 7/18, lines 513/200; both must reach the floor). Router rationale: This introduces substantial new runtime logic across independent request, search, page parsing, validation, and failure-handling paths, creating a dense set of easy-to-miss defects that benefits from redundant review passes.

Grey Divider

Tip of the day
💡 Did you know, you can switch off images and animations for a plain-text comment

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +123 to +126
const titles = hits
.map((hit) => hit.title)
.filter((title): title is string => typeof title === 'string');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Search hits shape crash 🐞 Bug ☼ Reliability

searchTitles() assumes query.search is an array and calls .map() without runtime validation, so a
JSON response where query.search is non-array will throw instead of returning a typed
unexpected_response failure. This breaks the intended contract that even bad/changed API responses
should be handled as Result failures rather than exceptions.
Agent Prompt
### Issue description
`searchTitles()` casts the JSON body to `SearchBody` and then assumes `query.search` is an array, calling `.map()` directly. Since the JSON is untrusted at runtime, `query.search` can be a non-array value (or even `null`) and this will throw, defeating the purpose of returning `Result` failures instead of exceptions.

### Issue Context
This module is explicitly designed around “failure as a value” (typed reasons like `unexpected_response`). Any runtime throw from parsing/shape assumptions undermines that goal.

### Fix Focus Areas
- packages/article/src/mediawiki.ts[119-129]

### Implementation notes
- After reading `hits`, validate with `Array.isArray(hits)`.
- If it is not an array, return `failed('unexpected_response', 'query.search was not an array')` (or similar).
- Optionally also validate that each hit is an object before reading `hit.title` (to avoid crashes on non-object items).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +169 to +174
if (answer.error !== undefined) {
// `missingtitle` is the ordinary "no such page"; anything else is a problem
// with the request rather than with the topic.
return answer.error.code === 'missingtitle'
? failed('not_found', `no page titled ${trimmed}`)
: failed('unexpected_response', `API error ${String(answer.error.code)}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Null error/parse throws 🐞 Bug ☼ Reliability

fetchRenderedPage() dereferences answer.error.code and parsed.title/revid/text without guarding
against null or non-object values, so certain JSON responses can still throw instead of returning
unexpected_response. This can crash the process in exactly the scenarios this client is trying to
handle gracefully (API changes, partial outages, or edge-case responses).
Agent Prompt
### Issue description
`fetchRenderedPage()` assumes `answer.error` (when present) is an object with a `code` property, and assumes `answer.parse` (when present) is a non-null object before reading `parsed.title`, `parsed.revid`, and `parsed.text`. Because the body is untrusted JSON, `error`/`parse` can be `null` or another type, causing runtime exceptions.

### Issue Context
The design intent is to return `Result` failures for malformed/unexpected API responses (`unexpected_response`) rather than throwing.

### Fix Focus Areas
- packages/article/src/mediawiki.ts[168-185]

### Implementation notes
- Validate `answer.error` with something like:
  - `if (answer.error != null && typeof answer.error === 'object' && 'code' in answer.error) { ... }`
  - otherwise, if `answer.error !== undefined` but not the expected shape, return `failed('unexpected_response', 'API error block had unexpected shape')`.
- Validate `parsed` similarly:
  - ensure `parsed != null && typeof parsed === 'object'` before reading properties.
- Consider also accepting `revid` as a numeric string if MediaWiki ever serializes it that way (optional), otherwise keep strict number but return a failure, not a throw.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@Willi363363

Copy link
Copy Markdown
Owner Author

Closed by the stack collapse, not abandoned.

Every commit of this pull request is in willi363/refonte via #114, which
merged the whole rewrite in one go: the stack was strictly linear, so the top
branch was an ancestor-of-nothing and a descendant-of-everything, and the merge
was a fast-forward with no conflict possible. The history keeps one commit per
step, which is what squash-per-step was there to produce.

Three commit messages were reworded on the way — a wip: type, a 74-character
subject and a (web,realtime) scope, none of which scripts/checks.sh allows.
The trees are byte-identical.

This description and its review thread stay readable here.

@Willi363363
Willi363363 deleted the feat/rewrite-phase-3-mediawiki branch August 29, 2026 16:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant