Skip to content

Learn: test the generated artifacts, fix build order - #178

Merged
JumpLink merged 8 commits into
mainfrom
learn/guard-translation-build-order
Sep 3, 2026
Merged

Learn: test the generated artifacts, fix build order#178
JumpLink merged 8 commits into
mainfrom
learn/guard-translation-build-order

Conversation

@JumpLink

@JumpLink JumpLink commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Why

On 2026-09-03, gjsify workspace @learn6502/translations build emptied the translation catalogs. The POT went from 457 msgids to 238, every tutorial paragraph became an obsolete #~ entry in all sixteen .po files, and the build exited 0. gjsify workspace @learn6502/translations check then reported "All translations passed structural validation" — a string that is gone is a string no rule can look at.

The cause: packages/translations/build.js lists ../learn/dist/**/*.ui among its xgettext sources, so extraction reads a build artifact of a sibling package. @learn6502/translations does declare @learn6502/learn as a dependency, but building one package does not build what it depends on, so the artifact is whatever the previous run left behind — and when packages/learn/dist is absent the glob matches nothing at all.

packages/learn renders 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's build gained a build:learn step that builds its one workspace dependency first, and build.js asserts the artifacts exist before extracting.

-t was 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/core and @learn6502/examples (measured ~4.9 s) whose output the extraction never reads; learn is this package's only workspace dependency and builds from committed source in 3.3 s. The accepted cost is that the root gjsify foreach --topological-dev build now builds learn twice (+3.3 s).

The assertion is the second half, for anyone who runs build:run on 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 partial dist/.

Reproduced both ways. With packages/learn/dist removed: before the change the POT collapsed to 238 msgids and 259 strings went obsolete in de.po; after it, 457 msgids and no loss.

2. A golden-output check for packages/learn

packages/learn/check.js + a check script, 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.

Rule Damage it prevents
All six artifacts were written An empty render passes every other rule by being read as nothing
.ui / .ns.xml are well-formed XML with the expected root Gtk.Builder and NativeScript's Builder.load refuse the whole document on a parse error — a blank screen, not a blank paragraph
Only known elements and object classes Same: an unresolvable GType fails the view, and the emitter is not what finds out
Every translatable label carries its TRANSLATORS: comment The catalogs are generated with noLocation, so it is a translator's only context
Every label is markup Pango accepts A Gtk.Label whose markup fails to parse renders as an empty string
The same code literals reach all three targets Each target encodes them differently (<tt>, an escaped w:SourceView, <code>), so a drop shows up nowhere else

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.

3. Two defects the check found on main, fixed first

  • Three tutorial paragraphs render blank in the GNOME app. tutorial.mdx uses &ndash; and &times;. Those are HTML entities; the label is Pango markup, and GMarkup knows only &amp; &lt; &gt; &quot; &apos; plus numeric references. Measured on Pango 1.57 — pango_parse_markup fails 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 an n, 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

  • Regenerating the catalogs. A translations build rewrites the POT, all sixteen .po files and packages/app-android/app/i18n/*.json as a side effect; none of that belongs here. git status is 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, and pt_BR.json / ta.json / zh_Hans.json are generated but were never committed.)
  • The GTK/NativeScript catalog divergence, filed as Learn: GTK and NativeScript targets do not share a catalog #177. Most Android tutorial strings have no catalog entry at all. How the two targets should share a catalog is a design decision, not a bug fix.

Validation

  • oxfmt --check (repo config, pinned version): clean
  • oxlint: exit 0, no new findings
  • gjsify workspace @learn6502/translations check: green
  • gjsify workspace @learn6502/learn check: green — and verified red on an injected &nbsp;

🤖 Generated with Claude Code

https://claude.ai/code/session_01URLYj1wgGxVNRbBpNT8xAV

Three tutorial paragraphs are currently blank in the GNOME app. `&ndash;` and
`&times;` are HTML entities; the tutorial's `<property name="label">` is Pango
markup, and GMarkup resolves only `&amp; &lt; &gt; &quot; &apos;` 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 `&ndash;`/`&times;` 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 `&ndash;` 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
@JumpLink

JumpLink commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review — four follow-up commits pushed

The central finding invalidates the PR's own validation line ("verified red on an injected &nbsp;"): the gate could not fail.

gjsify workspace @learn6502/learn check exited 0 while printing 303 problems and while the GJS process itself exited 1. Cause, measured in runScript (@gjsify/cli/dist/cli.gjs.mjs, pinned 0.16.3): a script body that tokenizes as a single plain gjsify … command is dispatched in-process, and the wrapper then exits with process.exitCode ?? 0 — which gjsify build and gjsify run never set. Any shell operator in the body takes the spawn path and the status survives.

Blast radius, all reproduced:

Command Before Now
learn check with 303 problems exit 0 exit 1
translations check over a <blink>/&ndash; msgstr exit 0 exit 1
translations build with a broken learn build exit 0, extraction ran exit 1, POT untouched at 457
learn build with a syntax error in tsx/ exit 0, re-ran the previous bundle exit 1

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

  • The assertion checked the wrong thing. With both .ui files present but truncated to zero bytes, extraction reproduced 2026-09-03 exactly: 457 → 238 msgids, 259 obsoleted in de.po, exit 0. Existence passes that; content does not. Now asserts translatable="yes" is present. (Agreed on dropping mtime — but the alternative to mtime was content, not nothing.)
  • Dropping every 7th plain-text paragraph from the .ui only — the exact loss this package exists to prevent — passed. Prose was never compared across targets, only code literals. The .html matches the .ui paragraph for paragraph (146/146, 80/80), so it is now the witness.
  • An emitter that stops writing an artifact passed locally, because check rebuilt into a dist/ still holding the previous run's file. check now clears first.
  • Rule 6 was order-coupled. Swapping 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. That is the disguised-snapshot failure the file's own header argues against. Fixed, with extraction self-tests pinning it.
  • The emitters swallowed write errors and exited 0 with the previous artifact in place.

Mutants

Killed: mangled ns code literal · missing TRANSLATORS comment · unknown object class · malformed XML · wrong root element · Pango-rejected entity in the MDX · empty artifact · missing artifact · dropped <code> in the html target · dropped .ui paragraphs (new) · stopped emitter with a stale file (new).

Surviving: a harmless property reorder is now correctly green; prose dropped from the .ns.xml only is still not caught — that needs the normalization #177 is about, measured and recorded there.

Scope

git diff main...HEAD touches no .po, .pot or app-android/app/i18n/*. The lagging catalogs, the three uncommitted Android JSONs, the LINGUAS trailing-newline churn and a 5602 typo in the line-buster description are #179 — a 20-file regeneration does not belong in a pipeline fix.

gjsify format --check / gjsify lint are themselves broken on the local GJS CLI (they re-parse the spawned tool's argv); validated with the pinned oxfmt / oxlint binaries directly — both exit 0.

@JumpLink
JumpLink merged commit 6a87e40 into main Sep 3, 2026
3 checks passed
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