Skip to content

feat(article): frozen Wikipedia fixtures and structural parity - #50

Closed
Willi363363 wants to merge 1 commit into
feat/rewrite-phase-3from
feat/rewrite-phase-3-parity
Closed

Willi363363 wants to merge 1 commit into
feat/rewrite-phase-3from
feat/rewrite-phase-3-parity

Conversation

@Willi363363

Copy link
Copy Markdown
Owner

Steps 3.1 and 3.3 of plans/rewrite/phase-03-article.md. Phase 3 opens here.

feat/rewrite-phase-3 branches off the top of phase 2 (#48). This is a step
branch onto it. Note: like the earlier phase branches, it has no PR of its
own yet — #49 is the integration PR that carries phases 1 and 2 into staging,
and phase 3 will need its own when it closes.

The parity test, first

This phase's opening pitfall: "The parity test first. It is the piece to write
first; the whole rest of the chain rests on it."

C3.2 is the invariant that cost the worst bug in the project's history — the
positions were drawn at random and the player was graded on paragraphs the model
never touched.

So collection does not return two lists that have to stay aligned. It returns
one object holding the loaded document and its nodes, and the text is derived
from those same nodes:

export interface CollectedArticle {
  readonly paragraphs: readonly string[];   // paragraphs[i] is nodes[i]
  readonly document: cheerio.CheerioAPI;
  readonly nodes: readonly Element[];
}

There is no second list to drift and no second tree. Injection writes to
nodes[i] and re-collecting the emitted HTML finds the change at index i —
which is the whole chain, end to end.

Verified against three regressions

regression caught by
a re-parse between collection and injection — the pitfall the sheet names writing to index i changes paragraph i and no other (both fixtures)
a filter applied after collection every text belongs to the node at the same index (both fixtures)
deduplication removed drops variants served twice

The fixtures are real

Two rendered pages from fr.wikipedia.org, reduced to their first paragraphs so
they stay readable in a diff. Every paragraph is byte-for-byte as MediaWiki
served it, with its revision number recorded and its licence stated — C6.1
applies to a fixture like any other reuse.

They carry what a hand-written fixture does not think to contain:

chat.html      14 paragraphs, 38–2003 chars, 11 with non-breaking spaces
chocolat.html  14 paragraphs, 55–1359 chars,  9 with non-breaking spaces

Both include a paragraph over 1000 characters, which step 3.4 needs for the
truncation bug. The short ones (38, 51) sit either side of the 50-character floor.

variants.html is constructed and says so: action=parse does not serve the
mobile/desktop duplication, so that shape is built around real paragraphs taken
from chat.html rather than around "aaa".

Two lint rules earned their keep

no-irregular-whitespace caught non-breaking spaces written literally inside
a character class. An invisible character in a character class is a character
class nobody can review — the codepoints are escapes now
(U+00A0, U+202F, U+2009, U+2007), with a comment naming each.

Prettier reformatted the fixtures. That would have made "byte-for-byte" false
and silently changed the text the parity test reads, since HTML whitespace is
exactly what this test is about. The directory is excluded — same reason as
drizzle's snapshots — and the files were regenerated from source.

A dependency I first tried to avoid

domhandler holds cheerio's node types. My first attempt reached for them
through inference and produced a signature nobody could read, plus
TS2742: the inferred type cannot be named without a reference to domhandler.
Declaring it is the honest answer: this module handles nodes.

Checks

CI=true pnpm test   article 28 · 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

Two new dependencies: cheerio (named by the phase objective) and domhandler
(its node types).

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

Note: the commit hook refused my first subject at 74 characters. Shortened, not
bypassed.

Next

3.2 (MediaWiki client with explicit language and user-agent), 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

Steps 3.1 and 3.3 of phase 3. This phase's first pitfall says the parity test
is the piece to write first; this is that piece.

C3.2 is the invariant that cost the worst bug in the project's history — the
positions were drawn at random and the player was graded on paragraphs the
model never touched. So the collection does not return two lists that have to
stay aligned. It returns one object holding the loaded document and its
nodes, with the text derived from those same nodes: there is no second list
to drift, and no second tree.

The fixtures are real. Two rendered pages from fr.wikipedia.org, reduced to
their first paragraphs so they stay readable in a diff, every paragraph
byte-for-byte as MediaWiki served it, revision recorded, licence stated —
they carry the shapes a hand-written fixture does not think to contain:
references, non-breaking spaces, nested inline tags, image captions, and
paragraphs from 38 to 2003 characters. The duplicated-variant case is
constructed, because `action=parse` does not serve the mobile/desktop
duplication, and the file says so.

The parity test was checked against three regressions, and catches all three:
a re-parse between collection and injection — the pitfall named in the sheet
— a filter applied after collection, and the deduplication removed.

Two lint rules earned their place here. `no-irregular-whitespace` caught
non-breaking spaces written literally in a character class: an invisible
character in a character class is a character class nobody can review, so the
codepoints are escapes now. And Prettier reformatted the fixtures, which
would have made "byte-for-byte" false and silently changed the text the
parity test reads — the directory is excluded, and the files were regenerated
from source.

`domhandler` is declared rather than reached through cheerio's inference. My
first attempt avoided the dependency and produced a signature nobody could
read; the dependency is the honest answer.

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

Article: add frozen Wikipedia fixtures and enforce paragraph/node parity

✨ Enhancement 🧪 Tests ⚙️ Configuration changes 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Add real frozen Wikipedia HTML fixtures and keep them untouched by formatting.
• Implement cheerio-based paragraph collection + injection with strict index parity.
• Add parity/normalisation regression tests and update rewrite plan status.
Diagram

graph TD
  T["paragraphs.test.ts"] --> H[/"Frozen HTML fixtures"/] --> C(["collectParagraphs"]) --> A["CollectedArticle (doc+nodes+text)"] --> I(["injectFalsifications"]) --> O[/"Emitted HTML"/] --> C
  subgraph Legend
    direction LR
    _test["Test"] ~~~ _fn(["Function"]) ~~~ _data[/"Data/Fixture"/]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Re-parse and re-locate nodes via stable selectors/paths
  • ➕ Avoids exposing raw DOM nodes to callers
  • ➕ Makes CollectedArticle easier to serialize/store
  • ➖ Hard to guarantee a stable mapping across DOM mutations and MediaWiki structure changes
  • ➖ Re-introduces the core risk the PR is guarding against (silent drift between text and nodes)
2. Return nodes only; derive paragraphs lazily on demand
  • ➕ Eliminates duplicated storage of paragraph strings
  • ➕ Guarantees text always reflects the current DOM state
  • ➖ Callers/tests become more complex (must normalise text repeatedly)
  • ➖ Harder to snapshot the original paragraphs for diffing/validation without extra work

Recommendation: Keep the current approach (single CollectedArticle holding document + nodes + derived text). It directly encodes the parity invariant structurally and makes the regression tests meaningful end-to-end (collect → inject → re-collect). The alternatives either reintroduce node/text drift risk or add complexity without improving correctness for the stated pitfall.

Files changed (15) +735 / -4

Enhancement (2) +157 / -0
index.tsExport paragraph collection/injection API surface +12/-0

Export paragraph collection/injection API surface

• Defines the package entrypoint exporting collection, injection, normalisation helpers, and the CollectedArticle type for downstream phases.

packages/article/src/index.ts

paragraphs.tsImplement cheerio paragraph collection with structural index parity +145/-0

Implement cheerio paragraph collection with structural index parity

• Implements CollectedArticle to bind paragraphs to their originating DOM nodes, preventing drift. Adds collection rules (single-pass document-order traversal, dedupe by normalised text, MIN_CONTENT_CHARS filter) plus in-place injection keyed by collected indices.

packages/article/src/paragraphs.ts

Tests (5) +302 / -0
chat.htmlAdd frozen Wikipedia fixture: frwiki “Chat” (revision recorded) +35/-0

Add frozen Wikipedia fixture: frwiki “Chat” (revision recorded)

• Adds a byte-for-byte captured subset of the rendered fr.wikipedia.org/wiki/Chat HTML, including inline tags, references, and non-breaking spaces, with revision and license noted.

packages/article/fixtures/chat.html

chocolat.htmlAdd frozen Wikipedia fixture: frwiki “Chocolat” (revision recorded) +36/-0

Add frozen Wikipedia fixture: frwiki “Chocolat” (revision recorded)

• Adds a byte-for-byte captured subset of the rendered fr.wikipedia.org/wiki/Chocolat HTML, including long paragraphs and NBSP usage, with revision and license noted.

packages/article/fixtures/chocolat.html

variants.htmlAdd constructed variant-duplication fixture for dedupe behavior +22/-0

Add constructed variant-duplication fixture for dedupe behavior

• Adds a constructed HTML fixture that reproduces mobile/desktop duplicated paragraph structure around real text, to exercise deduplication and minimum-length filtering.

packages/article/fixtures/variants.html

paragraphs.test.tsAdd parity + regression tests over frozen Wikipedia fixtures +208/-0

Add parity + regression tests over frozen Wikipedia fixtures

• Adds Vitest coverage for strict node/text index parity, end-to-end inject→recollect validation, deduplication and length filtering rules, and whitespace normalisation (including NBSP removal).

packages/article/src/paragraphs.test.ts

workspace-graph.test.tsRegister article package dependencies in workspace graph test +1/-0

Register article package dependencies in workspace graph test

• Updates the expected runtime dependency graph to include the new article package and its dependencies (cheerio, domhandler).

packages/config/src/workspace-graph.test.ts

Documentation (2) +10 / -4
README.mdMark phase 3 (Article) as in progress +1/-1

Mark phase 3 (Article) as in progress

• Updates the rewrite phase table to reflect that the Article phase has started.

plans/README.md

phase-03-article.mdMark steps 3.1 and 3.3 complete and document fixture/parity rationale +9/-3

Mark steps 3.1 and 3.3 complete and document fixture/parity rationale

• Sets phase state to in progress, marks fixtures and paragraph collection steps as complete, and expands notes describing frozen fixtures and constructed variant duplication case.

plans/rewrite/phase-03-article.md

Other (6) +266 / -0
.prettierignoreIgnore article HTML fixtures to preserve byte-for-byte fidelity +6/-0

Ignore article HTML fixtures to preserve byte-for-byte fidelity

• Adds packages/article/fixtures to Prettier ignore rules so frozen MediaWiki HTML is not reformatted. This keeps fixture whitespace stable for parity/normalisation assertions.

.prettierignore

eslint.config.jsAdd ESLint config entrypoint for the article package +1/-0

Add ESLint config entrypoint for the article package

• Introduces a package-local eslint.config.js that re-exports the shared @wikifake/config ESLint configuration.

packages/article/eslint.config.js

package.jsonCreate @wikifake/article workspace package +26/-0

Create @wikifake/article workspace package

• Adds the new article package manifest, scripts (lint/typecheck/test), and runtime dependencies (cheerio, domhandler) plus dev tooling dependencies.

packages/article/package.json

tsconfig.jsonAdd TypeScript config for the article package +8/-0

Add TypeScript config for the article package

• Extends the shared base tsconfig, scopes compilation to src, and sets Node types with noEmit for typecheck-only builds.

packages/article/tsconfig.json

vitest.config.tsAdd Vitest config entrypoint for the article package +1/-0

Add Vitest config entrypoint for the article package

• Re-exports the shared Vitest base configuration from @wikifake/config.

packages/article/vitest.config.ts

pnpm-lock.yamlLock new article package dependencies (cheerio/domhandler transitive set) +224/-0

Lock new article package dependencies (cheerio/domhandler transitive set)

• Adds lockfile entries for the new workspace importer and associated transitive dependencies introduced by cheerio/domhandler.

pnpm-lock.yaml

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Remediation recommended

1. Brittle fixture path join 🐞 Bug ☼ Reliability
Description
fixture(name) builds file paths via string concatenation, which implicitly relies on FIXTURES
always ending with a path separator; if that invariant changes (or on edge platform/path
normalization cases), reads will fail with ENOENT and the suite becomes fragile.
Code

packages/article/src/paragraphs.test.ts[R19-22]

+const FIXTURES = fileURLToPath(new URL('../fixtures/', import.meta.url));
+
+function fixture(name: string): string {
+  return readFileSync(`${FIXTURES}${name}`, 'utf8');
Evidence
The new test helper constructs fixture file paths by concatenating directory and filename, which
depends on a trailing separator and is easy to break during refactors or path normalization
differences.

packages/article/src/paragraphs.test.ts[19-23]

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

### Issue description
`fixture(name)` currently uses string concatenation to build a path: ``readFileSync(`${FIXTURES}${name}`)``. This is brittle because it depends on `FIXTURES` having a trailing separator.

### Issue Context
`FIXTURES` is derived from `fileURLToPath(new URL('../fixtures/', import.meta.url))`. The test suite should not depend on subtle trailing-slash behavior.

### Fix Focus Areas
- packages/article/src/paragraphs.test.ts[19-23]

### Suggested fix
Prefer a join that is correct regardless of trailing slashes:
- Option A (recommended): `import { join } from 'node:path'` and use `readFileSync(join(FIXTURES, name), 'utf8')`.
- Option B: keep everything URL-based for `fixture()` by using `readFileSync(new URL(`../fixtures/${name}`, import.meta.url), 'utf8')` and keep `FIXTURES` only for `readdirSync`.

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



Informational

2. Hard-coded fixture inventory 🐞 Bug ⚙ Maintainability
Description
The test suite asserts the fixture directory contains exactly chat.html and chocolat.html, so
adding any additional real-page fixture later will fail unrelated tests and slow iteration on
fixtures.
Code

packages/article/src/paragraphs.test.ts[R25-32]

+const REAL_PAGES = readdirSync(FIXTURES)
+  .filter((name) => name.endsWith('.html') && name !== 'variants.html')
+  .sort();
+
+describe('the fixtures are real pages', () => {
+  it('found them', () => {
+    expect(REAL_PAGES).toEqual(['chat.html', 'chocolat.html']);
+  });
Evidence
REAL_PAGES is computed by reading the fixtures directory, and the test asserts an exact array
match, which will fail as soon as more fixtures are added.

packages/article/src/paragraphs.test.ts[25-32]

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

### Issue description
The test `expect(REAL_PAGES).toEqual(['chat.html', 'chocolat.html'])` will fail if any new real `.html` fixture is added (even if it’s valid and needed for future regressions).

### Issue Context
`REAL_PAGES` is derived from the fixtures directory listing. This makes the test suite sensitive to fixture set growth.

### Fix Focus Areas
- packages/article/src/paragraphs.test.ts[25-32]

### Suggested fix
Change the assertion to require presence rather than exact equality, e.g.:
- `expect(REAL_PAGES).toEqual(expect.arrayContaining(['chat.html','chocolat.html']))`
- and optionally keep a separate assertion for “at least N real pages” if you want a guardrail.

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


Grey Divider

Context sources
Review mode: ⚖️ Balanced

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 +19 to +22
const FIXTURES = fileURLToPath(new URL('../fixtures/', import.meta.url));

function fixture(name: string): string {
return readFileSync(`${FIXTURES}${name}`, 'utf8');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

1. Brittle fixture path join 🐞 Bug ☼ Reliability

fixture(name) builds file paths via string concatenation, which implicitly relies on FIXTURES
always ending with a path separator; if that invariant changes (or on edge platform/path
normalization cases), reads will fail with ENOENT and the suite becomes fragile.
Agent Prompt
### Issue description
`fixture(name)` currently uses string concatenation to build a path: ``readFileSync(`${FIXTURES}${name}`)``. This is brittle because it depends on `FIXTURES` having a trailing separator.

### Issue Context
`FIXTURES` is derived from `fileURLToPath(new URL('../fixtures/', import.meta.url))`. The test suite should not depend on subtle trailing-slash behavior.

### Fix Focus Areas
- packages/article/src/paragraphs.test.ts[19-23]

### Suggested fix
Prefer a join that is correct regardless of trailing slashes:
- Option A (recommended): `import { join } from 'node:path'` and use `readFileSync(join(FIXTURES, name), 'utf8')`.
- Option B: keep everything URL-based for `fixture()` by using `readFileSync(new URL(`../fixtures/${name}`, import.meta.url), 'utf8')` and keep `FIXTURES` only for `readdirSync`.

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

Comment on lines +25 to +32
const REAL_PAGES = readdirSync(FIXTURES)
.filter((name) => name.endsWith('.html') && name !== 'variants.html')
.sort();

describe('the fixtures are real pages', () => {
it('found them', () => {
expect(REAL_PAGES).toEqual(['chat.html', 'chocolat.html']);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Informational

2. Hard-coded fixture inventory 🐞 Bug ⚙ Maintainability

The test suite asserts the fixture directory contains exactly chat.html and chocolat.html, so
adding any additional real-page fixture later will fail unrelated tests and slow iteration on
fixtures.
Agent Prompt
### Issue description
The test `expect(REAL_PAGES).toEqual(['chat.html', 'chocolat.html'])` will fail if any new real `.html` fixture is added (even if it’s valid and needed for future regressions).

### Issue Context
`REAL_PAGES` is derived from the fixtures directory listing. This makes the test suite sensitive to fixture set growth.

### Fix Focus Areas
- packages/article/src/paragraphs.test.ts[25-32]

### Suggested fix
Change the assertion to require presence rather than exact equality, e.g.:
- `expect(REAL_PAGES).toEqual(expect.arrayContaining(['chat.html','chocolat.html']))`
- and optionally keep a separate assertion for “at least N real pages” if you want a guardrail.

ⓘ 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-parity 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