Learn: test the generated artifacts, fix build order - #178
Conversation
Three tutorial paragraphs are currently blank in the GNOME app. `–` and `×` are HTML entities; the tutorial's `<property name="label">` is Pango markup, and GMarkup resolves only `& < > " '` plus numeric character references. Measured on Pango 1.57: `pango_parse_markup` fails with "entity name is not known", and a `Gtk.Label` whose markup fails to parse renders as an empty string — so the reader loses the whole paragraph, with no error logged anywhere. Write the characters themselves, which parse fine and are what the committed catalogs already carry as the msgid: the emitter has been out of step with the POT since the entities appeared, and nothing noticed because the POT is committed and only regenerated by hand. Claude-Session: https://claude.ai/code/session_01URLYj1wgGxVNRbBpNT8xAV
`endsWith("\\n")` tests for a backslash followed by an `n`, not for a newline,
so the NativeScript target never trimmed the trailing newline that the GTK and
HTML targets both strip. Every block of code in the Android tutorial therefore
ended with a blank line, and the same literal differed between the three
artifacts generated from one MDX source.
Claude-Session: https://claude.ai/code/session_01URLYj1wgGxVNRbBpNT8xAV
One MDX source is rendered into three unrelated artifacts — `dist/*.ui` for app-gnome, `dist/*.ns.xml` for app-android, `dist/*.html` for app-web — and nothing tested any of them. Only app-gnome fails loudly on a broken one, and it fails at runtime, in whatever paragraph the reader happens to open. `check.js` asserts structural properties rather than bytes: a snapshot of a 136 KB generated file is a diff nobody reads and everybody regenerates, so it stops being evidence the first time someone reformats the emitter. It checks that all six artifacts were written; that the `.ui` and `.ns.xml` are well-formed XML with the expected root and only elements and object classes their consumer can resolve (`Gtk.Builder` and NativeScript's `Builder.load` both refuse the whole document otherwise); that every translatable label carries its TRANSLATORS comment, which is a translator's only context given `noLocation`; that every label is markup Pango accepts; and that the same code literals reach all three targets, each of which encodes them differently. Written against the two defects it found on main, both fixed in the preceding commits: the `–`/`×` entities that blank three paragraphs, and the trailing newline the NativeScript target kept. 19 self-test cases run before any artifact is read, so the rules cannot rot into a check that passes over output nobody has looked at. `check` rebuilds `dist/` first, deliberately: validating whatever artifact happens to be on disk is the same defect that emptied the catalogs on 2026-09-03. Named `check` to match `@learn6502/translations`, so `gjsify foreach -t check` and the CI type-check job pick it up. Claude-Session: https://claude.ai/code/session_01URLYj1wgGxVNRbBpNT8xAV
`build.js` lists `../learn/dist/**/*.ui` among its xgettext sources, so the extraction reads a build artifact of a sibling package. `@learn6502/translations` declares `@learn6502/learn` as a dependency, but building one package does not build what it depends on, so the artifact was whatever the previous run left behind — and with `packages/learn/dist` absent the glob simply matched nothing. On 2026-09-03 `gjsify workspace @learn6502/translations build` therefore emptied the catalogs: the POT went from 457 msgids to 238, every tutorial paragraph turned into an obsolete `#~` entry in all sixteen `.po` files, and the build exited 0. `check` then reported "All translations passed structural validation", because a string that is gone is a string no rule can look at. Two changes, because the ordering and the loud failure are different jobs: - `build:learn` runs `gjsify workspace @learn6502/learn build` as the first step of `build`. The order now lives in the script that performs the extraction, which is what a developer and an agent both reach for. `-t` was the alternative and is not enough on its own: it is a flag at the call site, so nothing forces anyone to pass it, and it would also rebuild `@learn6502/core` and `@learn6502/examples` (~4.9 s) whose output the extraction never reads — `learn` is this package's only workspace dependency, and it builds from committed source in 3.3 s. - `build.js` asserts the artifacts exist before extracting, so running the extraction on its own fails with the command to run instead of silently rewriting sixteen catalogs. gjsify #1521 makes an unmatched source glob loud at the core; this keeps the failure attributable on the pinned toolchain, and covers the case of a partial `dist/`. Verified by reproduction: with `packages/learn/dist` removed, the POT collapsed to 238 msgids before the change and stays at 457 after it. Claude-Session: https://claude.ai/code/session_01URLYj1wgGxVNRbBpNT8xAV
A package script whose whole body is a single `gjsify …` command exits 0 no matter what that command did. Measured on the pinned CLI 0.16.3 (`runScript` in `@gjsify/cli/dist/cli.gjs.mjs`): such a body is tokenized and dispatched in-process, then the wrapper exits with `process.exitCode ?? 0` — and `gjsify build` / `gjsify run` report failure without setting it. Any shell operator in the body takes the spawn path instead and the status survives. `gjsify tsc` is unaffected; it spawns `gjs`. Every leaf of the learn/translations pipeline had that shape, so: - `gjsify workspace @learn6502/learn check` exited 0 while printing 303 problems — the gate added for exactly this failure class could not fail CI; - `gjsify workspace @learn6502/translations check` exited 0 over a catalog with `<blink>` and `–` in a msgstr, so the CI catalog gate has never been able to fail either; - a broken `@learn6502/learn` build exited 0 and the translations build went on to extract from whatever `dist/` held — the ordering step guarded nothing; - `learn`'s own build ran `dist/index.js` from the *previous* bundle after the current one failed to compile, and copied that output on. Collapsing the `:gen`/`:run` pairs into chains restores the status at every level: a broken learn build now stops the translations build with exit 1 and the POT untouched at 457 msgids (measured). The fix belongs upstream in gjsify; until it lands, splitting these chains back into single-command steps disarms the gates again, which is why AGENTS.md now says so. Claude-Session: https://claude.ai/code/session_01URLYj1wgGxVNRbBpNT8xAV
The three emitters caught every write error, logged it and returned. The build then exited 0 with the previous run's artifact still on disk, and everything downstream — the artifact check, and `xgettext` reading `dist/*.ui` — passed over output that no longer matched the source. There is no recovery to make here, so there is nothing for a catch to do. Let the rejection out: `gjsify run dist/index.js` now exits non-zero and, with the build script chained, so does the build. Claude-Session: https://claude.ai/code/session_01URLYj1wgGxVNRbBpNT8xAV
Three gaps found by mutating the emitter and re-running the check: Dropping every seventh plain-text paragraph from the `.ui` only — the file `xgettext` builds the catalogs from, so precisely the loss this package's check exists to prevent — passed. Only code literals were compared across targets; prose was not. The `.html` is rendered from the same MDX in the same run and matches the `.ui` paragraph for paragraph (146/146 and 80/80 today), so it can serve as the witness. It now does. An emitter that stops writing an artifact passed, because `check` rebuilt into a `dist/` that still held the previous run's file. On a fresh CI checkout it would have failed; locally, where the developer looks before pushing, it did not. `check` now clears `dist/` first. Reordering `code` and `language` inside a `SourceView` — meaningless in XML, ignored by `Gtk.Builder` — turned the check red with 22 messages blaming the *other* two targets, because the block-code extractor required `<property name="code">` to come first. It no longer does, and an extraction self-test pins that: an extractor that quietly matches less than it should does not report anything, it shrinks the set the rule compares against. A `SourceView` carrying no code at all is now reported rather than cancelling out on both sides of the comparison. Claude-Session: https://claude.ai/code/session_01URLYj1wgGxVNRbBpNT8xAV
The guard checked that `../learn/dist/*.ui` exists. Existence is not the property that matters: with both files present but truncated to zero bytes, extraction reproduced the 2026-09-03 collapse exactly — POT from 457 msgids to 238, 259 strings obsoleted in `de.po`, exit 0. That is the state a half-finished build leaves behind. Assert content instead: each file must carry `translatable="yes"`, the attribute `xgettext` keys on. Freshness is still not asserted, and an mtime would not assert it either — it says when a file was written, which a CI cache restores. What the build order guarantees is that the files were just produced by a build that succeeded; this assertion covers the case where someone runs the extraction on its own. Claude-Session: https://claude.ai/code/session_01URLYj1wgGxVNRbBpNT8xAV
Adversarial review — four follow-up commits pushedThe central finding invalidates the PR's own validation line ("verified red on an injected
Blast radius, all reproduced:
So the ordering step guarded nothing on its own — only the assertion did, and only for the fully-missing case. Filed upstream-shaped as #180. Also fixed
MutantsKilled: mangled Surviving: a harmless property reorder is now correctly green; prose dropped from the Scope
|
Why
On 2026-09-03,
gjsify workspace @learn6502/translations buildemptied the translation catalogs. The POT went from 457 msgids to 238, every tutorial paragraph became an obsolete#~entry in all sixteen.pofiles, and the build exited 0.gjsify workspace @learn6502/translations checkthen reported "All translations passed structural validation" — a string that is gone is a string no rule can look at.The cause:
packages/translations/build.jslists../learn/dist/**/*.uiamong itsxgettextsources, so extraction reads a build artifact of a sibling package.@learn6502/translationsdoes declare@learn6502/learnas a dependency, but building one package does not build what it depends on, so the artifact is whatever the previous run left behind — and whenpackages/learn/distis absent the glob matches nothing at all.packages/learnrenders one MDX source into three unrelated artifacts (dist/*.ui→ app-gnome,dist/*.ns.xml→ app-android,dist/*.html→ app-web) and nothing tested any of them.What changed
1. The build order lives where the extraction runs
@learn6502/translations'sbuildgained abuild:learnstep that builds its one workspace dependency first, andbuild.jsasserts the artifacts exist before extracting.-twas the alternative and is not sufficient on its own: it is a flag at the call site, so nothing forces anyone to pass it — and the invocation without it is exactly what a developer and an agent both reach for. It would also rebuild@learn6502/coreand@learn6502/examples(measured ~4.9 s) whose output the extraction never reads;learnis this package's only workspace dependency and builds from committed source in 3.3 s. The accepted cost is that the rootgjsify foreach --topological-dev buildnow buildslearntwice (+3.3 s).The assertion is the second half, for anyone who runs
build:runon its own: it fails with the command to run rather than silently rewriting sixteen catalogs. gjsify #1521 makes an unmatched source glob loud at the core; this keeps the failure attributable on the pinned toolchain and covers a partialdist/.Reproduced both ways. With
packages/learn/distremoved: before the change the POT collapsed to 238 msgids and 259 strings went obsolete inde.po; after it, 457 msgids and no loss.2. A golden-output check for
packages/learnpackages/learn/check.js+ acheckscript, wired into CI next to the catalog gate. It asserts structural properties, not bytes — a snapshot of a 136 KB generated file is a diff nobody reads and everybody regenerates, so it stops being evidence the first time someone reformats the emitter..ui/.ns.xmlare well-formed XML with the expected rootGtk.Builderand NativeScript'sBuilder.loadrefuse the whole document on a parse error — a blank screen, not a blank paragraphTRANSLATORS:commentnoLocation, so it is a translator's only contextGtk.Labelwhose markup fails to parse renders as an empty string<tt>, an escapedw:SourceView,<code>), so a drop shows up nowhere else19 self-test cases run before any artifact is read, so the rules cannot rot into a check that passes over output nobody has looked at.
3. Two defects the check found on
main, fixed firsttutorial.mdxuses–and×. Those are HTML entities; the label is Pango markup, and GMarkup knows only& < > " 'plus numeric references. Measured on Pango 1.57 —pango_parse_markupfails with "entity name is not known", and the label then renders as an empty string. The committed catalogs already carry the literal–/×as the msgid, so the emitter has been out of step with the POT since the entities appeared.endsWith("\\n")tests for a backslash and ann, not a newline, so the NativeScript target never stripped the trailing newline the GTK and HTML targets both strip.Neither fix changes a tracked file's content beyond its own source:
dist/is gitignored, and the MDX fix makes the emitter agree with the committed POT.Not in this PR
.pofiles andpackages/app-android/app/i18n/*.jsonas a side effect; none of that belongs here.git statusis clean of catalog changes. (For the record,main's catalogs are behind by the four strings the dino-run and line-buster-6502 examples added, andpt_BR.json/ta.json/zh_Hans.jsonare generated but were never committed.)Validation
oxfmt --check(repo config, pinned version): cleanoxlint: exit 0, no new findingsgjsify workspace @learn6502/translations check: greengjsify workspace @learn6502/learn check: green — and verified red on an injected 🤖 Generated with Claude Code
https://claude.ai/code/session_01URLYj1wgGxVNRbBpNT8xAV