Skip to content

Content extractor and the ContentCard union - #94

Merged
maximsan merged 8 commits into
mainfrom
feat/content-extractor
Aug 15, 2026
Merged

Content extractor and the ContentCard union#94
maximsan merged 8 commits into
mainfrom
feat/content-extractor

Conversation

@maximsan

Copy link
Copy Markdown
Owner

Closes #77.

Reads the prototype's authored content into the app's bundled JSON, and adds the type that receives it. These are one change because neither is testable alone: the union defines the wire format the extractor emits, and the extractor produces the cards the union's tests construct.

The extractor

node tool/extract_content.js reads five banks — modules, lessons, collectibles, dictionary terms and brew challenges — validates the whole cross-reference graph, and only then writes assets/content/generated/. On any violation it names the offending card and the broken reference, writes nothing, and exits non-zero.

Each bank is read by slicing its declaration and evaluating only that. Finding where a declaration ends goes through a real parser: counting brackets breaks on // Only 'match' cards., where an apostrophe in a comment desynchronises any scanner that tracks quotes but not comments. Instead V8 decides — a slice ending anywhere but the true end is unbalanced and won't compile, so the first candidate that parses is the end. Verified against apostrophes and brackets inside comments, and interpolated template literals.

brew-path/ is opened for reading only; a test asserts it is never written to.

Brew challenges are included for the validator's sake, not the app's. No screen reads them, but their 31 pointers (12 module + 7 lesson + 12 collectible) are edges the graph would otherwise not check — including into m1l1 and c1, the content slice 1 ships.

Mini-games are deferred. MINI_GAME_CONTENT is not a self-contained literal — one entry is a getter reading window.BAGPICK_ROUNDS, which bean-anatomy.jsx defines, so slice-and-evaluate returns an empty array for that game. MINI_GAMES and CARD_KIND_HELP are held back with it rather than split across two changes.

ContentCard

One flat sealed union discriminating on kind, with a variant per authored kind. The graded kinds carry an empty Gradable marker whose comment names the wrong-denominator bug it prevents. Dispatch is a single exhaustive switch — no registry or factory indirection.

⚠️ The issue's counts are stale — 14 kinds and 10 graded, not 15 and 11

The prototype authors 14 kinds, and lesson.jsx:165's quizTotal grades 10. Corroboration: the graded card total comes out at 144, exactly the figure docs/design/06-content.md states, and that doc independently says "14 authored". intro and takeaway still have renderers but zero authored cards — adding variants for them would have created cases no test could exercise.

Decisions worth a reviewer's eye

  • MODULE_REWARDS is read as a sixth declaration and joined onto each module as reward. Without it the five module Field Guide collectibles have no words, since a collectible stores identity only.
  • DICT_CATEGORIES is read but not emitted — it validates every term's cat; no dictionary surface exists yet.
  • status / locked are emitted verbatim and are the prototype's demo progress. Real progress lives in the database; nothing app-side should read those fields. They survive only because the extractor renames and drops nothing.
  • "Exactly one correct answer" bends for multi, whose answer is the whole set; it is checked for being neither empty nor everything.
  • The full register of what is joined, dropped, and deferred is in the header of tool/extract_content.js.

Tests

13 new tests. Beyond the two the issue asks for:

  • A drift check asserting the committed banks equal a fresh extraction. Without it a rename in brew-path/ stays green until someone happens to rerun the extractor — the other tests read the committed output, so that is what they keep agreeing with. Confirmed to fail correctly: a simulated explainexplanation rename turns 6 tests red.
  • A cross-language graded-kind assertion. The extractor publishes which kinds it grades and the union is asserted to agree; nothing else held those two lists together, and mastery divides by them.

flutter test remains the only test command. CI now pins Node, which the extractor test shells out to.

Full suite green (405 passing), flutter analyze clean, dart format clean, dart_code_linter metrics gate clean. content_card.dart carries a documented metrics-exclude entry — 14 factories plus fromJson exceeds number-of-methods: 12, the same case as mood_colors.dart, where the method count is the token count.

The prototype's data files are not modules and several carry React
markup beside their data, so a bank cannot be read by evaluating its
file. Each is sliced out and evaluated alone.

Finding where a declaration ends is the part worth explaining. Counting
brackets is the obvious approach and it is wrong: the source contains
`// Only 'match' cards.`, and an apostrophe inside a comment
desynchronises any scanner tracking quotes without also tracking
comments. Rather than re-implement JavaScript's lexer this asks the real
one — a slice ending anywhere but the true end is unbalanced and fails
to compile, so the first candidate V8 accepts is the end.

