Skip to content

feat: say what wikven does about Lua, and stop guessing at it - #473

Merged
lens0021 merged 3 commits into
mainfrom
claude/lua-modules
Aug 19, 2026
Merged

feat: say what wikven does about Lua, and stop guessing at it#473
lens0021 merged 3 commits into
mainfrom
claude/lua-modules

Conversation

@lens0021

@lens0021 lens0021 commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Scribunto is ordinary equipment on a MediaWiki wiki, and wikven's two products disagreed about it silently. Measured on a bake of one page invoking one module, every one of these exited 0:

Scribunto module file rendered module in the export
listed, engine present Module:Example Lua says hello no
not listed Module:Example {{#invoke:Greet|hello}} no, the file was ignored entirely
not listed Module:Example.wikitext {{#invoke:Greet|hello}} yes, as Module%3AGreet.html

Three of those four ways to have a Lua module published a site with braces where the page meant to say something, and one exported the module's own Lua source as a page. Nothing said a word. #465 guessed this would show up as "red module errors"; raw wikitext is worse, because it looks like someone's typo rather than a missing engine.

The answer, per the decision on #465

Supported under the Docker image, not in the standalone binary. That is not a preference. static-php-cli, which builds the binary's PHP, offers no Lua extension among the ~130 it supports, and Scribunto's other engine shells out to a lua program that a single executable has no way to ship. Checked against upstream's own list rather than assumed.

One refusal and one warning

includes/Scribunto.php holds both rules; build.php runs them before clearOutputDirectory, so a site that is refused still has its last bake on disk.

  • Scribunto where no engine can run it → refused, naming the product that cannot. The site asked for Lua in so many words and this build cannot give it; publishing braces instead is worse than being told no.
  • Module: files with no Scribunto in extensions → warned about, naming the files, and the bake goes on. The site never asked for Lua, and what those files are is a guess read off a name: they may be on their way in, on their way out, or kept for something else. Refusing there decides for the site, which is not this build's call. So it says what it sees — the invocations stay in the pages as their own source text, and a module named with the .wikitext marker is exported as a page — and lets the bake finish.

A site with no Lua at all is told nothing, which matters: that is most sites, and the binary is what they are pointed at.

Verified by baking, not by reading. The refusal:

=== scribunto-with-engine
  bake exit: 0
  index.html: present
=== no-lua-at-all
  bake exit: 0
  index.html: present

The engine-missing case was tested by mounting an empty ini over the image's docker-php-ext-luasandbox.ini, which is as close to the binary as this machine gets:

bake exit: 1
Wikven: this site lists Scribunto and no Lua engine is available here. The Docker image has one;
the standalone binary has none, so it cannot bake a site that uses Lua modules. ...

That run also proves the image has no lua on PATH: engine detection tries luasandbox, then a configured luaPath, then lua5.1/lua on PATH, and it still fired. A hand-rolled install with the standalone engine is therefore not refused for no reason.

The modules-without-Scribunto case was baked while it was still a refusal (bake exit: 1, no index.html, message naming Module:Greet). Since it became a warning it is covered by the unit suite and not by a fresh bake — the bake host here has no Docker. What changed is which of the two functions produces the string and whether build.php calls fatalError() or output() with it; the message and the file detection are the same code that bake exercised.

The page

Lua modules, between Extensions and JavaScript, in English and Korean. How to enable it, why a module file carries no .wikitext marker (its content model is Lua), what reaches the published site, and what each of the two mismatches does.

It demonstrates itself: it shows Module:Example in full, invokes it, and prints the answer. The module returns the title of the page it ran on and no prose, because a module's return value is not a translation unit and would reach a reader of any language in whatever language it was written in — the sentence around it carries the words instead.

Standalone binary gains a "What it cannot do" section: it claimed its output was the same as the image's, which is now known to be false in exactly one way. Extensions points at the new page. Development documents the new build step.

What holds it

smoke.yml asserts the module's answer is in the exported page in both languages, and that nothing named Module* was exported.

It deliberately does not assert the absence of {{#invoke: in the output. The new page documents the syntax inside <syntaxhighlight> and so contains it legitimately — confirmed in the bake, three occurrences. The positive assertion is the one that tells rendering from leaking.

Ran locally against a real bake of this site:

ok    dist/Lua_modules.html carries the module's answer
ok    dist/Lua_modules/ko.html carries it in Korean
ok    the module is not a page of the site
ok    414 language-bar entries, as the source predicts

Two bakes byte-identical, so a module costs no reproducibility. The prevnext chain reads Skins → Extensions → Lua modules → JavaScript → Searching, and shows Lua 모듈 in Korean, which incidentally exercises #466 on a brand-new page. translate check reports everything up to date.

A consequence worth stating plainly

This documentation site now has a Lua module, so the standalone binary cannot bake it. Nothing in CI notices, because nothing bakes anything with the binary: binary.yml builds it and runs ./wikven version. That gap is real, it is not this change's to close, and it is worth its own issue.

Closes #465.

Scribunto is ordinary equipment on a MediaWiki wiki, and wikven's two products disagreed about it silently. Measured on a bake of one page invoking one module, every one of these exited 0:

| Scribunto | module file | rendered | module in the export |
|---|---|---|---|
| listed, engine present | `Module:Example` | `Lua says hello` | no |
| not listed | `Module:Example` | `{{#invoke:Greet|hello}}` | no, the file was ignored entirely |
| not listed | `Module:Example.wikitext` | `{{#invoke:Greet|hello}}` | **yes, as `Module%3AGreet.html`** |

So three of four ways to have a Lua module produced a published site with braces where the page meant to say something, and one of them exported the module's own Lua source as a page. Nothing said a word.

## The answer

Scribunto is supported under the Docker image and not in the standalone binary. That is not a preference: static-php-cli, which builds the binary's PHP, offers no Lua extension among the hundred and thirty it supports, and Scribunto's other engine shells out to a `lua` program that a single executable has no way to ship. Checked upstream rather than assumed.

## Two refusals

`Scribunto` holds the rule, and `build.php` puts it before `clearOutputDirectory` so a site that is refused still has its last bake on disk.

* a site with `Module:` files and no `Scribunto` in `extensions` is refused, naming the files;
* a site that lists Scribunto where no engine can run it is refused, naming the product that cannot.

Verified by baking, not by reading. Modules without Scribunto now exit 1 with no output written. Scribunto with no engine was tested by mounting an empty ini over the image's `docker-php-ext-luasandbox.ini`, which is as close to the binary as this machine gets, and it exits 1 with the message about the binary. A site with Scribunto and an engine bakes as before; a site with no Lua at all is told nothing, which matters because that is most sites and the binary is what they are pointed at.

Engine detection is `luasandbox`, then a configured `luaPath`, then `lua5.1` or `lua` on PATH, so a hand-rolled install with the standalone engine is not refused for no reason. The image has no `lua` on PATH, which the test above also proves: the check fired.

## The page

`Lua modules`, in the docs section between Extensions and JavaScript, in English and Korean. It says how to enable it, that a module file carries no `.wikitext` marker because its content model is Lua, what reaches the published site, and what the two refusals look like. `Standalone binary` gains a "What it cannot do" section, since it claimed its output was the same as the image's, and that is now known to be false in one way. `Extensions` points at the new page.

The page demonstrates itself: it shows `Module:Example` in full, invokes it, and prints the answer. The module returns the title of the page it ran on and no prose, because a module's return value is not a translation unit and would reach a reader of any language in whatever language it was written in.

## What holds it

`smoke.yml` asserts the module's answer is in the exported page in both languages, and that nothing named `Module*` was exported. It cannot assert the absence of `{{#invoke:` anywhere, because the new page documents the syntax inside `<nowiki>` and so contains it legitimately -- the positive assertion is the one that distinguishes rendering from leaking.

Ran locally against a real bake of this site: both languages carry the answer, `<code>Lua modules</code>` and `<code>Lua modules/ko</code>`, the module is not a page, the language-bar count still matches what the source predicts (414), and two bakes are byte-identical, so a module does not cost reproducibility. The prevnext chain reads Skins, Extensions, Lua modules, JavaScript, Searching in English, and shows `Lua 모듈` in Korean.

Unit suite: `OK (196 tests, 299 assertions)` against main, eleven of them new.

## A consequence worth stating

This documentation site now has a Lua module, so the standalone binary cannot bake it. Nothing in CI notices, because nothing bakes anything with the binary -- `binary.yml` builds it and runs `./wikven version`. That gap is real and is not this change's to close.

Closes #465.

---
_Generated by [Claude Code](https://claude.ai/code/session_935f02d1)_

Co-authored-by: Claude <noreply@anthropic.com>
claude added 2 commits August 19, 2026 15:51
A source tree with Module: files and no Scribunto in extensions ended the
build. That is deciding for the site: what those files are is a guess read
off a name, and they may be on their way in, on their way out, or kept for
something else. The bake now goes on and says what it sees -- the
invocations stay in the pages as their own source text, and a module named
with the .wikitext marker is exported as a page.

The other rule stays fatal, because it is not the same thing. A site that
lists Scribunto where no engine can run it asked for Lua in so many words,
and this build cannot give it; publishing braces instead is worse than
being told no.

So Scribunto::problem() narrows to the engine case and a new warning()
carries the modules case, and build.php's check is no longer only an
assertion.
@github-actions
github-actions Bot temporarily deployed to preview August 19, 2026 15:54 Destroyed
@lens0021
lens0021 merged commit ea10b7d into main Aug 19, 2026
24 checks passed
@lens0021
lens0021 deleted the claude/lua-modules branch August 19, 2026 15:56
lens0021 added a commit that referenced this pull request Aug 20, 2026
… that found (#481)

Nothing had ever baked anything with the standalone binary. `binary.yml`
built it and ran `./wikven version` — which proves the static link,
since static linking fails at execution rather than compilation, but
proves nothing about the product — and `smoke.yml` only ever uses the
Docker image. So a site the image can bake and the binary cannot had
never been caught by anything, which is how this repository's own
documentation came to hold a Lua module the binary was documented as
unable to render.

This adds the missing job, fixes the three bugs it found on its first
runs, and rewrites the documentation those runs proved wrong. Closes
#465's remaining gap; the follow-up #473 flagged.

## The job

A `bake` job hanging off `binary` with `needs:`, so it runs exactly when
a binary was built. Not folded into `smoke.yml`: that job belongs to the
image, it is a required check, and Lua is a niche of a niche. It runs on
a plain runner with the binary downloaded as an artifact — the way
somebody who fetched a release meets it, with no Docker and nothing of
wikven's installed.

Three assertions, in this order, because the order is load-bearing:

| | asserts | where |
|---|---|---|
| a bake with no `src` | a refused bake exits non-zero | both |
| Scribunto's bundled lua, nothing configured, **before anything is
installed** | the out-of-the-box claim | x86_64 |
| `luaPath` at an interpreter the host provides | the arrangement that
works anywhere | both |

Putting the bundled-lua bake after the install step made it pass for the
wrong reason on the first run: `/usr/bin/lua5.1` was on `PATH`, which is
one of the things `luaEngineAvailable()` looks for, so the bake was
never asked whether it can manage without one.

A pull request builds x86_64 only; the arm64 leg runs on the nightly and
on releases, which is where an arm binary exists at all. It was
exercised for this branch on the throwaway #482.

## What it found

**1. A relative `WIKVEN_WORKDIR` broke the skin pass.** The first bake
ever run got through the import and the job queue and died:

```
--- vector-2022 pass failed (exit 1) ---
DBQueryError ... Error 1: no such table: page
Query: SELECT MIN(page_id) FROM "page"
#9 .../extensions/Wikven/maintenance/build.php(842): RebuildFileCache->execute()
```

A skin pass is spawned with the MediaWiki root as its working directory
— `proc_open(..., $GLOBALS['IP'], ...)`, because the binary's php-cli
needs script paths relative to the install root — and inherits
`WIKVEN_WORKDIR`. Inside the binary that root is the temporary directory
the embedded app is unpacked into, so a relative value means one place
in the orchestrator and another in every pass: the pass opened an empty
database beside the unpacked app instead of the one just filled. Not a
corner case — it is what `binary.Dockerfile`'s own usage note documents,
`WIKVEN_WORKDIR=. ./wikven build`. Under Docker the workdir is
`/workspace`, absolute, which is why it never showed up. Resolved with
`realpath()`.

**2. The engine check refused sites it could have baked.**
`luaEngineAvailable()` looked for `luasandbox`, a configured `luaPath`,
and `lua` on `PATH`, and never for the interpreter Scribunto carries in
its own source tree — which is what `luastandalone` runs when `luaPath`
is null, and what `luaautodetect` falls back to when `luasandbox` is
absent. It looks for it now, and **runs** it rather than trusting
`is_executable()`: `LuaStandaloneInterpreter` picks among the bundled
binaries by `PHP_OS` and `PHP_INT_SIZE` and never by architecture, so on
arm the file it selects is an x86-64 ELF that exists, is executable, and
cannot start. Executing it is what makes mirroring that choice safe — a
wrong guess answers no rather than promising Lua that never arrives.

**3. Every failed build exited 0.** Caught by the assertion added here,
on both architectures:

```
wikven: no source directory at /home/runner/work/wikven/wikven/empty/src
a bake with no source directory exited 0
```

`reexec()` returned the child's exit code with a nil error, meaning to
propagate the status without Caddy printing its own line over the
child's diagnostic. Caddy 2.11.4, `cmd/cobra.go`:

```go
status, err := f(Flags{cmd.Flags()})
if status > 1 {
    cmd.SilenceErrors = true
    return &exitError{ExitCode: status, Err: err}
}
return err
```

A status of exactly 1 is never turned into an exit status, and an
ordinary failed build is 1. So a Makefile, a CI job or a deploy script
running `wikven build` read every refusal as a success — the Lua
refusal, a missing source directory, a failed skin pass, all of them.
`reexec()` exits instead of returning; returning `(1, err)` would exit 1
but print Caddy's error line over the diagnostic the child already
wrote, which is what the original nil was avoiding.

## The documentation

`Lua modules` and `Standalone binary` both said the binary cannot render
Lua. Written before anything had baked with it, and wrong in two cases
of three:

| Product | Engine | What you configure |
|---|---|---|
| Docker image | `luasandbox`, compiled into its PHP | nothing |
| Standalone binary, x86-64 | the interpreter Scribunto ships | nothing
|
| Standalone binary, arm64 | an interpreter you install | `luaPath` |

Every row is measured rather than reasoned. `lua5.1 5.1.5-9build2`
installs from `universe` on the arm runner, no `luajit` fallback needed,
and the bake with `luaPath` pointed at it finished with the module's
answer in the page. arm64 with nothing installed refuses cleanly — no
output written, and now a non-zero status.

`Standalone binary`'s "What it cannot do" becomes "What it asks of the
host", which is what the section now describes. Korean follows,
restamped.

## Not done here

`binary` is still 504s and still off the critical path of nothing, which
is #479's problem, not this one's.

---------

Co-authored-by: Claude <noreply@anthropic.com>
lens0021 added a commit that referenced this pull request Aug 20, 2026
Closes #455.

## The duplication

The order of the documentation was written down twice: as
`MediaWiki:Sidebar`, and again as the arguments of the `{{prevnext}}` at
the foot of each of twenty-one pages. Nothing checked that the two
agreed, and twice they did not — a page reached the sidebar with no call
naming it, and the last page of the chain had no row at all, because a
lone argument read as the *next* step and no positional form could spell
"nothing after this".

They agree today, as it happens: twenty sidebar entries, and the chain
covers all twenty in the sidebar's order with `index` at the head. That
is the state to keep, not a state to rely on — both past divergences
were repaired by hand, and the shape that produced them was untouched.

## The change

`Module:Sequence` reads the sidebar, so `{{prevnext}}` takes no
arguments. Adding a page is now two edits instead of three, and the
third was the invisible one.

The direction is forced, as #455 worked out: `MediaWiki:Sidebar` is read
line by line by the skin rather than parsed as wikitext, so it can call
nothing; but it is an ordinary page, so anything can read it.

What the sidebar contributes is **order alone**. Labels stay in
`Template:prevnext/label`, which reads each target's own translated
title — so none of this reaches the translations. A prevnext sits
outside the `<translate>` tags, and no `ko.wikitext` changed in this
branch.

## The two decisions #455 left open

* **The group boundary.** The sequence keeps crossing from the docs
group into the references group, as it does today. A reader who reaches
the end of the first group is better sent on than stopped.
* **`index`.** It heads the sequence without being a sidebar entry, so
the module prepends it. The alternative — a sidebar entry for it — would
duplicate what every skin's logo already points at, which is why the
sidebar's own comment says it has none.

## The condition #455 set

> **check first that it runs in the image**: Scribunto needs either the
LuaSandbox PHP extension or a standalone `lua` binary, and neither is
obviously present in the Alpine-based image. If it is missing, this
proposal costs a Dockerfile change, and that changes the trade.

It runs. The base image does `pecl install LuaSandbox-4.1.2`, and this
site has rendered `Module:Example` since #473. **No Dockerfile change**,
which is what tips this over the alternative — #455's cheaper option was
a check that asserts the two copies agree, and a check that watches a
duplication is worth less than not having the duplication, once the
module is free.

## What holds it

`smoke` gets what the old shape got wrong, stated as three assertions:
every page the sidebar names has a navigation row, `index` has no
previous link, and `Licenses` has no next. The existing check that
`Searching/ko`'s row carries Translating's *Korean* title still stands
and now also proves the module renders at all.

Draft because the module cannot be run here — no Docker on this machine,
so the first real execution is CI's bake.


---
_Generated by [Claude
Code](https://claude.ai/code/session_01Uv1RzRurUH6wrgV5E9PESQ)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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.

Scribunto works under Docker and not in the standalone binary, and the docs say nothing about it

2 participants