This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Two local references back every Factorio question in this repo, and both are git-ignored, so a fresh clone has neither:
factorioLuaAPI/ |
Lua API docs | ~286 MB |
~/GitHub/factorio-data |
game data Lua (the map-gen source) | ~17 MB |
pnpm refs:sync creates and pins both to the version your installed
Factorio binary reports (~6 s from nothing, ~0.5 s when already in sync).
pnpm refs:sync --check reports drift without changing anything, and
pnpm refs:sync 2.1.11 pins to an explicit version instead. If either
directory is missing or a grep turns up empty, run it before concluding
anything is absent.
Why pinned to the binary rather than latest: Steam updates the binary without
asking, so it is the one version you do not control. Fetching "latest" for the
references races that updater and leaves them describing a different game than
the one your fixtures were captured against - which is exactly how
factorio-data ended up sitting at 2.1.11 under a 2.1.12 binary.
Before answering any Factorio API question or WebFetching
lua-api.factorio.com / wiki.factorio.com, grep this directory. It is the
authoritative source for how the map generator, noise expressions, and map-gen
settings work. pnpm refs:sync populates it from the official archive at
https://lua-api.factorio.com/<version>/static/archive.zip, flattened so the
paths below resolve; factorioLuaAPI/VERSION records which version it holds.
Useful entry points:
factorioLuaAPI/auxiliary/noise-expressions.html- named noise expressions and thecontrol:<name>:frequency|size|richness|biasconstants (e.g.control:moisture:frequency,control:aux:bias,control:temperature:*- the exact keys this app'sproperty_expression_namescodec round-trips).factorioLuaAPI/types/MapGenSettings.html,types/FrequencySizeRichness.html,types/AutoplaceControlID.html- map-gen settings structure and autoplace controls.factorioLuaAPI/runtime-api.jsonandprototype-api.json- machine-readable dumps; grep these for a signature/field faster than the HTML.
The JSON dumps are not a superset of the HTML - control:temperature:frequency
is in noise-expressions.html and nowhere in runtime-api.json - so grep the
whole directory, not just the JSON. Only fall back to WebFetch if something
genuinely is not in this mirror.
factorioLuaAPI/ above is the API docs. For the actual base-game map-gen
source (the noise expression trees, autoplace utils, resource prototypes)
that the client-side preview ports, read ~/GitHub/factorio-data - a clone of
the official wube/factorio-data repo with per-version git tags. pnpm refs:sync clones it if absent and checks out the tag matching the binary,
then verifies base/info.json actually reads that version (a checkout is not
proof; master may sit a few commits ahead of the newest tag).
Key files, in rough order of how often they matter here:
core/prototypes/noise-programs.lua (most named expressions - elevation,
cliffs, climate, trees), core/prototypes/noise-functions.lua
(resource_autoplace_all_patches), base/prototypes/noise-expressions.lua
(enemy bases, rocks), base/prototypes/tile/tiles.lua (tile autoplace),
base/prototypes/entity/trees.lua, and
space-age/prototypes/planet/planet-vulcanus-map-gen.lua. To locate anything
else, grep for its definition rather than guessing the file - a bare name
grep returns every caller too:
grep -rlE 'name *= *"<expression>"' ~/GitHub/factorio-data/{core,base,space-age} --include="*.lua"Version skew here is a real, silent hazard, not a formality.
starting_patches changed materially between 2.0.77 and 2.1.9 - radius
120 -> 150, region_size *2 -> *3, spacing 32 -> 48, the random_penalty
favorability term removed, a new 40-tile origin_excluder, and the lake mask
switched from a hardcoded elevation_lakes to the planet's own elevation.
Reading the wrong version's Lua produces a port that passes its own tests and
disagrees with the game. pnpm refs:sync --check before trusting a reading.
Note where that change lived: core/prototypes/noise-functions.lua. Neither
core/lualib/resource-autoplace.lua nor base/prototypes/entity/resources.lua
moved at all between 2.0.77 and 2.1.12, so guessing by filename would have
cleared the resource fixtures wrongly. noise-functions.lua and
noise-programs.lua are themselves unchanged across 2.1.9 - 2.1.12.
The Steam build ships unstripped - 1,088,238 symbols, a 27.9 MB string
table, and 375,101 STAB debug-map entries - so nm + c++filt resolve map-gen
internals directly (e.g. Noise::setSeed(unsigned int, unsigned char)). That
makes it the fastest oracle for a short generator function; see
docs/noise/basis-noise-NOTES.md. It lives at:
~/Library/Application Support/Steam/steamapps/common/Factorio/factorio.app/Contents/MacOS/factorio
Steam keeps it updated, which is fine: factorio.com/download/archive/ carries
every release from 0.6.4 onward, so reproducing an old measurement means
recording the version, not hoarding installs. Set FACTORIO_BIN to point
refs:sync at a different install.
A lot can be driven from the command line - see
https://wiki.factorio.com/Command_line_parameters (the game's own binary; this
is a wiki page, not in the factorioLuaAPI/ mirror). Relevant here:
- Map-gen testing / validation:
factorio --create <save> --map-gen-settings <json> --map-gen-seed <n> --mod-directory <dir>runs headless and exits cleanly even alongside a running game if you point an isolated--configINI'swrite-dataat a temp dir. This is how the codec is cross-validated against the game's own parse (a dumper mod callshelpers.parse_map_exchange_stringand writes JSON) - the resulting fixture lives attest/fixtures/map-exchange-parsed.default-seed123456.dump.json. - Preview rendering:
factorio --generate-map-previewis exactly whatpreview-service/container/shells out to.
Prefer the game as an oracle over byte-diffing when settling a codec question.
Run vp (Vite+) through pnpm - pnpm vp <cmd> - which is what every script
in package.json does.
npx vp fails; a bare vp does NOT. This line used to say both forms fail
with EBADDEVENGINES, and half of that was wrong (re-measured 2026-08-04). The
project pins pnpm via devEngines, so npx vp check dies with
EBADDEVENGINES ... Invalid name "pnpm" does not match "npm" - but the global
vp binary (v0.2.7) is not npm and runs fine: bare vp check exits 0 and
reports all 367 files formatted. Prefer pnpm vp anyway, because it is the form
the scripts and CI use and so the one that stays verified; just don't expect a
bare vp to fail, and don't "fix" a working command on the strength of this
note.
Node 26.7.0 (.node-version) is what the repo is developed and verified on.
engines.node stays a permissive floor (>=24.18.0) rather than matching the
pin - older versions are simply untested, not known-broken.
.node-version is machinery now, not documentation. That changed when
.github/workflows/verify.yml landed: actions/setup-node reads the file via
node-version-file, so it is what CI actually installs. Nothing local consumes
it still (node comes from Homebrew, no version manager is installed) and
Cloudflare Pages never builds this repo - deploy:app uploads an already-built
dist - so an edit to it changes the version the gate runs on and nothing else.
Bump it only alongside a local pnpm run verify on the new version.
Adding a root dependency needs pnpm add -w (or --workspace-root); a bare
pnpm add <pkg> at the root fails with ERR_PNPM_ADDING_TO_ROOT. Prefer
targeted pnpm add over pnpm up for dependency bumps - see the type-checking
note below for why pnpm up's transitive re-resolution can break vp check.
Always follow any add with a bare pnpm install: add relinks only its own
workspace and leaves sibling workspaces' symlinks dangling. Only the full
install prints Scope: all 3 workspace projects.
The 24-hour release-age guard is real but invisible to pnpm config.
pnpm config get minimumReleaseAge reports undefined, which reads like "no
policy here" - it only reports what is explicitly set, and nothing in this
repo or ~/.npmrc sets it. The value comes from pnpm 11's own defaults table,
"minimum-release-age": 24 * 60, // 1 day (read out of pnpm 11.17.0's shipped
code, 2026-07-29). So a package published less than a day ago will not install
without pnpm writing a minimumReleaseAgeExclude bypass into
pnpm-workspace.yaml - which is the thing to watch for in a diff. Don't
conclude the guard is off because config get came back empty, and don't rely
on it silently either: a pnpm 12 could change the default, so if it ever
matters, set it explicitly.
pnpm install- install depspnpm vp dev- dev serverpnpm vp test- full test suite (Vitest-compatible; tests import from"vite-plus/test")pnpm vp test test/controlScale.spec.ts- a single test filepnpm vp check --fix- format + lint + type-check of.ts, the main static-check step (see the type-checking note below). It does not see inside.vuebodies - that ischeck:vue's job, and the two together are the full net.pnpm run check:vue-vue-tsc --noEmit, the type-check of<script setup>bodies in the 22.vuefiles (~2.1s). Nothing else checks them.pnpm vp build- production build
vp dev is exercised by NOTHING - not verify, not CI. The build job
covers vp build, and the test shards cover vp test, but the dev server has
no automated coverage at all, while both dev and preview:app depend on it.
That gap has teeth on a vite-plus bump specifically: 0.2.8 changed bare vp dev
at a monorepo root to resolve a target package, with non-interactive runs
listing candidates and exiting 1 rather than serving. This repo is a monorepo
root, so that is a plausible break with a green CI. It did not break (checked
by hand on the PR branch: serves normally, no package picker, /version.json
answers on the chosen port), but nothing in the gate would have said so. Check it
by hand on any vite-plus bump:
pnpm vp dev --port 5199 --strictPort # expect a Local: URL, not a picker or exit 1-
pnpm run verify:lint-vp check+check:vue. Exists so CI can run the static phases without the app suite;verifycomposes it rather than repeating the commands, so there is still one definition of each phase. -
pnpm run verify:static-verify:lint+preview:test. Everything inverifythat is not the app suite. This is thestaticCI job. -
pnpm run verify:shard- barevp test, for CI's sharded matrix. Takes a passthrough arg:pnpm run verify:shard -- --shard=1/3. The--is required. -
pnpm run require:docker- preflight that fails loudly when no container runtime is reachable, naming the start command for whichever one you have installed (scripts/require-docker.ts).preview:devandpreview:deployrun it first, since both build the Factorio image. Deliberately not inpreview:testorverify- those must keep passing on a runner with no Docker at all, which is what makes the CI workflow possible. Auto-start is opt-in behindFMW_AUTO_START_DOCKER=1. -
pnpm run verify-verify:lint+vp test+preview:testin one gate. ~65-90s on a dev machine. On a runner it is no longer one job - see the CI section, which shards it. The~9.5sthis line claimed for a long time was simply wrong - already wrong by a factor of six beforecheck:vueexisted, because the suite grew through the Vulcanus and cliff work (143 files then; 152 as of 2026-08-01, which is what thetest/**/*.spec.tsinclude actually matches) - and the gap mattered: ~63s is exactly the duration at which people start skipping a manual gate, which is half the argument for the CI workflow below. Don't budget 10 seconds for this.The test phase runs through
vp run --cache test, not a barevp test. Measured 2026-08-02: the four phases arevp check2.0s,check:vue3.0s,vp test61.2s,preview:test3.1s - so one phase is 88% of the gate and it is the only one worth caching. That phase alone goes 62.0s cold to 0.6s warm; the whole gate goes 64.9s to 7.0s, the remainder being the three phases that are not cached.The cache is content-keyed, and that was established by trying to break it rather than by reading the flag's docs: an edit to a source file misses and re-runs; a planted failing assertion misses and still fails with rc=1, so a hit cannot mask a regression; and a
touchthat changes only the mtime still hits, which is what proves it hashes contents. Only the most recent result is stored, so reverting to a previously-seen tree misses.Consequences worth knowing before reading a fast
verifyas a skipped one:- It is a no-op in CI. Every runner starts cold, so the required
verifycheck runs in full whatever this flag says. - It pays on
deployand almost nowhere else.deploy:apprunsverifyimmediately after you have probably just run one by hand. The normal edit -> verify loop misses every time, by design. - A hit is a replay, not a run. Legitimate for file content, per the
probes above. What is NOT established is whether the key covers inputs
outside the tree - env vars, the node version, or whether a Factorio install
appeared or vanished (the oracle specs are
it.skipIf(!oracleAvailable()), so their skip status can change with no file changing). If you are chasing something environmental rather than something you edited, clear it withvp cache clean, or callvp testdirectly.
Changes that were measured and rejected, so they don't get retried: running the four phases in parallel is only 60.6s against 69.3s serial (13%), and it turns a 2s type error into a 61s one because
vp checkno longer runs first - it would also need a new script, since adependsOn: ["check"]would pull in thecheckscript, which isvp check --fixand must never run in a deploy path. AndmaxWorkersis already at its optimum: 4 -> 74.7s, 8 -> 61.7s, 11 -> 61.8s against a default of 61.2s, because the extra cores on this machine are E-cores.Three more, measured 2026-08-03 while sharding CI:
isolate: falseis not available to this suite. It fails 66 of 171 files. Those same files pass individually with--no-isolate, so it is cross-file module-state pollution, not a misconfiguration - the field DAG's memo caches are module-level. It only bought 7.6% anyway (68.24s -> 63.07s).--reporter=blob+--merge-reportsdoes not work here. Blob writes correctly, butvp test --merge-reportsdoes not merge, it re-runs: a 57-file shard's blob came back reporting 114 files. Vite+ is not bare vitest on this path. That is why the sharded CI job uploads no artifacts.- The wall clock is set by the slowest FILE, not by total CPU. The suite
is 497s of CPU in 68s of wall (~7.5x on 12 cores), and
test/previewAgreement.spec.tsalone is 67s of that 68s. Removing it drops the suite to 52.76s. So splitting the top files is the only local lever, and it is worth ~2s because the throughput bound (~66s) binds almost as hard.environment: "node"by default is worth ~3s more (only ~30 of 164 spec files touchdocument/window). Neither is worth the churn; see #119.
- It is a no-op in CI. Every runner starts cold, so the required
-
pnpm refs:sync- pinfactorioLuaAPI/+~/GitHub/factorio-datato the installed binary's version (--checkreports drift only;--fixturesreports which oracle fixtures predate the binary). Deliberately not part ofverify, which must pass on machines with no Factorio installed. -
pnpm run deploy- verify + build +wrangler pages deployto Cloudflare Pages -
pnpm run verify:deploy- after deploying, confirm the live site is running localHEAD(see below). Takes an optional origin argument.
.github/workflows/verify.yml runs on every pull request and every push to
main. Until 2026-08-03 it ran pnpm run verify verbatim as one job. It no
longer does, and the note that used to sit here said so emphatically ("do not
mirror the change into the YAML") - if you are here because the YAML does not
match that instruction, the instruction is what changed.
Why it changed: the single job measured 9m03s (PR #116), of which the test phase is ~95%. A runner is ~3x slower than a dev machine, and only 4 cores, so the phase that is 88% of the local gate dominates a CI run completely. Four jobs now run in parallel:
| job | what |
|---|---|
static |
pnpm run verify:static - vp check, check:vue, preview:test |
tests (1..3, 3) |
pnpm run verify:shard -- --shard=N/3 - the app suite |
verify |
the required check: asserts every job above succeeded |
build |
pnpm vp build, unchanged (issue #61) |
Measured result: 9m03s -> 4m36s.
The anti-drift rule still holds, by a different mechanism. The point of
"verbatim" was that there is exactly one definition of "this repo is
consistent". That is now enforced by the workflow naming only package.json
scripts - never the underlying commands - and by verify, verify:static
and verify:shard all composing the same verify:lint. Do not inline
vp check or vue-tsc into the YAML; add or edit a script instead.
Two traps in that file, both of which look like tidying:
- The job named
verifydoes no work, and must keep that name. RulesetEJrequires a status check calledverify; a required check that never appears blocks every PR forever, on a check that cannot run. Renaming that job needs a ruleset PUT in the same change - see the two-step below. - It asserts
needs.*.resultexplicitly rather than relying onneeds:. A job whose dependency failed is skipped, and a skipped required check does not block a merge. Deleting those assertions would make a red suite mergeable.if: ${{ !cancelled() }}rather thanalways()is also deliberate: a superseded push should stay cancelled, not become a failure.
Why three shards and not four, measured rather than guessed: vitest splits
by file, so one file is an unsplittable floor, and
test/previewAgreement.spec.ts is 67s of the 68s local suite. Slowest-shard
wall - which is what the gate waits on - is 55.7s at N=3 and 53.5s at N=4, i.e.
inside run-to-run noise, for 33% more runner minutes. Raise it only after
splitting that file into its three independent tests, and re-measure. Issue #119
tracks re-measuring this on CI, because a 12-core dev box cannot separate
the file floor from the CPU bound and a 4-core runner may answer differently.
A second job, build, runs pnpm vp build in parallel (issue #61). verify
is check + type-check + tests and none of them build, so a change could pass all
three, break the production build, and only surface days later when somebody
deployed. It is a separate job rather than a fourth phase of verify because
deploy already runs pnpm build right after pnpm run verify - folding it in
would build twice per deploy and slow the gate people run by hand. It does
not enforce zero warnings; the job comment records both routes to that and
why each was rejected.
Conventions that file establishes, and that anything added under .github/
should keep:
- Third-party actions are pinned to a full commit SHA, with the release named
in a trailing
# vX.Y.Zcomment. Never a moving tag.helpers:pinGitHubActionDigestsin the Renovate config makes that automatic for actions added later, and Renovate updates the SHA and the comment together. permissions:is declared explicitly and minimally (contents: read). Do not fall back on the default token scope.- No
version:input onpnpm/action-setup. v6+ readsdevEngines.packageManagerfrompackage.json, so the pnpm pin lives in one place. It must run beforesetup-node, becausecache: pnpmresolves the store path by invoking pnpm. - No secrets, no deploy job. Cloudflare Pages does not build this repo, so CI
is a check only.
pnpm refs:syncis absent for the same reason it is absent fromverify: no runner has a Factorio binary. - The
buildjob's default shallow checkout is correct, and that was measured. #61 assumed the build stamp needed deeper history; it does not.scripts/buildStamp.tsrunsrev-parse HEAD,rev-parse --short HEADandstatus --porcelain- it reads git state, not history. On apull_requestevent the checkout lands on the merge commit, so that job's stamp is a synthetic SHA; harmless only because CI never deploys its artifact.
preview:test needs no Docker on a runner, which was confirmed rather than
assumed: the worker tests are pool-workers (workerd arrives from npm) and the
container tests are node --test against render.mjs.
Renovate, not Dependabot - .github/renovate.json5. The reason is that this
project's dependency decisions are holds with reasoning behind them, and
Dependabot's ignore entries cannot express them; Renovate's packageRules +
prBodyNotes can, so the reasoning arrives attached to the proposal. typescript
is disabled outright, pako carries a 14-day age and a pointer at the
byte-exactness invariant, wrangler + @cloudflare/vitest-pool-workers are
grouped because pool-workers hard-pins wrangler, and the brace-expansion
override and engines.node floor are both marked as deliberate rather than stale.
enabled: false disables SECURITY updates too, and brace-expansion proved
it. That rule exists to stop Renovate proposing the 5.x spike, but it also
means no bot PR can ever arrive for the 2.x branch - including a CVE fix. On
2026-08-10 the pin was sitting at 2.1.3 against GHSA-rgw5-rvv9-x895
(published 2026-08-03), whose whole subject is bypassing the CVE-2026-14257
mitigation 2.1.3 was pinned for; 2.1.4 had been available since 2026-07-30.
Nothing was going to surface that, because the note in pnpm-workspace.yaml
said a red pnpm audit line was the expected state - which had been true of the
previous advisory and had since stopped being true. Any package held with
enabled: false needs re-checking against the advisory database by hand; read
the comment on the override before concluding a red audit is the known one.
That group's prBodyNotes says to regenerate the worker types BEFORE merging,
and that ordering is the whole point. It used to say after, which this file
flagged as a bug to fix; the config was corrected and the note now reads
correctly - confirmed on 2026-08-10 when #169 hit exactly this. The regen is a
precondition, not a follow-up: types:check runs inside preview:test, which
runs inside the required verify check, so a stale workerd stamp means the PR
cannot merge at all. This is not hypothetical - it is why #97 sat red, and why
#169 arrived red a year later with the fix named in its own PR body. The fix is
one script, which exists precisely so the formatter pass cannot be forgotten
(#177):
pnpm run types:syncOne interaction is worth knowing before touching that file. The workspace's
release-age guard is a pnpm default, not a line in pnpm-workspace.yaml, and
pnpm's response to being asked for something too fresh is to write a
minimumReleaseAgeExclude: bypass - which is how vue-tsc@3.3.8 once waived it
silently. minimumReleaseAge: "3 days" is therefore declared in the Renovate
config, above pnpm's default, so Renovate can never propose a release pnpm would
want a bypass for. If minimumReleaseAgeExclude: appears in a bot PR's diff,
that PR is wrong; fix the age rule, don't commit the bypass.
The app is live as of 2026-07-30 - enabled with "Automated PRs", "Require
config file" and "Create onboarding PRs". So Renovate opens real PRs on its own
now; automerge: false is what keeps anything from landing unread, and
dependencyDashboardApproval is deliberately unset because it would re-impose
scan-only behaviour at the config layer and defeat the app setting.
"Require config file" is the one with teeth: a config that fails to parse makes
Renovate do nothing at all, silently, which is indistinguishable from "no
updates available". Validate any edit with renovate-config-validator (run it
from outside the project root - a bare npx here fails with EBADDEVENGINES).
Two settings whose reasoning is not guessable from the outside:
lockFileMaintenanceis pinned off. It is automatedpnpm upfor the lockfile - the one dependency operation measured as harmful here, since transitive re-resolution is what triggered theTS2321pathology below.vulnerabilityAlerts.minimumReleaseAgeis"25 hours", not0. Security fixes skip the weekly window, but they cannot skip pnpm's 24-hour floor: a same-day PR would make pnpm write theminimumReleaseAgeExclude:bypass this whole section exists to prevent. 25 hours clears pnpm and still drops the wait from 3 days to ~1.
main is protected by a repository ruleset named EJ (2026-07-30, issue
#60), not by classic branch protection. Read it with
gh api repos/wormeyman/FactorioMapWebUI/rules/branches/main - the classic
/branches/main/protection endpoint returns 404, which looks exactly like
"unprotected" and is not.
| rule | |
|---|---|
pull_request |
required_approving_review_count: 0 |
required_status_checks |
verify + build, strict: true |
deletion, non_fast_forward |
blocked |
bypass_actors |
empty - binds the owner too |
Three things here are load-bearing and easy to break by "tidying":
verifyis now a gate job that does no work. Since the CI sharding above, the check by that name only asserts thatstaticand the threetestsshards passed. It looks deletable and is not: the ruleset matches required checks by name, so renaming or removing that job makes the requiredverifynever appear, which blocks every PR permanently.- The review count is 0 on purpose. GitHub does not let you approve your own
PR, so
1would makemainunmergeable by its only maintainer - a lockout that looks like correct hardening until the first PR. strict: trueis what makes the Renovate automerge rule safe. With strict checks a PR cannot merge having passed against a differentmainthan the one it lands on..github/renovate.json5automerges GitHub Action digest re-pins and only those; if bypass actors are ever added,verifydropped, or strict turned off, that rule must be removed in the same change. It is not independently safe, and the config says so at the rule.
Adding a new required check is a two-step, in this order. Land the job
first, confirm it ran green on main, and only then add its context to the
ruleset. Requiring a check that does not yet exist on main blocks the very PR
that introduces it, on a check that cannot run. build was added this way on
2026-07-30: merge #64 -> green on main -> PUT /rulesets/20021316. Send the
whole rules array in that PUT (fetch it first with
gh api repos/:owner/:repo/rulesets/20021316); it replaces rather than merges.
Note the second-order effect of strict: true: once a PR merges, every other
open PR is behind and needs Update branch before it can merge.
Everything else stays automerge: false, because verify proves the repo is
consistent, not that a bump is correct - see the pako table above for the year-long
wrong belief that a green suite endorsed.
Vitest's 5s default was too tight for this suite long before CI existed - 24
individual tests across 10 files carry an explicit }, 120000), which is the
same complaint made 24 times by hand. The first CI run proved the default was the
real problem rather than any one test: on a 4-core runner (~3x slower, 230s vs
71s for the same suite) elevationRenderRequest.spec.ts's view 'all' case
needs 9.8s, and that file has 27 tests and zero annotations. vite.config.ts
now sets testTimeout: 30_000; the existing 120000 annotations still win over it.
Do not reach for retry when a heavy render test fails in CI. Nothing here is
nondeterministic - these tests compare pixels against captured game output - so a
retry would only hide a genuine regression. A timeout means slow; read the
duration the reporter prints before assuming a hang.
Both are set in vite.config.ts (and, inertly, in the worker's config) as of
#144. vi.restoreAllMocks() - which a few files call in an afterEach - undoes
vi.spyOn spies and does not undo vi.stubGlobal, so before this a stubbed
global leaked into every later test in the same file.
The leak was real but nothing depended on it, which is the part worth
knowing: turning the flags on changed no existing test, and the two
previewPanel.spec.ts tests that were inheriting a URL stub pass alone too. So
the suite cannot tell whether these flags are set, and deleting them would be
silent. test/mockLeakGuards.spec.ts is the observation that makes them
load-bearing - two dirty/clean test PAIRS, deliberately order-dependent, each
failing with a message naming the missing flag. Both were confirmed to
discriminate by flipping each flag off and watching only its own pair fail.
Two weak-assertion patterns were checked and cleared, so don't re-audit them:
expect(wrapper.find(sel).attributes("disabled")).toBeUndefined() is not vacuous
on a missing element - @vue/test-utils throws Cannot call attributes on an empty DOMWrapper - and presetReset.spec.ts's activePreset?.x assertions are
not vacuous either, because a seeded preset makes them discriminate. Both were
settled by planting the failure, not by reading the code.
Both deploy paths refuse to ship a broken tree. deploy:app runs
pnpm run verify first, and the Worker's own deploy runs its test script
(which itself chains wrangler types --check). Verified by planting failures:
a type error and a failing test each stop the chain before wrangler is
reached, and a clean tree passes through.
Note verify uses plain vp check, not the check script - that one is
vp check --fix, and a deploy must never silently rewrite files on its way out.
The app deploy is gated on the whole monorepo, preview-service included, so a
Worker test failure will block an app deploy. That coupling is deliberate: it
means "the repo is inconsistent, don't ship." To deploy anyway in an emergency,
run the two steps by hand rather than adding a bypass script:
pnpm build && pnpm --filter @fmw/preview-worker exec wrangler pages deploy dist \
--cwd ../.. --project-name factoriomapwebui --branch main --commit-dirty=trueThe app is live at map.factorygamefan.com. The apex factorygamefan.com
is a separate landing page, not this app; the worker's ALLOWED_ORIGIN is the
map. subdomain.
Never confirm a deploy by grepping the live bundle. That is what was done
before, and it produced a false negative: a grep for a version string returned
zero because the minifier had turned the string into a numeric array, so a
shipped fix looked missing. Matching the hashed index-<hash>.js filename by eye
against the build log is the same class of fragile.
Instead the build emits a git-derived stamp to two places from one read:
- the titlebar shows
build <short sha>(-dirtywhen the tree had uncommitted changes - a deploy from a dirty tree is exactly when the SHA alone lies), and /version.jsoncarries the same object, machine-readably.
scripts/buildStamp.ts computes it and buildStampPlugin feeds both the
__BUILD_INFO__ define and the emitted asset from the same BuildInfo.
src/model/buildStamp.ts is a reader over that define - do not compute
anything there. Two independently computed stamps that could disagree would be
worse than none, and test/buildStamp.spec.ts pins that they don't.
pnpm run verify:deploy [origin] fetches that JSON with caching bypassed and
compares the commit against local HEAD: 0 = live is your HEAD, 1 = it is not
(and names the commit that IS live), 2 = the check could not be made, which is
not a pass. It works against vp dev too, because the plugin serves
/version.json from a dev middleware as well.
public/_headers gives that one path Cache-Control: no-store. Its URL is
constant across deploys, unlike the hashed bundles, so without that the edge
would happily answer with the previous deploy's stamp - an authoritative-looking
wrong answer. The rule sets no CSP, so the /* policy still applies unchanged;
script-src must never regain 'unsafe-eval' and the spec asserts it hasn't.
Preview-service stack (optional feature, needs Docker): pnpm localpreview
(memorable alias for pnpm preview:dev) runs the Worker (:8787) + app
(:5173) together; pnpm preview:test runs its unit tests. Both bind localhost
only - never add --host. See README for the full list.
preview:dev and preview:deploy are gated on require:docker, so a stopped
daemon now fails immediately with the start command for your runtime instead of
a wrangler build error several screens deep. preview:test is not gated -
it needs no Docker at all, which is what lets CI run it.
A static, backend-free SPA (Vue 3 <script setup> + Pinia) for authoring
Factorio map-generation presets, plus an optional Cloudflare preview
service in a separate workspace.
src/codec/mapExchangeString.ts decodes a map-exchange string to a
DecodedExchange and re-encodes it. The encoder must reproduce the game's zlib@9
stream byte-for-byte - re-emitting a string must equal the original.
Consequences that constrain any change here:
-
Deflate goes through
pakoat{ level: 9, legacyHash: true }, and that option is load-bearing - seesrc/codec/deflate.ts. The requirement is madler-zlib-compatible output at level 9, not any particular package. Measured against the 9 fixtures (Node v26.5.0,process.versions.zlib1.2.12; decode base64 -> inflate -> re-deflate -> compare):candidate byte-exact node:zlibdeflateSync({level:9})9/9 pako@3.0.1deflate(b,{level:9})(defaults)0/9 pako@3.0.1deflate(b,{level:9,legacyHash:true})9/9 pako@2.1.0deflate(b,{level:9})9/9 fflate@0.8.3zlibSync(b,{level:9})0/9 A level-1 deflate matches 0/9, confirming the comparison discriminates. fflate genuinely does diverge - it is an independent reimplementation and no option fixes it. Inflate is not a constraint at all: pako's
inflate,node:zlib, andDecompressionStream('deflate')all agree on all 9.Why the old belief ("pako diverges, so a WASM build of zlib is the live replacement path") was held, and why it was wrong. It was a true measurement of a false generalisation. pako 2.2.0 (2026-06-22) added an alternate, faster deflate hash behind a new
legacyHashoption defaulting totrue; pako 3.0.0 (2026-06-26) flipped that default tofalse. This repo adopted^3.0.0on 2026-07-01, five days later, and measured pako at its defaults - the one configuration that cannot match canonical zlib. The divergence was real; "no configuration of pako can match" was never tested. Issue #40's premise (zlib-asm is load-bearing) is refuted, and no WASM build is needed.The new risk is different and worth naming:
legacyHashis a pako extension, not part of the zlib API, from a library that has already flipped its default once in a major version.test/deflate.spec.tshas a dedicated block that fails with a message naming the option if it is ever dropped, renamed, or re-defaulted. Do not silence it by editing a fixture. -
The CSP does NOT need
unsafe-eval, and must not regain it. Nothing the app bundles usesevalat all -pakois plain ESM. This used to need a caveat: the codec was backed byzlib-asm, an abandoned (2016) asm.js port that shipped threeevalsites and needed a localpatches/zlib-asm.patchto strip them. That dependency, its patch, and both of itsvite.config.tsbuild-warning suppressions are gone. -
The exchange format is versioned and it moves.
SUPPORTED_VERSIONSis a known-good list (2.1.9.3,2.1.12.2), never a range - the schemas here are empirical, so accepting an unseen format would decode a changed layout into plausible wrong values. A version joins the list only with a fixture proving a real string of it round-trips byte-exact (test/mapExchangeVersions.spec.ts). This was a live bug: the app rejected every string from Factorio 2.1.12 until 2026-07-28. The UI now advertises the target so the next drift is visible. -
src/codec/fieldSchema.ts(readFields/writeFields) drives the typed binary layout;binaryReader/binaryWriter/crc32/base64are the primitives. -
test/fixtures/builtin-presets.json(9 presets captured from the game) is read-only ground truth. Codec tests decode→re-encode each and assert the bytes are identical. Never edit a fixture or an expected value to make a test pass - a mismatch is a real finding.
test/fixtures/PROVENANCE.json records, per fixture, the Factorio version its
ground truth was captured from and the evidence for that claim. It sits
beside the fixtures rather than inside them because several are verbatim copies
of the game's own JSON (autoplace-can-be-disabled.dump.json is a flat dict
keyed by control name, asserted key-for-key in catalog.spec.ts), so an added
metadata key would be data pollution.
test/fixtureProvenance.spec.tsruns always, needs no Factorio, and fails if a fixture has no entry or an entry has no fixture. Adding a fixture means adding its provenance.pnpm refs:sync --fixturesneeds a binary and reports which fixtures predate it. It is a report, not a gate - it always exits 0 and is deliberately not inverify. A 2.1.11 fixture is not wrong because the binary reached 2.1.12; it means that ground truth has not been re-validated, and whether the gap matters depends on whether the subsystem changed.evidencegrades confidence:statedbeatsinferred, andunknownmeans nobody wrote it down. Don't promote an inferred entry without re-capturing. The spec capsunknownat its current count so the gap can only shrink.
Turning "38 fixtures are old" into "these N need re-capturing" is a separate
audit, run 2026-07-28 and completed 2026-07-29:
docs/fixture-version-audit.md holds the procedure, the fixture-to-Lua-file
map, the rule for what counts as invalidating, and now its Conclusions. Unlike
docs/superpowers/specs/, that one is a live document - update it when it is
re-run.
The answer to "how many need re-capturing" was zero, twice over. All the
data-governed fixtures sit on map-gen Lua that is byte-identical 2.1.11 ->
2.1.12, and the ten noise-primitive fixtures - which no data diff can ever
clear, because they are native C++ ops that factorio-data only calls - were
re-sampled against the 2.1.12 binary and came back bit-identical on all 2648
values. Two things came out of it that staleness never would have: the live
2.1.12.2 format-tag bug (the app rejected every string from the current
game), and the fact that only oracle-basis had a standing re-sample guard
while the other primitives had none.
This exists because version skew is invisible from inside: the Vulcanus surface-seed bug passed every internal check for weeks because the fixture and the code agreed with each other while both disagreed with the game.
The codec speaks DecodedExchange (raw wire shape). The app speaks Preset
(src/model/types.ts). src/model/convert.ts maps between them
(presetFromDecoded / presetToEncodable). src/model/builtins.ts decodes the
9 fixtures once and hands out deep clones (getBuiltinPreset).
src/store/presets.ts (Pinia) holds userPresets: Preset[] + activeName. Two
getters matter: activePreset, and activeExchangeString (a live re-encode of
the active preset). Editing any control mutates the active Preset in place, and
activeExchangeString recomputes through Pinia reactivity - that is how edits
flow to the exported string. Edits are NOT persisted to localStorage until an
action calls saveToStorage() (most control-slider edits don't; they survive
in-session but are lost on reload until a Save). seed is the single source of
truth for "random each new map": null = random, which encodes to wire 0.
- Autoplace controls (iron, coal, enemy-base, cliffs, ...) have dedicated
frequency/size/richnessfloats stored inPreset.autoplaceControls.src/model/controlCatalog.tsis the catalog (labels, planet);ControlTable/ControlRowrender them bound to the store. - Climate controls (moisture, aux = "terrain type") have no dedicated
struct - only
frequency+bias, stored purely asproperty_expression_namesoverrides (control:moisture:frequency,control:aux:bias, ...). Accessed viasrc/model/climateControls.ts({ freqKey, biasKey }+ read/write helpers). Writing a value that snaps to the default notch deletes the key (so an edited-then-reset preset stays byte-identical to the game's empty dict). src/model/controlScale.tsholds the slider notch math: geometricPERCENT_STEPS, and theStepScaleabstraction (PERCENT_SCALE/BIAS_SCALE) that lets oneFPercentSliderserve both percent and bias. Scale is stored asfrequency = 1/scale; all wire values aretoFixed(6).
App.vue hosts the tabbed editor (Resources / Terrain / Enemy / Advanced).
src/ui/ is a Factorio-styled component kit (F* components + factorio.css).
Sliders bind through the store so edits reach activeExchangeString.
src/store/ui.ts (Pinia useUiStore) holds UI-only preferences - currently
just devMode - and persists immediately under fmw.devMode, unlike the
preset store's Save-gated persistence. Dev mode reveals the preview panel's six
view toggles and the elapsed-ms render readout; it is toggled by the toolbar
"Debug" checkbox and can be seeded from the URL with ?dev=1 (or forced off
with ?dev=0).
The Enemy tab (src/components/EnemyTab.vue) is the one tab that edits
MapSettings tail fields (mapSettings.enemyEvolution / enemyExpansion),
overlaid back onto the tail at encode time by writeEnemyToTail - so untouched
imports stay byte-exact (values are converted only on set). Three non-obvious UI
conventions live here:
- Evolution factors are scaled for display. The game's map-gen GUI shows
these tiny wire floats scaled up: time & pollution
display = wire * 1e7, destroy* 1e5(so default time0.000004reads40, destroy0.002reads200, pollution0.0000009reads9).EVO_DISPLAY_SCALEinEnemyTab.vueholds this; the slider/box work in display space, the wire stays raw. Verified against the game by importing strings with known wire values and reading the GUI. - Cooldowns display in minutes, stored as ticks (
* 3600). - Min/max expansion distance are linked (max always > min, both clamped
[1,20]); editing one drags the other.
Field labels carry in-game tooltip text via FInfo (an info prop on
EnemyValueRow, an info: entry in controlCatalog.ts for the enemy-base
autoplace rows).
A separate pnpm workspace (worker/ Cloudflare Worker + container/
digest-pinned Factorio headless image). Opt-in and the app's only outbound call;
the editor is fully functional offline without it.
The container's sizing is a measured cost decision, not a default (#116).
Memory bills on provisioned size for the whole time an instance is awake, so
instance_type is the dominant cost lever - it was standard-1 (4 GiB) while
production peaked at 603 MiB, idled at ~205 MiB, and served ~7 requests/day.
It is now basic (1 GiB / 4 GB) with max_instances: 1. Two things to know
before changing it:
-
sleepAfteris load-bearing and fragile.@cloudflare/containersonly decrements its inflight-request counter when a proxied response body finishes piping. Dropping a response without reading it - which the 502 path used to do- leaves the counter above zero,
isActivityExpired()returns false forever, and the instance never sleeps. Any new code path that discards a container response must drain it first; the guard is inpreview-service/worker/test/worker.spec.ts.
That drain fix did not, on its own, stop the container being awake 24/7, and a note here used to imply it had. Billing says the instance ran at 100% every full day from 2026-07-20 through 2026-08-03 - 95.6, 96.2, 98.2, 96.3, 96.0, 96.8, 95.6, 97.0, 95.3, 96.1, 95.7, 96.3, 99.0 GiB-hours/day against the 96.0 a 4 GiB instance bills for a whole day - including the five days after the drain fix deployed on 2026-07-29. So the ~$28/month was still being paid; the 2026-08-03 downsize to
basiccut it ~4x rather than ending it. The sufficient explanation is the SIGTERM bug in the bullet below, which was present throughout. Keep the drain guard - the hazard is real - but do not credit it with the bill. - leaves the counter above zero,
-
The container ignored SIGTERM, so it never stopped at all (#120). Node runs as PID 1 under the Dockerfile's exec-form
ENTRYPOINT, and Linux gives PID 1 no default signal dispositions.@cloudflare/containersstops an idle instance by sending SIGTERM and never escalating to SIGKILL, so with no handler the stop request was silently discarded and the instance only ever went away when a deploy replaced the placement. The handler and its regression test live inpreview-service/container/server.mjsandtest/shutdown.test.mjs. -
To check what is actually running, read the billing metrics, not
wrangler containers instances. That command reportedstate: runningwith an 80-minute-oldcreatedtimestamp during an hour when allocation was zero - it describes the placement, not whether you are paying. ThecontainersUsageAdaptiveGroupsGraphQL dataset is the truth, and the disk-to-memory ratio identifies the live instance type. Read it in bytes and the ratio is1.86=standard-1(4 GiB / 8 GB) and3.73=basic(1 GiB / 4 GB); the 2.0 and 4.0 this note used to quote are those same two numbers expressed in the mixed GiB/GB units the dashboard shows. -
That dataset BACKFILLS, and a bucket that has not landed yet is indistinguishable from sleep. This is not hypothetical: #120 read the 5-minute buckets ~14 minutes after a test render, found nothing past 22:45Z, and published an "~8.5 minute" idle tail. Re-read once settled, that same window has every bucket present and the placement it woke stayed allocated for 29.3 hours straight - 352 of 352 buckets, no gaps - on a total of 5 worker requests. The tail was never 8.5 minutes; there was no tail. Wait at least an hour before reading absence as sleep, and confirm with
placementIdcontinuity rather than bucket presence alone.
wrangler is not global - drive it through the workspace:
pnpm --filter @fmw/preview-worker exec wrangler <cmd>.
worker-configuration.d.ts is generated and must stay in sync with
wrangler.jsonc. It once drifted silently (the types declared the apex origin
while the config said the map. subdomain). Nothing caught that: it is not a
type error, so both vp check and the worker tests pass with a wrong value in
it. wrangler types --check now gates the worker's test and deploy scripts,
so pnpm preview:test fails loudly on drift.
-
Regenerate with
pnpm run types:sync, which iswrangler types && vp check --fixin one step. Use the script rather than the barewrangler types: the formatter pass is not optional - wrangler emits tabs/unwrapped types and the repo formats to 2-space/wrapped, so a raw regen shows a whole-file whitespace diff that hides the real change. Measured on #169: raw regen = 25,411 lines changed, after the formatter = 1. Bundling them is the whole point of #177; do not "simplify" the script back to one command. -
--checkcompares two things, and the second one surprises people. It checks the config against the hash in the generated file's header, AND theworkerdversion stamped on the line below it. Both halves were observed directly on 2026-08-03:- Editing the
containersblock ofwrangler.jsonc(instance_type,max_instances) regenerated the file with the hash identical and--checkpassing - so that block is not in the hash at all, and a regen after such an edit is a pure whitespace diff. - A wrangler-only bump invalidates the file with the hash unchanged: PR
#97's 4.115.0 -> 4.118.0 drags
workerd1.20260722.1 -> 1.20260730.1, every type body is byte-identical, andverifywent red on that one line.
So a wrangler bump that touches no binding still requires a regen. Reading this note in its old form ("compares the config against the hash") would rule that out, which is exactly the wrong call.
It still does not notice hand-edits to the generated file itself. Don't hand-edit it.
- Editing the
-
The worker deliberately has no
typescriptand no@cloudflare/workers-typesdevDependency, and ignores wrangler's "Install @types/node" advice. See the comment inpreview-service/worker/tsconfig.jsonbefore adding any of them back.
docs/superpowers/specs/anddocs/superpowers/plans/are point-in-time design/plan records, not living docs - don't treat them as current state.
vp check runs format, lint, and type checks. The type-check step is gated
behind lint.options.typeAware + lint.options.typeCheck in vite.config.ts -
both are on. Do not add a tsc-based typecheck script:
-
tscis not the type-check path, but not because it crashes any more. It used to: bare./node_modules/.bin/tsc --noEmitthrewDebug Failure. False expression: parameter should have errors when reporting errors- a TypeScript 6.0.3 compiler bug, not a type error, triggered byvite.config.tsalone. Thevue() as Plugincast below fixed that too, and both now exit 0. Still don't add atsc-basedtypecheckscript: it duplicates whatvp checkalready does through tsgolint, and it is one transitive-graph shift away from crashing again. Beware also that passing globs (tsc --noEmit 'src/**/*.ts') silently ignorestsconfig.jsonand reports a misleading "ok". -
vp checkdoes not see inside.vuebodies - it reports no type errors inside<script setup lang="ts">(measured, not assumed: a plantedTS2322insrc/ui/FInfo.vueleft it printing "Found no warnings, lint errors, or type errors in 301 files"). That gap is now covered bypnpm run check:vue, chained intoverify- see below.vp checkalone is still a partial net. -
vite.config.tssits near TypeScript's comparison-depth limit. A shift in the transitive dependency graph can tip it over, makingvp checkfail withTS2321: Excessive stack depth comparing types ... and 'UserConfig'- the same pathology behind thetsccrash, and nothing to do with the file being wrong. This is why dependency bumps use targetedpnpm addrather thanpnpm up:pnpm upre-resolves ~22 surrounding packages and triggered exactly this, while installing the same target versions directly did not. If it reappears, suspect the transitive graph, not the named package. Two fixes, one that works and one that doesn't:-
Annotating the config with an explicit type does not help - the augmented
UserConfiglives in@voidzero-dev/vite-plus-core, which is not resolvable, and vitest's exportedViteUserConfiglacks thestaged/lint/fmtfields. -
Casting the plugin does help (found 2026-07-23 adopting vp 0.2.6, whose tsgolint-7 engine bump - not a transitive shift - re-triggered the TS2321).
@vitejs/plugin-vue'svue()return type references its own bundled Vite'sPlugin; casting it to vite-plus's ownPlugin(plugins: [vue() as Plugin],type Pluginimported fromvite-plus) collapses the comparison without suppressing type-checking of the rest of the config. See voidzero-dev/vite-plus#2010's comment thread.That one cast is load-bearing for three tools, not one. Removing it (measured 2026-07-29, by deleting it and re-running) reproduces all three failures at once:
vp checkfailsTS2321, andtscandvue-tscboth die on theDebug Failureassertion. SoTS2321invp checkand theDebug Failurecrash are one pathology with one fix - don't treat a reappearance of either as a separate problem.
-
-
The project stays on
typescript6.0.3 as the editor/LSP compiler, and TypeScript 7 is not an upgrade this repo can take - see below. Note the type-check already effectively runs on TS7 semantics via tsgolint, so nothing is being given up by staying.
Taking that row literally breaks the toolchain, so it is worth knowing why before someone bumps it. Re-derived 2026-07-29:
- TS 7.0 exposes no programmatic API at all. It is a CLI-only Go binary;
the API is planned for 7.1. Anything that consumes the compiler
programmatically - tsserver,
vue-tsc, typescript-eslint - cannot run on it. - The official migration is therefore a dual install, not a bump:
typescriptaliased tonpm:@typescript/typescript6(the JS API line) plus@typescript/nativealiased tonpm:typescript@^7(the Gotsc). vue-tscon a baretypescript@7does not degrade, it hard-crashes:ERR_PACKAGE_PATH_NOT_EXPORTED: './lib/tsc'(vuejs/language-tools#6124).vue-tsc@3.3.8added shim resolution so it works behind the alias - which is the one thing 3.3.8 adds over 3.3.7, and it only matters if the alias is adopted.- And there is nothing to gain. This repo's
typescriptdevDep is purely the editor/LSP compiler; the type-check already runs TS7 semantics through tsgolint. The dual install would add a second compiler and an alias to buy nothing the repo consumes.
Revisit when 7.1 ships a programmatic API. Until then this is a "don't", not a "blocked on someone else".
pnpm run check:vue (vue-tsc --noEmit) runs in verify, between vp check
and vp test. It is the only thing that type-checks <script setup lang="ts">
bodies.
- The guard is not vacuous, and was proven so before landing. A planted
TS2322insrc/ui/FInfo.vuemakesvue-tscreportsrc/ui/FInfo.vue(3,7): error TS2322andpnpm run verifyexit 2 with the test suite never running - whilevp checkon the same tree still printed "Found no warnings, lint errors, or type errors in 301 files". If a future change makescheck:vuepass on a planted error, it has been neutered. - Against the real codebase: 22
.vuefiles, 0 errors, ~2.1s. There was no latent breakage behind the gap; this is a guard against regressions, not a bug hunt. It ran on the existingtypescript6.0.3 -vue-tsc's peer range is>=5.0.0, so no TS7 work was needed. - It needs no separate tsconfig, and a note here previously said it did.
That was measured 2026-07-22, one day before the
vue() as Plugincast landed; the cast fixedvue-tsc's crash along withvp check'sTS2321. Barevue-tsc --noEmiton the roottsconfig.jsonis now clean.
Why it was not adopted on 2026-07-22, and why that reason expired. The only
blocker was supply-chain freshness: vue-tsc@3.3.8 was under an hour old, and
installing it made pnpm silently write a bypass into pnpm-workspace.yaml:
minimumReleaseAgeExclude:
- "@vue/language-core@3.3.8"
- vue-tsc@3.3.8Watch for that block appearing in any diff - it means a freshness guard was
waived. Don't commit one without a deliberate decision. It did not appear
this time: 3.3.8 was 7.3 days old when adopted, so the gate passed on its own
and pnpm-workspace.yaml was untouched. The old advice to "pick 3.3.7 instead"
is obsolete - just take the latest once it has aged past the policy.
pnpm preview:test prints four lines like
Sourcemap for ".../@cloudflare/containers/dist/index.js" points to missing source files
This is an upstream packaging bug, not a local problem: @cloudflare/containers
(0.3.7, the latest) ships dist/ with maps whose sources point at ../src/*.ts,
but no src/ is published and the maps carry no sourcesContent. Vite emits it
via an unconditional logger.warnOnce, so it is not reachable from
build.rollupOptions.onLog - that hook only sees the build, and this happens in
vite-node during tests. Two workarounds were tried and rejected:
test.server.deps.externalfor the package - does not help, pool-workers bundles it regardless (measured; the warnings persist).- A Vite
customLogger- would mean addingviteas a worker devDependency (it is not resolvable there under pnpm isolation, andvitest/configdoes not re-exportcreateLogger) purely to mute a cosmetic upstream warning. Not worth a dependency. Revisit if@cloudflare/containersfixes its packaging.
Exactly one deliberate suppression now lives in vite.config.ts:
typescript/unbound-method is off for test/**/*.spec.ts, because
expect(mock.fn).toHaveBeenCalled() passes an unbound reference by design.
There is no build.rollupOptions.onLog hook at all any more. It once held
two filters, both existing solely for zlib-asm:
- an
[EVAL]filter, dropped whenpatches/zlib-asm.patchremoved the three Emscriptenevalsites; and - an
fs/pathbrowser-externalization filter for zlib-asm's Node fallback imports, matched on therolldown:vite-resolveplugin plus/zlib-asm/in the importer path.
Replacing zlib-asm with pako (plain ESM, no eval, no Node builtins) made
the second one dead too. Verified by removing it rather than assuming: the
build still prints nothing.
pnpm vp build prints no warnings at all, so anything that does appear is new
and worth reading. Do not add a suppression back - a direct eval or an
externalized builtin appearing anywhere in the bundle needs to surface.
The block below is generated by Vite+ and delimited by a pair of HTML
comment markers (grep the file for VITE PLUS to see them). Leave those markers
and everything between them alone so the tool can resync the block; put any
local correction outside them, like this paragraph.
The markers are deliberately not reproduced literally in this prose - a resync that matches on the marker text would otherwise find this sentence first and rewrite the wrong region.
Where this repo overrides it: the checklist says vp install / vp check /
vp test. Use pnpm vp <cmd> instead - that is what package.json and CI
run, so it is the form that stays verified. A bare vp does work (see the
Commands section); npx vp does not. And prefer pnpm install over
vp install, because this repo's install discipline is specific: pnpm add -w
for root deps, always followed by a bare pnpm install, and a 24-hour
release-age guard that must not be bypassed.
This project is using Vite+, a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called vp. Vite+ is distinct from Vite, and it invokes Vite through vp dev and vp build. Run vp help to print a list of commands and vp <command> --help for information about a specific command.
Docs are local at node_modules/vite-plus/docs or online at https://viteplus.dev/guide/.
vp <name> runs a built-in command. vp run <name> runs a package.json script or a vite.config.ts task. Scripts cannot overwrite built-ins, so vp dev and vp run dev may do different things. Check package.json and vite.config.ts first, and run vp run <name> when the project defines a script or task with that name.
- Run
vp installafter pulling remote changes and before getting started. - Run
vp checkandvp testto format, lint, type check and test changes. - Check if there are
vite.config.tstasks orpackage.jsonscripts necessary for validation, run viavp run <script>. - If setup, runtime, or package-manager behavior looks wrong, run
vp env doctorand include its output when asking for help.