The shape guard is belt-and-braces. A truncated slice cannot compile, but
that argument rests on the prototype's formatting, and the cost of being
wrong is a bank that is quietly short rather than a run that fails.
Every check answers one question: would this render as something broken,
blank or wrong in the app? A pointer that no longer resolves, a graded
card with no correct answer, a dictionary entry whose deeper material has
lost the explanation it hangs off. None of these throw at runtime — they
degrade quietly, which is why they are caught here.

Violations are collected rather than thrown, so one run reports every
problem and names the card and the reference in each.

The graded kinds are the key set of the answer-check map rather than a
second list beside it. A kind that gains a check is graded by
construction, so the two cannot drift — and drifting is how mastery
starts dividing by the wrong number.

One rule bends: "exactly one correct answer" cannot hold for `multi`,
whose answer is the whole set. It is checked for being neither empty nor
everything instead.
Validate-and-refuse-to-write is the point of the tool. It reads every
bank, checks the whole graph, and only then writes — so a run can never
leave a stale mixture of old and new files behind.

Brew challenges are extracted for the validator's sake, not the app's. No
screen reads them, but their 31 lesson, module and collectible pointers
are edges the graph would otherwise not check — including into m1l1 and
c1, the content the first slice ships.

Mini-games are deferred: `MINI_GAME_CONTENT` is not a self-contained
literal, since one entry is a getter reading a global another file
defines. Slice-and-evaluate hands back an empty array for that game, so
it needs a different mechanism and no surface yet justifies building it.

The prototype's field vocabulary is emitted verbatim; Dart takes
idiomatic names through annotations. The known cost is that a
prototype-side rename yields a runtime null rather than a compile error.
The header carries the full register of what is joined, what is dropped,
and where a rule bends — including that module and lesson `status` and
`locked` are the prototype's demo progress and nothing should read them.
Fourteen variants, one per kind the prototype actually authors. `intro`
and `takeaway` are absent on purpose: renderers for them survive but no
card uses either, having been superseded by `predict` and `recall`.

The ten graded kinds carry an empty `Gradable` marker. It is empty
because they share no field, and it exists so a scoring seam can take
`List<Gradable>` rather than `List<ContentCard>` — which makes the
wrong-denominator bug unrepresentable rather than merely avoidable.
Counting an ungraded card deflates every score; leaving a graded one out
lets mastery exceed 100%, which the prototype shipped when `flavor`
scored while missing from the graded list. The comment says so, because
without it the marker reads as ceremony and gets deleted.

Dispatch is a plain exhaustive switch — no registry, builder map or
factory indirection. The sealed union makes an unhandled kind a compile
error, and that guarantee is worth more than the indirection saves.

The metrics exemption is the same case as mood_colors.dart: the method
count is the kind count, so number-of-methods measures the prototype's
content design rather than anything about this file.
Constructing every card the extractor emits is what turns a prototype
field rename into a red build rather than a runtime null. Its complement
matters as much: no card may carry a field the union silently drops,
which is what a renamed optional would look like.

The drift check is the one that makes the others honest. Reading the
committed output means the committed output is what they keep agreeing
with, so a rename in brew-path/ would stay green until someone happened
to rerun the extractor. Asserting that the committed banks equal a fresh
extraction closes that: the script is deterministic, so byte equality is
the whole check.

The graded-kind assertion spans the language boundary. The extractor
decides which kinds get an answer check and the union decides which ones
score; nothing else holds those two lists together, and mastery divides
by them.

The refusal path is driven as a subprocess because the behaviour under
test is the process contract — what it writes, and what it declines to.
Its source is a scratch copy, so brew-path/ stays read-only; one test
asserts that directly.
The extractor test shells out to `node`. The runner ships one already, so
this changes nothing today — but relying on an accident of the image is
how it breaks later, silently, in a job that has nothing to do with it.

Nothing to install: the script takes no npm packages, which is why it
parses with V8 rather than pulling in acorn.
It landed between flutter_test and integration_test, splitting the two
SDK deps that belong together. pubspec deps here are grouped by concern
rather than sorted, so placement is the only thing carrying that.
@maximsan
maximsan merged commit a72b7b7 into main Aug 15, 2026
5 checks passed
@maximsan
maximsan deleted the feat/content-extractor branch August 15, 2026 21:17
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.

Content extractor and the ContentCard union

1 participant