The skip of newer-format queued writes only exists from this release on,
so a rollback to an older release still replays them. On a rolled-back
install the skipped rows also stay pending with no retry or discard path
and hold back later writes to their entity, leaving Clear cache as the
only way out. Say both in the wiki and in client/CLAUDE.md instead of
promising that a rollback is always safe.
Each version() of the offline database runs once, on a user's device, when a
new bundle opens what an older one wrote. None of them ran in CI: the v3 blob
cache backfill and the v8 area-fingerprint reset had never been exercised
against an older database.
The new test builds the database as each previous version left it in
fake-indexeddb, seeds a row into every table it had, opens it with the current
schema and checks that the rows survived and that the version did what it is
for. A guard reads the declared versions from offlineDb.ts and fails on a
duplicate, on a version without an upgrade case, or on stores the history
fixture does not reproduce. TrekOfflineDb is exported so the test can open a
named database with it.
A queued write outlives the bundle that wrote it. After a deploy a tab can
still run the previous bundle, and an install can be rolled back, so a row
written by a newer build may be replayed by an older one that does not know
its shape.
Each row enqueue() writes now carries schemaVersion (MUTATION_SCHEMA_VERSION)
and the UI build that queued it. Both fields are optional, so rows queued
before this read as format 1 and replay as before. A flush leaves a row from a
newer format in the queue without sending, marking or dropping it, and holds
back the later writes to the same entity as it does behind a parked write, so
they keep their order until a build that knows the format replays them.
The demo seed runs in the schema bootstrap, at boot and again after every
reopen. Since the baseline save moved out of the seed into DemoResetJob,
only the boot read the seed's mark. A restore onto a demo instance with no
baseline, whose archive held no admin trips, seeded the example trips
during the reopen and then saved nothing, so the hourly reset kept logging
"No baseline found"; the mark also stayed set for the rest of the process.
Before the move, that path saved the baseline from inside the seed.
DatabaseLifecycle now runs listeners after a reopen's schema bootstrap,
and DemoResetJob registers one in demo mode that does the same check as at
boot: save through the backup port when the seed just ran and no baseline
exists. Each check clears the mark. A failed save is logged, never turned
into a failed reopen. AdminModule imports DatabaseLifecycleModule from the
shared database kernel for it.
The DB paragraph said MaintenanceRepository is never built with `new` in
src/, but MaintenanceModule's factory does exactly that. Say what is
actually true: the module's factory is the one place that builds it, and
every consumer injects it.
DatabaseBackupModule stays @Global, but its comment gave a vague reason.
The real one is concrete: the backup domain already reaches admin through
auth and settings, so importing the module from AdminModule would add a
domain cycle that lint:boundaries rejects. The module comment now says
that and points at the e2e harness that imports it by hand, and
AdminModule notes where its DATABASE_BACKUP comes from.
SqliteDatabaseBackup.replace reopens the connection in a finally block, so
it runs even when the swap throws. A reopen that failed there was stored
and then dropped: the swap's exception propagated past the return that
would have carried it. The demo reset caught that swap error, logged
"Failed to restore baseline" and returned normally, leaving the instance
without a database connection and nothing in the log to say so. Before
the port, the same branch awaited reinitialize() and its rejection reached
the tick's log line.
replace now throws a DatabaseConnectionLostError when both steps fail. It
carries the swap error and has the reopen failure as its cause, and its
message tells the operator to restart. The demo reset lets that error
through to DemoResetJob's tick, which logs it, and still logs and skips a
failed swap whose reopen succeeded. A restore that hits it answers the
same 500 as any other thrown restore error, with both failures in the
server log.
Moving the first baseline save out of the boot-time seed into DemoResetJob
also changed when it happened. The seed saved it only on the boot that put
the example trips in; a later boot returned early and saved nothing, so an
instance with data but no baseline logged "No baseline found, skipping" on
every hourly reset. The job instead saved one on any demo boot that found
no travel-baseline.db, freezing whatever the database held at that moment,
user edits included, as the target the hourly reset rolls back to.
The seed now leaves a mark when it seeds the example trips, and the job
saves the first baseline only when that mark is set and no baseline
exists, which is the old trigger. As before, the save does not depend on
the cron registrar being enabled. The demo mode wiki page says so.
Backup, restore and the demo reset each handled the SQLite file
themselves: backup.impl closed and reinitialized the connection, built
MaintenanceRepository with new, and assumed the database was
data/travel.db. That tied the whole chain to one local file and hid two
bugs.
They now go through DATABASE_BACKUP, a port declared in the shared
database kernel (snapshot, verify, keep a copy before a restore, replace).
SqliteDatabaseBackup implements it with what the code did before:
VACUUM INTO for a consistent copy, and a close, copy, sidecar drop,
rename and reopen through DatabaseLifecycle for a restore. BackupService
injects it and hands it to backup.impl. The demo reset and the baseline
save use the same port; the first baseline is saved by DemoResetJob once
the app is up, because the boot-time seed runs before anything can be
injected. Another engine can plug in a logical dump behind the port.
Fixes on the way:
- The backup and the restore ignored TREK_DB_FILE. The backup looked for
data/travel.db and, with the database elsewhere, silently left it out
of the archive; the restore wrote data/travel.db and never replaced
the live database. Both now use DatabaseLifecycle.file, the path the
connection runs on, and the pre-restore copy sits next to it.
- A failed VACUUM INTO fell back to archiving the live WAL-mode file
without a word, the torn copy the snapshot exists to prevent. The
backup now fails: nothing is archived or committed, the existing
backups are untouched, a partial snapshot is removed, and the log says
why. A missing database file and a failed checkpoint are logged too.
MaintenanceRepository was built with new over an injected EntityManager
in the readiness probe and in UserCleanupService, outside the container.
MaintenanceModule now provides it, as a factory over the same
context-resolving EntityManager Nest injects everywhere, and both inject
it. The class itself stays free of Nest decorators, like the rest of
src/db, so tests keep building one over their own EntityManager.
No behaviour changes: the probe still pings and the erasure still runs
inside the account deletion's transaction.
db/database.ts opened the connection as a side effect of being imported,
so the moment anything reached it the database was open. index.ts had to
defer loading the app until a first-boot restore was done, and nothing
in the container decided when the connection opened or closed.
The module now opens the connection on first use and opens nothing at
import. A connection closed on purpose (a restore, the shutdown) stays
closed until it is reopened, as before. The new DatabaseLifecycle
provider owns the decisions: buildApp() opens through it, which also
binds the ORM to later swaps (the hook attachOrm used to register) and
runs the schema bootstrap in the same order as before, and the shutdown
closes through it. Its file property is the one resolved database path.
The module keeps every export, so the test suites that replace it with
vi.mock keep working; buildDbMock gains the new openDb.
_flushRun holds the running pass or null. Testing it as a boolean is
correct but reads like a forgotten await, and SonarCloud counts it as a
reliability bug (S6544). The explicit comparison says what is meant.
en now reads "Upload {n} photos", but ru, uk, fr and sv still said the
photos had already been uploaded, and the new ru and uk plural forms
copied that wording. ca, es and it used a status noun ("upload of",
"loading of") instead of the action, and ko put the counter in front of
the noun. All locales now name the same action, with each plural form
matching its Intl.PluralRules category.
NotificationsService.send() now reports how many deliveries it attempted
and how many went out, so the reminder jobs can tell a failed send from a
sent one. WPDISP-008 and WPDISP-011 asserted that the send resolved to
undefined, which only meant "no error" and broke on the new result. They
now pin the counts: the in-app row and the webhook go out, and a push the
broken key pair cannot send is skipped rather than counted as failed.
CI lints the client with lint:warnings and runs the size, boundary, layering,
offline, skip, format, i18n key and open contract checks, but the dev setup
page still called the client lint:check the CI name and its pre-push list
stopped at typecheck and lint. Add the new scripts to the command tables and
spell out the server, client and shared quality steps from test.yml, so a
contributor who follows the page passes the same gates CI does.
A run Dependabot triggers only sees Dependabot secrets, and its PRs come
from a branch in this repo, so the fork guard let s3-contract start with an
empty MINIO_LICENSE_KEY. AIStor then never became healthy and ci-ok went red
on every grouped bump PR. s3-contract and scan now also skip when Dependabot
is the actor; ci-ok already counts a skip as passed.
changed-areas skipped every gate for a change that touched only Markdown
outside wiki/. server/tests/fixtures/wiki/ is Markdown too, but wiki.test.ts
parses it, so a PR that only edited those fixtures passed ci-ok without the
test that reads them ever running.
The image tag rows already say the next release is 5.0.0 and that 4.x gets
no minor tag, while the rollback, email case and APP_VERSION notes still
pointed at a 4.4 that will never be published. Use 5.0 throughout so the
pages agree with each other and with docker.yml.
Both server ratchets only logged an entry the tree had moved past. A
file that shrank could grow back to its old size entry unseen, a
deleted file left its allowance to the next file at its path, and a
boundary violation that was fixed stayed allowed until someone ran
--update. The client ratchets now fail on such entries, so the server
ones do the same.
lint:size fails on an entry above its file's size or for a file that is
gone, and lint:boundaries on an entry that no longer occurs; both tell
to run --update, which still only lowers or drops. Both baselines match
the tree today.
lint:size, lint:layers, lint:rtl (both baselines), lint:skips and
lint:warnings only failed a count that passed its entry. A count that
fell below its entry was logged and nothing more, so a file that shrank
could grow back to its old entry unseen, and the entry of a deleted
file stayed behind for the next file at that path. lint:format and
lint:offline already fail on such a stale entry, and the docs said
these counts may only shrink.
Each of them now fails on an entry that lowerCounts would lower or
drop, naming its count or saying the file is gone, and tells to run
--update, which still only lowers. staleCounts and reportStale in
lib/ratchet.mjs hold the shared part. Every baseline matches the tree
today, so none needed lowering. The client CLAUDE.md says so, and that
CI lints through lint:warnings rather than lint:check.
The planner hook tests spy on the store's actions for the visit times,
and the note now goes through a store action as well. Asserting on that
action instead of on the raw note route keeps these tests about the hook
and keeps the file inside its lint:size entry, which the response stub
the route needed had pushed three lines past.
flush() returned at once when this tab already had a flush going, and
that includes one still waiting for another tab's Web Lock. The socket's
reconnect hook chains a refetch onto flush(), so a reconnect landing in
that window rehydrated the trip while a replay was still on its way, the
race the lock wait was meant to close. sendSoon and the online, periodic
and visibility triggers make such an overlap common.
flush() now keeps the running flush as a promise. A second call still
asks it for one more pass and then gets that same promise back, so it
resolves only once every pass, including the one it asked for, is done.
The pass itself moves to _flushPass, and the repeat runs as a loop
instead of a nested flush() call. The client CLAUDE.md sync bullet now
says so, and loses its dash.
The place dialog stopped reloading the days after a save, so the new
visit note only reached the Zustand store. Nothing wrote it to Dexie,
the tab never sees its own assignment:updated echo, and the time save
before it cached the server row with the old note. A trip reopened
offline then showed the old note until the next full sync.
The note now goes through assignmentRepo.setNotes, which caches the
saved row on its day online and queues the write behind any other
write of the same visit offline, like setTimes does. A new store
action applies it, so the page hook no longer patches the store by
hand. usePlaceEdits drops its direct API import and leaves the
offline baseline.
Each gate came with its npm script but ran nowhere. The server job now
runs lint:size, lint:boundaries and shared's contracts:open after the
shared build; the client job runs lint:warnings in place of lint:check
(it fails on errors the same way and also holds the warning count per
rule), then lint:format, lint:layers, lint:offline, lint:skips and
lint:i18n-keys. All of them are steps of jobs ci-ok already needs.
Both size baselines are taken on the merged branch. Four server files
grew while the other changes landed (the claimed reminder, the email
uniqueness error and the generator rule for an expression index) and
four client files by two or three lines with the plural forms; one
client file shrank and is lowered.
The image tag tables showed mauriceboe/trek:4.3 as the minor tag example,
"published for releases after 4.3.3". The next release is 5.0.0, so no
4.3 tag will ever be pushed, and a self-hoster copying the example into
compose gets "manifest unknown". The example is now 5.0, and the row says
4.x releases have no minor tag and are pinned by major or full version.
Both release workflows refused a commit if any ci-ok check run on it was
not green. Every workflow run is its own check suite, so a red run stays
on the commit after a later one passed: a flaky job on the dev push, or a
push run cancelled by a manual run on the same ref (ci-ok runs with
always() and fails on the cancelled jobs). Running Tests & SonarQube
again, as the error told the maintainer to do, added a green suite but
never cleared the red one, and the message did not say which run was
red.
The gate now lives in scripts/ci/ci-ok-verdict.mjs. It fetches every
attempt (filter=all), keeps the newest attempt per check suite, refuses
while one is still going and requires the run that finished last to be
green. It prints the link of every run it counted, and an older red run
it overrode shows up as a notice. A response it cannot read, one cut off
by pagination or a run without a suite, conclusion or completion time
fails the release instead of passing it. Tests run with test:ci-scripts.
The changes job listed the diff with rename detection on, so a file moved
out of an area showed up only under its new path. Moving server code or a
chart template under docs/ turned every area off and ci-ok passed on a
change that removed source from the build. The diff now runs with
--no-renames, and a test reads the exact command out of test.yml and runs
it against a scratch repository with a move in it.
The code area also missed files the test suites read from disk outside
the packages. config-templates.test.ts pins docker-compose.yml and the
Helm chart to the code defaults, help.test.ts serves wiki pages and
images, and the client's help registry test checks its links against the
wiki headings. A chart, compose or wiki change now runs the tests, and a
wiki change also builds the image, which ships wiki/ as the in-app help.
Markdown is still ignored everywhere except under wiki/.
docker.yml built and published whatever `main` pointed to when the build
and merge jobs started, not the commit it had just bumped and tagged, so
anything that landed in between shipped under a version that did not
contain it. build, merge and release-helm now check out the bump commit,
the way docker-dev.yml already pinned its prerelease builds. The push of
that commit fast-forwards main from the commit the run started on, so a
race fails the release instead of skipping over it.
Nothing tied a release to a green CI run. Both release workflows now
refuse a commit unless every ci-ok check run on it has finished green,
and docker.yml refuses to run on anything but main.
bump=auto fell back to a patch whenever no prerelease was in flight,
which would have published the ORM release, the largest change the
project has made, as 4.3.4. auto now only finalizes a prerelease above
the last stable tag and otherwise stops, asking for patch, minor or
major. The inputs are unchanged. A patch that adds migrations gets a
warning, not a refusal, since patch releases have carried them before.
Stable releases also publish :MAJOR.MINOR, so an install can take the
fixes of one minor line without being moved onto the next on day one.
The tag tables in the wiki list it and the prerelease tag, and the root
CLAUDE.md describes the release and prerelease flow.
security.yml, lint-prettier.yml and the release workflows still ran
actions built for Node 20 (checkout and setup-node v4, github-script v7,
the docker actions v3/v5, create-github-app-token v1), which GitHub now
forces onto Node 24 with a deprecation warning on every run. They move
to the Node 24 majors test.yml already uses (download-artifact to v7,
since v6 is still Node 20). Third-party actions, several of which hold
the Docker Hub and GitHub App secrets, are pinned by commit with the tag
in a comment. ubuntu-latest moves to a new Ubuntu release on 10-19, so
every job names ubuntu-24.04 and the switch happens in a PR. Every
setup-node reads .nvmrc; publish-plugin-sdk leaves 22, which it ran
under a comment claiming it matched the server.
security.yml gains an npm audit job over the lockfile, on PRs and pushes
into dev as well as main. It covers what the image installs and the
plugin SDK, takes seconds, and would have flagged the brace-expansion
regression the ORM squash brought in the day it landed. Scout keeps its
image scan on main. Neither becomes a required check: a CVE published
overnight would turn every open PR red, including the ones that cannot
fix it. The scout job now also skips PRs into dev, which the trigger
change would otherwise hand it.
.github/dependabot.yml adds weekly version updates into dev for the root
workspace, plugin-sdk, the actions and the Docker base images, minor and
patch grouped per ecosystem, majors one PR each. The Node major in the
Dockerfile is left to a hand-made change together with .nvmrc.
The ruleset required only `lint` (shared's eslint and prettier) and
`check-target`. Types, lint, coverage, end-to-end and the S3 contract
were advisory, and the three health PRs merged with red checks. Nothing
on a PR into dev built the Dockerfile or rendered the Helm chart either,
which is how #2610 shipped a Dockerfile no build could parse.
ci-ok needs every gate, test, image and chart job and is meant to be the
one check the ruleset requires. It runs with always(), fails when a job
it needs failed or was cancelled, and passes when they passed or were
skipped. The Sonar scan stays outside: its quality gate reports on the
PR by itself, and an outage there should not block merges.
A required check cannot sit behind a workflow-level path filter: on a
docs-only PR the workflow never starts and the check never reports. The
filter moves into a `changes` job. It diffs the PR's merge commit against
its base (or a push against the previous head) and scripts/ci/
changed-areas.mjs maps the files to code, image and deploy areas. It
fails closed: no usable diff, an empty one or a path it cannot read
cleanly runs everything. Its tests run in the same job.
docker-image builds the Dockerfile with buildx (layer cache written by
pushes only, read by PRs), starts it with the same hardening as
docker-compose.yml, waits up to three minutes for /api/health/ready,
checks the VERSION file and that / serves the client, and prints the
container log. deploy-manifests lints the chart, renders it with the
default values and with each secret, persistence and ingress mode,
validates the output with kubeconform, and runs `docker compose config`
on every compose file. helm and kubeconform are pinned by version and
sha256.
While the file is open: every job runs on ubuntu-24.04 instead of the
moving ubuntu-latest, reads its Node version from .nvmrc (the plugin-sdk
jobs ran 22 under a comment saying it matched the server, which runs
24), download-artifact moves from v6, still on Node 20, to v7, and the
Sonar action is pinned by commit.
Nothing said which Node TREK runs on. The image is node:24, part of CI
ran 22 under comments saying 22 matched the server runtime, and the dev
guide said "22+". A bare-metal or dev install on an unsupported Node
installed without a word and then failed at runtime: isomorphic-dompurify
needs ^22.22.2 or ^24.15.0, the plugin jail's --permission flag is new
in 22.x.
.nvmrc now holds the major the Docker image runs, for nvm/fnm and for
every workflow to read. engines in the root and each workspace states
the range the runtime dependencies accept, the same one
isomorphic-dompurify declares. Without engine-strict npm only warns
outside it, so no existing install stops installing.
The image scan has been red since 10-06 on five advisories in four
runtime packages: proxy-addr 2.0.7 (critical, IP spoofing through an
IPv4-mapped IPv6 trust subnet), brace-expansion 2.1.4, source-map-js
1.2.1 and @modelcontextprotocol/sdk 1.30.0. brace-expansion had been
fixed once already; the ORM squash regenerated the lockfile and took it
back to 2.1.4 without anything noticing.
The overrides block in server/package.json was meant to hold such floors,
but npm reads overrides from the root package.json only, so none of them
ever applied: @nestjs/platform-ws kept its own ws 8.21.2 next to the
8.21.3 the block asked for. The floors move to the root, together with
new ones for every package this lifts, so the next lockfile regeneration
cannot quietly undo them. picomatch ^4 is dropped instead of moved: at
the root it would force micromatch's 2.x copy across a major. js-yaml is
lifted only inside its 5.x line, the one @nestjs/swagger pins.
The SDK goes to 1.31.0, the first fixed release; it stays hoisted, which
the CJS path aliases in server/tsconfig.json depend on. dompurify,
fast-uri, ip-address and js-yaml come along because npm audit flags the
same lockfile for them and each fix is inside the range already used.
@mikro-orm/postgresql (with pg), marked and topojson-client are dropped:
nothing in src, tests or scripts imports them, and the ORM config names
only the SQLite driver. The Postgres driver comes back with a Postgres
job that exercises it.
npm audit --omit=dev now reports only maplibre-gl, whose fix is the
6.x major tracked separately.
The count-string gate only knew {count} and {n}, the params t() picks a
plural form by. A quantity named anything else kept one form in every
language and passed: Russian read "Все {days} дней" for 2 days and
"{objects} объектов" for 3 objects, Greek "{minutes} λεπτά" for 1 minute.
Twelve such en strings become plural groups with their forms in all 27
locales, the quantity renamed to {count}: the trip length limit, the
chat image limit, the API key limit, the storage move prompt and its
reclaimable line, the Dawarich wish rule and suggestion duration, the
road trip lateness warnings, and the road trip echo and window texts.
Their callers pass the count as a number; the storage panels used to
pass it as a string, which no form is picked by. The reclaimable line
loses its dash for a comma in every locale while it is rewritten.
i18n-parity now treats the quantity names in QUANTITY_PARAMS ({days},
{minutes}, {objects}, {max} and the like) like {count}. The four strings
where the number is followed by nothing but a unit symbol (the Dawarich
durations, the upload size limit) go on NOT_PLURAL with that reason.
A template key was read as a pattern in which every interpolation is a
wildcard, so t(`roadtrip.window.${a ? 'onRoute' : 'onRoutTypo'}`) passed
as long as some other roadtrip.window key existed, and the typo rendered
as raw text.
An interpolation that can only produce string literals (a ternary chain
of them, in parentheses or not) is now expanded, and each resulting key is
checked like a literal one. Thirteen call sites become literal keys that
way. Where data is involved, a literal fallback (`${role ?? 'editor'}`)
is still checked as a key, with the template kept as a pattern under
its own allow-list site. Literals in a condition (`phase === 'start'`)
are never taken for results.
Narrow templates over data still pass without an allow-list entry. The
script header now says plainly what that leaves unchecked: a runtime
value with no key under a narrow prefix, which needs the value's type.
Two holes let English through the ratchet unseen.
A plural form en has no key for (Russian .few and .many, Arabic .two)
was compared with en's value for that same key, which never exists, so a
locale could copy en's text into exactly the forms a new plural group
needs and the count stayed put. Such a form is now compared with every
form en spells out for the group. A form en does declare keeps the exact
comparison: Italian "{count} file" is the Italian plural, not a copy of
en's "{count} file".
The // same-as-en marker excused any value in a Latin-script locale, so
one comment waved a whole copied sentence through. It is honoured only
on a value holding a single plain word, the case shared/CLAUDE.md
already describes. No locale carries the marker today, so nothing that
passed before fails now; the counts are unchanged.
The panel fires a test photos_shared event with count: '5', which reads
like the string counts the plural work removed elsewhere. Notification
params are Record<string, string> from the server through the in-app
store, the real sender passes String(added), and resolveTemplate reads a
numeric string as the plural count, so the test event matches what
production sends.
lint:i18n-keys let any template key through as long as its fixed parts
matched one en key, and each interpolation could stand for any run of
characters, dots included. `settings.${mode}` therefore reached all 574
settings keys and `help.guide.${id}.${part}` nearly 2000, so a wrong
suffix behind such a prefix could never fail, and none of the 109
templates had been looked at.
An interpolation now stands for one key segment, and a template passes
by itself only when it reaches between 1 and MAX_IMPLICIT_MATCHES (30)
en keys, the size of an enum such as the docsync error codes. A wider
one fails unless DYNAMIC_ALLOWED has an entry for that file and template
saying what bounds the value. The twelve wide templates in use today
get one each: the help key builders (checked by the registry test),
literal tuples beside the call, typed unions, the generated plugin
permissions and the seeded photo provider fields. An entry whose file no
longer uses the template, or whose template has become narrow enough to
pass alone, fails, so the list stays short and current.
Listing all 109 templates instead was considered and left out: the
narrow ones would only restate their own prefix, and the bound already
keeps a mistake inside one small namespace.
The ratchet excused any value equal to en that held at most one plain
word, so "Settings", "Appearance", "Readability" or "Users" copied into
another locale were never counted, Japanese and Arabic included. Buttons,
tabs and section titles are mostly one word, so a feature could still add
26 English copies of its labels and stay green.
An identical value is now excused only when nothing ordinary is left
after dropping placeholders, markup, addresses, a listed set of product
names (BRAND_NAMES, which now holds one-word names such as Atlas, Immich
and Mapbox) and unit symbols (UNITS). A word that really is the locale's
own, German "Status" or French "Transport", carries a `// same-as-en`
marker on its declaration, which the catalogue reader now reports. The
marker is honoured only in Latin-script locales: a Latin word left in
Japanese or Russian is English whatever it says. Every locale folder
must be on LATIN_LOCALES or NON_LATIN_LOCALES, so a new language cannot
pass the rule unclassified.
The baseline is re-recorded from the narrower rule: the unmarked copies
go from 1252 to 3238, the marked fallbacks stay at 3157, and no entry
went down. This is the measured state under the new rule, not a raised
gate; translating, or marking a word that is genuinely the same, lowers
it with --update as before.
Parity proves a locale has every key, not that the key holds the
locale's language: copying the en string passes it, so a feature could
add 26 English copies and stay green. Nothing counted them either. Today
the locales hold 3157 strings marked `// en-fallback` and 1252 unmarked
values identical to en.
scripts/i18n-untranslated.mjs counts both per locale and file and holds
them at scripts/i18n-untranslated-baseline.json, recorded from today's
tree. i18n-parity --strict runs it, so the existing CI step enforces it.
The two counts are separate on purpose: dropping a marker without
translating moves the string from one count to the other and still
fails. --update lowers the baseline to the current counts and never
raises an entry. A missing or malformed baseline, a locale lacking one
of en's files or a value the reader cannot parse is an error, not a
pass.
An identical value is not counted when it reads the same in every
language: after dropping placeholders, markup, URLs, e-mail addresses,
paths and a short list of multi-word names (Google Maps, Home
Assistant), at most one plain word may be left, where acronyms, single
letters and names with an inner capital are not plain words. That
excuses "{count} km", "GPX", "OAuth" and "Budget" and keeps "Source
code" counted.
The plural parity check only knows the groups en already declares, so a
"{count} ..." string added as a plain key kept one form in every
language: Polish read "Usuń 1 spakowanych", Czech "Odstranit 1
hotových" and Greek "1 λεπτά". 44 such en keys become plural groups with their
forms in all 27 locales: the collection, packing, day plan and offline
banner counts, the relative times, the audit log, the tour and transit
minutes, the share and photo hints. Labels like "Notes: {count}" get
their forms too, so a language can still inflect them later. The
dashboard's hand-rolled activeOne/activeMany pair, unused, becomes one
group, dashboard.subtitle.active.
i18n-parity now refuses an en string with {count} or {n} that is no
plural group. NOT_PLURAL lists the eleven where the number is never a
quantity (a day number, a step, a waypoint, the equator multiplier),
each with its reason, and an entry that stops matching fails as well.
Callers that worked around the engine:
- Copy to trip switched between two keys on placeIds.length > 1;
collections.copyN now carries the one-place wording as its `one` form,
where the language's `one` is the number 1 alone.
- The plugin dependency toasts said "addon(s)" and "plugin(s)"; they are
groups now and the panels pass the list length as the count.
- The dismissed suggestions count went in as a string and the comp days
as a formatted string, so no form was picked; both pass numbers.
BudgetPanel titled its add button with budget.addCategory (since April)
and the trip form labelled its cover remove button with common.remove
(since August). Neither key was ever in en, and t() returns an unknown
key unchanged, so both rendered as the raw key in every language. Both
are added to all 27 locales.
Locale parity compares the locales with en, never en with the code, so
nothing could catch this. client/scripts/i18n-keys.mjs (npm run
lint:i18n-keys) now resolves every literal key src/ names against en:
the first argument of t(), tHtml() and tr() (both sides of a conditional
included), <TransHtml html>, the fallback of translateApiError and any
*Key property or prop. A template key, a prefix with the rest appended
and the help registry's key builders pass when their fixed parts match
an en key; the one template with no fixed part is on an allow-list that
names the keys it resolves to, and an entry nothing uses fails. With
--unused it lists the en keys nothing reaches, as information only.
en is read with shared/scripts/i18n-catalog.mjs, a reader for the
locale tables as text that the parity tooling will share. It refuses a
value that is not one string literal instead of skipping the key.
skipIf and runIf were left out as run-time skips, which they are with a
real condition. With the literal true (skipIf) or false (runIf) they
switch a test off for good and gave a free way past the ratchet, while
the same file already counted Playwright's test.skip(true, 'why') for
that reason. Both now count as skips; nothing in the client uses them
today, so the counts stay as they are.
A block comment such as /* eslint @typescript-eslint/no-explicit-any: off */
turns a rule off for its file without leaving a message behind, neither
among the warnings nor among the suppressed ones, so a new file could
switch any warning rule off and the ratchet stayed green. eslint-disable
comments were already counted under "suppressed"; this was the way
around them.
lintClient now reads the block comments of every file ESLint linted from
the TypeScript parser, so the same text in a string, a template or a
regex does not count, and the tally reports each config comment as an
error. Nothing in the client uses one today, so the check is zero
tolerance. The tests baseline drops by one no-unused-vars warning that
is gone.
The size check walked src/ and tests/ only, while lint:format and
lint:skips already cover e2e/. The help center fixtures under
e2e/help/fixtures.ts hold 2291 wrapped lines, past the 2000 a test may
reach, and could grow without bound. e2e/ is now measured with the test
limit; fixtures.ts enters the baseline at its current size, every other
file there is within the limit.
A file's layer was the first path segment under src/, so src/types.ts,
the shared types module every layer imports as '../types', got the
layer "types.ts" and no rule at all, while the imports into it already
resolved to "types". It could import a component or a page unnoticed.
A file directly in src/ is now the layer of its name without the
extension, for the importer and the import alike. No file changes its
count: types.ts imports only @trek/shared today.
OAuthAuthorizePage reloads the user on mount and useDashboard's retry
reloads it too. The real loadUser then registers the background sync
triggers, a 30 s heartbeat and window listeners that nothing in either
spec unregisters, so they outlive the tests the same way they did in the
account specs before 27db5dd5e. Both specs now stub the triggers, which
neither is about.
The check only looked at src/ and tests/, so a new spec under e2e/ or a
new script under scripts/ could land unformatted, and the ratchet scripts
themselves did (fixed in the previous commit). It now walks e2e/ and
scripts/ too and takes .mjs alongside .ts, .tsx and .css. The 58 files
there that were unformatted before this branch (the e2e specs and help
guides, the older build and lint scripts) join the baseline, which keeps
only shrinking; JSON baselines stay out of scope.